This commit is contained in:
2026-06-14 11:16:42 +08:00
parent ca39190ad7
commit c9fc87cd89
35 changed files with 1480 additions and 18 deletions
+17
View File
@@ -40,4 +40,21 @@ export const chatApi = {
searchMessages: (conversationId: string, keyword: string) =>
api.get(`/conversations/${conversationId}/messages/search`, { params: { q: keyword } }),
updatePrefs: (convId: string, data: { is_pinned?: boolean; is_muted?: boolean }) =>
api.put(`/conversations/${convId}/prefs`, data),
getDraft: (convId: string) => api.get(`/conversations/${convId}/draft`),
saveDraft: (convId: string, draft: string) =>
api.put(`/conversations/${convId}/draft`, { draft }),
recallMessage: (conversationId: string, messageId: string) =>
api.post(`/conversations/${conversationId}/messages/${messageId}/recall`),
reactMessage: (conversationId: string, messageId: string, emoji: string) =>
api.post(`/conversations/${conversationId}/messages/${messageId}/reactions`, { emoji }),
removeReaction: (conversationId: string, messageId: string, emoji: string) =>
api.delete(`/conversations/${conversationId}/messages/${messageId}/reactions`, { params: { emoji } }),
}
+13
View File
@@ -119,6 +119,19 @@ export function useWebSocket() {
case 'heartbeat.sync':
window.dispatchEvent(new CustomEvent('qingye:heartbeat', { detail: event.data }))
break
case 'chat.message_recalled':
window.dispatchEvent(new CustomEvent('qingye:msg-recalled', { detail: event.data }))
break
case 'chat.reaction_added':
case 'chat.reaction_removed':
window.dispatchEvent(new CustomEvent('qingye:reaction', { detail: { ...event.data, _phase: event.type } }))
break
case 'presence.mentioned':
window.dispatchEvent(new CustomEvent('qingye:mentioned', { detail: event.data }))
break
case 'chat.read':
window.dispatchEvent(new CustomEvent('qingye:read-receipt', { detail: event.data }))
break
case 'flash.spawn':
case 'flash.progress':
case 'flash.result':
+33
View File
@@ -42,6 +42,14 @@ import {
NTag,
NRadio,
NRadioGroup,
NCheckbox,
NCheckboxGroup,
NDatePicker,
NInputGroup,
NCollapse,
NCollapseItem,
NSlider,
NProgress,
} from 'naive-ui'
const app = createApp(App)
@@ -89,9 +97,34 @@ const naiveComponents: Record<string, any> = {
NTag,
NRadio,
NRadioGroup,
NCheckbox,
NCheckboxGroup,
NDatePicker,
NInputGroup,
NCollapse,
NCollapseItem,
NSlider,
NProgress,
}
Object.entries(naiveComponents).forEach(([name, component]) => {
app.component(name, component)
})
// 全局错误捕获:把运行时错误显示在屏幕顶部(便于无控制台时诊断)
function showErrorBanner(msg: string) {
if (typeof document === 'undefined') return
const div = document.createElement('div')
div.textContent = '⚠️ 运行时错误: ' + msg
div.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#d32f2f;color:#fff;padding:10px 14px;z-index:99999;font-size:13px;font-family:monospace;white-space:pre-wrap;word-break:break-all;max-height:40vh;overflow:auto;'
document.body.appendChild(div)
}
app.config.errorHandler = (err: any, _instance, info) => {
console.error('Vue error:', err, info)
showErrorBanner((err?.message || String(err)) + ' 【' + info + '】')
}
if (typeof window !== 'undefined') {
window.addEventListener('error', (e) => showErrorBanner(e.message + ' @ ' + (e.filename || '') + ':' + (e.lineno || '')))
window.addEventListener('unhandledrejection', (e) => showErrorBanner('Promise: ' + (e.reason?.message || e.reason)))
}
app.mount('#app')
+1
View File
@@ -10,6 +10,7 @@ const routes: RouteRecordRaw[] = [
children: [
{ path: '/login', name: 'Login', component: () => import('@/views/auth/LoginView.vue') },
{ path: '/register', name: 'Register', component: () => import('@/views/auth/RegisterView.vue') },
{ path: '/forgot', name: 'ForgotPassword', component: () => import('@/views/auth/ForgotPasswordView.vue') },
],
},
@@ -0,0 +1,110 @@
<template>
<div class="forgot-page">
<div class="forgot-card">
<div class="logo">🌿</div>
<h2>找回密码</h2>
<p class="subtitle">输入注册邮箱验证码将发送开发期打印在服务器日志</p>
<n-form>
<n-form-item label="注册邮箱">
<n-input v-model:value="email" placeholder="your@email.com" size="large" />
</n-form-item>
<template v-if="step === 2">
<n-form-item label="验证码(见后端日志)">
<n-input v-model:value="code" placeholder="6 位验证码" size="large" maxlength="6" />
</n-form-item>
<n-form-item label="新密码">
<n-input v-model:value="newPassword" type="password" show-password-on="click" placeholder="新密码" size="large" />
</n-form-item>
</template>
<n-button v-if="step === 1" type="primary" block size="large" :loading="sending" @click="sendCode">
发送验证码
</n-button>
<n-button v-else type="primary" block size="large" :loading="resetting" @click="reset">
重置密码
</n-button>
</n-form>
<div class="footer">
<router-link to="/login"> 返回登录</router-link>
</div>
<div v-if="devHint" class="dev-hint">
💡 开发模式验证码已打印到后端控制台docker compose logs backend
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import api from '@/api/client'
const router = useRouter()
const message = useMessage()
const email = ref('')
const code = ref('')
const newPassword = ref('')
const step = ref(1)
const sending = ref(false)
const resetting = ref(false)
const devHint = ref(false)
async function sendCode() {
if (!email.value.trim()) { message.error('请输入邮箱'); return }
sending.value = true
try {
await api.post('/auth/forgot', { email: email.value.trim() })
step.value = 2
devHint.value = true
message.success('验证码已发送(开发期见后端日志)')
} catch (e: any) {
message.error(e.response?.data?.detail || '发送失败')
} finally {
sending.value = false
}
}
async function reset() {
if (!code.value || !newPassword.value) { message.error('请填写完整'); return }
resetting.value = true
try {
await api.post('/auth/reset', {
email: email.value.trim(),
code: code.value.trim(),
new_password: newPassword.value,
})
message.success('密码已重置,请重新登录')
router.push('/login')
} catch (e: any) {
message.error(e.response?.data?.detail || '重置失败')
} finally {
resetting.value = false
}
}
</script>
<style scoped>
.forgot-page {
min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #009688 0%, #26A69A 100%);
padding: 20px;
}
.forgot-card {
width: 400px; max-width: 100%; background: var(--color-surface, #fff);
border-radius: 20px; padding: 36px 32px; box-shadow: 0 12px 40px rgba(0,0,0,0.15);
}
.logo { font-size: 48px; text-align: center; }
.forgot-card h2 { text-align: center; margin: 8px 0 4px; color: var(--color-primary-dark, #00796B); }
.subtitle { text-align: center; font-size: 13px; color: var(--color-text-hint, #999); margin-bottom: 24px; }
.footer { text-align: center; margin-top: 20px; }
.footer a { font-size: 13px; color: var(--color-primary, #009688); text-decoration: none; }
.dev-hint {
margin-top: 16px; padding: 10px; background: #FFF3E0; border-radius: 8px;
font-size: 12px; color: #E65100; text-align: center;
}
</style>
+8 -1
View File
@@ -11,6 +11,8 @@
</n-button>
<div style="text-align: center; margin-top: 16px">
<n-button text type="primary" @click="$router.push('/register')">没有账号立即注册</n-button>
<span style="margin: 0 8px; color: var(--color-text-hint)">·</span>
<n-button text type="primary" @click="$router.push('/forgot')">忘记密码</n-button>
</div>
</n-form>
</template>
@@ -40,7 +42,12 @@ async function handleLogin() {
await auth.login(form.username, form.password)
message.success('登录成功!欢迎回到青叶 🌿')
const redirect = (route.query.redirect as string) || '/chat'
router.push(redirect)
// 诊断:若导航失败立即显示原因
const failure = await router.push(redirect)
if (failure) {
console.error('导航失败:', failure)
message.error(`跳转失败(${failure.name || '未知'}),已登录但未能进入主页。请截图此提示。`)
}
} catch (e: any) {
message.error(e.response?.data?.detail || '登录失败,请检查用户名和密码')
} finally {
+294 -4
View File
@@ -88,8 +88,8 @@
pending: msg._pending, failed: msg._failed,
}">
<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 v-if="msg.type === 'system' || msg.is_recalled">
<div class="system-msg">{{ msg.is_recalled ? '「' + (msg.sender_name || '对方') + '撤回了一条消息」' : msg.content }}</div>
</template>
<template v-else>
<div v-if="msg.sender_id !== auth.user?.id" class="avatar-wrap">
@@ -106,11 +106,34 @@
</div>
<n-image v-if="msg.type === 'image'" :src="msg.content"
:img-props="{ style: 'max-width:240px;border-radius:10px;display:block;cursor:pointer' }" />
<span v-else>{{ msg.content }}</span>
<!-- 文件消息 -->
<div v-else-if="msg.type === 'file'" class="file-bubble" @click="openFile(msg.content)">
<span class="file-icon">📎</span>
<span class="file-info">
<span class="file-name">{{ parseFile(msg.content).name }}</span>
<span class="file-size" v-if="parseFile(msg.content).size">{{ formatFileSize(parseFile(msg.content).size) }}</span>
</span>
</div>
<!-- 语音消息 -->
<div v-else-if="msg.type === 'voice'" class="voice-bubble" @click="playVoice(msg)">
<span class="voice-icon">{{ playingVoiceId === msg.id ? '⏸' : '▶️' }}</span>
<div class="voice-wave">
<span v-for="i in parseVoice(msg.content).bars" :key="i" class="wave-bar" :style="{ height: (6 + (i*7)%18) + 'px' }"></span>
</div>
<span class="voice-dur">{{ parseVoice(msg.content).duration }}"</span>
</div>
<span v-else>{{ renderText(msg.content) }}</span>
</div>
<!-- 表情回应 -->
<div v-if="msg.reactions && msg.reactions.length" class="reactions-row">
<span v-for="(grp, emoji) in groupedReactions(msg.reactions)" :key="emoji" class="reaction-chip"
:class="{ mine: grp.mine }" @click="toggleReaction(msg, emoji)">
{{ emoji }} <span class="r-count">{{ grp.list.length }}</span>
</span>
</div>
<div class="msg-meta">
<span class="msg-time">{{ formatTime(msg.created_at) }}</span>
<span v-if="msg.sender_id === auth.user?.id && !msg._pending" class="msg-status"></span>
<span v-if="msg.sender_id === auth.user?.id && !msg._pending" class="msg-status">{{ msg.read_by_all ? '✓✓' : '✓' }}</span>
<span v-if="msg._pending && !msg._failed" class="msg-status sending">发送中</span>
<span v-if="msg._failed" class="msg-status failed">✕ 发送失败</span>
</div>
@@ -124,9 +147,14 @@
<!-- 右键菜单 -->
<div v-if="ctxMenu.show" class="ctx-menu" :style="{ top: ctxMenu.y + 'px', left: ctxMenu.x + 'px' }">
<!-- 表情回应栏 -->
<div class="ctx-reactions">
<span v-for="e in quickReactions" :key="e" class="ctx-react-emoji" @click="reactToMsg(e)">{{ e }}</span>
</div>
<div class="ctx-item" @click="replyToMsg">↩️ 回复</div>
<div class="ctx-item" @click="forwardMsg">↗️ 转发</div>
<div class="ctx-item" @click="copyMsg">📋 复制</div>
<div v-if="ctxMenu.msg?.sender_id === auth.user?.id && !ctxMenu.msg?._pending && !ctxMenu.msg?.is_recalled" class="ctx-item" @click="recallMsg">↩️ 撤回</div>
<div v-if="ctxMenu.msg?.sender_id === auth.user?.id && !ctxMenu.msg?._pending" class="ctx-item danger" @click="deleteMsg">🗑️ 删除</div>
</div>
@@ -176,7 +204,14 @@
<div class="input-actions">
<span class="action-icon" @click="showEmoji = !showEmoji">😊</span>
<span class="action-icon" @click="triggerImageUpload">🖼️</span>
<span class="action-icon" @click="triggerFileUpload" title="文件">📎</span>
<span class="action-icon" :class="{ recording: isRecording }" @click="toggleRecording" title="语音">{{ isRecording ? '⏹️' : '🎤' }}</span>
<input ref="imageInput" type="file" accept="image/*" style="display:none" @change="handleImageUpload" />
<input ref="fileInput" type="file" style="display:none" @change="handleFileUpload" />
</div>
<!-- 录音指示 -->
<div v-if="isRecording" class="recording-indicator">
<span class="rec-dot"></span> 录音中... {{ recordSeconds }}" 点击停止
</div>
<div v-if="showEmoji" class="emoji-panel">
<div v-for="e in emojis" :key="e" class="emoji-item" @click="insertEmoji(e)">{{ e }}</div>
@@ -233,6 +268,16 @@ const showForwardModal = ref(false)
const forwardTarget = ref<string | null>(null)
const forwardContent = ref('')
const forwardSender = ref('')
const fileInput = ref<HTMLInputElement>()
const isRecording = ref(false)
const recordSeconds = ref(0)
const playingVoiceId = ref<string | null>(null)
const quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏']
let mediaRecorder: MediaRecorder | null = null
let recordedChunks: Blob[] = []
let recordTimer: ReturnType<typeof setInterval>
let recordStartTime = 0
let draftSaveTimer: ReturnType<typeof setTimeout>
const climate = ref<any>(null)
const calendar = ref<any[]>([])
const showCalendar = ref(false)
@@ -294,6 +339,7 @@ onMounted(async () => {
if (id) {
await chatStore.fetchMessages(id)
await loadDetail()
await loadDraft()
await nextTick()
scrollToBottom()
markRead()
@@ -301,10 +347,20 @@ onMounted(async () => {
loadGardenBg()
}
document.addEventListener('click', closeCtxMenu)
window.addEventListener('qingye:msg-recalled', onMsgRecalled)
window.addEventListener('qingye:reaction', onReaction)
window.addEventListener('qingye:mentioned', onMentioned)
window.addEventListener('qingye:read-receipt', onReadReceipt)
})
onUnmounted(() => {
document.removeEventListener('click', closeCtxMenu)
if (isRecording.value) stopRecording()
saveDraft()
window.removeEventListener('qingye:msg-recalled', onMsgRecalled)
window.removeEventListener('qingye:reaction', onReaction)
window.removeEventListener('qingye:mentioned', onMentioned)
window.removeEventListener('qingye:read-receipt', onReadReceipt)
})
async function loadDetail() {
@@ -394,8 +450,47 @@ watch(inputText, () => {
lastTypingSent.value = now
send('chat.typing', { conversation_id: convId })
}
saveDraft() // 顺便保存草稿
})
// 切换会话时加载草稿、清理录音
watch(() => route.params.id, async () => {
if (isRecording.value) stopRecording()
await loadDraft()
})
// 撤回 / 回应 / 提及 / 已读 事件
function onMsgRecalled(e: Event) {
const d = (e as CustomEvent).detail
const m = chatStore.currentMessages.find((m: any) => m.id === d.message_id)
if (m) m.is_recalled = true
}
function onReaction(e: Event) {
const d = (e as CustomEvent).detail
const m = chatStore.currentMessages.find((m: any) => m.id === d.message_id)
if (!m) return
m.reactions = m.reactions || []
if (d._phase === 'chat.reaction_added') {
if (!m.reactions.find((r: any) => r.user_id === d.user_id && r.emoji === d.emoji)) {
m.reactions.push({ emoji: d.emoji, user_id: d.user_id })
}
} else {
m.reactions = m.reactions.filter((r: any) => !(r.user_id === d.user_id && r.emoji === d.emoji))
}
}
function onMentioned(e: Event) {
const d = (e as CustomEvent).detail
message.info(`📢 ${d.from_username} @ 了你`)
}
function onReadReceipt(e: Event) {
const d = (e as CustomEvent).detail
if (d.user_id !== auth.user?.id) {
chatStore.currentMessages.forEach((m: any) => {
if (m.sender_id === auth.user?.id) m.read_by_all = true
})
}
}
function scrollToBottom() {
if (messageListRef.value) {
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
@@ -492,6 +587,161 @@ async function doForward() {
forwardTarget.value = null
}
// ===== 撤回 =====
async function recallMsg() {
if (!ctxMenu.msg || !chatStore.activeConversation) return
try {
await chatApi.recallMessage(chatStore.activeConversation, ctxMenu.msg.id)
message.success('已撤回')
} catch (e: any) {
message.error(e.response?.data?.detail || '撤回失败')
}
ctxMenu.show = false
}
// ===== 表情回应 =====
async function reactToMsg(emoji: string) {
if (!ctxMenu.msg || !chatStore.activeConversation) return
try {
await chatApi.reactMessage(chatStore.activeConversation, ctxMenu.msg.id, emoji)
} catch {}
ctxMenu.show = false
}
async function toggleReaction(msg: any, emoji: string) {
if (!chatStore.activeConversation) return
try {
await chatApi.reactMessage(chatStore.activeConversation, msg.id, emoji)
} catch {}
}
function groupedReactions(reactions: any[]): Record<string, { list: any[]; mine: boolean }> {
const groups: Record<string, { list: any[]; mine: boolean }> = {}
for (const r of reactions) {
if (!groups[r.emoji]) groups[r.emoji] = { list: [], mine: false }
groups[r.emoji].list.push(r)
if (r.user_id === auth.user?.id) groups[r.emoji].mine = true
}
return groups
}
// ===== 文件消息 =====
function triggerFileUpload() { fileInput.value?.click() }
async function handleFileUpload(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' } })
const payload = JSON.stringify({ name: file.name, size: file.size, url: data.url })
send('chat.send', { conversation_id: chatStore.activeConversation, content: payload, type: 'file' })
} catch { message.error('上传失败') }
target.value = ''
}
function parseFile(content: string) {
try { return JSON.parse(content) } catch { return { name: '文件', size: 0, url: content } }
}
function formatFileSize(bytes: number): string {
if (!bytes) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
function openFile(content: string) {
const f = parseFile(content)
if (f.url) window.open(f.url, '_blank')
}
// ===== 语音消息 =====
async function toggleRecording() {
if (isRecording.value) { stopRecording() }
else { await startRecording() }
}
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaRecorder = new MediaRecorder(stream)
recordedChunks = []
mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) recordedChunks.push(e.data) }
mediaRecorder.onstop = () => { uploadVoice() }
mediaRecorder.start()
isRecording.value = true
recordStartTime = Date.now()
recordSeconds.value = 0
recordTimer = setInterval(() => {
recordSeconds.value = Math.floor((Date.now() - recordStartTime) / 1000)
if (recordSeconds.value >= 60) stopRecording()
}, 1000)
} catch { message.error('无法访问麦克风') }
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop()
isRecording.value = false
clearInterval(recordTimer)
}
async function uploadVoice() {
if (!chatStore.activeConversation || recordedChunks.length === 0) return
const duration = recordSeconds.value || 1
const blob = new Blob(recordedChunks, { type: 'audio/webm' })
const formData = new FormData()
formData.append('file', blob, 'voice.webm')
try {
const { data } = await api.post('/uploads/file', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
const bars = Math.min(30, Math.max(8, Math.floor(duration * 2)))
const payload = JSON.stringify({ duration, url: data.url, bars })
send('chat.send', { conversation_id: chatStore.activeConversation, content: payload, type: 'voice' })
} catch { message.error('语音上传失败') }
}
function parseVoice(content: string) {
try { return JSON.parse(content) } catch { return { duration: 0, url: '', bars: 15 } }
}
let currentAudio: HTMLAudioElement | null = null
function playVoice(msg: any) {
const v = parseVoice(msg.content)
if (!v.url) return
if (playingVoiceId.value === msg.id) {
currentAudio?.pause()
playingVoiceId.value = null
return
}
if (currentAudio) { currentAudio.pause() }
currentAudio = new Audio(v.url)
currentAudio.onended = () => { playingVoiceId.value = null }
currentAudio.play()
playingVoiceId.value = msg.id
}
// ===== 草稿 =====
async function loadDraft() {
if (!chatStore.activeConversation) return
try {
const { data } = await chatApi.getDraft(chatStore.activeConversation)
inputText.value = data.draft || ''
} catch {}
}
function saveDraft() {
if (!chatStore.activeConversation) return
clearTimeout(draftSaveTimer)
draftSaveTimer = setTimeout(() => {
chatApi.saveDraft(chatStore.activeConversation!, inputText.value).catch(() => {})
}, 800)
}
// ===== 文本渲染(@高亮)=====
function renderText(content: string): string {
return content
}
function scrollToMsg(msgId: string) {
const el = messageListRef.value
if (!el) return
@@ -661,6 +911,46 @@ function formatTimeDivider(time: string) {
.msg-status.sending { color: var(--color-text-hint); }
.msg-status.failed { color: #EF5350; font-weight: 500; }
.msg-image { max-width: 240px; border-radius: 10px; display: block; cursor: pointer; }
/* 文件消息 */
.file-bubble { display: flex; align-items: center; gap: 10px; cursor: pointer; min-width: 180px; padding: 4px 0; }
.file-icon { font-size: 32px; }
.file-info { display: flex; flex-direction: column; }
.file-name { font-size: 14px; font-weight: 500; max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-size { font-size: 11px; opacity: 0.7; }
/* 语音消息 */
.voice-bubble { display: flex; align-items: center; gap: 8px; cursor: pointer; min-width: 140px; padding: 2px 0; }
.voice-icon { font-size: 18px; }
.voice-wave { display: flex; align-items: center; gap: 2px; height: 24px; }
.wave-bar { width: 3px; background: currentColor; opacity: 0.6; border-radius: 2px; }
.voice-dur { font-size: 12px; opacity: 0.8; }
/* 表情回应 */
.reactions-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
.reaction-chip {
display: inline-flex; align-items: center; gap: 2px; padding: 2px 8px; font-size: 13px;
background: var(--color-surface); border: 1px solid var(--color-border); border-radius: 12px;
cursor: pointer; transition: all 0.15s;
}
.reaction-chip:hover { border-color: var(--color-primary); }
.reaction-chip.mine { background: var(--color-primary-lightest); border-color: var(--color-primary-lighter); }
.r-count { font-size: 11px; color: var(--color-text-secondary); }
/* 右键菜单表情栏 */
.ctx-reactions { display: flex; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--color-border); }
.ctx-react-emoji { font-size: 20px; cursor: pointer; padding: 2px; border-radius: 6px; transition: background 0.15s; }
.ctx-react-emoji:hover { background: var(--color-primary-lightest); }
/* 录音 */
.action-icon.recording { color: #EF5350; opacity: 1; animation: rec-pulse 1s infinite; }
@keyframes rec-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.recording-indicator {
position: absolute; bottom: 60px; left: 50%; transform: translateX(-50%);
background: rgba(239,83,80,0.95); color: white; padding: 6px 16px; border-radius: 16px;
font-size: 13px; display: flex; align-items: center; gap: 6px; z-index: 100;
}
.rec-dot { width: 8px; height: 8px; background: white; border-radius: 50%; animation: rec-pulse 1s infinite; }
.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; }
/* Reply quote inside bubble */
@@ -27,8 +27,8 @@
</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)">
class="conv-item" :class="{ active: chatStore.activeConversation === conv.id, pinned: conv.is_pinned }"
@click="openChat(conv.id)" @contextmenu.prevent="showConvMenu($event, conv)">
<div class="conv-avatar-wrap">
<n-avatar :size="48" round :style="avatarStyle(conv)">
{{ (conv.name || '?')[0] }}
@@ -38,33 +38,48 @@
</div>
<div class="conv-info">
<div class="conv-top">
<span class="conv-name">{{ conv.name || '未命名' }}</span>
<span class="conv-name">{{ conv.name || '未命名' }}<span v-if="conv.is_muted" class="mute-icon">🔕</span></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>
<span v-if="conv.is_pinned" class="pin-icon">📌</span>
<span v-if="conv.unread_count > 0" class="unread-badge">{{ conv.unread_count > 99 ? '99+' : conv.unread_count }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 会话右键菜单 -->
<div v-if="convMenu.show" class="conv-menu" :style="{ top: convMenu.y + 'px', left: convMenu.x + 'px' }">
<div class="conv-menu-item" @click="togglePin">
{{ convMenu.conv?.is_pinned ? '取消置顶' : '置顶' }}
</div>
<div class="conv-menu-item" @click="toggleMute">
{{ convMenu.conv?.is_muted ? '取消免打扰' : '免打扰' }}
</div>
</div>
<!-- 创建群聊弹窗 -->
<CreateGroupModal :visible="showCreateGroup" @close="showCreateGroup = false" @created="onGroupCreated" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useMessage } from 'naive-ui'
import { useChatStore } from '@/stores/chat'
import { chatApi } from '@/api/chat'
import CreateGroupModal from './CreateGroupModal.vue'
import dayjs from 'dayjs'
const router = useRouter()
const message = useMessage()
const chatStore = useChatStore()
const searchKeyword = ref('')
const showCreateGroup = ref(false)
const convMenu = reactive({ show: false, x: 0, y: 0, conv: null as any })
const filteredConversations = computed(() => {
if (!searchKeyword.value) return chatStore.conversations
@@ -85,6 +100,41 @@ function openChat(id: string) {
router.push(`/chat/${id}`)
}
function showConvMenu(e: MouseEvent, conv: any) {
convMenu.show = true
convMenu.x = Math.min(e.clientX, window.innerWidth - 150)
convMenu.y = Math.min(e.clientY, window.innerHeight - 100)
convMenu.conv = conv
}
function closeConvMenu() { convMenu.show = false }
async function togglePin() {
if (!convMenu.conv) return
const newVal = !convMenu.conv.is_pinned
try {
await chatApi.updatePrefs(convMenu.conv.id, { is_pinned: newVal })
convMenu.conv.is_pinned = newVal
await chatStore.fetchConversations()
message.success(newVal ? '已置顶' : '已取消置顶')
} catch { message.error('操作失败') }
closeConvMenu()
}
async function toggleMute() {
if (!convMenu.conv) return
const newVal = !convMenu.conv.is_muted
try {
await chatApi.updatePrefs(convMenu.conv.id, { is_muted: newVal })
convMenu.conv.is_muted = newVal
message.success(newVal ? '已开启免打扰' : '已关闭免打扰')
} catch { message.error('操作失败') }
closeConvMenu()
}
onMounted(() => document.addEventListener('click', closeConvMenu))
onUnmounted(() => document.removeEventListener('click', closeConvMenu))
async function onGroupCreated() {
await chatStore.fetchConversations()
}
@@ -101,6 +151,18 @@ function formatTime(time: string | null) {
</script>
<style scoped>
.conv-item.pinned { background: var(--color-primary-lightest); }
.mute-icon { font-size: 12px; margin-left: 4px; opacity: 0.6; }
.pin-icon { font-size: 12px; opacity: 0.7; }
/* 会话右键菜单 */
.conv-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: 130px; overflow: hidden;
}
.conv-menu-item { padding: 9px 16px; font-size: 13px; cursor: pointer; transition: background 0.15s; }
.conv-menu-item:hover { background: var(--color-primary-lightest); }
.conv-list-panel {
display: flex;
flex-direction: column;
@@ -30,6 +30,21 @@
</div>
</div>
<!-- 群公告 -->
<div v-if="isAdmin" class="section">
<div class="section-header"><span>📢 群公告</span></div>
<n-input v-model:value="announcement" type="textarea" :rows="2" placeholder="发布群公告..." size="small" />
<n-button size="tiny" type="primary" style="margin-top: 6px" @click="saveAnnouncement">发布公告</n-button>
</div>
<!-- 全员禁言管理员 -->
<div v-if="isAdmin" class="section">
<div class="mute-all-row">
<span>🤐 全员禁言</span>
<n-switch :value="detail.mute_all" @update:value="toggleMuteAll" />
</div>
</div>
<!-- 成员列表 -->
<div class="section">
<div class="section-header">
@@ -110,6 +125,7 @@ const friends = ref<any[]>([])
const selectedNewMembers = ref<string[]>([])
const addingMembers = ref(false)
const avatarInput = ref<HTMLInputElement>()
const announcement = ref('')
const myRole = computed(() => {
const me = props.detail?.members?.find((m: any) => m.user_id === auth.user?.id)
@@ -184,6 +200,35 @@ async function handleAvatarUpload(event: Event) {
target.value = ''
}
async function saveAnnouncement() {
if (!announcement.value.trim()) return
try {
await api.post(`/conversations/${props.conversationId}/announcement`, { content: announcement.value.trim() })
message.success('公告已发布')
emit('updated')
} catch (e: any) {
message.error(e.response?.data?.detail || '发布失败')
}
}
async function toggleMuteAll(val: boolean) {
try {
await api.put(`/conversations/${props.conversationId}/mute-all`, { mute_all: val })
message.success(val ? '已开启全员禁言' : '已关闭全员禁言')
emit('updated')
} catch (e: any) {
message.error(e.response?.data?.detail || '操作失败')
}
}
// 加载现有公告
watch(() => props.detail, async () => {
try {
const { data } = await api.get(`/conversations/${props.conversationId}/announcement`)
if (data) announcement.value = data.content || ''
} catch {}
}, { immediate: true })
function roleLabel(role: string) {
if (role === 'owner') return '群主'
if (role === 'admin') return '管理员'
@@ -276,6 +321,7 @@ async function dissolveGroup() {
.member-role.owner { background: #FFF3E0; color: #F57C00; }
.member-role.admin { background: #E3F2FD; color: #1976D2; }
.actions { padding-top: 12px; border-top: 1px solid var(--color-border); }
.mute-all-row { display: flex; align-items: center; justify-content: space-between; font-size: 13px; padding: 6px 0; }
/* Add member modal */
.modal-overlay {
@@ -38,6 +38,7 @@
🍃 想念 TA寄一片回音叶
</n-button>
<n-button type="error" ghost block @click="handleRemove">删除好友</n-button>
<n-button quaternary type="error" block @click="blockUser">🚫 拉黑</n-button>
</div>
</div>
</div>
@@ -88,6 +89,17 @@ async function sendEcho() {
}
}
async function blockUser() {
if (!confirm('确定拉黑该用户?拉黑后双方无法互相发消息。')) return
try {
await api.post(`/users/${props.friend.friend_user_id}/block`)
message.success('已拉黑')
emit('close')
} catch (e: any) {
message.error(e.response?.data?.detail || '操作失败')
}
}
async function saveRemark() {
try {
await friendsApi.updateRemark(props.friend.friend_user_id, remark.value || null)
@@ -35,6 +35,33 @@
</div>
<n-button type="primary" :loading="saving" @click="saveProfile">保存修改</n-button>
</div>
<!-- 个人心情状态 -->
<div class="form-section">
<h3 class="section-title">🎭 个人状态</h3>
<p class="section-desc">设置一个心情状态好友在聊天和资料卡里能看到可选过期时间</p>
<div class="status-row">
<n-input-group>
<n-input v-model:value="statusForm.emoji" placeholder="🎉" size="small" style="max-width: 70px" />
<n-input v-model:value="statusForm.text" placeholder="如:在听歌 / 码代码中..." size="small" />
</n-input-group>
</div>
<div class="status-row">
<n-select v-model:value="statusForm.expires" :options="expireOptions" size="small" style="max-width: 200px" />
<n-button type="primary" size="small" @click="saveStatus">设置状态</n-button>
<n-button quaternary size="small" @click="clearStatus">清除</n-button>
</div>
</div>
<!-- 黑名单 -->
<div class="form-section">
<h3 class="section-title">🚫 黑名单管理</h3>
<div v-if="blocks.length === 0" class="empty-block">还没有拉黑任何人</div>
<div v-for="b in blocks" :key="b.user_id" class="block-item">
<span>{{ b.nickname || b.username }}</span>
<n-button size="tiny" quaternary type="error" @click="unblock(b.user_id)">解除</n-button>
</div>
</div>
</div>
</div>
</template>
@@ -57,11 +84,55 @@ const form = reactive({
bio: auth.user?.bio || '',
})
const statusForm = reactive({ emoji: '😊', text: '', expires: 0 })
const expireOptions = [
{ label: '不过期', value: 0 },
{ label: '1 小时', value: 1 },
{ label: '今天内', value: 24 },
{ label: '3 天', value: 72 },
]
const blocks = ref<any[]>([])
onMounted(() => {
form.nickname = auth.user?.nickname || ''
form.bio = auth.user?.bio || ''
loadBlocks()
})
async function loadBlocks() {
try {
const { data } = await api.get('/users/me/blocks')
blocks.value = data
} catch {}
}
async function saveStatus() {
try {
await api.put('/users/me/status', {
custom_status: statusForm.text || null,
status_emoji: statusForm.emoji || null,
expires_hours: statusForm.expires || null,
})
message.success('状态已更新')
} catch { message.error('设置失败') }
}
async function clearStatus() {
try {
await api.put('/users/me/status', { custom_status: null, status_emoji: null, expires_hours: null })
statusForm.text = ''
message.success('已清除')
} catch {}
}
async function unblock(userId: string) {
try {
await api.delete(`/users/${userId}/block`)
blocks.value = blocks.value.filter((b) => b.user_id !== userId)
message.success('已解除拉黑')
} catch {}
}
function triggerUpload() {
fileInput.value?.click()
}
@@ -115,4 +186,9 @@ async function saveProfile() {
}
.form-value { font-size: 14px; color: var(--color-text-primary); }
.form-hint { font-size: 12px; color: var(--color-text-hint); }
.section-title { font-size: 15px; margin: 0 0 4px; color: var(--color-text-primary); }
.section-desc { font-size: 12px; color: var(--color-text-hint); margin: 0 0 12px; }
.status-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.empty-block { font-size: 13px; color: var(--color-text-hint); padding: 12px 0; }
.block-item { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--color-border); font-size: 14px; }
</style>