@@ -0,0 +1,15 @@
import { useEffect , useState } from "react" ;
import { createRoot } from "react-dom/client" ;
import { LiveClient } from "@lxc/live-client" ;
import type { LiveEvent } from "@lxc/protocol" ;
import "./style.css" ;
const api = async < T , > ( url : string , options? : RequestInit ) : Promise < T > = > { const response = await fetch ( url , { headers : { "content-type" : "application/json" , . . . ( options ? . headers ? ? { } ) } , . . . options } ) ; if ( ! response . ok ) throw new Error ( ( await response . json ( ) . catch ( ( ) = > ( { error : response.statusText } ) ) ) . error ) ; return response . json ( ) ; } ;
const wsUrl = ( token? : string ) = > ` ${ location . protocol === "https:" ? "wss" : "ws" } :// ${ location . host } /ws ${ token ? ` ?token= ${ encodeURIComponent ( token ) } ` : "" } ` ;
function useEvents ( token? : string ) { const [ events , setEvents ] = useState < LiveEvent [ ] > ( [ ] ) ; useEffect ( ( ) = > { const client = new LiveClient ( { url : wsUrl ( token ) } ) ; client . onAny ( event = > setEvents ( current = > [ event , . . . current ] . slice ( 0 , 30 ) ) ) ; client . connect ( ) ; return ( ) = > client . close ( ) ; } , [ token ] ) ; return events ; }
function Login ( { onSuccess } : { onSuccess ( ) : void } ) { const [ password , setPassword ] = useState ( "" ) ; const [ error , setError ] = useState ( "" ) ; return < main className = "login" > < h1 > 洛 星 瓷 直 播 转 盘 < / h1 > < form onSubmit = { async e = > { e . preventDefault ( ) ; try { await api ( "/api/auth/login" , { method : "POST" , body : JSON.stringify ( { password } ) } ) ; onSuccess ( ) ; } catch ( e ) { setError ( ( e as Error ) . message ) ; } } } > < input aria-label = "管理员密码" type = "password" value = { password } onChange = { e = > setPassword ( e . target . value ) } placeholder = "管理员密码" / > < button > 登 录 < / button > { error && < p className = "error" > { error } < / p > } < / form > < / main > }
function Admin() { const [ loggedIn , setLoggedIn ] = useState ( false ) ; const [ status , setStatus ] = useState < any > ( ) ; const [ viewers , setViewers ] = useState < any [ ] > ( [ ] ) ; const [ ledger , setLedger ] = useState < any [ ] > ( [ ] ) ; const [ search , setSearch ] = useState ( "" ) ; const events = useEvents ( ) ; const load = async ( ) = > { try { setStatus ( await api ( "/api/admin/status" ) ) ; setViewers ( await api ( ` /api/admin/viewers?search= ${ encodeURIComponent ( search ) } ` ) ) ; setLedger ( await api ( "/api/admin/ledger" ) ) ; setLoggedIn ( true ) ; } catch { setLoggedIn ( false ) ; } } ; useEffect ( ( ) = > { void load ( ) ; } , [ search ] ) ; if ( ! loggedIn ) return < Login onSuccess = { load } / > ; return < main > < header > < h1 > 管 理 员 控 制 台 < / h1 > < nav > < a href = "/test" > 测 试 页 面 < / a > < a href = "/obs" target = "_blank" > OBS 展 示 < / a > < button onClick = { ( ) = > void api ( "/api/auth/logout" , { method : "POST" } ) . then ( ( ) = > setLoggedIn ( false ) ) } > 退 出 < / button > < / nav > < / header > < section className = "cards" > < article > < b > 直 播 连 接 < / b > < p > { status ? . source . connected ? "已连接" : "未连接" } < / p > < small > { status ? . source . detail } < / small > < button onClick = { ( ) = > void api ( "/api/admin/reconnect" , { method : "POST" } ) . then ( load ) } > 重 连 < / button > < / article > < article > < b > CookieCloud < / b > < p > { status ? . source . cookieCloud ? "可用" : "异常" } < / p > < small > { status ? . cookieCloudHost } < / small > < / article > < article > < b > 聊 天 回 复 < / b > < p > { status ? . reply . enabled ? "已启用" : "已关闭" } < / p > < small > { status ? . reply . detail } < / small > < button onClick = { ( ) = > void api ( "/api/admin/reply" , { method : "POST" , body : JSON.stringify ( { enabled : ! status ? . reply . enabled } ) } ) . then ( load ) } > 切 换 < / button > < / article > < article > < b > OBS < / b > < button onClick = { ( ) = > void api < string > ( "/api/admin/obs-url" ) . then ( url = > navigator . clipboard . writeText ( url ) ) } > 复 制 浏 览 器 源 地 址 < / button > < / article > < / section > < section > < h2 > 观 众 点 数 < / h2 > < input value = { search } onChange = { e = > setSearch ( e . target . value ) } placeholder = "UID 或昵称" / > < table > < thead > < tr > < th > UID < / th > < th > 昵 称 < / th > < th > 点 数 < / th > < / tr > < / thead > < tbody > { viewers . map ( v = > < tr key = { v . uid } > < td > { v . uid } < / td > < td > { v . displayName } < / td > < td > { v . points } < / td > < / tr > ) } < / tbody > < / table > < / section > < section > < h2 > 最 近 流 水 < / h2 > < table > < thead > < tr > < th > 观 众 < / th > < th > 变 化 < / th > < th > 原 因 < / th > < th > 时 间 < / th > < / tr > < / thead > < tbody > { ledger . map ( x = > < tr key = { x . id } > < td > { x . displayName } < / td > < td > { x . delta } < / td > < td > { x . reason } < / td > < td > { new Date ( x . createdAt ) . toLocaleString ( ) } < / td > < / tr > ) } < / tbody > < / table > < / section > < EventList events = { events } / > < / main > }
function TestPage() { const [ allowed , setAllowed ] = useState < boolean > ( ) ; const [ form , setForm ] = useState ( { kind : "gift" , uid : "test-1" , name : "测试观众" , giftName : "测试礼物" , battery : 150 , quantity : 1 , text : "转盘 流行" } ) ; const [ notice , setNotice ] = useState ( "" ) ; useEffect ( ( ) = > { void api ( "/api/admin/status" ) . then ( ( ) = > setAllowed ( true ) ) . catch ( ( ) = > setAllowed ( false ) ) ; } , [ ] ) ; if ( allowed === false ) return < Login onSuccess = { ( ) = > setAllowed ( true ) } / > ; if ( allowed === undefined ) return < main > 正 在 验 证 管 理 员 会 话 … < / main > ; const submit = async ( kind : string ) = > { try { const body : any = { kind , uid : form.uid , name : form.name } ; if ( kind === "gift" ) Object . assign ( body , { giftName : form.giftName , battery : Number ( form . battery ) , quantity : Number ( form . quantity ) } ) ; if ( kind === "danmaku" ) body . text = form . text ; await api ( "/api/test/event" , { method : "POST" , body : JSON.stringify ( body ) } ) ; setNotice ( "测试事件已发出;请在 OBS 展示页确认效果。" ) ; } catch ( e ) { setNotice ( ( e as Error ) . message ) ; } } ; const set = ( name : string , value : any ) = > setForm ( f = > ( { . . . f , [ name ] : value } ) ) ; return < main > < header > < h1 > 弹 幕 触 发 测 试 < / h1 > < nav > < a href = "/admin" > 管 理 台 < / a > < a href = "/obs" target = "_blank" > OBS 展 示 < / a > < / nav > < / header > < p > 测 试 使 用 独 立 的 测 试 账 户 和 点 数 范 围 , 不 影 响 真 实 观 众 或 聊 天 回 复 。 < / p > < section className = "form" > < label > UID < input value = { form . uid } onChange = { e = > set ( "uid" , e . target . value ) } / > < / label > < label > 昵 称 < input value = { form . name } onChange = { e = > set ( "name" , e . target . value ) } / > < / label > < label > 礼 物 名 称 < input value = { form . giftName } onChange = { e = > set ( "giftName" , e . target . value ) } / > < / label > < label > 电 池 < input type = "number" value = { form . battery } onChange = { e = > set ( "battery" , e . target . value ) } / > < / label > < label > 数 量 < input type = "number" value = { form . quantity } onChange = { e = > set ( "quantity" , e . target . value ) } / > < / label > < label > 弹 幕 < input value = { form . text } onChange = { e = > set ( "text" , e . target . value ) } / > < / label > < div > < button onClick = { ( ) = > void submit ( "enter" ) } > 模 拟 进 房 < / button > < button onClick = { ( ) = > void submit ( "gift" ) } > 模 拟 礼 物 < / button > < button onClick = { ( ) = > void submit ( "danmaku" ) } > 模 拟 弹 幕 < / button > < / div > { notice && < p > { notice } < / p > } < / section > < / main > }
function EventList ( { events } : { events : LiveEvent [ ] } ) { return < section > < h2 > 实 时 事 件 < / h2 > < ol className = "events" > { events . map ( event = > < li key = { event . id } > < b > { event . type } < / b > < code > { JSON . stringify ( event . payload ) } < / code > < / li > ) } < / ol > < / section > }
function Obs() { const token = new URLSearchParams ( location . search ) . get ( "token" ) ? ? undefined ; const events = useEvents ( token ) ; const latest = events [ 0 ] ; return < main className = "obs" > { latest ? < div className = { ` event event- ${ latest . type . replaceAll ( "." , "-" ) } ` } > < span className = "type" > { latest . type } < / span > { latest . type === "wheel.result" ? < > < h1 > { latest . payload . viewer . name } 抽 中 了 < / h1 > < h2 > 《 { latest . payload . song . title } 》 < / h2 > < p > { latest . payload . category } · 剩 余 { latest . payload . balance } 点 < / p > < / > : latest . type === "live.gift" ? < > < h1 > { latest . payload . viewer . name } 送 出 { latest . payload . giftName } < / h1 > < h2 > + { latest . payload . battery * latest . payload . quantity } 点 < / h2 > < / > : latest . type === "live.enter" ? < h1 > 欢 迎 { latest . payload . viewer . name } 进 入 直 播 间 < / h1 > : < h2 > { JSON . stringify ( latest . payload ) } < / h2 > } < / div > : < div className = "event standby" > 等 待 直 播 事 件 … < / div > } < / main > }
const path = location . pathname ; createRoot ( document . getElementById ( "root" ) ! ) . render ( path === "/test" ? < TestPage / > : path === "/obs" ? < Obs / > : < Admin / > ) ;