proper productionize project

This commit is contained in:
2026-07-15 23:54:53 -07:00
parent b8850c87a4
commit edb6d2b5b4
46 changed files with 11346 additions and 1560 deletions
+784
View File
@@ -0,0 +1,784 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { FormEvent, ReactNode } from 'react'
import {
ApiError,
api,
copyToClipboard,
errorMessage,
json,
normalizeComponents,
normalizeInvitations,
normalizeSettings,
normalizeSource,
} from './api'
import { Overlay } from './overlay'
import { PwaControls, usePwaUpdateBlocker } from './pwa'
import { defaultOverlaySettings } from './types'
import type {
AuthUser,
ComponentSummary,
CookieCloudSource,
Invitation,
OverlaySettings,
} from './types'
const previewPresets = [
{ label: '窄侧栏', width: 360, height: 600 },
{ label: '竖屏', width: 440, height: 760 },
{ label: '高清竖栏', width: 600, height: 1080 },
{ label: '横向条', width: 720, height: 320 },
]
function isDanmakuKind(kind: string): boolean {
return kind === 'danmaku_overlay' || kind === 'danmaku'
}
type Flash = { kind: 'success' | 'error'; text: string } | undefined
function Panel({ title, description, aside, children, className = '' }: {
title: string
description?: string
aside?: ReactNode
children: ReactNode
className?: string
}) {
return (
<section className={`dashboard-panel jade-panel ${className}`}>
<header className="panel-header">
<div>
<h2>{title}</h2>
{description && <p>{description}</p>}
</div>
{aside}
</header>
{children}
</section>
)
}
function FlashMessage({ flash }: { flash: Flash }) {
if (!flash) return null
return <div className={`notice ${flash.kind}`} role={flash.kind === 'error' ? 'alert' : 'status'}>{flash.text}</div>
}
function ControlLayout({ user, active, onLogout, children }: {
user: AuthUser
active: 'components' | 'invitations'
onLogout: () => Promise<void>
children: ReactNode
}) {
const isAdmin = user.role === 'system_admin'
return (
<main className="dashboard-shell">
<header className="dashboard-topbar">
<a className="brand" href="/control/" aria-label="返回组件控制台">
<span aria-hidden="true">星</span>
<div><b>洛星瓷直播云台</b><small>OBS COMPONENT STUDIO</small></div>
</a>
<nav aria-label="控制台导航">
<a className={active === 'components' ? 'active' : ''} href="/control/">我的组件</a>
{isAdmin && <a className={active === 'invitations' ? 'active' : ''} href="/control/invitations">邀请码</a>}
</nav>
<div className="account-menu">
<PwaControls />
<div><b>{user.displayName || user.username}</b><small>{isAdmin ? '系统管理员' : '用户'}</small></div>
<button type="button" className="ghost-button" onClick={() => void onLogout()}>退出</button>
</div>
</header>
{children}
</main>
)
}
function SettingsEditor({ settings, onChange, onSave, saving }: {
settings: OverlaySettings
onChange: (settings: OverlaySettings) => void
onSave: () => Promise<void>
saving: boolean
}) {
const edit = <K extends keyof OverlaySettings>(key: K, value: OverlaySettings[K]) => {
onChange({ ...settings, [key]: value })
}
const eventToggles: Array<[keyof OverlaySettings, string]> = [
['showDanmaku', '弹幕'],
['showEnter', '进房'],
['showGift', '礼物'],
['showSuperchat', '醒目留言'],
['showGuard', '舰长'],
['showLike', '点赞'],
['showShare', '分享'],
]
return (
<div className="settings-editor">
<div className="slider-grid">
<label>
<span>字号 <output>{settings.fontScale}%</output></span>
<input type="range" min="50" max="300" step="5" value={settings.fontScale} onChange={event => edit('fontScale', +event.target.value)} />
</label>
<label>
<span>最大可见条数 <output>{settings.maxVisible}</output></span>
<input type="range" min="1" max="12" value={settings.maxVisible} onChange={event => edit('maxVisible', +event.target.value)} />
</label>
<label>
<span>自动收缩 <output>{settings.collapseAfterSeconds}s</output></span>
<input type="range" min="2" max="120" value={settings.collapseAfterSeconds} onChange={event => edit('collapseAfterSeconds', +event.target.value)} />
</label>
<label>
<span>卷轴展开时长 <output>{(settings.unfoldDurationMs / 1000).toFixed(1)}s</output></span>
<input type="range" min="200" max="5000" step="100" value={settings.unfoldDurationMs} onChange={event => edit('unfoldDurationMs', +event.target.value)} />
</label>
<label>
<span>动效强度 <output>{settings.motionIntensity}%</output></span>
<input type="range" min="0" max="100" value={settings.motionIntensity} onChange={event => edit('motionIntensity', +event.target.value)} />
</label>
<label>
<span>每卡粒子数量 <output>{settings.particleCount}</output></span>
<input type="range" min="0" max="12" value={settings.particleCount} onChange={event => edit('particleCount', +event.target.value)} />
</label>
<label>
<span>粒子动画速度 <output>{settings.particleSpeed}%</output></span>
<input type="range" min="25" max="300" step="25" value={settings.particleSpeed} onChange={event => edit('particleSpeed', +event.target.value)} />
</label>
</div>
<fieldset className="toggle-grid">
<legend>显示事件</legend>
{eventToggles.map(([key, label]) => (
<label key={key}>
<input
type="checkbox"
checked={Boolean(settings[key])}
onChange={event => edit(key, event.target.checked as never)}
/>
<span>{label}</span>
</label>
))}
<label>
<input type="checkbox" checked={settings.lowPerformanceMode} onChange={event => edit('lowPerformanceMode', event.target.checked)} />
<span>低性能模式</span>
</label>
</fieldset>
<div className="field-grid two-columns">
<label>
高价值礼物阈值(厘)
<input type="number" min="0" value={settings.highValueThreshold} onChange={event => edit('highValueThreshold', +event.target.value)} />
</label>
<label>
特别高价值阈值(厘)
<input type="number" min="0" value={settings.featuredValueThreshold} onChange={event => edit('featuredValueThreshold', +event.target.value)} />
</label>
</div>
<div className="form-actions align-end">
<button type="button" disabled={saving} onClick={() => void onSave()}>{saving ? '正在保存…' : '保存并实时同步'}</button>
</div>
</div>
)
}
function OverlayPreview({ settings }: { settings: OverlaySettings }) {
const [preset, setPreset] = useState(previewPresets[1])
return (
<Panel title="自适应预览" description="预设只改变预览尺寸;OBS 浏览器源仍可使用任意宽高。" className="preview-panel">
<div className="preset-buttons">
{previewPresets.map(size => (
<button
type="button"
className={size.label === preset.label ? 'active' : 'secondary'}
onClick={() => setPreset(size)}
key={size.label}
>
{size.label}<small>{size.width}×{size.height}</small>
</button>
))}
</div>
<div className="preview-viewport">
<div className="preview-frame" style={{ width: preset.width, height: preset.height }}>
<Overlay preview previewSettings={settings} />
</div>
</div>
</Panel>
)
}
function SourceEditor({ source, onSaved }: {
source: CookieCloudSource
onSaved: (source: CookieCloudSource) => void
}) {
const [roomId, setRoomId] = useState(source.roomId)
const [host, setHost] = useState(source.cookieCloud.host)
const [key, setKey] = useState(source.cookieCloud.key)
const [password, setPassword] = useState('')
const [busy, setBusy] = useState(false)
const [flash, setFlash] = useState<Flash>()
const sourceDirty = roomId !== source.roomId
|| host.trim() !== source.cookieCloud.host
|| key !== source.cookieCloud.key
|| Boolean(password)
usePwaUpdateBlocker('live-source', '保存或还原直播源与 CookieCloud 设置', busy || sourceDirty)
useEffect(() => {
setRoomId(source.roomId)
setHost(source.cookieCloud.host)
setKey(source.cookieCloud.key)
setPassword('')
}, [source])
const submit = async (event: FormEvent) => {
event.preventDefault()
setBusy(true)
setFlash(undefined)
try {
const payload = await api<unknown>('/api/v1/source', json('PUT', {
roomId: roomId.trim(),
cookieCloud: {
host: host.trim(),
key: key.trim(),
...(password ? { password } : {}),
},
}))
const next = normalizeSource(payload)
onSaved(next)
setPassword('')
setFlash({ kind: 'success', text: '直播源已保存,连接会使用新的隔离配置。' })
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, '直播源保存失败') })
} finally {
setBusy(false)
}
}
return (
<Panel
title="Bilibili 直播源"
description="该 CookieCloud 凭据仅属于当前账户,不会与其他用户共享。"
aside={source.connected === undefined ? undefined : (
<span className={`status-chip ${source.connected ? 'online' : 'offline'}`}>
{source.connected ? '已连接' : '未连接'}
</span>
)}
>
{source.detail && <p className="source-detail">{source.detail}</p>}
<form className="field-grid two-columns" onSubmit={submit}>
<label>
邀请码绑定的直播间 ID
<input required readOnly inputMode="numeric" value={roomId} onChange={event => setRoomId(event.target.value)} />
<small>直播间由系统管理员签发邀请码时固定,用户不能自行切换。</small>
</label>
<label>
CookieCloud 地址
<input required type="url" placeholder="https://cookie.example.com" value={host} onChange={event => setHost(event.target.value)} />
</label>
<label>
CookieCloud UUID / Key
<input
required={!source.cookieCloud.keyConfigured}
autoComplete="off"
placeholder={source.cookieCloud.keyConfigured ? '已设置;留空保持不变' : '请输入同步 UUID / Key'}
value={key}
onChange={event => setKey(event.target.value)}
/>
</label>
<label>
CookieCloud 密码
<input
type="password"
autoComplete="new-password"
placeholder={source.cookieCloud.passwordConfigured ? '已设置;留空保持不变' : '请输入同步密码'}
required={!source.cookieCloud.passwordConfigured}
value={password}
onChange={event => setPassword(event.target.value)}
/>
</label>
<FlashMessage flash={flash} />
<div className="form-actions align-end span-all">
<button disabled={busy}>{busy ? '正在验证并保存…' : '保存直播源'}</button>
</div>
</form>
</Panel>
)
}
type TokenState = {
publicId: string
configured: boolean
updatedAt?: string
address?: string
}
function tokenState(value: unknown, fallbackPublicId: string): TokenState {
const root = value && typeof value === 'object' ? value as Record<string, unknown> : {}
const token = typeof root.token === 'string' ? root.token : undefined
const publicId = String(root.publicId ?? fallbackPublicId)
let address = typeof root.url === 'string'
? root.url
: typeof root.path === 'string'
? root.path
: undefined
if (address) address = new URL(address, location.origin).toString()
if (!address && token) {
const url = new URL(`/obs/${encodeURIComponent(publicId)}`, location.origin)
url.hash = new URLSearchParams({ token }).toString()
address = url.toString()
}
return {
publicId,
configured: root.configured === true || root.hasToken === true || root.tokenConfigured === true || Boolean(token || address),
updatedAt: typeof root.updatedAt === 'string' ? root.updatedAt : undefined,
address,
}
}
function ObsAccessPanel({ component }: { component: ComponentSummary }) {
const [state, setState] = useState<TokenState>({ publicId: component.publicId, configured: false })
const [busy, setBusy] = useState(false)
const [flash, setFlash] = useState<Flash>()
usePwaUpdateBlocker(
`obs-token:${component.id}`,
'复制并妥善保存本次生成的 OBS 地址',
busy || Boolean(state.address),
)
useEffect(() => {
let cancelled = false
setState({ publicId: component.publicId, configured: false })
setFlash(undefined)
api<unknown>(`/api/v1/components/${encodeURIComponent(component.id)}/token`)
.then(payload => { if (!cancelled) setState(tokenState(payload, component.publicId)) })
.catch(reason => {
if (cancelled) return
setState({ publicId: component.publicId, configured: false })
setFlash({ kind: 'error', text: errorMessage(reason, '无法读取当前组件的 OBS 令牌状态') })
})
return () => { cancelled = true }
}, [component.id, component.publicId])
const rotate = async () => {
if (state.configured && !window.confirm('轮换后,所有使用旧地址的 OBS 浏览器源会立即失效。确定继续吗?')) return
setBusy(true)
setFlash(undefined)
try {
const payload = await api<unknown>(`/api/v1/components/${encodeURIComponent(component.id)}/token`, json('POST'))
const next = tokenState(payload, component.publicId)
setState(next)
setFlash({ kind: 'success', text: '新 OBS 令牌已生成。请立即复制,离开本页后不会再次显示明文令牌。' })
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, '无法轮换 OBS 令牌') })
} finally {
setBusy(false)
}
}
const copy = async () => {
if (!state.address) return
const copied = await copyToClipboard(state.address)
setFlash(copied
? { kind: 'success', text: 'OBS 浏览器源地址已复制。' }
: { kind: 'error', text: '浏览器阻止了复制,请手工选择下方地址。' })
}
return (
<Panel title="OBS 只读访问" description="令牌仅能订阅这一组件;轮换不会影响账户登录或其他组件。">
<div className="token-summary">
<div>
<small>访问状态</small>
<b>{state.configured ? '已生成令牌' : '尚未生成'}</b>
</div>
<div>
<small>组件公开标识</small>
<code>{state.publicId}</code>
</div>
{state.updatedAt && <div><small>最近轮换</small><b>{formatDate(state.updatedAt)}</b></div>}
</div>
<FlashMessage flash={flash} />
{state.address && (
<label className="secret-address">
本次生成的 OBS 地址
<input readOnly value={state.address} onFocus={event => event.currentTarget.select()} />
</label>
)}
<div className="form-actions">
<button type="button" className={state.configured ? 'danger' : ''} disabled={busy} onClick={() => void rotate()}>
{busy ? '正在生成…' : state.configured ? '轮换令牌' : '生成 OBS 地址'}
</button>
{state.address && <button type="button" className="secondary" onClick={() => void copy()}>复制地址</button>}
</div>
</Panel>
)
}
function TestEvents({ componentId }: { componentId: string }) {
const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift'>('danmaku')
const [uid, setUid] = useState('test-viewer')
const [name, setName] = useState('测试观众')
const [text, setText] = useState('今天也要闪闪发光!')
const [giftName, setGiftName] = useState('小花花')
const [quantity, setQuantity] = useState(1)
const [battery, setBattery] = useState(100)
const [flash, setFlash] = useState<Flash>()
const [busy, setBusy] = useState(false)
const submit = async (event: FormEvent) => {
event.preventDefault()
setBusy(true)
setFlash(undefined)
try {
await api(`/api/v1/components/${encodeURIComponent(componentId)}/test-events`, json('POST', {
kind,
uid,
name,
...(kind === 'danmaku' ? { text } : {}),
...(kind === 'gift' ? { giftName, quantity, battery } : {}),
}))
setFlash({ kind: 'success', text: '测试事件已发送到当前组件。' })
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, '测试事件发送失败') })
} finally {
setBusy(false)
}
}
return (
<Panel title="事件测试" description="模拟事件只进入当前用户、当前组件,不会向 Bilibili 发送消息。">
<form className="field-grid two-columns" onSubmit={submit}>
<label>
事件类型
<select value={kind} onChange={event => setKind(event.target.value as typeof kind)}>
<option value="danmaku">弹幕</option>
<option value="enter">进入直播间</option>
<option value="gift">礼物</option>
</select>
</label>
<label>测试 UID<input required value={uid} onChange={event => setUid(event.target.value)} /></label>
<label>测试昵称<input required value={name} onChange={event => setName(event.target.value)} /></label>
{kind === 'danmaku' && <label>弹幕内容<input required value={text} onChange={event => setText(event.target.value)} /></label>}
{kind === 'gift' && (
<>
<label>礼物名称<input required value={giftName} onChange={event => setGiftName(event.target.value)} /></label>
<label>数量<input required type="number" min="1" value={quantity} onChange={event => setQuantity(+event.target.value)} /></label>
<label>电池数<input required type="number" min="0" value={battery} onChange={event => setBattery(+event.target.value)} /></label>
</>
)}
<FlashMessage flash={flash} />
<div className="form-actions align-end span-all"><button disabled={busy}>{busy ? '正在发送…' : '触发测试事件'}</button></div>
</form>
</Panel>
)
}
function ComponentList({ components, selectedId, onSelect }: {
components: ComponentSummary[]
selectedId?: string
onSelect: (component: ComponentSummary) => void
}) {
return (
<aside className="component-sidebar jade-panel">
<div className="component-sidebar-heading">
<p className="eyebrow">COMPONENTS</p>
<h2>我的组件</h2>
</div>
{components.length === 0
? <div className="empty-state"><b>还没有组件</b><p>账户初始化完成后,服务会为你创建默认弹幕姬。</p></div>
: (
<div className="component-list">
{components.map(component => (
<button
type="button"
className={component.id === selectedId ? 'selected' : ''}
onClick={() => onSelect(component)}
key={component.id}
>
<span className="component-icon" aria-hidden="true">{isDanmakuKind(component.kind) ? '弹' : '件'}</span>
<span><b>{component.name}</b><small>{isDanmakuKind(component.kind) ? '直播弹幕姬' : component.kind}</small></span>
<i className={component.enabled === false ? 'disabled' : 'enabled'} />
</button>
))}
</div>
)}
<div className="future-components">
<span>即将支持</span>
<small>礼物展示 · 点歌姬</small>
</div>
</aside>
)
}
export function ComponentsPage({ user, onLogout }: { user: AuthUser; onLogout: () => Promise<void> }) {
const [components, setComponents] = useState<ComponentSummary[]>([])
const [selectedId, setSelectedId] = useState<string>()
const [settings, setSettings] = useState<OverlaySettings>()
const [source, setSource] = useState<CookieCloudSource>()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [flash, setFlash] = useState<Flash>()
const selectedIdRef = useRef<string | undefined>(undefined)
const settingsRequestRef = useRef(0)
const savedSettingsRef = useRef<string | undefined>(undefined)
const selected = useMemo(() => components.find(component => component.id === selectedId), [components, selectedId])
const settingsDirty = Boolean(settings)
&& JSON.stringify(settings) !== savedSettingsRef.current
usePwaUpdateBlocker('component-settings', '保存或还原当前组件设置', saving || settingsDirty)
const loadComponentSettings = useCallback(async (component: ComponentSummary) => {
const requestId = ++settingsRequestRef.current
setSaving(false)
setSettings(undefined)
savedSettingsRef.current = undefined
setFlash(undefined)
try {
const payload = await api<unknown>(`/api/v1/components/${encodeURIComponent(component.id)}/settings`)
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== component.id) return
const next = { ...defaultOverlaySettings, ...normalizeSettings(payload) }
savedSettingsRef.current = JSON.stringify(next)
setSettings(next)
} catch (reason) {
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== component.id) return
setFlash({ kind: 'error', text: errorMessage(reason, '无法读取组件设置') })
}
}, [])
useEffect(() => {
let cancelled = false
const load = async () => {
setLoading(true)
try {
const [componentPayload, sourcePayload] = await Promise.all([
api<unknown>('/api/v1/components'),
api<unknown>('/api/v1/source'),
])
if (cancelled) return
const nextComponents = normalizeComponents(componentPayload)
setComponents(nextComponents)
setSource(normalizeSource(sourcePayload))
const requested = new URLSearchParams(location.search).get('component')
const first = nextComponents.find(component => component.id === requested)
?? nextComponents.find(component => isDanmakuKind(component.kind))
?? nextComponents[0]
if (first) {
selectedIdRef.current = first.id
setSelectedId(first.id)
await loadComponentSettings(first)
}
} catch (reason) {
if (!cancelled) setFlash({ kind: 'error', text: errorMessage(reason, '控制台数据加载失败') })
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
settingsRequestRef.current += 1
}
}, [loadComponentSettings])
const choose = (component: ComponentSummary) => {
selectedIdRef.current = component.id
setSelectedId(component.id)
const url = new URL(location.href)
url.searchParams.set('component', component.id)
history.replaceState(null, '', url)
void loadComponentSettings(component)
}
const saveSettings = async () => {
if (!selected || !settings) return
const componentId = selected.id
const requestId = settingsRequestRef.current
setSaving(true)
setFlash(undefined)
try {
const payload = await api<unknown>(
`/api/v1/components/${encodeURIComponent(componentId)}/settings`,
json('PUT', settings),
)
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return
const next = { ...defaultOverlaySettings, ...normalizeSettings(payload) }
savedSettingsRef.current = JSON.stringify(next)
setSettings(next)
setFlash({ kind: 'success', text: '组件设置已保存,并实时同步到已连接的 OBS。' })
} catch (reason) {
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return
setFlash({ kind: 'error', text: errorMessage(reason, '组件设置保存失败') })
} finally {
if (requestId === settingsRequestRef.current && selectedIdRef.current === componentId) setSaving(false)
}
}
return (
<ControlLayout user={user} active="components" onLogout={onLogout}>
<div className="dashboard-grid">
<ComponentList components={components} selectedId={selectedId} onSelect={choose} />
<div className="dashboard-content">
{loading && <div className="loading-panel jade-panel">正在展开云台…</div>}
<FlashMessage flash={flash} />
{!loading && selected && (
<>
<div className="page-heading">
<div><p className="eyebrow">{selected.kind.toUpperCase()}</p><h1>{selected.name}</h1></div>
<span className={`status-chip ${selected.enabled === false ? 'offline' : 'online'}`}>
{selected.enabled === false ? '已停用' : '运行中'}
</span>
</div>
{isDanmakuKind(selected.kind) && settings
? (
<>
<Panel title="弹幕姬设置" description="每一项都独立保存在当前用户的组件下。">
<SettingsEditor settings={settings} onChange={setSettings} onSave={saveSettings} saving={saving} />
</Panel>
<OverlayPreview settings={settings} />
</>
)
: <Panel title="组件设置"><div className="empty-state">该组件类型的设置编辑器尚未安装。</div></Panel>}
<ObsAccessPanel component={selected} key={selected.id} />
<TestEvents componentId={selected.id} />
</>
)}
{!loading && !selected && <Panel title="欢迎来到直播云台"><div className="empty-state">当前账户还没有可配置的组件。</div></Panel>}
{source && <SourceEditor source={source} onSaved={setSource} />}
</div>
</div>
</ControlLayout>
)
}
function formatDate(value?: string): string {
if (!value) return '永久'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
}
function invitationStatus(invitation: Invitation): { label: string; className: string } {
if (invitation.revokedAt) return { label: '已撤销', className: 'offline' }
if (invitation.expiresAt && new Date(invitation.expiresAt).getTime() <= Date.now()) return { label: '已过期', className: 'offline' }
if (invitation.consumedAt) return { label: '已使用', className: 'offline' }
return { label: '可使用', className: 'online' }
}
export function InvitationsPage({ user, onLogout }: { user: AuthUser; onLogout: () => Promise<void> }) {
const [invitations, setInvitations] = useState<Invitation[]>([])
const [roomId, setRoomId] = useState('')
const [expiresInHours, setExpiresInHours] = useState(24)
const [newCode, setNewCode] = useState('')
const [busy, setBusy] = useState(false)
const [flash, setFlash] = useState<Flash>()
usePwaUpdateBlocker('invitation-code', '复制并妥善保存本次生成的一次性邀请码', busy || Boolean(newCode))
const load = useCallback(async () => {
const payload = await api<unknown>('/api/v1/invitations')
setInvitations(normalizeInvitations(payload))
}, [])
useEffect(() => {
void load().catch(reason => setFlash({ kind: 'error', text: errorMessage(reason, '邀请码读取失败') }))
}, [load])
const create = async (event: FormEvent) => {
event.preventDefault()
setBusy(true)
setFlash(undefined)
setNewCode('')
try {
const payload = await api<unknown>('/api/v1/invitations', json('POST', { roomId: roomId.trim(), expiresInHours }))
const root = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {}
const invitation = root.invitation && typeof root.invitation === 'object'
? root.invitation as Record<string, unknown>
: root
const code = String(invitation.code ?? root.code ?? '')
setNewCode(code)
setFlash({ kind: 'success', text: '邀请码已创建。明文只显示这一次。' })
await load()
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, '邀请码创建失败') })
} finally {
setBusy(false)
}
}
const revoke = async (invitation: Invitation) => {
if (!window.confirm('确定撤销这个邀请码吗?尚未完成的注册会立即失效。')) return
setFlash(undefined)
try {
await api(`/api/v1/invitations/${encodeURIComponent(invitation.id)}`, json('DELETE'))
setFlash({ kind: 'success', text: '邀请码已撤销。' })
await load()
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, '邀请码撤销失败') })
}
}
const registerAddress = useMemo(() => {
if (!newCode) return ''
const url = new URL('/control/register', location.origin)
url.hash = new URLSearchParams({ invite: newCode }).toString()
return url.toString()
}, [newCode])
return (
<ControlLayout user={user} active="invitations" onLogout={onLogout}>
<div className="admin-content">
<div className="page-heading">
<div><p className="eyebrow">SYSTEM ADMIN</p><h1>邀请码管理</h1></div>
<span className="status-chip online">仅系统管理员</span>
</div>
<FlashMessage flash={flash} />
<Panel title="创建邀请码" description="默认单次使用、24 小时有效;邀请码只用于注册,不能用于日常登录。">
<form className="field-grid two-columns" onSubmit={create}>
<label>绑定的 Bilibili 直播间 ID<input required inputMode="numeric" value={roomId} onChange={event => setRoomId(event.target.value)} /></label>
<label>有效小时数<input type="number" min="1" max="720" value={expiresInHours} onChange={event => setExpiresInHours(+event.target.value)} /></label>
<div className="form-actions align-end span-all"><button disabled={busy}>{busy ? '正在创建…' : '创建邀请码'}</button></div>
</form>
{newCode && (
<div className="one-time-secret">
<b>仅显示一次</b>
<code>{newCode}</code>
<input readOnly value={registerAddress} onFocus={event => event.currentTarget.select()} />
<div className="form-actions">
<button type="button" className="secondary" onClick={() => void copyToClipboard(newCode)}>复制邀请码</button>
<button type="button" className="secondary" onClick={() => void copyToClipboard(registerAddress)}>复制注册链接</button>
</div>
</div>
)}
</Panel>
<Panel title="历史邀请码" description="列表不包含邀请码明文,只显示可审计的状态和使用次数。">
<div className="table-wrap">
<table>
<thead><tr><th>直播间</th><th>邀请码前缀</th><th>创建时间</th><th>有效期</th><th>状态</th><th /></tr></thead>
<tbody>
{invitations.map(invitation => {
const status = invitationStatus(invitation)
return (
<tr key={invitation.id}>
<td><code>{invitation.roomId}</code></td>
<td><code>{invitation.codePrefix || '—'}</code></td>
<td>{formatDate(invitation.createdAt)}</td>
<td>{formatDate(invitation.expiresAt)}</td>
<td><span className={`status-chip ${status.className}`}>{status.label}</span></td>
<td>{status.className === 'online' && <button type="button" className="danger small" onClick={() => void revoke(invitation)}>撤销</button>}</td>
</tr>
)
})}
{invitations.length === 0 && <tr><td colSpan={6}><div className="empty-state">尚未创建邀请码。</div></td></tr>}
</tbody>
</table>
</div>
</Panel>
</div>
</ControlLayout>
)
}
export function ForbiddenPage({ user, onLogout }: { user: AuthUser; onLogout: () => Promise<void> }) {
return (
<ControlLayout user={user} active="components" onLogout={onLogout}>
<div className="admin-content"><Panel title="没有访问权限"><p>邀请码管理仅对系统管理员开放。</p><a className="button-link" href="/control/">返回我的组件</a></Panel></div>
</ControlLayout>
)
}
export function isUnauthorized(error: unknown): boolean {
return error instanceof ApiError && error.status === 401
}