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.
 
 
 
 

173 lines
5.3 KiB

/**
* 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,
}
}