Browse Source

feat(chat): implement chat widget and backend services

- 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
npmrun 1 month ago
parent
commit
31f96dd49a
  1. 3
      .reasonix/desktop-topic-title-sources.json
  2. 3
      .reasonix/desktop-topic-titles.json
  3. 1
      app/app.vue
  4. 202
      app/components/chat/ChatBubble.vue
  5. 469
      app/components/chat/ChatRoom.vue
  6. 208
      app/components/chat/ChatWidget.vue
  7. 173
      app/composables/useChat.ts
  8. 59
      app/pages/index/index.vue
  9. 2
      packages/common/config/index.ts
  10. BIN
      packages/drizzle-pkg/db.sqlite
  11. 18
      packages/drizzle-pkg/lib/schema/content.ts
  12. 8
      packages/drizzle-pkg/migrations/0008_chat_messages.sql
  13. 7
      packages/drizzle-pkg/migrations/meta/_journal.json
  14. 14
      server/api/chat/messages.get.ts
  15. 23
      server/api/chat/send.post.ts
  16. 52
      server/api/chat/sse.get.ts
  17. 9
      server/api/chat/unread.get.ts
  18. 9
      server/api/chat/unread/clear.post.ts
  19. 24
      server/plugins/05.chat-migrate.ts
  20. 231
      server/service/chat/index.ts

3
.reasonix/desktop-topic-title-sources.json

@ -1,3 +1,4 @@
{
"topic_20260626-113733_9874ba9ab889b7ed": "manual"
"topic_20260626-113733_9874ba9ab889b7ed": "manual",
"topic_20260626-172249_f199eec9a4e051f4": "auto"
}

3
.reasonix/desktop-topic-titles.json

@ -1,3 +1,4 @@
{
"topic_20260626-113733_9874ba9ab889b7ed": "新的会话"
"topic_20260626-113733_9874ba9ab889b7ed": "新的会话",
"topic_20260626-172249_f199eec9a4e051f4": "新的会话"
}

1
app/app.vue

@ -17,4 +17,5 @@ console.log('Global config siteName:', siteName.value)
<NuxtPage />
</BoConfigProvider>
</NuxtLayout>
<ChatWidget />
</template>

202
app/components/chat/ChatBubble.vue

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

469
app/components/chat/ChatRoom.vue

@ -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">作者在线 &middot; <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>

208
app/components/chat/ChatWidget.vue

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

173
app/composables/useChat.ts

@ -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,
}
}

59
app/pages/index/index.vue

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

2
packages/common/config/index.ts

@ -28,6 +28,8 @@ export const API_ALLOWLIST: RouteRule[] = [
{ path: "/api/tools", methods: ["GET"] },
{ path: "/api/tools/:id", methods: ["GET"] },
{ path: "/api/pic/random", methods: ["GET"] },
/** 聊天消息只读 — 公开 */
{ path: "/api/chat/messages", methods: ["GET"] },
];
export const FRONTEND_LOGIN_PATH = "/auth/login"

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

18
packages/drizzle-pkg/lib/schema/content.ts

@ -193,3 +193,21 @@ export const articleCards = sqliteTable(
index("idx_article_card_card").on(table.cardId),
],
);
// ============ ChatMessage(聊天室消息)============
export const chatMessages = sqliteTable(
"chat_messages",
{
id: integer("id").primaryKey({ autoIncrement: true }),
nickname: text("nickname", { length: 20 }).notNull(),
content: text("content").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.defaultNow()
.notNull(),
clientId: text("client_id"),
userId: integer("user_id"),
},
(table) => [
index("idx_chat_msg_created").on(table.createdAt),
],
);

8
packages/drizzle-pkg/migrations/0008_chat_messages.sql

@ -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`);

7
packages/drizzle-pkg/migrations/meta/_journal.json

@ -57,6 +57,13 @@
"when": 1780705829924,
"tag": "0007_gigantic_franklin_storm",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1780800000000,
"tag": "0008_chat_messages",
"breakpoints": true
}
]
}

14
server/api/chat/messages.get.ts

@ -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 }
})

23
server/api/chat/send.post.ts

@ -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,
}
})

52
server/api/chat/sse.get.ts

@ -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())
})
})

9
server/api/chat/unread.get.ts

@ -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 } }
})

9
server/api/chat/unread/clear.post.ts

@ -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 }
})

24
server/plugins/05.chat-migrate.ts

@ -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)
`)
})

231
server/service/chat/index.ts

@ -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…
Cancel
Save