Files
lxc-streamutils/apps/overlay/src/control.tsx
T
2026-07-16 00:12:26 -07:00

1162 lines
37 KiB
TypeScript
Raw 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.
/**
* Authenticated component studio.
*
* This module composes tenant-scoped component settings, CookieCloud source
* configuration, isolated test events, OBS token rotation and system-admin
* invitations. One-time secrets remain local to the panel that created them;
* update blockers prevent the PWA from refreshing until they are saved.
*/
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
}