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
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>
|
|
|