move live source ownership to accounts

This commit is contained in:
2026-07-18 22:24:00 -07:00
parent 1598d3c403
commit 53ae90ec13
20 changed files with 219 additions and 124 deletions
+62 -26
View File
@@ -1,8 +1,8 @@
/**
* Authenticated component studio.
*
* This module composes tenant-scoped component settings, CookieCloud source
* configuration, isolated test events, OBS token rotation and system-admin
* This module composes tenant-scoped component settings, account-level live
* 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.
*/
@@ -98,7 +98,7 @@ function ControlLayout({
children,
}: {
user: AuthUser
active: 'components' | 'invitations'
active: 'components' | 'account' | 'invitations'
onLogout: () => Promise<void>
children: ReactNode
}) {
@@ -117,6 +117,9 @@ function ControlLayout({
<a className={active === 'components' ? 'active' : ''} href="/control/">
我的组件
</a>
<a className={active === 'account' ? 'active' : ''} href="/control/account">
直播账户
</a>
{isAdmin && (
<a className={active === 'invitations' ? 'active' : ''} href="/control/invitations">
邀请码
@@ -509,21 +512,17 @@ function SourceEditor({
source: CookieCloudSource
onSaved: (source: CookieCloudSource) => void
}) {
const [roomId, setRoomId] = useState(source.roomId)
const roomId = 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)
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('')
@@ -535,7 +534,7 @@ function SourceEditor({
setFlash(undefined)
try {
const payload = await api<unknown>(
'/api/v1/source',
'/api/v1/account/live-source',
json('PUT', {
roomId: roomId.trim(),
cookieCloud: {
@@ -548,7 +547,7 @@ function SourceEditor({
const next = normalizeSource(payload)
onSaved(next)
setPassword('')
setFlash({ kind: 'success', text: '直播源已保存,连接会使用新的隔离配置。' })
setFlash({ kind: 'success', text: '账户直播源已保存,所有组件会共享这条事件流。' })
} catch (reason) {
setFlash({ kind: 'error', text: errorMessage(reason, '直播源保存失败') })
} finally {
@@ -559,7 +558,7 @@ function SourceEditor({
return (
<Panel
title="Bilibili 直播源"
description="该 CookieCloud 凭据仅属于当前账户,不会与其他用户共享。"
description="每个账户只有一条监听连接和一份 CookieCloud 凭据;监听事件会提供给本账户的所有组件。"
aside={
source.connected === undefined ? undefined : (
<span className={`status-chip ${source.connected ? 'online' : 'offline'}`}>
@@ -572,13 +571,7 @@ function SourceEditor({
<form className="field-grid two-columns" onSubmit={submit}>
<label>
邀请码绑定的直播间 ID
<input
required
readOnly
inputMode="numeric"
value={roomId}
onChange={event => setRoomId(event.target.value)}
/>
<input required readOnly inputMode="numeric" value={roomId} />
<small>直播间由系统管理员签发邀请码时固定,用户不能自行切换。</small>
</label>
<label>
@@ -942,7 +935,6 @@ export function ComponentsPage({
const [components, setComponents] = useState<ComponentSummary[]>([])
const [selectedId, setSelectedId] = useState<string>()
const [settings, setSettings] = useState<ComponentSettings>()
const [source, setSource] = useState<CookieCloudSource>()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [flash, setFlash] = useState<Flash>()
@@ -984,14 +976,10 @@ export function ComponentsPage({
const load = async () => {
setLoading(true)
try {
const [componentPayload, sourcePayload] = await Promise.all([
api<unknown>('/api/v1/components'),
api<unknown>('/api/v1/source'),
])
const componentPayload = await api<unknown>('/api/v1/components')
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) ??
@@ -1132,13 +1120,61 @@ export function ComponentsPage({
<div className="empty-state">当前账户还没有可配置的组件。</div>
</Panel>
)}
{source && <SourceEditor source={source} onSaved={setSource} />}
</div>
</div>
</ControlLayout>
)
}
/** Account-owned upstream configuration, intentionally separate from every
* component editor so adding future components cannot create another listener. */
export function AccountLiveSourcePage({
user,
onLogout,
}: {
user: AuthUser
onLogout: () => Promise<void>
}) {
const [source, setSource] = useState<CookieCloudSource>()
const [loading, setLoading] = useState(true)
const [flash, setFlash] = useState<Flash>()
useEffect(() => {
let cancelled = false
api<unknown>('/api/v1/account/live-source')
.then(payload => {
if (!cancelled) setSource(normalizeSource(payload))
})
.catch(reason => {
if (!cancelled)
setFlash({ kind: 'error', text: errorMessage(reason, '无法读取账户直播源') })
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
return (
<ControlLayout user={user} active="account" onLogout={onLogout}>
<div className="admin-content">
<div className="page-heading">
<div>
<p className="eyebrow">ACCOUNT LIVE STREAM</p>
<h1>直播账户</h1>
<p>配置一次直播间与 CookieCloud,供当前账户下所有现有及未来组件使用。</p>
</div>
</div>
<FlashMessage flash={flash} />
{loading && <div className="loading-panel jade-panel">正在读取账户直播源…</div>}
{source && <SourceEditor source={source} onSaved={setSource} />}
</div>
</ControlLayout>
)
}
async function loadCompleteSongQueue(componentId: string): Promise<SongRequestPage> {
// A queue may change between paginated HTTP requests. Restart instead of
// presenting pages from different revisions as one ordering.
+11 -1
View File
@@ -10,7 +10,13 @@ import { useCallback, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
import { EnrollmentPage, LoginPage } from './auth'
import { ComponentsPage, ForbiddenPage, InvitationsPage, SongRequestsPage } from './control'
import {
AccountLiveSourcePage,
ComponentsPage,
ForbiddenPage,
InvitationsPage,
SongRequestsPage,
} from './control'
import { Overlay, tokenFromFragment } from './overlay'
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
import { SongRequestOverlay } from './songOverlay'
@@ -166,6 +172,10 @@ function App() {
if (!session.user) return <Redirect to="/control/login" />
return <ComponentsPage user={session.user} onLogout={logout} />
}
if (path === '/control/account') {
if (!session.user) return <Redirect to="/control/login" />
return <AccountLiveSourcePage user={session.user} onLogout={logout} />
}
if (path === '/control/invitations') {
if (!session.user) return <Redirect to="/control/login" />
if (session.user.role !== 'system_admin')