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