This commit is contained in:
2026-06-13 07:47:49 +08:00
parent 11bd086685
commit ddc90e4b0d
11 changed files with 562 additions and 456 deletions
+16 -5
View File
@@ -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 }
}
-203
View File
@@ -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>
-38
View File
@@ -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>
+28 -6
View File
@@ -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;
+160 -8
View File
@@ -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>
+155 -115
View File
@@ -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>
+57 -1
View File
@@ -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>
+3 -1
View File
@@ -12,4 +12,6 @@
账号绑定邮箱
不要列表卡片瀑布流,功能丰富一点,不要太单调,界面也丰富一点
不要列表卡片瀑布流,功能丰富一点,不要太单调,界面也丰富一点
要多功能,界面丰富不单调。