Browse Source
- Add ChatWidget component for real-time chat functionality, including unread message notifications and @mention toasts. - Create useChat composable for managing chat messages and SSE connections. - Update index page layout to welcome users. - Define API routes for fetching chat messages, sending messages, and managing unread counts. - Implement chat message storage in SQLite with necessary migrations. - Add server-side logic for handling chat message broadcasting and user connection tracking. - Ensure chat messages are persisted across server restarts and implement rate limiting for message sending.acas
20 changed files with 1512 additions and 3 deletions
@ -1,3 +1,4 @@ |
|||
{ |
|||
"topic_20260626-113733_9874ba9ab889b7ed": "manual" |
|||
"topic_20260626-113733_9874ba9ab889b7ed": "manual", |
|||
"topic_20260626-172249_f199eec9a4e051f4": "auto" |
|||
} |
|||
@ -1,3 +1,4 @@ |
|||
{ |
|||
"topic_20260626-113733_9874ba9ab889b7ed": "新的会话" |
|||
"topic_20260626-113733_9874ba9ab889b7ed": "新的会话", |
|||
"topic_20260626-172249_f199eec9a4e051f4": "新的会话" |
|||
} |
|||
@ -0,0 +1,202 @@ |
|||
<script setup lang="ts"> |
|||
import type { ChatMessage } from "~~/server/service/chat" |
|||
|
|||
const props = defineProps<{ |
|||
message: ChatMessage |
|||
isMine: boolean |
|||
}>() |
|||
|
|||
const time = computed(() => { |
|||
const d = new Date(props.message.createdAt) |
|||
const now = new Date() |
|||
const isToday = d.toDateString() === now.toDateString() |
|||
const timeStr = d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) |
|||
if (isToday) return timeStr |
|||
return `${d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })} ${timeStr}` |
|||
}) |
|||
|
|||
function renderContent(content: string): string { |
|||
return content.replace( |
|||
/@(\S+)/g, |
|||
'<span class="mention-tag">@$1</span>' |
|||
) |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="chat-bubble" :class="{ 'is-mine': isMine }"> |
|||
<div class="bubble-avatar" :class="{ 'mine-avatar': isMine, 'author-avatar': message.isAuthor }"> |
|||
<span>{{ message.isAuthor ? '作' : message.nickname[0] }}</span> |
|||
</div> |
|||
<div class="bubble-body"> |
|||
<div class="bubble-meta"> |
|||
<span class="bubble-nickname" :class="{ 'mine-name': isMine, 'author-name': message.isAuthor }"> |
|||
{{ message.nickname }} |
|||
<span v-if="message.isAuthor" class="author-badge">作者</span> |
|||
<span v-else-if="message.mentionNotified" class="notified-badge">已通知</span> |
|||
<span v-else-if="message.mentionSkipped" class="skipped-badge">作者在线</span> |
|||
</span> |
|||
<span class="bubble-time">{{ time }}</span> |
|||
</div> |
|||
<div class="bubble-content" :class="{ 'mine-content': isMine }" v-html="renderContent(message.content)" /> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.chat-bubble { |
|||
display: flex; |
|||
gap: 8px; |
|||
padding: 4px 0; |
|||
align-items: flex-start; |
|||
animation: slide-up 0.25s cubic-bezier(0.16, 1, 0.3, 1); |
|||
} |
|||
|
|||
@keyframes slide-up { |
|||
from { |
|||
opacity: 0; |
|||
transform: translateY(10px); |
|||
} |
|||
to { |
|||
opacity: 1; |
|||
transform: translateY(0); |
|||
} |
|||
} |
|||
|
|||
.chat-bubble.is-mine { |
|||
flex-direction: row-reverse; |
|||
} |
|||
|
|||
.chat-bubble.is-mine .bubble-body { |
|||
align-items: flex-end; |
|||
} |
|||
|
|||
/* ── Avatar ── */ |
|||
.bubble-avatar { |
|||
flex-shrink: 0; |
|||
width: 30px; |
|||
height: 30px; |
|||
border-radius: 50%; |
|||
background: var(--color-surface-card, #efe9de); |
|||
color: var(--color-body-strong, #252523); |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
font-size: 12px; |
|||
font-weight: 700; |
|||
letter-spacing: 0; |
|||
border: 1px solid var(--color-hairline, #e6dfd8); |
|||
} |
|||
|
|||
.mine-avatar { |
|||
background: var(--color-primary, #cc785c); |
|||
color: #fff; |
|||
border-color: transparent; |
|||
box-shadow: 0 2px 8px rgba(204, 120, 92, 0.25); |
|||
} |
|||
|
|||
/* Author avatar — distinctive warm coral glow */ |
|||
.author-avatar { |
|||
background: linear-gradient(135deg, #cc785c 0%, #a9583e 100%); |
|||
color: #fff; |
|||
border-color: transparent; |
|||
box-shadow: 0 2px 12px rgba(204, 120, 92, 0.35), 0 0 0 2px rgba(204, 120, 92, 0.12); |
|||
} |
|||
|
|||
/* ── Body ── */ |
|||
.bubble-body { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 3px; |
|||
max-width: 70%; |
|||
} |
|||
|
|||
.bubble-meta { |
|||
display: flex; |
|||
align-items: baseline; |
|||
gap: 8px; |
|||
padding: 0 4px; |
|||
} |
|||
|
|||
.bubble-nickname { |
|||
font-size: 11.5px; |
|||
font-weight: 600; |
|||
color: var(--color-body-strong, #252523); |
|||
letter-spacing: 0; |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 6px; |
|||
} |
|||
|
|||
.mine-name { |
|||
color: var(--color-primary-active, #a9583e); |
|||
} |
|||
|
|||
/* Author name — warm coral accent */ |
|||
.author-name { |
|||
color: var(--color-primary, #cc785c); |
|||
} |
|||
|
|||
/* Badges */ |
|||
.author-badge { |
|||
font-size: 10px; |
|||
font-weight: 700; |
|||
color: #fff; |
|||
background: var(--color-primary, #cc785c); |
|||
padding: 1px 7px; |
|||
border-radius: 4px; |
|||
letter-spacing: 0.5px; |
|||
} |
|||
|
|||
.notified-badge { |
|||
font-size: 10px; |
|||
font-weight: 600; |
|||
color: var(--color-accent-teal, #5db8a6); |
|||
background: rgba(93, 184, 166, 0.1); |
|||
padding: 1px 6px; |
|||
border-radius: 3px; |
|||
} |
|||
|
|||
.skipped-badge { |
|||
font-size: 10px; |
|||
font-weight: 600; |
|||
color: var(--color-accent-amber, #e8a55a); |
|||
background: rgba(232, 165, 90, 0.1); |
|||
padding: 1px 6px; |
|||
border-radius: 3px; |
|||
} |
|||
|
|||
.bubble-time { |
|||
font-size: 10.5px; |
|||
color: var(--color-muted-soft, #8e8b82); |
|||
font-weight: 400; |
|||
} |
|||
|
|||
/* ── Content bubble ── */ |
|||
.bubble-content { |
|||
background: #ffffff; |
|||
border: 1px solid var(--color-hairline, #e6dfd8); |
|||
border-radius: 6px 14px 14px 14px; |
|||
padding: 8px 12px; |
|||
font-size: 13.5px; |
|||
line-height: 1.6; |
|||
color: var(--color-body, #3d3d3a); |
|||
word-break: break-word; |
|||
box-shadow: 0 1px 2px rgba(0,0,0,0.03); |
|||
} |
|||
|
|||
.mine-content { |
|||
border-radius: 16px 6px 16px 16px; |
|||
background: #fbf8f3; |
|||
border-color: var(--color-hairline-soft, #ebe6df); |
|||
} |
|||
|
|||
/* ── @mention ── */ |
|||
.bubble-content :deep(.mention-tag) { |
|||
color: var(--color-primary, #cc785c); |
|||
font-weight: 700; |
|||
background: rgba(204, 120, 92, 0.08); |
|||
border-radius: 3px; |
|||
padding: 0 3px; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,469 @@ |
|||
<script setup lang="ts"> |
|||
const emit = defineEmits<{ close: [] }>() |
|||
|
|||
const { messages, connected, authorOnline, loading, loadingMore, hasMore, error, send, loadMore } = useChat() |
|||
const { user } = useAuthSession() |
|||
const currentUserId = computed(() => user.value?.id) |
|||
|
|||
const inputText = ref('') |
|||
const listRef = ref<HTMLElement | null>(null) |
|||
const inputRef = ref<HTMLInputElement | null>(null) |
|||
const sending = ref(false) |
|||
|
|||
watch(() => messages.value.length, () => { |
|||
scrollToBottom() |
|||
}) |
|||
|
|||
// Also scroll when loading finishes (initial load) |
|||
watch(loading, (val) => { |
|||
if (!val && messages.value.length > 0) { |
|||
nextTick(() => scrollToBottom()) |
|||
} |
|||
}) |
|||
|
|||
// Infinite scroll: load older messages when scrolling to top. |
|||
// Guard against triggering during initial load or programmatic scroll-to-bottom. |
|||
let scrollLock = false |
|||
|
|||
function scrollToBottom() { |
|||
scrollLock = true |
|||
nextTick(() => { |
|||
const el = listRef.value |
|||
if (el && el.lastElementChild) { |
|||
el.lastElementChild.scrollIntoView({ block: 'end' }) |
|||
} |
|||
setTimeout(() => { scrollLock = false }, 300) |
|||
}) |
|||
} |
|||
|
|||
function onScroll() { |
|||
if (scrollLock) return |
|||
const el = listRef.value |
|||
if (!el || loading.value || loadingMore.value || !hasMore.value) return |
|||
if (el.scrollTop < 80) { |
|||
const prevHeight = el.scrollHeight |
|||
loadMore().then(() => { |
|||
nextTick(() => { |
|||
if (listRef.value) { |
|||
listRef.value.scrollTop = listRef.value.scrollHeight - prevHeight |
|||
} |
|||
}) |
|||
}) |
|||
} |
|||
} |
|||
|
|||
async function handleSend() { |
|||
const text = inputText.value.trim() |
|||
if (!text || sending.value) return |
|||
sending.value = true |
|||
try { |
|||
await send(text) |
|||
inputText.value = '' |
|||
} catch { /* composable handles error */ } |
|||
finally { |
|||
sending.value = false |
|||
nextTick(() => inputRef.value?.focus()) |
|||
} |
|||
} |
|||
|
|||
function handleKeydown(e: KeyboardEvent) { |
|||
if (e.key === 'Enter' && !e.shiftKey) { |
|||
e.preventDefault() |
|||
handleSend() |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="chat-room"> |
|||
<!-- Header --> |
|||
<div class="chat-header"> |
|||
<div class="header-left"> |
|||
<h2 class="chat-title">聊天</h2> |
|||
<span v-if="authorOnline" class="author-dot" title="作者在线" /> |
|||
</div> |
|||
<div class="header-right"> |
|||
<span v-if="!connected" class="connecting-text">连接中…</span> |
|||
<button class="close-btn" @click="emit('close')" aria-label="关闭"> |
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"> |
|||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> |
|||
</svg> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Messages --> |
|||
<div ref="listRef" class="chat-messages" @scroll="onScroll"> |
|||
<div v-if="loadingMore" class="load-more-indicator"> |
|||
<div class="empty-spinner" /> |
|||
</div> |
|||
<div v-if="loading" class="chat-empty"> |
|||
<div class="empty-spinner" /> |
|||
<span>加载消息中</span> |
|||
</div> |
|||
<div v-else-if="messages.length === 0" class="chat-empty"> |
|||
<span>还没有消息,说点什么吧</span> |
|||
</div> |
|||
<template v-else> |
|||
<ChatBubble |
|||
v-for="msg in messages" |
|||
:key="msg.id" |
|||
:message="msg" |
|||
:is-mine="msg.userId != null && msg.userId === currentUserId" |
|||
/> |
|||
</template> |
|||
</div> |
|||
|
|||
<!-- Error --> |
|||
<Transition name="error-slide"> |
|||
<div v-if="error" class="chat-error">{{ error }}</div> |
|||
</Transition> |
|||
|
|||
<!-- Input --> |
|||
<div class="chat-input-area"> |
|||
<div class="input-composer"> |
|||
<input |
|||
ref="inputRef" |
|||
v-model="inputText" |
|||
class="chat-input" |
|||
placeholder="输入消息…" |
|||
maxlength="500" |
|||
@keydown="handleKeydown" |
|||
/> |
|||
<button class="at-btn" title="@作者" @click="inputText += (inputText ? ' ' : '') + '@作者 '">@</button> |
|||
<button |
|||
class="send-btn" |
|||
:disabled="!inputText.trim() || sending" |
|||
@click="handleSend" |
|||
> |
|||
<svg v-if="!sending" width="16" height="16" viewBox="0 0 18 18" fill="none"> |
|||
<path d="M2 9L16 2L9 16L7.5 10.5L2 9Z" fill="currentColor"/> |
|||
<path d="M7.5 10.5L11 7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> |
|||
</svg> |
|||
<span v-else class="sending-spinner" /> |
|||
</button> |
|||
</div> |
|||
<p class="input-hint"> |
|||
<template v-if="authorOnline">作者在线 · <code>@作者</code> 无需邮件</template> |
|||
<template v-else><code>@作者</code> 可邮件通知</template> |
|||
</p> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
/* ═══════════════════════════════════════════ |
|||
WARM CREAM CHAT ROOM |
|||
═══════════════════════════════════════════ */ |
|||
|
|||
.chat-room { |
|||
--bg: #faf9f5; |
|||
--bg-card: #ffffff; |
|||
--bg-soft: #f5f0e8; |
|||
--text: #141413; |
|||
--text-body: #3d3d3a; |
|||
--text-soft: #8e8b82; |
|||
--accent: #cc785c; |
|||
--accent-active: #a9583e; |
|||
--hairline: #e6dfd8; |
|||
--hairline-soft: #ebe6df; |
|||
|
|||
display: flex; |
|||
flex-direction: column; |
|||
height: 100%; |
|||
width: 100%; |
|||
background: var(--bg); |
|||
} |
|||
|
|||
/* ── Header ── */ |
|||
.chat-header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
padding: 12px 16px; |
|||
border-bottom: 1px solid var(--hairline-soft); |
|||
background: var(--bg-card); |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.header-left { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 10px; |
|||
} |
|||
|
|||
.chat-title { |
|||
font-family: 'Abril Fatface', Georgia, serif; |
|||
font-size: 20px; |
|||
font-weight: 400; |
|||
color: var(--text); |
|||
letter-spacing: -0.3px; |
|||
margin: 0; |
|||
line-height: 1; |
|||
} |
|||
|
|||
/* Author online dot */ |
|||
.author-dot { |
|||
width: 8px; |
|||
height: 8px; |
|||
border-radius: 50%; |
|||
background: #5db872; |
|||
box-shadow: 0 0 6px rgba(93, 184, 114, 0.4); |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
/* Header right */ |
|||
.header-right { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.connecting-text { |
|||
font-size: 11px; |
|||
color: var(--text-soft); |
|||
} |
|||
|
|||
/* Close button — only visible on narrow screens */ |
|||
.close-btn { |
|||
display: none; |
|||
width: 28px; |
|||
height: 28px; |
|||
border-radius: 6px; |
|||
background: transparent; |
|||
color: var(--text-soft); |
|||
border: 1px solid transparent; |
|||
cursor: pointer; |
|||
align-items: center; |
|||
justify-content: center; |
|||
transition: all 0.15s; |
|||
} |
|||
|
|||
.close-btn:hover { |
|||
background: var(--bg-soft); |
|||
color: var(--text); |
|||
border-color: var(--hairline); |
|||
} |
|||
|
|||
/* ── Messages ── */ |
|||
.chat-messages { |
|||
flex: 1; |
|||
overflow-y: auto; |
|||
padding: 16px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 2px; |
|||
scroll-behavior: smooth; |
|||
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg) 80px); |
|||
} |
|||
|
|||
.chat-messages::-webkit-scrollbar { |
|||
width: 4px; |
|||
} |
|||
|
|||
.chat-messages::-webkit-scrollbar-track { |
|||
background: transparent; |
|||
} |
|||
|
|||
.chat-messages::-webkit-scrollbar-thumb { |
|||
background: rgba(142, 139, 130, 0.18); |
|||
border-radius: 9999px; |
|||
} |
|||
|
|||
.chat-messages::-webkit-scrollbar-thumb:hover { |
|||
background: rgba(142, 139, 130, 0.3); |
|||
} |
|||
|
|||
/* Empty */ |
|||
.chat-empty { |
|||
flex: 1; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
color: var(--text-soft); |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.load-more-indicator { |
|||
display: flex; |
|||
justify-content: center; |
|||
padding: 8px 0; |
|||
} |
|||
|
|||
.empty-spinner { |
|||
width: 20px; |
|||
height: 20px; |
|||
border-radius: 50%; |
|||
border: 2px solid var(--hairline); |
|||
border-top-color: var(--accent); |
|||
animation: spin 0.7s linear infinite; |
|||
margin-right: 8px; |
|||
} |
|||
|
|||
@keyframes spin { to { transform: rotate(360deg); } } |
|||
|
|||
/* ── Error ── */ |
|||
.chat-error { |
|||
padding: 8px 16px; |
|||
font-size: 12px; |
|||
color: #c64545; |
|||
background: rgba(198, 69, 69, 0.05); |
|||
border-top: 1px solid rgba(198, 69, 69, 0.12); |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.error-slide-enter-active, |
|||
.error-slide-leave-active { |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
.error-slide-enter-from, |
|||
.error-slide-leave-to { |
|||
opacity: 0; |
|||
transform: translateY(100%); |
|||
} |
|||
|
|||
/* ── Input ── */ |
|||
.chat-input-area { |
|||
flex-shrink: 0; |
|||
padding: 10px 12px 8px; |
|||
border-top: 1px solid var(--hairline-soft); |
|||
background: var(--bg-card); |
|||
} |
|||
|
|||
.input-composer { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 6px; |
|||
} |
|||
|
|||
.chat-input { |
|||
flex: 1; |
|||
border: 1px solid var(--hairline); |
|||
border-radius: 8px; |
|||
padding: 8px 12px; |
|||
font-size: 13.5px; |
|||
color: var(--text-body); |
|||
background: var(--bg); |
|||
outline: none; |
|||
transition: border-color 0.2s, box-shadow 0.2s; |
|||
box-sizing: border-box; |
|||
} |
|||
|
|||
.chat-input:focus { |
|||
border-color: var(--accent); |
|||
box-shadow: 0 0 0 2px rgba(204, 120, 92, 0.08); |
|||
} |
|||
|
|||
.chat-input::placeholder { |
|||
color: var(--text-soft); |
|||
} |
|||
|
|||
.chat-input:disabled { |
|||
opacity: 0.5; |
|||
} |
|||
|
|||
/* @ button */ |
|||
.at-btn { |
|||
flex-shrink: 0; |
|||
width: 32px; |
|||
height: 32px; |
|||
background: var(--bg); |
|||
color: var(--accent); |
|||
border: 1px solid var(--hairline); |
|||
border-radius: 8px; |
|||
font-size: 14px; |
|||
font-weight: 700; |
|||
cursor: pointer; |
|||
transition: all 0.15s; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.at-btn:hover { |
|||
background: var(--accent); |
|||
color: #fff; |
|||
border-color: var(--accent); |
|||
} |
|||
|
|||
/* Send button */ |
|||
.send-btn { |
|||
flex-shrink: 0; |
|||
width: 32px; |
|||
height: 32px; |
|||
background: var(--accent); |
|||
color: #fff; |
|||
border: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
transition: all 0.15s; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
} |
|||
|
|||
.send-btn:hover:not(:disabled) { |
|||
background: var(--accent-active); |
|||
} |
|||
|
|||
.send-btn:active:not(:disabled) { |
|||
transform: scale(0.95); |
|||
} |
|||
|
|||
.send-btn:disabled { |
|||
opacity: 0.35; |
|||
cursor: not-allowed; |
|||
} |
|||
|
|||
.sending-spinner { |
|||
width: 14px; |
|||
height: 14px; |
|||
border-radius: 50%; |
|||
border: 2px solid rgba(255,255,255,0.3); |
|||
border-top-color: #fff; |
|||
animation: spin 0.6s linear infinite; |
|||
} |
|||
|
|||
/* Hint */ |
|||
.input-hint { |
|||
margin: 6px 0 0; |
|||
font-size: 11px; |
|||
color: var(--text-soft); |
|||
opacity: 0.5; |
|||
padding: 0 2px; |
|||
} |
|||
|
|||
.input-hint code { |
|||
background: var(--bg-soft); |
|||
padding: 1px 4px; |
|||
border-radius: 3px; |
|||
font-size: 10px; |
|||
color: var(--accent); |
|||
font-weight: 600; |
|||
} |
|||
|
|||
/* ── Mobile fullscreen override ── */ |
|||
|
|||
@media (max-width: 640px) { |
|||
.close-btn { |
|||
display: flex; |
|||
} |
|||
|
|||
.chat-header { |
|||
padding: 10px 14px; |
|||
} |
|||
|
|||
.chat-title { |
|||
font-size: 18px; |
|||
} |
|||
|
|||
.chat-messages { |
|||
padding: 14px; |
|||
} |
|||
|
|||
.chat-input-area { |
|||
padding: 8px 10px 8px; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,208 @@ |
|||
<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> |
|||
@ -0,0 +1,173 @@ |
|||
/** |
|||
* useChat — composable for the public chat room. |
|||
* |
|||
* Global SSE connection (module-level singleton) for: |
|||
* - messages (shared array) |
|||
* - authorOnline status (reactive) |
|||
* - lastMention notification (for toast) |
|||
*/ |
|||
import { request, unwrapApiBody, type ApiResponse } from "../utils/http/factory" |
|||
import type { ChatMessage } from "~~/server/service/chat" |
|||
|
|||
// ══════ Shared SSE state (module-level singleton) ══════
|
|||
const messages = ref<ChatMessage[]>([]) |
|||
const connected = ref(false) |
|||
const authorOnline = ref(false) |
|||
const lastMention = ref<{ from: string; content: string } | null>(null) |
|||
let sseStarted = false |
|||
let sseAbort: AbortController | null = null |
|||
|
|||
function startSSE() { |
|||
if (import.meta.server) return |
|||
if (sseStarted) return |
|||
sseStarted = true |
|||
|
|||
sseAbort?.abort() |
|||
sseAbort = new AbortController() |
|||
|
|||
fetch('/api/chat/sse', { signal: sseAbort.signal, credentials: 'include' }) |
|||
.then(async (response) => { |
|||
if (!response.ok || !response.body) throw new Error(`SSE failed: ${response.status}`) |
|||
const reader = response.body.getReader() |
|||
const decoder = new TextDecoder() |
|||
let buffer = '' |
|||
while (true) { |
|||
const { done, value } = await reader.read() |
|||
if (done) break |
|||
buffer += decoder.decode(value, { stream: true }) |
|||
const lines = buffer.split('\n') |
|||
buffer = lines.pop() || '' |
|||
for (const line of lines) { |
|||
if (!line.startsWith('data: ')) continue |
|||
const payload = line.slice(6) |
|||
if (payload.startsWith(':')) continue |
|||
try { |
|||
const parsed = JSON.parse(payload) |
|||
if (parsed.type === 'connected') { |
|||
connected.value = true |
|||
authorOnline.value = !!parsed.authorOnline |
|||
} else if (parsed.type === 'authorOnline') { |
|||
authorOnline.value = !!parsed.data |
|||
} else if (parsed.type === 'mentionNotify') { |
|||
lastMention.value = parsed.data |
|||
} else if (parsed.type === 'message' && parsed.data) { |
|||
messages.value = [...messages.value, parsed.data] |
|||
} |
|||
} catch { /* skip malformed */ } |
|||
} |
|||
} |
|||
}) |
|||
.catch((err: any) => { |
|||
if (err?.name !== 'AbortError') { |
|||
sseStarted = false |
|||
setTimeout(startSSE, 3000) |
|||
} |
|||
}) |
|||
} |
|||
|
|||
// ══════ Per-instance helpers ══════
|
|||
const NICKNAME_PREFIXES = ['小', '大', '阿', '老', '' as string] |
|||
const NICKNAME_SUFFIXES = ['白', '黑', '花', '黄', '蓝', '云', '风', '月', '星', '龙', '虎', '猫', '狗', '鹿', '鹏'] |
|||
|
|||
function randomNickname(): string { |
|||
const prefix = NICKNAME_PREFIXES[Math.floor(Math.random() * NICKNAME_PREFIXES.length)] |
|||
const suffix = NICKNAME_SUFFIXES[Math.floor(Math.random() * NICKNAME_SUFFIXES.length)] |
|||
return `${prefix}${suffix}` |
|||
} |
|||
|
|||
function getClientId(): string { |
|||
if (import.meta.server) return '' |
|||
const key = 'chat_client_id' |
|||
let id = sessionStorage.getItem(key) |
|||
if (!id) { |
|||
id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` |
|||
sessionStorage.setItem(key, id) |
|||
} |
|||
return id |
|||
} |
|||
|
|||
export function useChat() { |
|||
const loading = ref(false) |
|||
const loadingMore = ref(false) |
|||
const hasMore = ref(true) |
|||
const error = ref<string | null>(null) |
|||
const clientId = getClientId() |
|||
|
|||
const _anonNickname = ref('') |
|||
function getAnonNickname(): string { |
|||
if (import.meta.server) return '' |
|||
if (!_anonNickname.value) { |
|||
_anonNickname.value = localStorage.getItem('chat_anon_nickname') || randomNickname() |
|||
localStorage.setItem('chat_anon_nickname', _anonNickname.value) |
|||
} |
|||
return _anonNickname.value |
|||
} |
|||
|
|||
async function loadHistory() { |
|||
loading.value = true |
|||
error.value = null |
|||
try { |
|||
const res = await request<ApiResponse<ChatMessage[]>>('/api/chat/messages') |
|||
messages.value = unwrapApiBody(res) |
|||
hasMore.value = messages.value.length >= 20 |
|||
} catch (e: any) { |
|||
error.value = e?.message || '加载消息失败' |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
async function loadMore() { |
|||
if (loadingMore.value || !hasMore.value || messages.value.length === 0) return |
|||
loadingMore.value = true |
|||
try { |
|||
const oldestId = messages.value[0]?.id |
|||
if (!oldestId) { hasMore.value = false; return } |
|||
const res = await request<ApiResponse<ChatMessage[]>>(`/api/chat/messages?before=${oldestId}&limit=20`) |
|||
const older = unwrapApiBody(res) |
|||
if (older.length < 20) hasMore.value = false |
|||
// Prepend older messages
|
|||
messages.value = [...older, ...messages.value] |
|||
} catch (e: any) { |
|||
error.value = e?.message || '加载更多失败' |
|||
} finally { |
|||
loadingMore.value = false |
|||
} |
|||
} |
|||
|
|||
async function send(content: string, nickname?: string) { |
|||
const trimmed = content.trim() |
|||
if (!trimmed) return |
|||
error.value = null |
|||
try { |
|||
await request('/api/chat/send', { |
|||
method: 'POST', |
|||
body: { content: trimmed, nickname: nickname || getAnonNickname(), clientId }, |
|||
}) |
|||
} catch (e: any) { |
|||
error.value = e?.data?.message || e?.message || '发送失败' |
|||
throw e |
|||
} |
|||
} |
|||
|
|||
if (import.meta.client) { |
|||
onMounted(() => { |
|||
startSSE() |
|||
loadHistory() |
|||
}) |
|||
} |
|||
|
|||
return { |
|||
messages, |
|||
connected, |
|||
authorOnline, |
|||
lastMention, |
|||
loading, |
|||
loadingMore, |
|||
hasMore, |
|||
error, |
|||
send, |
|||
loadHistory, |
|||
loadMore, |
|||
} |
|||
} |
|||
@ -1 +1,58 @@ |
|||
<template><div>aaaaaaaa</div></template> |
|||
<script setup lang="ts"> |
|||
definePageMeta({ |
|||
layout: 'home', |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="home-page"> |
|||
<div class="hero"> |
|||
<h1 class="hero-title">欢迎来到 Dash</h1> |
|||
<p class="hero-sub">一个收集、分享和交流的地方</p> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.home-page { |
|||
flex: 1; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
min-height: calc(100vh - 64px); |
|||
padding: 48px 24px; |
|||
} |
|||
|
|||
.hero { |
|||
text-align: center; |
|||
max-width: 600px; |
|||
} |
|||
|
|||
.hero-title { |
|||
font-family: 'Abril Fatface', Georgia, serif; |
|||
font-size: 48px; |
|||
font-weight: 400; |
|||
color: var(--color-ink, #141413); |
|||
letter-spacing: -1px; |
|||
margin: 0 0 16px; |
|||
line-height: 1.15; |
|||
} |
|||
|
|||
.hero-sub { |
|||
font-size: 18px; |
|||
color: var(--color-muted, #6c6a64); |
|||
margin: 0; |
|||
font-weight: 400; |
|||
letter-spacing: 0; |
|||
} |
|||
|
|||
@media (max-width: 640px) { |
|||
.hero-title { |
|||
font-size: 34px; |
|||
} |
|||
|
|||
.hero-sub { |
|||
font-size: 16px; |
|||
} |
|||
} |
|||
</style> |
|||
|
|||
Binary file not shown.
@ -0,0 +1,8 @@ |
|||
CREATE TABLE `chat_messages` ( |
|||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, |
|||
`nickname` text(20) NOT NULL, |
|||
`content` text NOT NULL, |
|||
`created_at` integer NOT NULL |
|||
); |
|||
--> statement-breakpoint |
|||
CREATE INDEX `idx_chat_msg_created` ON `chat_messages` (`created_at`); |
|||
@ -0,0 +1,14 @@ |
|||
/** |
|||
* GET /api/chat/messages — get recent chat messages |
|||
* Query: ?limit=50&before=123 (cursor pagination by message ID) |
|||
*/ |
|||
import { getMessages } from "#server/service/chat" |
|||
|
|||
export default defineEventHandler(async (event) => { |
|||
const query = getQuery(event) |
|||
const limit = Math.min(Number(query.limit) || 20, 100) |
|||
const before = query.before ? Number(query.before) : undefined |
|||
|
|||
const messages = await getMessages(limit, before) |
|||
return { code: 0, data: messages } |
|||
}) |
|||
@ -0,0 +1,23 @@ |
|||
/** |
|||
* POST /api/chat/send — send a message to the chat room (auth required) |
|||
*/ |
|||
import { sendMessage } from "#server/service/chat" |
|||
import { getCurrentUser } from "#server/utils/context" |
|||
|
|||
export default defineEventHandler(async (event) => { |
|||
const body = await readBody<{ content: string; nickname?: string; clientId?: string }>(event) |
|||
const ip = getRequestIP(event, { xForwardedFor: true }) ?? '127.0.0.1' |
|||
|
|||
const user = await getCurrentUser(event) |
|||
const isAuthor = user?.role === 'admin' |
|||
const userId = user?.id |
|||
|
|||
const defaultNick = user?.nickname || user?.username || undefined |
|||
|
|||
const msg = await sendMessage(body?.content ?? '', ip, body?.nickname || defaultNick, body?.clientId, isAuthor, userId) |
|||
|
|||
return { |
|||
code: 0, |
|||
data: msg, |
|||
} |
|||
}) |
|||
@ -0,0 +1,52 @@ |
|||
/** |
|||
* GET /api/chat/sse — Server-Sent Events stream for real-time chat messages |
|||
*/ |
|||
import { subscribe, authorConnected, authorDisconnected, isAuthorOnline, userConnected, userDisconnected } from "#server/service/chat" |
|||
import { getCurrentUser } from "#server/utils/context" |
|||
|
|||
export default defineEventHandler(async (event) => { |
|||
const res = event.node.res |
|||
|
|||
const user = await getCurrentUser(event) |
|||
const isAuthor = user?.role === 'admin' |
|||
const userId = user?.id |
|||
|
|||
// Register connection BEFORE sending connected event (so isAuthorOnline is accurate)
|
|||
if (isAuthor) authorConnected() |
|||
if (userId) userConnected(String(userId)) |
|||
|
|||
res.writeHead(200, { |
|||
'Content-Type': 'text/event-stream', |
|||
'Cache-Control': 'no-cache, no-transform', |
|||
'Connection': 'keep-alive', |
|||
'X-Accel-Buffering': 'no', |
|||
}) |
|||
|
|||
// Send actual global author online status (not just the connecting user's role)
|
|||
res.write(`data: ${JSON.stringify({ type: 'connected', authorOnline: isAuthorOnline() })}\n\n`) |
|||
|
|||
const unsub = subscribe((msg) => { |
|||
// msg is either a ChatMessage object or a status-string from broadcastStatus
|
|||
if (typeof msg === 'string') { |
|||
// Status broadcast: send directly (already JSON: {"type":"authorOnline","data":true})
|
|||
res.write(`data: ${msg}\n\n`) |
|||
} else { |
|||
res.write(`data: ${JSON.stringify({ type: 'message', data: msg })}\n\n`) |
|||
} |
|||
}) |
|||
|
|||
const heartbeat = setInterval(() => { |
|||
res.write(': heartbeat\n\n') |
|||
}, 30_000) |
|||
|
|||
event.node.req.on('close', () => { |
|||
unsub() |
|||
clearInterval(heartbeat) |
|||
if (isAuthor) authorDisconnected() |
|||
if (userId) userDisconnected(String(userId)) |
|||
}) |
|||
|
|||
await new Promise<void>((resolve) => { |
|||
event.node.req.on('close', () => resolve()) |
|||
}) |
|||
}) |
|||
@ -0,0 +1,9 @@ |
|||
/** |
|||
* GET /api/chat/unread — get unread @mention count for current user |
|||
*/ |
|||
import { getUnreadCount } from "#server/service/chat" |
|||
|
|||
export default defineEventHandler(async () => { |
|||
const count = getUnreadCount('_global') |
|||
return { code: 0, data: { count } } |
|||
}) |
|||
@ -0,0 +1,9 @@ |
|||
/** |
|||
* POST /api/chat/unread/clear — clear unread count for current user |
|||
*/ |
|||
import { clearUnread } from "#server/service/chat" |
|||
|
|||
export default defineEventHandler(async () => { |
|||
clearUnread('_global') |
|||
return { code: 0, data: null } |
|||
}) |
|||
@ -0,0 +1,24 @@ |
|||
/** |
|||
* Auto-migrate: ensure chat_messages table and columns exist. |
|||
* Runs on every server start — idempotent DDL. |
|||
*/ |
|||
import { dbGlobal } from "drizzle-pkg/lib/db" |
|||
|
|||
export default defineNitroPlugin(async () => { |
|||
await dbGlobal.run(` |
|||
CREATE TABLE IF NOT EXISTS chat_messages ( |
|||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, |
|||
nickname TEXT(20) NOT NULL, |
|||
content TEXT NOT NULL, |
|||
created_at INTEGER NOT NULL, |
|||
client_id TEXT, |
|||
user_id INTEGER |
|||
) |
|||
`)
|
|||
try { await dbGlobal.run(`ALTER TABLE chat_messages ADD COLUMN client_id TEXT`) } catch {} |
|||
try { await dbGlobal.run(`ALTER TABLE chat_messages ADD COLUMN user_id INTEGER`) } catch {} |
|||
|
|||
await dbGlobal.run(` |
|||
CREATE INDEX IF NOT EXISTS idx_chat_msg_created ON chat_messages (created_at) |
|||
`)
|
|||
}) |
|||
@ -0,0 +1,231 @@ |
|||
/** |
|||
* Chat Service — DB-persisted pub/sub for the public chat room. |
|||
* |
|||
* Messages are stored in SQLite (survives server restarts). |
|||
* SSE subscribers are tracked so new messages are broadcast in real-time. |
|||
*/ |
|||
import { assertUnderRateLimit } from "#server/utils/simple-rate-limit" |
|||
import { sendMail } from "#server/service/email" |
|||
import { dbGlobal } from "drizzle-pkg/lib/db" |
|||
import { chatMessages } from "drizzle-pkg/lib/schema/content" |
|||
import { desc, sql } from "drizzle-orm" |
|||
|
|||
// ── Types ──
|
|||
export interface ChatMessage { |
|||
id: number |
|||
nickname: string |
|||
content: string |
|||
createdAt: number |
|||
/** ID of the user who sent this message */ |
|||
userId?: number |
|||
clientId?: string |
|||
/** Sent by the author (admin) account */ |
|||
isAuthor?: boolean |
|||
/** @mention triggered an email notification */ |
|||
mentionNotified?: boolean |
|||
/** @mention would have triggered email but author was online — skipped */ |
|||
mentionSkipped?: boolean |
|||
} |
|||
|
|||
// ── Config ──
|
|||
const MAX_MESSAGES = 500 |
|||
const NICKNAME_PREFIXES = ['小', '大', '阿', '老', '' as string] |
|||
const NICKNAME_SUFFIXES = ['白', '黑', '花', '黄', '蓝', '云', '风', '月', '星', '龙', '虎', '猫', '狗', '鹿', '鹏'] |
|||
|
|||
function randomNickname(): string { |
|||
const prefix = NICKNAME_PREFIXES[Math.floor(Math.random() * NICKNAME_PREFIXES.length)] |
|||
const suffix = NICKNAME_SUFFIXES[Math.floor(Math.random() * NICKNAME_SUFFIXES.length)] |
|||
return `${prefix}${suffix}` |
|||
} |
|||
|
|||
// ── @mention → email mapping ──
|
|||
const MENTION_EMAIL_MAP: Record<string, string> = { |
|||
'作者': '1549469775@qq.com', |
|||
} |
|||
|
|||
function parseMentions(content: string): string[] { |
|||
const matches = content.match(/@(\S+)/g) |
|||
if (!matches) return [] |
|||
return matches.map(m => m.slice(1)) |
|||
} |
|||
|
|||
// ── SSE subscribers ──
|
|||
type SSECallback = (msg: ChatMessage | string) => void |
|||
const subscribers = new Set<SSECallback>() |
|||
|
|||
// ── User online tracking (userId → connection count) ──
|
|||
const userConnections = new Map<string, number>() |
|||
|
|||
export function userConnected(userId: string) { |
|||
userConnections.set(userId, (userConnections.get(userId) || 0) + 1) |
|||
} |
|||
|
|||
export function userDisconnected(userId: string) { |
|||
const count = userConnections.get(userId) || 0 |
|||
if (count <= 1) userConnections.delete(userId) |
|||
else userConnections.set(userId, count - 1) |
|||
} |
|||
|
|||
export function isUserOnline(userId: string): boolean { |
|||
return (userConnections.get(userId) || 0) > 0 |
|||
} |
|||
|
|||
// ── Unread @mention tracking (userId → count) ──
|
|||
const unreadCounts = new Map<string, number>() |
|||
|
|||
export function getUnreadCount(userId: string): number { |
|||
return unreadCounts.get(userId) || 0 |
|||
} |
|||
|
|||
export function clearUnread(userId: string) { |
|||
unreadCounts.delete(userId) |
|||
} |
|||
|
|||
// ── Author online tracking ──
|
|||
let authorConnectionCount = 0 |
|||
|
|||
export function authorConnected() { |
|||
const wasOffline = authorConnectionCount === 0 |
|||
authorConnectionCount++ |
|||
if (wasOffline) broadcastStatus('authorOnline', true) |
|||
} |
|||
|
|||
export function authorDisconnected() { |
|||
if (authorConnectionCount > 0) authorConnectionCount-- |
|||
if (authorConnectionCount === 0) broadcastStatus('authorOnline', false) |
|||
} |
|||
|
|||
function broadcastStatus(type: string, value: any) { |
|||
const evt = JSON.stringify({ type, data: value }) |
|||
for (const cb of subscribers) { |
|||
try { cb(evt as any) } catch {} |
|||
} |
|||
} |
|||
|
|||
export function isAuthorOnline(): boolean { |
|||
return authorConnectionCount > 0 |
|||
} |
|||
|
|||
// ── Public API ──
|
|||
|
|||
/** Get recent messages (newest last) from DB. Pass beforeId for cursor pagination. */ |
|||
export async function getMessages(limit = 20, beforeId?: number): Promise<ChatMessage[]> { |
|||
let query = dbGlobal |
|||
.select() |
|||
.from(chatMessages) |
|||
.orderBy(desc(chatMessages.createdAt)) |
|||
.limit(limit) |
|||
|
|||
if (beforeId) { |
|||
query = query.where(sql`${chatMessages.id} < ${beforeId}`) |
|||
} |
|||
|
|||
const rows = await query |
|||
return rows.reverse().map(r => ({ |
|||
id: r.id, |
|||
nickname: r.nickname, |
|||
content: r.content, |
|||
createdAt: r.createdAt instanceof Date ? r.createdAt.getTime() : r.createdAt, |
|||
userId: r.userId ?? undefined, |
|||
clientId: r.clientId ?? undefined, |
|||
})) |
|||
} |
|||
|
|||
/** |
|||
* Send a message to the chat room. |
|||
*/ |
|||
export async function sendMessage( |
|||
content: string, ip: string, nickname?: string, clientId?: string, isAuthor?: boolean, userId?: number |
|||
): Promise<ChatMessage> { |
|||
assertUnderRateLimit(`chat:${ip}`, 10, 10_000) |
|||
|
|||
const trimmed = content.trim() |
|||
if (!trimmed || trimmed.length > 500) { |
|||
throw createError({ |
|||
statusCode: 400, |
|||
statusMessage: trimmed ? '消息不能超过 500 字' : '消息不能为空', |
|||
}) |
|||
} |
|||
|
|||
const displayNickname = isAuthor ? '作者' : (nickname?.trim() || randomNickname()) |
|||
const now = Date.now() |
|||
|
|||
const [inserted] = await dbGlobal |
|||
.insert(chatMessages) |
|||
.values({ |
|||
nickname: displayNickname, |
|||
content: trimmed, |
|||
createdAt: new Date(now), |
|||
clientId: clientId || null, |
|||
userId: userId || null, |
|||
}) |
|||
.returning({ id: chatMessages.id }) |
|||
|
|||
const msg: ChatMessage = { |
|||
id: inserted!.id, |
|||
nickname: displayNickname, |
|||
content: trimmed, |
|||
createdAt: now, |
|||
userId: userId || undefined, |
|||
clientId: clientId || undefined, |
|||
isAuthor: isAuthor || undefined, |
|||
} |
|||
|
|||
// ── Handle @mentions → email notification + unread tracking ──
|
|||
const mentions = parseMentions(trimmed) |
|||
const hasSubscribers = subscribers.size > 0 |
|||
|
|||
for (const mention of mentions) { |
|||
const email = MENTION_EMAIL_MAP[mention] |
|||
if (email) { |
|||
if (isAuthorOnline()) { |
|||
msg.mentionSkipped = true |
|||
// Broadcast a live notification to the author via SSE (toast)
|
|||
broadcastStatus('mentionNotify', { from: displayNickname, content: trimmed }) |
|||
break |
|||
} |
|||
assertUnderRateLimit(`chat-mention:${ip}:${mention}`, 1, 5 * 60_000) |
|||
sendMail({ |
|||
to: email, |
|||
subject: `[聊天室] @${mention} — 来自 ${displayNickname} 的消息`, |
|||
text: `有人在聊天室 @${mention} 了你:\n\n"${trimmed}"\n\n—— ${displayNickname}\n时间:${new Date(now).toLocaleString('zh-CN')}\n\n网站:${process.env.APP_URL || '(未配置)'}`, |
|||
}).catch(err => { |
|||
console.error('Failed to send @mention email:', err) |
|||
}) |
|||
msg.mentionNotified = true |
|||
break |
|||
} |
|||
|
|||
// For any @mention: if no SSE subscribers at all, increment global unread
|
|||
if (!hasSubscribers) { |
|||
const current = unreadCounts.get('_global') || 0 |
|||
unreadCounts.set('_global', current + 1) |
|||
break |
|||
} |
|||
} |
|||
|
|||
pruneMessages() |
|||
|
|||
for (const cb of subscribers) { |
|||
try { cb(msg) } catch { /* drop broken subscriber */ } |
|||
} |
|||
|
|||
return msg |
|||
} |
|||
|
|||
async function pruneMessages() { |
|||
const [{ count }] = await dbGlobal |
|||
.select({ count: sql<number>`count(*)` }) |
|||
.from(chatMessages) |
|||
if (count > MAX_MESSAGES) { |
|||
const excess = count - MAX_MESSAGES |
|||
await dbGlobal.run( |
|||
sql`DELETE FROM chat_messages WHERE id IN (SELECT id FROM chat_messages ORDER BY created_at ASC LIMIT ${excess})` |
|||
) |
|||
} |
|||
} |
|||
|
|||
export function subscribe(cb: SSECallback): () => void { |
|||
subscribers.add(cb) |
|||
return () => { subscribers.delete(cb) } |
|||
} |
|||
Loading…
Reference in new issue