flnxi-Wu/components/DanmakuWidget.tsx
2026-05-28 18:32:59 +08:00

278 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* DanmakuWidget 组件
*
* 全站弹幕组件
* 支持匿名发送弹幕,实时显示全站弹幕消息
* 使用三层架构样式系统,支持深浅色主题
*
* @created 2025-01-XX
* @component
*/
'use client'
import { useState, useEffect, useRef } from 'react'
import { cn } from '@/lib/ui-styles'
import Input from '@/components/ui/Input'
import Button from '@/components/ui/Button'
import { useSession } from 'next-auth/react'
import { useDialog } from '@/components/ui/DialogProvider'
interface Danmaku {
id: string
content: string
username: string
createdAt: string
}
export default function DanmakuWidget() {
const { data: session } = useSession()
const { alert } = useDialog()
const [isOpen, setIsOpen] = useState(false)
const [content, setContent] = useState('')
const [danmakus, setDanmakus] = useState<Danmaku[]>([])
const [sending, setSending] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const lastDanmakuIdRef = useRef<string | null>(null) // 用于轮询新消息
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
// 获取弹幕列表:首次加载100条,然后每2秒轮询新消息
useEffect(() => {
if (!isOpen) return
const fetchDanmakus = async () => {
try {
const response = await fetch('/api/danmaku?limit=100')
if (response.ok) {
const data = await response.json()
// 反转数组,使最新的消息在底部
const normalized = Array.isArray(data) ? [...data].reverse() : []
setDanmakus(normalized)
lastDanmakuIdRef.current = normalized[normalized.length - 1]?.id ?? null
setTimeout(scrollToBottom, 100)
}
} catch (error) {
console.error('Failed to fetch danmakus:', error)
}
}
fetchDanmakus()
// 轮询新弹幕
const interval = setInterval(async () => {
try {
const response = await fetch('/api/danmaku?limit=1')
if (response.ok) {
const data = await response.json()
const latestId = data[0]?.id
if (latestId && latestId !== lastDanmakuIdRef.current) {
lastDanmakuIdRef.current = latestId
setDanmakus(prev => [...prev, data[0]])
setTimeout(scrollToBottom, 100)
}
}
} catch (error) {
console.error('Failed to poll danmakus:', error)
}
}, 2000)
return () => clearInterval(interval)
}, [isOpen])
// 发送弹幕
const handleSend = async () => {
if (!content.trim() || sending) return
setSending(true)
try {
const response = await fetch('/api/danmaku', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: content.trim() }),
})
if (response.ok) {
const newDanmaku = await response.json()
setDanmakus(prev => [...prev, newDanmaku])
lastDanmakuIdRef.current = newDanmaku.id
setContent('')
setTimeout(scrollToBottom, 100)
} else {
const error = await response.json()
alert({
title: '发送失败',
message: error.error || '发送失败',
variant: 'error',
})
}
} catch (error) {
console.error('Send danmaku error:', error)
alert({
title: '发送失败',
message: '发送失败,请稍后重试',
variant: 'error',
})
} finally {
setSending(false)
}
}
// 点击外部关闭弹窗
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
if (!session) return null
return (
<div ref={containerRef} className="fixed bottom-6 right-6 z-50">
{/* 弹幕对话框 */}
{isOpen && (
<div className="absolute bottom-16 right-0 w-96 overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-900">
{/* 头部 */}
<div className="px-4 py-3 border-b border-slate-200 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-base flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
全站弹幕(匿名)
</h3>
<button
onClick={() => setIsOpen(false)}
className="rounded-md p-1 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
aria-label="关闭弹幕"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* 弹幕列表 */}
<div className={cn(
'h-96 overflow-y-auto p-4 space-y-3',
'bg-slate-50 dark:bg-slate-900'
)}>
{danmakus.length === 0 ? (
<div className="text-center py-12 text-slate-500 dark:text-slate-400">
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
<p>暂无弹幕</p>
</div>
) : (
danmakus.map((danmaku) => (
<div
key={danmaku.id}
className="flex gap-3"
>
<div className="flex-shrink-0 w-8 h-8 rounded-md bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-700 dark:text-slate-200 font-semibold text-sm">
{danmaku.username.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-slate-900 dark:text-white text-sm">
{danmaku.username}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{new Date(danmaku.createdAt).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
<p className="text-slate-700 dark:text-slate-300 text-sm break-words">
{danmaku.content}
</p>
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div className={cn(
'p-4 border-t',
'bg-white dark:bg-slate-800',
'border-slate-200 dark:border-slate-700'
)}>
<div className="flex gap-2">
<Input
type="text"
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && !sending) {
handleSend()
}
}}
placeholder="匿名发送弹幕..."
maxLength={100}
disabled={sending}
fullWidth
className="text-sm"
/>
<Button
onClick={handleSend}
disabled={!content.trim() || sending}
variant="primary"
loading={sending}
>
{sending ? '发送中...' : '发送'}
</Button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400 mt-2">
{content.length}/100 · 按Enter快速发送 · 匿名发送,保护隐私
</p>
</div>
</div>
)}
{/* 悬浮按钮 */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`w-12 h-12 rounded-lg border shadow-sm transition-colors ${
isOpen
? 'border-slate-700 bg-slate-700 text-white hover:bg-slate-800 dark:border-slate-500 dark:bg-slate-700 dark:hover:bg-slate-600'
: 'border-slate-300 bg-white text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800'
} flex items-center justify-center`}
aria-label={isOpen ? '关闭弹幕' : '打开弹幕'}
>
{isOpen ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
)}
</button>
</div>
)
}