1162 lines
37 KiB
TypeScript
1162 lines
37 KiB
TypeScript
/**
|
||
* 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
|
||
}
|