initial commit

This commit is contained in:
2026-07-14 21:31:59 -07:00
commit bd79966218
61 changed files with 19379 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@lxc/web",
"private": true,
"type": "module",
"scripts": { "dev": "vite", "build": "tsc -b && vite build", "check": "tsc -b --pretty false" },
"dependencies": { "@lxc/live-client": "*", "@lxc/protocol": "*", "react": "^19.0.0", "react-dom": "^19.0.0" },
"devDependencies": { "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react": "^4.4.1", "typescript": "^5.8.3", "vite": "^6.2.2" }
}
+15
View File
@@ -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/>);
+1
View File
@@ -0,0 +1 @@
:root{font-family:system-ui,"Microsoft YaHei",sans-serif;color:#f5ecff;background:#160d24}*{box-sizing:border-box}body{margin:0}main{max-width:1100px;margin:auto;padding:2rem}header{display:flex;justify-content:space-between;align-items:center;gap:1rem}nav{display:flex;gap:.75rem;align-items:center}a,button{color:#fff;background:#7b36d7;border:0;border-radius:.5rem;padding:.55rem .8rem;text-decoration:none;cursor:pointer}button:hover{background:#9956eb}input{display:block;width:100%;margin:.3rem 0 1rem;padding:.6rem;border:1px solid #765497;border-radius:.4rem;background:#29163d;color:#fff}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:1rem;margin:1.5rem 0}.cards article,section,.form{background:#241334;border-radius:.8rem;padding:1rem}.cards button{margin-top:.5rem}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:.55rem;border-bottom:1px solid #4a2b67}.events{max-height:300px;overflow:auto;padding-left:1.5rem}.events li{margin:.5rem 0}.events code{word-break:break-all}.form{max-width:520px}.form label{display:block}.form div{display:flex;gap:.5rem;flex-wrap:wrap}.login{max-width:380px;margin-top:12vh}.error{color:#ff9da8}.obs{max-width:none;min-height:100vh;display:grid;place-items:center;background:transparent}.event{padding:2.5rem 4rem;text-align:center;border:3px solid #e6c7ff;border-radius:1rem;background:#180d26dd;box-shadow:0 0 40px #a94eff}.event h1{font-size:3rem;margin:.3rem}.event h2{font-size:4rem;margin:.3rem;color:#f9d86e}.type{opacity:.65}@media(max-width:650px){main{padding:1rem}header{align-items:flex-start;flex-direction:column}.event{padding:1rem}.event h1{font-size:1.8rem}.event h2{font-size:2.5rem}}
+1
View File
@@ -0,0 +1 @@
{ "compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "Bundler", "allowImportingTsExtensions": false, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true }, "include": ["src"] }
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()] });