This commit is contained in:
2026-06-13 07:36:26 +08:00
parent 24017e7454
commit 11bd086685
2 changed files with 201 additions and 149 deletions
+115 -42
View File
@@ -2,22 +2,22 @@
<div class="chat-room">
<!-- 聊天头部 -->
<div class="chat-header">
<n-button v-if="uiStore.isMobile" quaternary circle @click="$router.push('/chat')"></n-button>
<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'">
<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 }})
{{ convDetail.members?.length || 0 }} 位成员
</span>
<span v-else class="member-count online-status">在线</span>
</div>
<!-- 聊天风格切换 -->
<n-button-group size="tiny" style="margin-left: auto">
<n-button :type="uiStore.chatStyle === 'classic' ? 'primary' : 'default'" @click="uiStore.setChatStyle('classic')">经典</n-button>
<n-button :type="uiStore.chatStyle === 'compact' ? 'primary' : 'default'" @click="uiStore.setChatStyle('compact')">紧凑</n-button>
<n-button :type="uiStore.chatStyle === 'bubble' ? 'primary' : 'default'" @click="uiStore.setChatStyle('bubble')">气泡</n-button>
</n-button-group>
<!-- 群聊信息按钮 -->
<n-button v-if="convDetail?.type === 'group'" quaternary circle size="small" @click="showGroupInfo = !showGroupInfo" title="群信息">
<n-button v-if="convDetail?.type === 'group'" quaternary size="small" @click="showGroupInfo = !showGroupInfo" title="群信息">
群信息
</n-button>
</div>
@@ -25,41 +25,44 @@
<!-- 消息列表 -->
<div class="message-list" ref="messageListRef">
<div v-if="chatStore.currentMessages.length === 0" class="no-messages">
<p>开始聊天吧 🌿</p>
<div class="no-msg-icon">🌿</div>
<p class="no-msg-title">开始聊天吧</p>
<p class="no-msg-hint">发送第一条消息</p>
</div>
<div
v-for="msg in chatStore.currentMessages" :key="msg.id"
class="message-row" :class="{
'own': msg.sender_id === auth.user?.id,
'other': msg.sender_id !== auth.user?.id,
[`style-${uiStore.chatStyle}`]: true,
}"
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">
<n-avatar :size="uiStore.chatStyle === 'compact' ? 28 : 34" round
:style="{ background: 'var(--color-primary)' }">
<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>
</div>
<div class="bubble-area">
<div v-if="uiStore.chatStyle !== 'compact' && msg.sender_id !== auth.user?.id" class="sender-name">
<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" />
<span v-else>{{ msg.content }}</span>
</div>
<div v-if="uiStore.chatStyle === 'classic'" class="msg-time">
{{ formatTime(msg.created_at) }}
<div class="msg-meta">
<span class="msg-time">{{ formatTime(msg.created_at) }}</span>
</div>
</div>
<div v-if="msg.sender_id === auth.user?.id" class="avatar">
<n-avatar :size="uiStore.chatStyle === 'compact' ? 28 : 34" round
style="background: var(--color-primary-dark)"></n-avatar>
<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>
</div>
</template>
</div>
@@ -77,10 +80,14 @@
<!-- 输入框 -->
<div class="input-bar">
<div class="input-actions">
<span class="action-icon" title="表情">😊</span>
<span class="action-icon" title="图片">🖼</span>
</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" circle>
<n-button type="primary" :disabled="!inputText.trim()" @click="sendMessage" round size="small">
发送
</n-button>
</div>
</div>
@@ -150,45 +157,111 @@ function sendMessage() {
})
}
function avatarColor(name: string) {
const colors = ['#009688', '#26A69A', '#00796B', '#00897B', '#4DB6AC', '#00838F', '#00695C']
const idx = (name || '').charCodeAt(0) % colors.length
return colors[idx]
}
function shouldShowTime(msg: any, idx: number) {
if (idx === 0) return true
const prev = chatStore.currentMessages[idx - 1]
if (!prev) return false
return dayjs(msg.created_at).diff(dayjs(prev.created_at), 'minute') > 5
}
function formatTime(time: string) {
return dayjs(time).format('HH:mm')
}
function formatTimeDivider(time: string) {
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 '昨天 ' + d.format('HH:mm')
return d.format('MM月DD日 HH:mm')
}
</script>
<style scoped>
.chat-room { display: flex; flex-direction: column; height: 100%; }
.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);
}
.header-info { display: flex; align-items: baseline; gap: 4px; }
.room-name { font-weight: 600; font-size: 16px; }
.header-info { flex: 1; display: flex; flex-direction: column; gap: 1px; }
.room-name { font-weight: 600; font-size: 16px; color: var(--color-text-primary); }
.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; }
.message-list { flex: 1; overflow-y: auto; padding: 16px 20px; }
.no-messages { text-align: center; padding-top: 120px; color: var(--color-text-hint); }
.message-row { display: flex; align-items: flex-start; margin-bottom: 12px; gap: 8px; }
.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;
}
.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; }
.message-row.style-compact { margin-bottom: 4px; }
.bubble-area { max-width: 65%; }
.sender-name { font-size: 12px; color: var(--color-text-hint); margin-bottom: 2px; margin-left: 4px; }
.bubble { padding: 10px 14px; border-radius: 12px; font-size: 15px; line-height: 1.5; word-break: break-word; }
.bubble-self { background: var(--color-bubble-self); color: var(--color-bubble-self-text); border-top-right-radius: 4px; }
.bubble-other { background: var(--color-bubble-other); color: var(--color-bubble-other-text); border-top-left-radius: 4px; }
.msg-time { font-size: 11px; color: var(--color-text-hint); margin-top: 2px; }
.msg-image { max-width: 200px; border-radius: 8px; }
.avatar-wrap { flex-shrink: 0; }
.system-msg { text-align: center; font-size: 12px; color: var(--color-text-hint); margin: 8px 0; padding: 4px 12px; }
.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;
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; }
.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-actions { display: flex; gap: 4px; padding-bottom: 4px; }
.action-icon {
font-size: 20px; cursor: pointer; opacity: 0.6; transition: opacity 0.2s;
}
.action-icon:hover { opacity: 1; }
</style>
+86 -107
View File
@@ -11,76 +11,43 @@
<div class="search-bar">
<n-input v-model:value="searchKeyword" placeholder="搜索会话..." size="small" clearable />
</div>
<!-- 布局模式切换 -->
<div class="layout-switch">
<n-button-group size="tiny">
<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>
<!-- 会话列表 -->
<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="filteredConversations.length === 0" class="empty-state">
<div style="font-size: 48px">💬</div>
<p>{{ searchKeyword ? '没有找到匹配的会话' : '暂无消息' }}</p>
<p style="font-size: 13px; color: var(--color-text-hint)">去通讯录找朋友聊天吧</p>
<div v-if="chatStore.isLoading" class="loading-state">
<div class="loading-spinner"></div>
<span>加载中...</span>
</div>
<!-- 列表模式 -->
<template v-if="uiStore.layoutMode === 'list'">
<div v-else-if="filteredConversations.length === 0" class="empty-state">
<div class="empty-icon">💬</div>
<p class="empty-title">{{ searchKeyword ? '没有找到匹配的会话' : '暂无消息' }}</p>
<p class="empty-hint">去通讯录找朋友聊天吧</p>
<n-button v-if="!searchKeyword" type="primary" size="small" @click="$router.push('/contacts/search')" style="margin-top: 12px">
添加好友
</n-button>
</div>
<div v-else>
<div v-for="conv in filteredConversations" :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-avatar-wrap">
<n-avatar :size="48" round :style="avatarStyle(conv)">
{{ (conv.name || '?')[0] }}
</n-avatar>
<span v-if="conv.type === 'group'" class="conv-type-badge"></span>
<span v-if="conv.unread_count > 0" class="unread-dot"></span>
</div>
<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" />
<span class="conv-preview">{{ conv.last_message_preview || '暂无消息' }}</span>
<span v-if="conv.unread_count > 0" class="unread-badge">{{ conv.unread_count > 99 ? '99+' : conv.unread_count }}</span>
</div>
</div>
</div>
</template>
<!-- 卡片模式 -->
<template v-else-if="uiStore.layoutMode === 'card'">
<div class="card-grid">
<div v-for="conv in filteredConversations" :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 filteredConversations" :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>
<!-- 创建群聊弹窗 -->
<CreateGroupModal :visible="showCreateGroup" @close="showCreateGroup = false" @created="onGroupCreated" />
@@ -91,13 +58,11 @@
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useChatStore } from '@/stores/chat'
import { useUiStore } from '@/stores/ui'
import CreateGroupModal from './CreateGroupModal.vue'
import dayjs from 'dayjs'
const router = useRouter()
const chatStore = useChatStore()
const uiStore = useUiStore()
const searchKeyword = ref('')
const showCreateGroup = ref(false)
@@ -110,6 +75,12 @@ const filteredConversations = computed(() => {
)
})
function avatarStyle(conv: any) {
const colors = ['#009688', '#26A69A', '#00796B', '#00897B', '#4DB6AC']
const idx = (conv.name || '').charCodeAt(0) % colors.length
return { background: colors[idx] }
}
function openChat(id: string) {
router.push(`/chat/${id}`)
}
@@ -124,6 +95,7 @@ function formatTime(time: string | null) {
const now = dayjs()
if (d.isSame(now, 'day')) return d.format('HH:mm')
if (d.isSame(now.subtract(1, 'day'), 'day')) return '昨天'
if (now.diff(d, 'day') < 7) return `${now.diff(d, 'day')}天前`
return d.format('MM/DD')
}
</script>
@@ -142,6 +114,7 @@ function formatTime(time: string | null) {
padding: 16px;
border-bottom: 1px solid var(--color-border);
}
.header-actions { display: flex; gap: 4px; }
.panel-title {
margin: 0;
@@ -150,67 +123,73 @@ function formatTime(time: string | null) {
color: var(--color-text-primary);
}
.search-bar {
padding: 8px 12px;
}
.search-bar { padding: 8px 12px; }
.layout-switch {
padding: 4px 12px 8px;
text-align: center;
}
.conversation-list { flex: 1; overflow-y: auto; }
.conversation-list {
flex: 1;
overflow-y: auto;
/* 加载状态 */
.loading-state {
display: flex; flex-direction: column; align-items: center;
gap: 8px; padding: 60px 20px; color: var(--color-text-hint);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--color-text-secondary);
.loading-spinner {
width: 28px; height: 28px; border: 3px solid var(--color-border);
border-top-color: var(--color-primary); border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 列表模式 */
/* 空状态 */
.empty-state { text-align: center; padding: 60px 20px; }
.empty-icon { font-size: 48px; margin-bottom: 8px; }
.empty-title { color: var(--color-text-secondary); font-size: 15px; margin: 0; }
.empty-hint { color: var(--color-text-hint); font-size: 13px; margin-top: 4px; }
/* 会话列表项 */
.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);
display: flex; align-items: center; padding: 12px 16px; gap: 12px;
cursor: pointer; transition: all 0.2s; position: relative;
}
.conv-item:hover { background: var(--color-primary-lightest); }
.conv-item.active { background: var(--color-primary-lightest); border-left: 3px solid var(--color-primary); }
.conv-item.active {
background: var(--color-primary-lightest);
}
.conv-item.active::before {
content: ''; position: absolute; left: 0; top: 50%;
transform: translateY(-50%); width: 3px; height: 60%;
background: var(--color-primary); border-radius: 0 2px 2px 0;
}
.conv-avatar-wrap { position: relative; flex-shrink: 0; }
.conv-type-badge {
position: absolute; bottom: -2px; right: -2px;
background: #FF9800; color: white; font-size: 9px;
padding: 0 4px; border-radius: 6px; line-height: 14px; font-weight: 600;
}
.unread-dot {
position: absolute; top: 0; right: 0;
width: 10px; height: 10px; background: #EF5350;
border-radius: 50%; border: 2px solid var(--color-surface);
}
.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: 14px; }
.conv-time { font-size: 11px; color: var(--color-text-hint); }
.conv-top {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 4px;
}
.conv-name { font-weight: 500; font-size: 14px; color: var(--color-text-primary); }
.conv-time { font-size: 11px; color: var(--color-text-hint); flex-shrink: 0; }
.conv-bottom { display: flex; justify-content: space-between; align-items: center; }
.conv-preview { font-size: 12px; 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: 8px; padding: 8px 12px; }
.conv-card {
background: var(--color-surface-elevated); border-radius: 10px; padding: 14px;
text-align: center; cursor: pointer; border: 1px solid var(--color-border);
transition: all 0.2s; position: relative;
.conv-preview {
font-size: 12px; color: var(--color-text-secondary);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
}
.conv-card:hover { border-color: var(--color-primary-lighter); box-shadow: 0 2px 8px rgba(0,150,136,0.08); }
.conv-card.active { border-color: var(--color-primary); background: var(--color-primary-lightest); }
.conv-card-name { font-weight: 500; margin-top: 6px; font-size: 13px; }
.conv-card-preview { font-size: 11px; color: var(--color-text-hint); margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* 瀑布流模式 */
.waterfall-grid { columns: 2; column-gap: 8px; padding: 8px 12px; }
.conv-waterfall {
break-inside: avoid; background: var(--color-surface-elevated); border-radius: 10px;
padding: 12px; margin-bottom: 8px; cursor: pointer; border: 1px solid var(--color-border);
transition: all 0.2s;
.unread-badge {
background: #EF5350; color: white; font-size: 11px; font-weight: 600;
min-width: 18px; height: 18px; border-radius: 9px;
display: flex; align-items: center; justify-content: center;
padding: 0 5px; flex-shrink: 0; margin-left: 6px;
}
.conv-waterfall:hover { border-color: var(--color-primary-lighter); }
.wf-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.wf-name { font-weight: 500; font-size: 13px; }
.wf-content { font-size: 12px; color: var(--color-text-secondary); line-height: 1.4; margin-bottom: 6px; }
.wf-footer { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: var(--color-text-hint); }
</style>