You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

208 lines
4.9 KiB

<script setup lang="ts">
/**
* ChatWidget — floating chat button + panel in bottom-right corner.
* Only visible to logged-in users. Shows unread badge for @mentions
* and toast notifications when the author gets @mentioned.
*/
const { loggedIn, user } = useAuthSession()
const { $toast } = useNuxtApp()
const { lastMention } = useChat()
const open = ref(false)
const unread = ref(0)
let pollTimer: ReturnType<typeof setInterval> | null = null
// Watch for @mention notifications → toast (admin only, when panel closed)
watch(lastMention, (data) => {
if (!data) return
if (user.value?.role === 'admin' && !open.value) {
$toast.info(`${data.from}: ${data.content}`, { autoClose: 5000 })
}
lastMention.value = null // always reset after handling
})
if (import.meta.client) {
onMounted(() => {
if (!loggedIn.value) return
fetchUnread()
pollTimer = setInterval(fetchUnread, 10_000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
}
watch(loggedIn, (val) => {
if (val && !pollTimer) {
fetchUnread()
pollTimer = setInterval(fetchUnread, 10_000)
} else if (!val && pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
})
async function fetchUnread() {
try {
const res = await $fetch<{ code: number; data: { count: number } }>('/api/chat/unread', { credentials: 'include' })
unread.value = res.data?.count || 0
} catch { /* ignore */ }
}
async function clearUnread() {
try {
await $fetch('/api/chat/unread/clear', { method: 'POST', credentials: 'include' })
unread.value = 0
} catch { /* ignore */ }
}
function toggle() {
open.value = !open.value
if (open.value) {
if (unread.value > 0) clearUnread()
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
} else {
fetchUnread()
pollTimer = setInterval(fetchUnread, 10_000)
}
}
</script>
<template>
<div v-if="loggedIn" class="chat-widget" :class="{ 'is-open': open }">
<!-- Floating button -->
<button class="widget-trigger" @click="toggle" :title="open ? '关闭聊天' : '打开聊天'">
<span v-if="unread > 0" class="widget-badge">{{ unread > 99 ? '99+' : unread }}</span>
<svg v-if="!open" width="22" height="22" viewBox="0 0 22 22" fill="none">
<path d="M2 4h18v12H6l-4 4V4z" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<svg v-else width="22" height="22" viewBox="0 0 22 22" fill="none">
<path d="M6 6l10 10M16 6L6 16" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</button>
<!-- Chat panel -->
<Transition name="chat-panel">
<div v-if="open" class="widget-panel">
<ChatRoom @close="open = false" />
</div>
</Transition>
</div>
</template>
<style scoped>
.chat-widget {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
}
/* ── Trigger button ── */
.widget-trigger {
position: relative;
z-index: 10;
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--color-primary, #cc785c);
color: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
position: relative;
box-shadow: 0 4px 16px rgba(204, 120, 92, 0.35);
transition: transform 0.2s, box-shadow 0.2s;
}
.widget-trigger:hover {
transform: scale(1.08);
box-shadow: 0 6px 24px rgba(204, 120, 92, 0.45);
}
.widget-trigger:active {
transform: scale(0.96);
}
/* Badge */
.widget-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 20px;
height: 20px;
border-radius: 10px;
background: #c64545;
color: #fff;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
padding: 0 5px;
box-shadow: 0 2px 6px rgba(198, 69, 69, 0.4);
}
/* ── Panel ── */
.widget-panel {
width: 380px;
height: 480px;
max-height: calc(100vh - 100px);
background: var(--color-canvas, #faf9f5);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid var(--color-hairline-soft, #ebe6df);
display: flex;
flex-direction: column;
}
/* Panel transition */
.chat-panel-enter-active {
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
.chat-panel-leave-active {
transition: all 0.2s ease-in;
}
.chat-panel-enter-from {
opacity: 0;
transform: translateY(16px) scale(0.96);
}
.chat-panel-leave-to {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
/* ── Mobile ── */
@media (max-width: 640px) {
.chat-widget {
bottom: 16px;
right: 16px;
}
.chat-widget.is-open .widget-trigger {
display: none;
}
.widget-panel {
position: fixed;
bottom: 0;
right: 0;
width: 100vw;
height: 100vh;
height: 100dvh;
max-height: none;
border-radius: 0;
}
}
</style>