1.2
This commit is contained in:
@@ -8,8 +8,11 @@ let ws: WebSocket | null = null
|
||||
let reconnectAttempts = 0
|
||||
const maxReconnectAttempts = 10
|
||||
|
||||
// 共享状态
|
||||
const connected = ref(false)
|
||||
const onlineUsers = ref(new Map<string, boolean>())
|
||||
|
||||
export function useWebSocket() {
|
||||
const connected = ref(false)
|
||||
|
||||
function connect() {
|
||||
const auth = useAuthStore()
|
||||
@@ -36,7 +39,6 @@ export function useWebSocket() {
|
||||
ws.onclose = () => {
|
||||
connected.value = false
|
||||
console.log('🌿 WebSocket 已断开')
|
||||
// 自动重连
|
||||
if (reconnectAttempts < maxReconnectAttempts) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)
|
||||
reconnectAttempts++
|
||||
@@ -71,12 +73,17 @@ export function useWebSocket() {
|
||||
chatStore.addMessage(event.data)
|
||||
break
|
||||
case 'presence.online':
|
||||
onlineUsers.value.set(event.data.user_id, true)
|
||||
console.log(`用户 ${event.data.user_id} 上线`)
|
||||
break
|
||||
case 'presence.offline':
|
||||
// 可以在这里更新联系人在线状态
|
||||
console.log(`用户 ${event.data.user_id} ${event.type === 'presence.online' ? '上线' : '下线'}`)
|
||||
onlineUsers.value.set(event.data.user_id, false)
|
||||
console.log(`用户 ${event.data.user_id} 下线`)
|
||||
break
|
||||
case 'friend.request':
|
||||
console.log('收到好友请求:', event.data)
|
||||
// 通过自定义事件通知全局
|
||||
window.dispatchEvent(new CustomEvent('qingye:friend-request', { detail: event.data }))
|
||||
break
|
||||
case 'error':
|
||||
console.error('服务端错误:', event.data.message)
|
||||
@@ -84,5 +91,9 @@ export function useWebSocket() {
|
||||
}
|
||||
}
|
||||
|
||||
return { connected, connect, disconnect, send }
|
||||
function isUserOnline(userId: string): boolean {
|
||||
return onlineUsers.value.get(userId) === true
|
||||
}
|
||||
|
||||
return { connected, onlineUsers, connect, disconnect, send, isUserOnline }
|
||||
}
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
<template>
|
||||
<div class="chat-layout">
|
||||
<!-- 左侧:会话列表面板 -->
|
||||
<div class="left-panel" :class="{ collapsed: uiStore.sidebarCollapsed && uiStore.isMobile }">
|
||||
<div class="panel-header">
|
||||
<div class="user-info" @click="$router.push('/profile')">
|
||||
<n-avatar v-if="auth.user?.avatar_url" :src="auth.user.avatar_url" :size="36" round />
|
||||
<n-avatar v-else :size="36" round style="background: var(--color-primary)">
|
||||
{{ (auth.user?.username || '?')[0].toUpperCase() }}
|
||||
</n-avatar>
|
||||
<span class="username">{{ auth.user?.username || '青叶用户' }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<n-button quaternary circle @click="$router.push('/contacts')">
|
||||
<template #icon>👥</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 布局模式切换 -->
|
||||
<div class="layout-switch">
|
||||
<n-button-group size="small">
|
||||
<n-button :type="uiStore.layoutMode === 'list' ? 'primary' : 'default'" @click="uiStore.setLayoutMode('list')">列表</n-button>
|
||||
<n-button :type="uiStore.layoutMode === 'card' ? 'primary' : 'default'" @click="uiStore.setLayoutMode('card')">卡片</n-button>
|
||||
<n-button :type="uiStore.layoutMode === 'waterfall' ? 'primary' : 'default'" @click="uiStore.setLayoutMode('waterfall')">瀑布流</n-button>
|
||||
</n-button-group>
|
||||
</div>
|
||||
<router-view name="left" />
|
||||
<div class="conversation-list">
|
||||
<div v-if="chatStore.isLoading" style="text-align: center; padding: 40px; color: var(--color-text-hint)">加载中...</div>
|
||||
<div v-else-if="chatStore.conversations.length === 0" class="empty-state">
|
||||
<div style="font-size: 48px">💬</div>
|
||||
<p>暂无消息</p>
|
||||
<p style="font-size: 13px; color: var(--color-text-hint)">去通讯录找朋友聊天吧</p>
|
||||
</div>
|
||||
<!-- 列表模式 -->
|
||||
<template v-if="uiStore.layoutMode === 'list'">
|
||||
<div v-for="conv in chatStore.conversations" :key="conv.id"
|
||||
class="conv-item" :class="{ active: chatStore.activeConversation === conv.id }"
|
||||
@click="openChat(conv.id)">
|
||||
<n-avatar :size="46" round :style="{ background: 'var(--color-primary)' }">
|
||||
{{ (conv.name || '?')[0] }}
|
||||
</n-avatar>
|
||||
<div class="conv-info">
|
||||
<div class="conv-top">
|
||||
<span class="conv-name">{{ conv.name || '未命名' }}</span>
|
||||
<span class="conv-time">{{ formatTime(conv.last_message_at) }}</span>
|
||||
</div>
|
||||
<div class="conv-bottom">
|
||||
<span class="conv-preview">{{ conv.last_message_preview || '' }}</span>
|
||||
<n-badge v-if="conv.unread_count > 0" :value="conv.unread_count" :max="99" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 卡片模式 -->
|
||||
<template v-else-if="uiStore.layoutMode === 'card'">
|
||||
<div class="card-grid">
|
||||
<div v-for="conv in chatStore.conversations" :key="conv.id"
|
||||
class="conv-card" :class="{ active: chatStore.activeConversation === conv.id }"
|
||||
@click="openChat(conv.id)">
|
||||
<n-avatar :size="56" round :style="{ background: 'var(--color-primary)' }">
|
||||
{{ (conv.name || '?')[0] }}
|
||||
</n-avatar>
|
||||
<div class="conv-card-name">{{ conv.name || '未命名' }}</div>
|
||||
<div class="conv-card-preview">{{ conv.last_message_preview?.substring(0, 30) || '' }}</div>
|
||||
<n-badge v-if="conv.unread_count > 0" :value="conv.unread_count" :max="99" style="position: absolute; top: 8px; right: 8px" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 瀑布流模式 -->
|
||||
<template v-else>
|
||||
<div class="waterfall-grid">
|
||||
<div v-for="conv in chatStore.conversations" :key="conv.id"
|
||||
class="conv-waterfall" @click="openChat(conv.id)">
|
||||
<div class="wf-header">
|
||||
<n-avatar :size="32" round :style="{ background: 'var(--color-primary)' }">
|
||||
{{ (conv.name || '?')[0] }}
|
||||
</n-avatar>
|
||||
<span class="wf-name">{{ conv.name }}</span>
|
||||
</div>
|
||||
<div class="wf-content">{{ conv.last_message_preview || '暂无消息' }}</div>
|
||||
<div class="wf-footer">
|
||||
<span>{{ formatTime(conv.last_message_at) }}</span>
|
||||
<n-badge v-if="conv.unread_count > 0" :value="conv.unread_count" :max="99" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:聊天窗口 -->
|
||||
<div class="right-panel">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const chatStore = useChatStore()
|
||||
const uiStore = useUiStore()
|
||||
const { connect } = useWebSocket()
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.fetchProfile()
|
||||
await chatStore.fetchConversations()
|
||||
connect()
|
||||
})
|
||||
|
||||
function openChat(id: string) {
|
||||
router.push(`/chat/${id}`)
|
||||
}
|
||||
|
||||
function formatTime(time: string | null) {
|
||||
if (!time) return ''
|
||||
const d = dayjs(time)
|
||||
const now = dayjs()
|
||||
if (d.isSame(now, 'day')) return d.format('HH:mm')
|
||||
if (d.isSame(now.subtract(1, 'day'), 'day')) return '昨天'
|
||||
return d.format('MM/DD')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-layout { display: flex; height: 100vh; background: var(--color-bg); }
|
||||
|
||||
.left-panel {
|
||||
width: 340px; background: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex; flex-direction: column;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
.left-panel.collapsed { width: 0; overflow: hidden; }
|
||||
|
||||
.panel-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px; border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.user-info { display: flex; align-items: center; gap: 10px; cursor: pointer; }
|
||||
.username { font-weight: 500; font-size: 15px; }
|
||||
|
||||
.layout-switch { padding: 8px 16px; border-bottom: 1px solid var(--color-border); text-align: center; }
|
||||
|
||||
.conversation-list { flex: 1; overflow-y: auto; }
|
||||
|
||||
.empty-state { text-align: center; padding: 80px 20px; color: var(--color-text-secondary); }
|
||||
|
||||
/* 列表模式 */
|
||||
.conv-item {
|
||||
display: flex; align-items: center; padding: 12px 16px; gap: 12px;
|
||||
cursor: pointer; transition: background 0.15s; border-bottom: 0.5px solid var(--color-border);
|
||||
}
|
||||
.conv-item:hover { background: var(--color-primary-lightest); }
|
||||
.conv-item.active { background: var(--color-primary-lightest); border-left: 3px solid var(--color-primary); }
|
||||
.conv-info { flex: 1; min-width: 0; }
|
||||
.conv-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.conv-name { font-weight: 500; font-size: 15px; }
|
||||
.conv-time { font-size: 12px; color: var(--color-text-hint); }
|
||||
.conv-bottom { display: flex; justify-content: space-between; align-items: center; }
|
||||
.conv-preview { font-size: 13px; color: var(--color-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
|
||||
/* 卡片模式 */
|
||||
.card-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 12px; }
|
||||
.conv-card {
|
||||
background: var(--color-surface-elevated); border-radius: 12px; padding: 16px;
|
||||
text-align: center; cursor: pointer; border: 1px solid var(--color-border);
|
||||
transition: all 0.2s; position: relative;
|
||||
}
|
||||
.conv-card:hover { border-color: var(--color-primary-lighter); box-shadow: 0 2px 12px rgba(0,150,136,0.1); }
|
||||
.conv-card.active { border-color: var(--color-primary); background: var(--color-primary-lightest); }
|
||||
.conv-card-name { font-weight: 500; margin-top: 8px; font-size: 14px; }
|
||||
.conv-card-preview { font-size: 12px; color: var(--color-text-hint); margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* 瀑布流模式 */
|
||||
.waterfall-grid { columns: 2; column-gap: 10px; padding: 12px; }
|
||||
.conv-waterfall {
|
||||
break-inside: avoid; background: var(--color-surface-elevated); border-radius: 12px;
|
||||
padding: 14px; margin-bottom: 10px; cursor: pointer; border: 1px solid var(--color-border);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.conv-waterfall:hover { border-color: var(--color-primary-lighter); }
|
||||
.wf-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.wf-name { font-weight: 500; font-size: 14px; }
|
||||
.wf-content { font-size: 13px; color: var(--color-text-secondary); line-height: 1.5; margin-bottom: 8px; }
|
||||
.wf-footer { display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--color-text-hint); }
|
||||
|
||||
.right-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.left-panel { width: 100%; }
|
||||
.right-panel { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<div class="main-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-logo">🌿</div>
|
||||
<nav class="sidebar-nav">
|
||||
<router-link to="/chat" class="nav-item" active-class="active" title="消息">
|
||||
<span class="nav-icon">💬</span>
|
||||
</router-link>
|
||||
<router-link to="/contacts" class="nav-item" active-class="active" title="通讯录">
|
||||
<span class="nav-icon">👥</span>
|
||||
</router-link>
|
||||
<router-link to="/profile" class="nav-item" active-class="active" title="我的">
|
||||
<span class="nav-icon">👤</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main-layout { display: flex; height: 100vh; }
|
||||
.sidebar {
|
||||
width: 64px; background: var(--color-surface); border-right: 1px solid var(--color-border);
|
||||
display: flex; flex-direction: column; align-items: center; padding-top: 16px;
|
||||
}
|
||||
.sidebar-logo { font-size: 28px; margin-bottom: 24px; }
|
||||
.sidebar-nav { display: flex; flex-direction: column; gap: 8px; }
|
||||
.nav-item {
|
||||
width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 10px; text-decoration: none; font-size: 20px; transition: all 0.2s;
|
||||
}
|
||||
.nav-item:hover { background: var(--color-primary-lightest); }
|
||||
.nav-item.active { background: var(--color-primary-lightest); color: var(--color-primary); }
|
||||
.main-content { flex: 1; overflow-y: auto; background: var(--color-bg); }
|
||||
</style>
|
||||
@@ -1,5 +1,10 @@
|
||||
<template>
|
||||
<div class="unified-layout">
|
||||
<!-- 断线提示条 -->
|
||||
<div v-if="!wsConnected" class="reconnect-bar">
|
||||
<span class="reconnect-dot"></span>
|
||||
连接断开,正在重连...
|
||||
</div>
|
||||
<!-- 左侧固定图标导航栏 -->
|
||||
<aside class="icon-rail">
|
||||
<div class="rail-logo" @click="$router.push('/chat')">🌿</div>
|
||||
@@ -42,7 +47,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
@@ -54,16 +59,19 @@ const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const chatStore = useChatStore()
|
||||
const uiStore = useUiStore()
|
||||
const { connect } = useWebSocket()
|
||||
const { connect, connected: wsConnected } = useWebSocket()
|
||||
|
||||
const isMobile = computed(() => uiStore.isMobile)
|
||||
const hideSecondary = computed(() => {
|
||||
// 在移动端,当进入具体内容页时隐藏辅助面板
|
||||
return route.matched.some((r) => r.meta.hideSecondary)
|
||||
})
|
||||
|
||||
const pendingRequestCount = ref(0)
|
||||
|
||||
function onFriendRequest() {
|
||||
pendingRequestCount.value++
|
||||
}
|
||||
|
||||
const activeFeature = computed(() => {
|
||||
const path = route.path
|
||||
if (path.startsWith('/chat')) return 'chat'
|
||||
@@ -80,6 +88,7 @@ const totalUnread = computed(() =>
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('qingye:friend-request', onFriendRequest)
|
||||
try {
|
||||
await auth.fetchProfile()
|
||||
} catch {
|
||||
@@ -90,13 +99,15 @@ onMounted(async () => {
|
||||
loadPendingRequests()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('qingye:friend-request', onFriendRequest)
|
||||
})
|
||||
|
||||
async function loadPendingRequests() {
|
||||
try {
|
||||
const { data } = await friendsApi.getPendingRequests()
|
||||
pendingRequestCount.value = Array.isArray(data) ? data.length : 0
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -111,6 +122,17 @@ import { ref } from 'vue'
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.reconnect-bar {
|
||||
position: fixed; top: 0; left: 0; right: 0; z-index: 200;
|
||||
background: #FFF3E0; color: #E65100; font-size: 13px; text-align: center;
|
||||
padding: 6px; display: flex; align-items: center; justify-content: center; gap: 6px;
|
||||
}
|
||||
.reconnect-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; background: #FF9800;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0.3; } }
|
||||
|
||||
/* 左侧图标栏 */
|
||||
.icon-rail {
|
||||
width: 64px;
|
||||
|
||||
@@ -1,14 +1,166 @@
|
||||
<template>
|
||||
<div class="chat-list-placeholder">
|
||||
<div style="font-size: 64px">🌿</div>
|
||||
<h2 style="color: var(--color-primary-dark)">青叶</h2>
|
||||
<p style="color: var(--color-text-secondary)">选择一个会话开始聊天</p>
|
||||
<div class="welcome-panel">
|
||||
<div class="welcome-bg"></div>
|
||||
<div class="welcome-content">
|
||||
<div class="welcome-header">
|
||||
<div class="logo-circle">🌿</div>
|
||||
<h1 class="app-name">青叶</h1>
|
||||
<p class="app-slogan">清新社交,畅快聊天</p>
|
||||
</div>
|
||||
|
||||
<!-- 快捷统计 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<span class="stat-num">{{ chatStore.conversations.length }}</span>
|
||||
<span class="stat-label">会话</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-num">{{ totalUnread }}</span>
|
||||
<span class="stat-label">未读</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-num">{{ friendCount }}</span>
|
||||
<span class="stat-label">好友</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近会话 -->
|
||||
<div v-if="recentConversations.length > 0" class="section">
|
||||
<h3 class="section-title">最近消息</h3>
|
||||
<div class="recent-list">
|
||||
<div v-for="conv in recentConversations" :key="conv.id" class="recent-item" @click="$router.push(`/chat/${conv.id}`)">
|
||||
<n-avatar :size="40" round :style="avatarStyle(conv)">
|
||||
{{ (conv.name || '?')[0] }}
|
||||
</n-avatar>
|
||||
<div class="recent-info">
|
||||
<span class="recent-name">{{ conv.name || '未命名' }}</span>
|
||||
<span class="recent-preview">{{ conv.last_message_preview || '暂无消息' }}</span>
|
||||
</div>
|
||||
<span v-if="conv.unread_count > 0" class="recent-badge">{{ conv.unread_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷操作 -->
|
||||
<div class="quick-actions">
|
||||
<div class="action-card" @click="$router.push('/contacts/search')">
|
||||
<span class="action-icon">🔍</span>
|
||||
<span class="action-text">搜索好友</span>
|
||||
</div>
|
||||
<div class="action-card" @click="showCreateGroup = true">
|
||||
<span class="action-icon">👥</span>
|
||||
<span class="action-text">创建群聊</span>
|
||||
</div>
|
||||
<div class="action-card" @click="$router.push('/moments')">
|
||||
<span class="action-icon">🌿</span>
|
||||
<span class="action-text">朋友圈</span>
|
||||
</div>
|
||||
<div class="action-card" @click="$router.push('/settings')">
|
||||
<span class="action-icon">⚙️</span>
|
||||
<span class="action-text">设置</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CreateGroupModal :visible="showCreateGroup" @close="showCreateGroup = false" @created="() => chatStore.fetchConversations()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-list-placeholder {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 12px;
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { friendsApi } from '@/api/friends'
|
||||
import CreateGroupModal from './CreateGroupModal.vue'
|
||||
|
||||
const chatStore = useChatStore()
|
||||
const friendCount = ref(0)
|
||||
const showCreateGroup = ref(false)
|
||||
|
||||
const totalUnread = computed(() =>
|
||||
chatStore.conversations.reduce((sum: number, c: any) => sum + (c.unread_count || 0), 0)
|
||||
)
|
||||
|
||||
const recentConversations = computed(() =>
|
||||
chatStore.conversations.slice(0, 3)
|
||||
)
|
||||
|
||||
function avatarStyle(conv: any) {
|
||||
const colors = ['#009688', '#26A69A', '#00796B', '#00897B', '#4DB6AC']
|
||||
const idx = (conv.name || '').charCodeAt(0) % colors.length
|
||||
return { background: colors[idx] }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await friendsApi.getFriends()
|
||||
friendCount.value = Array.isArray(data) ? data.length : 0
|
||||
} catch {}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.welcome-panel {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
position: relative; overflow: hidden;
|
||||
}
|
||||
.welcome-bg {
|
||||
position: absolute; inset: 0;
|
||||
background: radial-gradient(ellipse at 30% 20%, rgba(0,150,136,0.08) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 70% 80%, rgba(0,121,107,0.06) 0%, transparent 50%);
|
||||
}
|
||||
.welcome-content {
|
||||
position: relative; width: 420px; text-align: center;
|
||||
animation: fadeUp 0.5s ease;
|
||||
}
|
||||
@keyframes fadeUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.welcome-header { margin-bottom: 32px; }
|
||||
.logo-circle {
|
||||
width: 72px; height: 72px; border-radius: 50%;
|
||||
background: linear-gradient(135deg, #009688, #26A69A);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 36px; margin: 0 auto 12px;
|
||||
box-shadow: 0 4px 16px rgba(0,150,136,0.25);
|
||||
}
|
||||
.app-name { font-size: 24px; font-weight: 700; color: var(--color-primary-dark); margin: 0; }
|
||||
.app-slogan { font-size: 14px; color: var(--color-text-secondary); margin-top: 4px; }
|
||||
|
||||
.stats-row { display: flex; gap: 16px; justify-content: center; margin-bottom: 28px; }
|
||||
.stat-card {
|
||||
background: var(--color-surface); border-radius: 12px; padding: 16px 24px;
|
||||
border: 1px solid var(--color-border); min-width: 80px;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
}
|
||||
.stat-num { font-size: 24px; font-weight: 700; color: var(--color-primary); }
|
||||
.stat-label { font-size: 12px; color: var(--color-text-hint); margin-top: 2px; }
|
||||
|
||||
.section { text-align: left; margin-bottom: 24px; }
|
||||
.section-title { font-size: 13px; color: var(--color-text-secondary); margin-bottom: 8px; font-weight: 500; }
|
||||
.recent-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.recent-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
|
||||
border-radius: 10px; cursor: pointer; transition: background 0.2s;
|
||||
}
|
||||
.recent-item:hover { background: var(--color-primary-lightest); }
|
||||
.recent-info { flex: 1; min-width: 0; text-align: left; }
|
||||
.recent-name { font-size: 14px; font-weight: 500; display: block; }
|
||||
.recent-preview { font-size: 12px; color: var(--color-text-hint); display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.recent-badge {
|
||||
background: #EF5350; color: white; font-size: 11px; font-weight: 600;
|
||||
min-width: 20px; height: 20px; border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center; padding: 0 6px;
|
||||
}
|
||||
|
||||
.quick-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.action-card {
|
||||
background: var(--color-surface); border: 1px solid var(--color-border);
|
||||
border-radius: 12px; padding: 16px; cursor: pointer;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.action-card:hover { border-color: var(--color-primary-lighter); background: var(--color-primary-lightest); transform: translateY(-1px); }
|
||||
.action-icon { font-size: 24px; }
|
||||
.action-text { font-size: 13px; color: var(--color-text-secondary); }
|
||||
</style>
|
||||
|
||||
@@ -3,118 +3,115 @@
|
||||
<!-- 聊天头部 -->
|
||||
<div class="chat-header">
|
||||
<n-button v-if="uiStore.isMobile" quaternary circle @click="$router.push('/chat')" size="small">←</n-button>
|
||||
<div class="header-avatar" v-if="convDetail?.type === 'group'">
|
||||
<div v-if="convDetail?.type === 'group'" class="header-avatar">
|
||||
<n-avatar :size="36" round :style="{ background: 'var(--color-primary)' }">
|
||||
{{ (conversationName || '群')[0] }}
|
||||
</n-avatar>
|
||||
</div>
|
||||
<div class="header-info">
|
||||
<span class="room-name">{{ conversationName }}</span>
|
||||
<span v-if="convDetail?.type === 'group'" class="member-count">
|
||||
{{ convDetail.members?.length || 0 }} 位成员
|
||||
</span>
|
||||
<span v-else class="member-count online-status">在线</span>
|
||||
<span v-if="convDetail?.type === 'group'" class="member-count">{{ convDetail.members?.length || 0 }} 位成员</span>
|
||||
<span v-else class="member-count" :class="{ online: isOtherOnline }">{{ isOtherOnline ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
<!-- 群聊信息按钮 -->
|
||||
<n-button v-if="convDetail?.type === 'group'" quaternary size="small" @click="showGroupInfo = !showGroupInfo" title="群信息">
|
||||
群信息 ▾
|
||||
</n-button>
|
||||
<n-button v-if="convDetail?.type === 'group'" quaternary size="small" @click="showGroupInfo = !showGroupInfo">群信息 ▾</n-button>
|
||||
</div>
|
||||
|
||||
<div class="chat-body">
|
||||
<!-- 消息列表 -->
|
||||
<div class="message-list" ref="messageListRef">
|
||||
<div class="message-list" ref="messageListRef" @contextmenu.prevent>
|
||||
<div v-if="chatStore.currentMessages.length === 0" class="no-messages">
|
||||
<div class="no-msg-icon">🌿</div>
|
||||
<p class="no-msg-title">开始聊天吧</p>
|
||||
<p class="no-msg-hint">发送第一条消息</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="(msg, idx) in chatStore.currentMessages" :key="msg.id"
|
||||
class="message-row" :class="{ 'own': msg.sender_id === auth.user?.id, 'other': msg.sender_id !== auth.user?.id }"
|
||||
>
|
||||
<!-- 时间分隔线 -->
|
||||
<div v-if="shouldShowTime(msg, idx)" class="time-divider">
|
||||
{{ formatTimeDivider(msg.created_at) }}
|
||||
</div>
|
||||
|
||||
<div v-for="(msg, idx) in chatStore.currentMessages" :key="msg.id"
|
||||
class="message-row" :class="{ own: msg.sender_id === auth.user?.id, other: msg.sender_id !== auth.user?.id }">
|
||||
<div v-if="shouldShowTime(msg, idx)" class="time-divider">{{ formatTimeDivider(msg.created_at) }}</div>
|
||||
<template v-if="msg.type === 'system'">
|
||||
<div class="system-msg">{{ msg.content }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div v-if="msg.sender_id !== auth.user?.id" class="avatar-wrap">
|
||||
<n-avatar :size="36" round :style="{ background: avatarColor(msg.sender_name) }">
|
||||
{{ (msg.sender_name || '?')[0] }}
|
||||
</n-avatar>
|
||||
<n-avatar :size="36" round :style="{ background: avatarColor(msg.sender_name) }">{{ (msg.sender_name || '?')[0] }}</n-avatar>
|
||||
</div>
|
||||
<div class="bubble-area">
|
||||
<div v-if="msg.sender_id !== auth.user?.id && convDetail?.type === 'group'" class="sender-name">
|
||||
{{ msg.sender_name }}
|
||||
</div>
|
||||
<div class="bubble" :class="{ 'bubble-self': msg.sender_id === auth.user?.id, 'bubble-other': msg.sender_id !== auth.user?.id }">
|
||||
<img v-if="msg.type === 'image'" :src="msg.content" class="msg-image" />
|
||||
<div v-if="msg.sender_id !== auth.user?.id && convDetail?.type === 'group'" class="sender-name">{{ msg.sender_name }}</div>
|
||||
<div class="bubble" :class="{ 'bubble-self': msg.sender_id === auth.user?.id, 'bubble-other': msg.sender_id !== auth.user?.id }"
|
||||
@contextmenu.prevent="onMsgContext($event, msg)">
|
||||
<img v-if="msg.type === 'image'" :src="msg.content" class="msg-image" @click="previewImage(msg.content)" />
|
||||
<span v-else>{{ msg.content }}</span>
|
||||
</div>
|
||||
<div class="msg-meta">
|
||||
<span class="msg-time">{{ formatTime(msg.created_at) }}</span>
|
||||
<span v-if="msg.sender_id === auth.user?.id" class="msg-status">{{ msg._read ? '✓✓' : '✓' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="msg.sender_id === auth.user?.id" class="avatar-wrap">
|
||||
<n-avatar :size="36" round :style="{ background: 'var(--color-primary-dark)' }">
|
||||
{{ (auth.user?.nickname || auth.user?.username || '我')[0] }}
|
||||
</n-avatar>
|
||||
<n-avatar :size="36" round :style="{ background: 'var(--color-primary-dark)' }">{{ (auth.user?.nickname || auth.user?.username || '我')[0] }}</n-avatar>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 群聊信息侧栏 -->
|
||||
<GroupInfoPanel
|
||||
v-if="showGroupInfo && convDetail?.type === 'group'"
|
||||
:conversation-id="String(route.params.id)"
|
||||
:detail="convDetail"
|
||||
@close="showGroupInfo = false"
|
||||
@updated="loadDetail"
|
||||
/>
|
||||
<!-- 右键菜单 -->
|
||||
<div v-if="ctxMenu.show" class="ctx-menu" :style="{ top: ctxMenu.y + 'px', left: ctxMenu.x + 'px' }">
|
||||
<div class="ctx-item" @click="copyMsg">📋 复制</div>
|
||||
<div v-if="ctxMenu.msg?.sender_id === auth.user?.id" class="ctx-item danger" @click="deleteMsg">🗑️ 删除</div>
|
||||
</div>
|
||||
|
||||
<GroupInfoPanel v-if="showGroupInfo && convDetail?.type === 'group'"
|
||||
:conversation-id="String(route.params.id)" :detail="convDetail"
|
||||
@close="showGroupInfo = false" @updated="loadDetail" />
|
||||
</div>
|
||||
|
||||
<!-- 输入框 -->
|
||||
<div class="input-bar">
|
||||
<div class="input-actions">
|
||||
<span class="action-icon" title="表情">😊</span>
|
||||
<span class="action-icon" title="图片">🖼️</span>
|
||||
<span class="action-icon" @click="showEmoji = !showEmoji">😊</span>
|
||||
<span class="action-icon" @click="triggerImageUpload">🖼️</span>
|
||||
<input ref="imageInput" type="file" accept="image/*" style="display:none" @change="handleImageUpload" />
|
||||
</div>
|
||||
<!-- Emoji 面板 -->
|
||||
<div v-if="showEmoji" class="emoji-panel">
|
||||
<div v-for="e in emojis" :key="e" class="emoji-item" @click="insertEmoji(e)">{{ e }}</div>
|
||||
</div>
|
||||
<n-input v-model:value="inputText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }"
|
||||
placeholder="输入消息..." @keydown.enter.exact.prevent="sendMessage" />
|
||||
<n-button type="primary" :disabled="!inputText.trim()" @click="sendMessage" round size="small">
|
||||
发送
|
||||
</n-button>
|
||||
placeholder="输入消息..." @keydown.enter.exact.prevent="sendMessage" @focus="showEmoji = false" />
|
||||
<n-button type="primary" :disabled="!inputText.trim()" @click="sendMessage" round size="small">发送</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch } from 'vue'
|
||||
import { ref, reactive, onMounted, nextTick, watch, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
import { useWebSocket } from '@/composables/useWebSocket'
|
||||
import { chatApi } from '@/api/chat'
|
||||
import api from '@/api/client'
|
||||
import GroupInfoPanel from './GroupInfoPanel.vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const EMOJIS = ['😀','😃','😄','😁','😆','😅','🤣','😂','🙂','😊','😇','🥰','😍','🤩','😘','😗','😋','😛','😜','🤪','😝','🤑','🤗','🤭','🤫','🤔','😐','😑','😶','😏','😒','🙄','😬','😮','😯','😲','😳','🥺','😦','😧','😨','😰','😥','😢','😭','😱','😖','😣','😞','😓','😩','😫','🥱','😤','😡','🤬','😈','👿','💀','☠️','💩','🤡','👻','👽','🤖','😺','😸','😹','😻','😼','😽','🙀','😿','😾','👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','🖕','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','👐','🤲','🤝','🙏','💪','🦾','❤️','🧡','💛','💚','💙','💜','🖤','🤍','🤎','💔','❣️','💕','💞','💓','💗','💖','💘','💝','💟','☮️','✝️','☪️','🕉','☸️','✡️','🔯','🕎','☯️','🛐','⛎','♈','♉','♊','♋','♌','♍','♎','♏','♐','♑','♒','♓','🆔','⚛️','🉑','☢️','☣️','📴','📳','🈶','🈚','🈸','🈺','🈷️','✴️','🆚','💮','🉐','㊙️','㊗️','🈴','🈵','🈹','🈲','🅰️','🅱️','🆎','🆑','🅾️','🆘','⛔','📛','🚫','💯','💢','♨️','🚷','🚯','🚳','🚱','🔞','📵','🚭','❗','❕','❓','❔','‼️','⁉️','🔅','🔆','〽️','⚠️','🚸','🔱','⚜️','🔰','♻️','✅','🈯','💹','❇️','✳️','❎','🌐','💠','Ⓜ️','🌀','💤','🏧','🚾','♿','🅿️','🈳','🈂️','🛂','🛃','🛄','🛅','🚹','🚺','🚼','🚻','🚮','🎦','📶','🈁','🔣','ℹ️','🔤','🔡','🔠','🆖','🆗','🆙','🆒','🆕','🆓','0️⃣','1️⃣','2️⃣','3️⃣','4️⃣','5️⃣','6️⃣','7️⃣','8️⃣','9️⃣','🔟','🔢','#️⃣','*️⃣','⏏️','▶️','⏸️','⏯️','⏹️','⏺️','⏭️','⏮️','⏩','⏪','⏫','⏬','◀️','🔼','🔽','➡️','⬅️','⬆️','⬇️','↗️','↘️','↙️','↖️','↕️','↔️','↪️','↩️','⤴️','⤵️','🔀','🔁','🔂','🔄','🔃','🎵','🎶','➕','➖','➗','✖️','♾️','💲','💱','™️','©️','®️','👁️🗨️','🔚','🔙','🔛','🔝','🔜']
|
||||
|
||||
const route = useRoute()
|
||||
const chatStore = useChatStore()
|
||||
const auth = useAuthStore()
|
||||
const uiStore = useUiStore()
|
||||
const { send } = useWebSocket()
|
||||
const { send, connected } = useWebSocket()
|
||||
|
||||
const inputText = ref('')
|
||||
const messageListRef = ref<HTMLElement>()
|
||||
const imageInput = ref<HTMLInputElement>()
|
||||
const conversationName = ref('')
|
||||
const convDetail = ref<any>(null)
|
||||
const showGroupInfo = ref(false)
|
||||
const showEmoji = ref(false)
|
||||
const isOtherOnline = ref(false)
|
||||
const emojis = EMOJIS
|
||||
|
||||
const ctxMenu = reactive({ show: false, x: 0, y: 0, msg: null as any })
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id as string
|
||||
@@ -123,7 +120,13 @@ onMounted(async () => {
|
||||
await loadDetail()
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
markRead()
|
||||
}
|
||||
document.addEventListener('click', closeCtxMenu)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeCtxMenu)
|
||||
})
|
||||
|
||||
async function loadDetail() {
|
||||
@@ -133,9 +136,22 @@ async function loadDetail() {
|
||||
const { data } = await chatApi.getConversationDetail(id)
|
||||
convDetail.value = data
|
||||
conversationName.value = data.name || '未命名'
|
||||
// 私聊检查对方在线状态
|
||||
if (data.type === 'private') {
|
||||
const other = data.members?.find((m: any) => m.user_id !== auth.user?.id)
|
||||
isOtherOnline.value = other?.status === 'online'
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function markRead() {
|
||||
const msgs = chatStore.currentMessages
|
||||
if (msgs.length > 0 && chatStore.activeConversation) {
|
||||
const lastMsg = msgs[msgs.length - 1]
|
||||
chatStore.markAsRead(chatStore.activeConversation, lastMsg.id)
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => chatStore.currentMessages.length, () => {
|
||||
nextTick(scrollToBottom)
|
||||
})
|
||||
@@ -150,17 +166,67 @@ function sendMessage() {
|
||||
const text = inputText.value.trim()
|
||||
if (!text || !chatStore.activeConversation) return
|
||||
inputText.value = ''
|
||||
send('chat.send', {
|
||||
conversation_id: chatStore.activeConversation,
|
||||
content: text,
|
||||
type: 'text',
|
||||
})
|
||||
showEmoji.value = false
|
||||
send('chat.send', { conversation_id: chatStore.activeConversation, content: text, type: 'text' })
|
||||
}
|
||||
|
||||
function insertEmoji(e: string) {
|
||||
inputText.value += e
|
||||
}
|
||||
|
||||
function triggerImageUpload() {
|
||||
imageInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleImageUpload(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file || !chatStore.activeConversation) return
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const { data } = await api.post('/uploads/file', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
send('chat.send', { conversation_id: chatStore.activeConversation, content: data.url, type: 'image' })
|
||||
} catch {}
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
function previewImage(url: string) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// 右键菜单
|
||||
function onMsgContext(e: MouseEvent, msg: any) {
|
||||
e.preventDefault()
|
||||
ctxMenu.show = true
|
||||
ctxMenu.x = Math.min(e.clientX, window.innerWidth - 160)
|
||||
ctxMenu.y = Math.min(e.clientY, window.innerHeight - 100)
|
||||
ctxMenu.msg = msg
|
||||
}
|
||||
|
||||
function closeCtxMenu() {
|
||||
ctxMenu.show = false
|
||||
}
|
||||
|
||||
function copyMsg() {
|
||||
if (ctxMenu.msg?.content) {
|
||||
navigator.clipboard.writeText(ctxMenu.msg.content)
|
||||
}
|
||||
ctxMenu.show = false
|
||||
}
|
||||
|
||||
async function deleteMsg() {
|
||||
if (!ctxMenu.msg || !chatStore.activeConversation) return
|
||||
try {
|
||||
await chatApi.deleteMessage(chatStore.activeConversation, ctxMenu.msg.id)
|
||||
chatStore.currentMessages = chatStore.currentMessages.filter((m: any) => m.id !== ctxMenu.msg.id)
|
||||
} catch {}
|
||||
ctxMenu.show = false
|
||||
}
|
||||
|
||||
function avatarColor(name: string) {
|
||||
const colors = ['#009688', '#26A69A', '#00796B', '#00897B', '#4DB6AC', '#00838F', '#00695C']
|
||||
const idx = (name || '').charCodeAt(0) % colors.length
|
||||
return colors[idx]
|
||||
return { background: colors[(name || '').charCodeAt(0) % colors.length] }
|
||||
}
|
||||
|
||||
function shouldShowTime(msg: any, idx: number) {
|
||||
@@ -170,9 +236,7 @@ function shouldShowTime(msg: any, idx: number) {
|
||||
return dayjs(msg.created_at).diff(dayjs(prev.created_at), 'minute') > 5
|
||||
}
|
||||
|
||||
function formatTime(time: string) {
|
||||
return dayjs(time).format('HH:mm')
|
||||
}
|
||||
function formatTime(time: string) { return dayjs(time).format('HH:mm') }
|
||||
|
||||
function formatTimeDivider(time: string) {
|
||||
const d = dayjs(time)
|
||||
@@ -185,83 +249,59 @@ function formatTimeDivider(time: string) {
|
||||
|
||||
<style scoped>
|
||||
.chat-room { display: flex; flex-direction: column; height: 100%; background: var(--color-bg); }
|
||||
|
||||
.chat-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 12px 20px; border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.chat-header { display: flex; align-items: center; gap: 12px; padding: 12px 20px; border-bottom: 1px solid var(--color-border); background: var(--color-surface); }
|
||||
.header-info { flex: 1; display: flex; flex-direction: column; gap: 1px; }
|
||||
.room-name { font-weight: 600; font-size: 16px; color: var(--color-text-primary); }
|
||||
.room-name { font-weight: 600; font-size: 16px; }
|
||||
.member-count { font-size: 12px; color: var(--color-text-hint); }
|
||||
.online-status { color: var(--color-success); }
|
||||
.online-status::before {
|
||||
content: ''; display: inline-block; width: 6px; height: 6px;
|
||||
background: var(--color-success); border-radius: 50%; margin-right: 4px;
|
||||
}
|
||||
|
||||
.chat-body { flex: 1; display: flex; overflow: hidden; }
|
||||
.member-count.online { color: var(--color-success); }
|
||||
.member-count.online::before { content: ''; display: inline-block; width: 6px; height: 6px; background: var(--color-success); border-radius: 50%; margin-right: 4px; }
|
||||
|
||||
.chat-body { flex: 1; display: flex; overflow: hidden; position: relative; }
|
||||
.message-list { flex: 1; overflow-y: auto; padding: 16px 20px; }
|
||||
|
||||
.no-messages { text-align: center; padding-top: 100px; }
|
||||
.no-msg-icon { font-size: 48px; margin-bottom: 8px; }
|
||||
.no-msg-title { color: var(--color-text-secondary); font-size: 16px; margin: 0; }
|
||||
.no-msg-hint { color: var(--color-text-hint); font-size: 13px; margin-top: 4px; }
|
||||
|
||||
/* 时间分隔线 */
|
||||
.time-divider {
|
||||
text-align: center; margin: 16px 0 8px;
|
||||
font-size: 11px; color: var(--color-text-hint);
|
||||
}
|
||||
.time-divider::before, .time-divider::after {
|
||||
content: ''; display: inline-block; width: 40px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
vertical-align: middle; margin: 0 8px;
|
||||
}
|
||||
.time-divider { text-align: center; margin: 16px 0 8px; font-size: 11px; color: var(--color-text-hint); }
|
||||
.time-divider::before, .time-divider::after { content: ''; display: inline-block; width: 40px; border-top: 1px solid var(--color-border); vertical-align: middle; margin: 0 8px; }
|
||||
|
||||
.message-row { display: flex; align-items: flex-start; margin-bottom: 16px; gap: 8px; }
|
||||
.message-row.own { justify-content: flex-end; }
|
||||
.message-row.other { justify-content: flex-start; }
|
||||
|
||||
.avatar-wrap { flex-shrink: 0; }
|
||||
|
||||
.bubble-area { max-width: 60%; }
|
||||
.sender-name { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 3px; margin-left: 4px; font-weight: 500; }
|
||||
.bubble { padding: 10px 14px; font-size: 15px; line-height: 1.6; word-break: break-word; }
|
||||
.bubble-self { background: var(--color-bubble-self); color: var(--color-bubble-self-text); border-radius: 16px 16px 4px 16px; box-shadow: 0 1px 2px rgba(0,150,136,0.15); }
|
||||
.bubble-other { background: var(--color-bubble-other); color: var(--color-bubble-other-text); border-radius: 16px 16px 16px 4px; }
|
||||
.msg-meta { margin-top: 2px; display: flex; align-items: center; gap: 4px; }
|
||||
.own .msg-meta { justify-content: flex-end; margin-right: 4px; }
|
||||
.msg-time { font-size: 10px; color: var(--color-text-hint); }
|
||||
.msg-status { font-size: 10px; color: var(--color-primary); }
|
||||
.msg-image { max-width: 240px; border-radius: 10px; display: block; cursor: pointer; }
|
||||
.system-msg { text-align: center; font-size: 12px; color: var(--color-text-hint); margin: 12px 0; padding: 4px 16px; background: rgba(0,0,0,0.03); border-radius: 12px; display: inline-block; }
|
||||
|
||||
.bubble {
|
||||
padding: 10px 14px; font-size: 15px; line-height: 1.6; word-break: break-word;
|
||||
position: relative;
|
||||
}
|
||||
.bubble-self {
|
||||
background: var(--color-bubble-self); color: var(--color-bubble-self-text);
|
||||
border-radius: 16px 16px 4px 16px;
|
||||
box-shadow: 0 1px 2px rgba(0,150,136,0.15);
|
||||
}
|
||||
.bubble-other {
|
||||
background: var(--color-bubble-other); color: var(--color-bubble-other-text);
|
||||
border-radius: 16px 16px 16px 4px;
|
||||
}
|
||||
.msg-meta { margin-top: 2px; }
|
||||
.msg-time { font-size: 10px; color: var(--color-text-hint); margin-left: 4px; }
|
||||
.own .msg-meta { text-align: right; margin-right: 4px; }
|
||||
/* 右键菜单 */
|
||||
.ctx-menu { position: fixed; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.12); z-index: 999; min-width: 120px; overflow: hidden; }
|
||||
.ctx-item { padding: 8px 16px; font-size: 13px; cursor: pointer; transition: background 0.15s; }
|
||||
.ctx-item:hover { background: var(--color-primary-lightest); }
|
||||
.ctx-item.danger { color: #EF5350; }
|
||||
|
||||
.msg-image { max-width: 240px; border-radius: 10px; display: block; }
|
||||
|
||||
.system-msg {
|
||||
text-align: center; font-size: 12px; color: var(--color-text-hint);
|
||||
margin: 12px 0; padding: 4px 16px;
|
||||
background: rgba(0,0,0,0.03); border-radius: 12px; display: inline-block;
|
||||
}
|
||||
|
||||
.input-bar {
|
||||
display: flex; align-items: flex-end; gap: 8px;
|
||||
padding: 12px 20px; border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
/* 输入栏 */
|
||||
.input-bar { position: relative; display: flex; align-items: flex-end; gap: 8px; padding: 12px 20px; border-top: 1px solid var(--color-border); background: var(--color-surface); }
|
||||
.input-actions { display: flex; gap: 4px; padding-bottom: 4px; }
|
||||
.action-icon {
|
||||
font-size: 20px; cursor: pointer; opacity: 0.6; transition: opacity 0.2s;
|
||||
}
|
||||
.action-icon { font-size: 20px; cursor: pointer; opacity: 0.6; transition: opacity 0.2s; user-select: none; }
|
||||
.action-icon:hover { opacity: 1; }
|
||||
|
||||
/* Emoji 面板 */
|
||||
.emoji-panel {
|
||||
position: absolute; bottom: 60px; left: 12px; width: 340px; max-height: 200px;
|
||||
background: var(--color-surface); border: 1px solid var(--color-border);
|
||||
border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
overflow-y: auto; padding: 8px; display: flex; flex-wrap: wrap; gap: 2px; z-index: 100;
|
||||
}
|
||||
.emoji-item { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer; border-radius: 4px; transition: background 0.15s; }
|
||||
.emoji-item:hover { background: var(--color-primary-lightest); }
|
||||
</style>
|
||||
|
||||
@@ -24,16 +24,23 @@
|
||||
</div>
|
||||
<div v-for="friend in filteredFriends" :key="friend.id" class="friend-item"
|
||||
:class="{ active: selectedFriendId === friend.friend_user_id }"
|
||||
@click="selectFriend(friend)">
|
||||
@click="showFriendProfile(friend)">
|
||||
<n-avatar :size="38" round :style="{ background: 'var(--color-primary)' }">
|
||||
{{ (friend.remark || friend.username || '?')[0] }}
|
||||
{{ (friend.remark || friend.nickname || friend.username || '?')[0] }}
|
||||
</n-avatar>
|
||||
<div class="friend-info">
|
||||
<span class="friend-name">{{ friend.remark || friend.username }}</span>
|
||||
<span class="friend-name">{{ friend.remark || friend.nickname || friend.username }}</span>
|
||||
<span class="friend-status" :class="friend.status">·</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 好友资料卡片 -->
|
||||
<FriendProfileCard
|
||||
:visible="showProfile"
|
||||
:friend="selectedFriend"
|
||||
@close="showProfile = false"
|
||||
@updated="loadFriends"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -42,7 +49,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { friendsApi } from '@/api/friends'
|
||||
import { chatApi } from '@/api/chat'
|
||||
import FriendProfileCard from './FriendProfileCard.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -52,6 +59,8 @@ const friends = ref<any[]>([])
|
||||
const pendingRequests = ref<any[]>([])
|
||||
const isLoading = ref(false)
|
||||
const selectedFriendId = computed(() => route.query.friend_id as string || '')
|
||||
const showProfile = ref(false)
|
||||
const selectedFriend = ref<any>({})
|
||||
|
||||
const filteredFriends = computed(() => {
|
||||
if (!searchKeyword.value) return friends.value
|
||||
@@ -74,11 +83,9 @@ async function loadRequests() {
|
||||
try { const { data } = await friendsApi.getPendingRequests(); pendingRequests.value = data } catch {}
|
||||
}
|
||||
|
||||
async function selectFriend(friend: any) {
|
||||
try {
|
||||
const { data } = await chatApi.createPrivateConversation(friend.friend_user_id)
|
||||
router.push(`/chat/${data.id}`)
|
||||
} catch { message.error('创建会话失败') }
|
||||
function showFriendProfile(friend: any) {
|
||||
selectedFriend.value = friend
|
||||
showProfile.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div v-if="visible" class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="profile-card">
|
||||
<div class="card-header" :style="{ background: 'linear-gradient(135deg, #009688, #26A69A)' }">
|
||||
<span class="close-btn" @click="$emit('close')">✕</span>
|
||||
<n-avatar :size="72" round :style="{ background: '#fff', color: '#009688', fontSize: '28px' }">
|
||||
{{ (friend.nickname || friend.username || '?')[0] }}
|
||||
</n-avatar>
|
||||
<h3 class="card-name">{{ friend.nickname || friend.username }}</h3>
|
||||
<p class="card-username" v-if="friend.nickname">@{{ friend.username }}</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="info-row" v-if="friend.bio">
|
||||
<span class="info-label">签名</span>
|
||||
<span class="info-value">{{ friend.bio }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">状态</span>
|
||||
<span class="info-value">
|
||||
<span class="status-dot" :class="friend.status === 'online' ? 'online' : ''"></span>
|
||||
{{ friend.status === 'online' ? '在线' : '离线' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 修改备注 -->
|
||||
<div class="remark-section">
|
||||
<span class="info-label">备注名</span>
|
||||
<div class="remark-row">
|
||||
<n-input v-model:value="remark" placeholder="设置备注名" size="small" style="flex: 1" />
|
||||
<n-button size="small" type="primary" @click="saveRemark">保存</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="card-actions">
|
||||
<n-button type="primary" block @click="startChat">发消息</n-button>
|
||||
<n-button type="error" ghost block @click="handleRemove">删除好友</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { chatApi } from '@/api/chat'
|
||||
import { friendsApi } from '@/api/friends'
|
||||
|
||||
const props = defineProps<{ visible: boolean; friend: any }>()
|
||||
const emit = defineEmits<{ close: []; updated: [] }>()
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const remark = ref('')
|
||||
|
||||
watch(() => props.friend, (f) => {
|
||||
remark.value = f?.remark || ''
|
||||
}, { immediate: true })
|
||||
|
||||
async function startChat() {
|
||||
try {
|
||||
const { data } = await chatApi.createPrivateConversation(props.friend.friend_user_id)
|
||||
emit('close')
|
||||
router.push(`/chat/${data.id}`)
|
||||
} catch {
|
||||
message.error('创建会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRemark() {
|
||||
try {
|
||||
await friendsApi.updateRemark(props.friend.friend_user_id, remark.value || null)
|
||||
message.success('备注已更新')
|
||||
emit('updated')
|
||||
} catch {
|
||||
message.error('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
try {
|
||||
await friendsApi.removeFriend(props.friend.friend_user_id)
|
||||
message.success('已删除好友')
|
||||
emit('updated')
|
||||
emit('close')
|
||||
} catch {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.profile-card {
|
||||
width: 380px; background: var(--color-surface); border-radius: 16px;
|
||||
overflow: hidden; box-shadow: 0 12px 40px rgba(0,0,0,0.15);
|
||||
}
|
||||
.card-header {
|
||||
text-align: center; padding: 32px 24px 24px; color: white; position: relative;
|
||||
}
|
||||
.close-btn {
|
||||
position: absolute; top: 12px; right: 16px; cursor: pointer;
|
||||
font-size: 18px; opacity: 0.8;
|
||||
}
|
||||
.close-btn:hover { opacity: 1; }
|
||||
.card-name { margin: 12px 0 2px; font-size: 20px; font-weight: 600; }
|
||||
.card-username { font-size: 13px; opacity: 0.8; margin: 0; }
|
||||
.card-body { padding: 20px 24px; }
|
||||
.info-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 0; border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.info-label { font-size: 13px; color: var(--color-text-secondary); }
|
||||
.info-value { font-size: 14px; display: flex; align-items: center; gap: 6px; }
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; background: var(--color-text-hint);
|
||||
}
|
||||
.status-dot.online { background: var(--color-success); }
|
||||
.remark-section { padding: 10px 0; border-bottom: 1px solid var(--color-border); }
|
||||
.remark-row { display: flex; gap: 8px; margin-top: 6px; }
|
||||
.card-actions { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
|
||||
</style>
|
||||
@@ -49,6 +49,20 @@
|
||||
<div class="compose-body">
|
||||
<n-input v-model:value="composeText" type="textarea" :rows="4"
|
||||
placeholder="分享你的想法..." maxlength="1000" show-count />
|
||||
<!-- 图片选择 -->
|
||||
<div class="image-upload-area">
|
||||
<div class="image-previews">
|
||||
<div v-for="(img, i) in composeImages" :key="i" class="image-preview-item">
|
||||
<img :src="img" />
|
||||
<span class="remove-img" @click="composeImages.splice(i, 1)">✕</span>
|
||||
</div>
|
||||
<div v-if="composeImages.length < 9" class="add-image-btn" @click="triggerMomentImage">
|
||||
<span>+</span>
|
||||
<span style="font-size:11px">图片</span>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="momentImageInput" type="file" accept="image/*" multiple style="display:none" @change="handleMomentImages" />
|
||||
</div>
|
||||
<div class="compose-options">
|
||||
<div class="visibility-select">
|
||||
<span class="option-label">可见范围:</span>
|
||||
@@ -73,6 +87,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useMomentsStore } from '@/stores/moments'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import api from '@/api/client'
|
||||
import MomentCard from './MomentCard.vue'
|
||||
|
||||
const momentsStore = useMomentsStore()
|
||||
@@ -82,18 +97,41 @@ const showCompose = ref(false)
|
||||
const composeText = ref('')
|
||||
const composeVisibility = ref('friends')
|
||||
const publishing = ref(false)
|
||||
const composeImages = ref<string[]>([])
|
||||
const momentImageInput = ref<HTMLInputElement>()
|
||||
|
||||
onMounted(() => {
|
||||
momentsStore.fetchFeed(true)
|
||||
})
|
||||
|
||||
function triggerMomentImage() {
|
||||
momentImageInput.value?.click()
|
||||
}
|
||||
|
||||
async function handleMomentImages(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const files = target.files
|
||||
if (!files) return
|
||||
for (let i = 0; i < files.length && composeImages.value.length < 9; i++) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', files[i])
|
||||
const { data } = await api.post('/uploads/file', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
composeImages.value.push(data.url)
|
||||
} catch {}
|
||||
}
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
async function publishMoment() {
|
||||
if (!composeText.value.trim()) return
|
||||
publishing.value = true
|
||||
try {
|
||||
await momentsStore.createMoment(composeText.value.trim(), undefined, composeVisibility.value)
|
||||
const images = composeImages.value.length > 0 ? composeImages.value : undefined
|
||||
await momentsStore.createMoment(composeText.value.trim(), images, composeVisibility.value)
|
||||
message.success('动态发布成功')
|
||||
composeText.value = ''
|
||||
composeImages.value = []
|
||||
showCompose.value = false
|
||||
} catch {
|
||||
message.error('发布失败')
|
||||
@@ -159,6 +197,24 @@ async function handleComment(momentId: string, content: string) {
|
||||
.close-btn:hover { color: var(--color-text-primary); }
|
||||
.compose-body { padding: 16px 24px; }
|
||||
.compose-options { margin-top: 12px; }
|
||||
|
||||
/* 图片上传 */
|
||||
.image-upload-area { margin-top: 10px; }
|
||||
.image-previews { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.image-preview-item { width: 64px; height: 64px; position: relative; border-radius: 8px; overflow: hidden; }
|
||||
.image-preview-item img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.remove-img {
|
||||
position: absolute; top: 2px; right: 2px; width: 18px; height: 18px;
|
||||
background: rgba(0,0,0,0.5); color: white; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 10px; cursor: pointer;
|
||||
}
|
||||
.add-image-btn {
|
||||
width: 64px; height: 64px; border: 2px dashed var(--color-border); border-radius: 8px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
cursor: pointer; color: var(--color-text-hint); font-size: 24px; transition: border-color 0.2s;
|
||||
}
|
||||
.add-image-btn:hover { border-color: var(--color-primary-lighter); color: var(--color-primary); }
|
||||
.visibility-select { display: flex; align-items: center; gap: 8px; }
|
||||
.option-label { font-size: 13px; color: var(--color-text-secondary); }
|
||||
.compose-footer {
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<div class="profile-card">
|
||||
<n-avatar :size="80" round :style="{ background: 'var(--color-primary)', fontSize: '32px' }">
|
||||
{{ (auth.user?.username || '?')[0].toUpperCase() }}
|
||||
</n-avatar>
|
||||
<div class="profile-info">
|
||||
<h2>{{ auth.user?.username }}</h2>
|
||||
<p>{{ auth.user?.email }}</p>
|
||||
<p class="bio">{{ auth.user?.bio || '这个人很懒,什么都没写' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-card title="个人设置" style="margin-top: 16px">
|
||||
<n-form :model="form" label-placement="left" label-width="80">
|
||||
<n-form-item label="用户名">
|
||||
<n-input v-model:value="form.username" />
|
||||
</n-form-item>
|
||||
<n-form-item label="个性签名">
|
||||
<n-input v-model:value="form.bio" type="textarea" :rows="2" />
|
||||
</n-form-item>
|
||||
<n-button type="primary" @click="saveProfile">保存修改</n-button>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card style="margin-top: 16px">
|
||||
<n-button type="error" ghost block @click="handleLogout">退出登录</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import api from '@/api/client'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const form = reactive({ username: '', bio: '' })
|
||||
|
||||
onMounted(() => {
|
||||
form.username = auth.user?.username || ''
|
||||
form.bio = auth.user?.bio || ''
|
||||
})
|
||||
|
||||
async function saveProfile() {
|
||||
try {
|
||||
await api.put('/users/me', form)
|
||||
await auth.fetchProfile()
|
||||
message.success('保存成功')
|
||||
} catch { message.error('保存失败') }
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-page { max-width: 600px; margin: 0 auto; padding: 24px; }
|
||||
.profile-card { display: flex; align-items: center; gap: 20px; padding: 24px; background: var(--color-surface); border-radius: 16px; }
|
||||
.profile-info h2 { margin: 0 0 4px; color: var(--color-primary-dark); }
|
||||
.profile-info p { margin: 0; color: var(--color-text-secondary); font-size: 14px; }
|
||||
.bio { margin-top: 8px !important; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user