/** * 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 (

{title}

{description &&

{description}

}
{aside}
{children}
) } function FlashMessage({ flash }: { flash: Flash }) { if (!flash) return null return (
{flash.text}
) } function ControlLayout({ user, active, onLogout, children, }: { user: AuthUser active: 'components' | 'invitations' onLogout: () => Promise children: ReactNode }) { const isAdmin = user.role === 'system_admin' return (
洛星瓷直播云台 OBS COMPONENT STUDIO
{user.displayName || user.username} {isAdmin ? '系统管理员' : '用户'}
{children}
) } function SettingsEditor({ settings, onChange, onSave, saving, }: { settings: OverlaySettings onChange: (settings: OverlaySettings) => void onSave: () => Promise saving: boolean }) { const edit = (key: K, value: OverlaySettings[K]) => { onChange({ ...settings, [key]: value }) } const eventToggles: Array<[keyof OverlaySettings, string]> = [ ['showDanmaku', '弹幕'], ['showEnter', '进房'], ['showGift', '礼物'], ['showSuperchat', '醒目留言'], ['showGuard', '舰长'], ['showLike', '点赞'], ['showShare', '分享'], ] return (
显示事件 {eventToggles.map(([key, label]) => ( ))}
) } function OverlayPreview({ settings }: { settings: OverlaySettings }) { const [preset, setPreset] = useState(previewPresets[1]) return (
{previewPresets.map(size => ( ))}
) } 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() 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( '/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 ( {source.connected ? '已连接' : '未连接'} ) } > {source.detail &&

{source.detail}

}
) } 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) : {} 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({ publicId: component.publicId, configured: false, }) const [busy, setBusy] = useState(false) const [flash, setFlash] = useState() usePwaUpdateBlocker( `obs-token:${component.id}`, '复制并妥善保存本次生成的 OBS 地址', busy || Boolean(state.address), ) useEffect(() => { let cancelled = false setState({ publicId: component.publicId, configured: false }) setFlash(undefined) api(`/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( `/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 (
访问状态 {state.configured ? '已生成令牌' : '尚未生成'}
组件公开标识 {state.publicId}
{state.updatedAt && (
最近轮换 {formatDate(state.updatedAt)}
)}
{state.address && ( )}
{state.address && ( )}
) } 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() 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 (
{kind === 'danmaku' && ( )} {kind === 'gift' && ( <> )}
) } function ComponentList({ components, selectedId, onSelect, }: { components: ComponentSummary[] selectedId?: string onSelect: (component: ComponentSummary) => void }) { return ( ) } export function ComponentsPage({ user, onLogout, }: { user: AuthUser onLogout: () => Promise }) { const [components, setComponents] = useState([]) const [selectedId, setSelectedId] = useState() const [settings, setSettings] = useState() const [source, setSource] = useState() const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [flash, setFlash] = useState() const selectedIdRef = useRef(undefined) const settingsRequestRef = useRef(0) const savedSettingsRef = useRef(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( `/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('/api/v1/components'), api('/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( `/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 (
{loading &&
正在展开云台…
} {!loading && selected && ( <>

{selected.kind.toUpperCase()}

{selected.name}

{selected.enabled === false ? '已停用' : '运行中'}
{isDanmakuKind(selected.kind) && settings ? ( <> ) : (
该组件类型的设置编辑器尚未安装。
)} )} {!loading && !selected && (
当前账户还没有可配置的组件。
)} {source && }
) } 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 }) { const [invitations, setInvitations] = useState([]) const [roomId, setRoomId] = useState('') const [expiresInHours, setExpiresInHours] = useState(24) const [newCode, setNewCode] = useState('') const [busy, setBusy] = useState(false) const [flash, setFlash] = useState() usePwaUpdateBlocker( 'invitation-code', '复制并妥善保存本次生成的一次性邀请码', busy || Boolean(newCode), ) const load = useCallback(async () => { const payload = await api('/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( '/api/v1/invitations', json('POST', { roomId: roomId.trim(), expiresInHours }), ) const root = payload && typeof payload === 'object' ? (payload as Record) : {} const invitation = root.invitation && typeof root.invitation === 'object' ? (root.invitation as Record) : 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 (

SYSTEM ADMIN

邀请码管理

仅系统管理员
{newCode && (
仅显示一次 {newCode} event.currentTarget.select()} />
)}
{invitations.map(invitation => { const status = invitationStatus(invitation) return ( ) })} {invitations.length === 0 && ( )}
直播间 邀请码前缀 创建时间 有效期 状态
{invitation.roomId} {invitation.codePrefix || '—'} {formatDate(invitation.createdAt)} {formatDate(invitation.expiresAt)} {status.label} {status.className === 'online' && ( )}
尚未创建邀请码。
) } export function ForbiddenPage({ user, onLogout, }: { user: AuthUser onLogout: () => Promise }) { return (

邀请码管理仅对系统管理员开放。

返回我的组件
) } export function isUnauthorized(error: unknown): boolean { return error instanceof ApiError && error.status === 401 }