proper productionize project
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user