update UI details for new stream

This commit is contained in:
2026-08-14 12:20:46 -07:00
parent acc4c117f7
commit a36d511d39
18 changed files with 424 additions and 146 deletions
+11 -10
View File
@@ -4,17 +4,18 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域
## 路由
| 路由 | 权限 | 作用 |
| --------------------------------------- | --------------- | ------------------------------------ |
| `/control/` | 登录用户 | 直播源、组件、测试、设置和 OBS token |
| `/control/invitations` | system admin | 创建/撤销绑定房间的邀请码 |
| `/control/components/:id/song-requests` | 登录用户 | 点歌队列、统计与管理操作 |
| `/control/login` | 匿名 | 用户名 + TOTP/恢复码登录 |
| `/control/register` | 匿名受邀用户 | 邀请码注册与 TOTP enrollment |
| `/control/setup` | 首次部署 | 创建唯一 system admin |
| `/obs/:publicId` | component token | 透明 OBS 浏览器源 |
| 路由 | 权限 | 作用 |
| --------------------------------------- | --------------- | -------------------------------- |
| `/control/` | 登录用户 | 组件实例、测试、设置和 OBS token |
| `/control/invitations` | system admin | 创建/撤销绑定房间的邀请码 |
| `/control/components/:id/song-requests` | 登录用户 | 点歌队列、统计与管理操作 |
| `/control/login` | 匿名 | 用户名 + TOTP/恢复码登录 |
| `/control/register` | 匿名受邀用户 | 邀请码注册与 TOTP enrollment |
| `/control/setup` | 首次部署 | 创建唯一 system admin |
| `/obs/:publicId` | component token | 透明 OBS 浏览器源 |
`main.tsx` 在初始化控制台前先识别 OBS 路由,因此 OBS 不会注册 PWA 或请求账户 session。
`main.tsx`
在初始化控制台前先识别 OBS 路由,因此 OBS 不会注册 PWA 或请求账户 session。控制台允许同一组件类型创建多个命名实例;列表必须显示实例名称而不是只显示类型,以便不同 OBS 场景的样式和地址可被区分。
## 文件职责
+7 -1
View File
@@ -189,7 +189,13 @@ export function normalizeRecoveryCodes(value: unknown): string[] {
export function normalizeComponents(value: unknown): ComponentSummary[] {
const root = object(value)
const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : []
const list = Array.isArray(value)
? value
: Array.isArray(root.components)
? root.components
: root.component && typeof root.component === 'object'
? [root.component]
: []
return list
.map(entry => {
const item = object(entry)
+35 -7
View File
@@ -1994,13 +1994,37 @@ select {
box-shadow: 0 0 9px rgba(86, 232, 197, 0.7);
}
.future-components {
.component-create {
margin: 18px 8px 0;
padding-top: 15px;
display: grid;
gap: 4px;
gap: 8px;
border-top: 1px solid rgba(94, 187, 173, 0.12);
color: #567d78;
}
.component-create > b {
color: #bcebe2;
}
.component-create > small {
color: #719e98;
line-height: 1.4;
}
.component-create select,
.component-create input {
width: 100%;
min-width: 0;
min-height: 40px;
padding: 8px 10px;
border: 1px solid rgba(75, 157, 151, 0.45);
border-radius: 9px;
color: #edfffb;
background: rgba(3, 18, 26, 0.82);
}
.component-create button {
width: 100%;
}
.dashboard-content {
@@ -2017,6 +2041,14 @@ select {
align-items: center;
}
.page-heading-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
align-items: center;
}
.page-heading h1 {
margin: 0;
color: #e2fff9;
@@ -2352,10 +2384,6 @@ td:last-child {
.component-list {
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
}
.future-components {
display: none;
}
}
@media (max-width: 680px) {
+179 -66
View File
@@ -205,6 +205,32 @@ function isGiftMenuKind(kind: string): boolean {
return kind === 'gift_menu'
}
const componentKinds = ['danmaku_overlay', 'song_request', 'gift_effect', 'gift_menu'] as const
function componentKindLabel(kind: string): string {
return isDanmakuKind(kind)
? translate('components.danmaku_type')
: isSongRequestKind(kind)
? translate('components.song_type')
: isGiftEffectKind(kind)
? translate('components.gift_type')
: isGiftMenuKind(kind)
? translate('components.gift_menu_type')
: kind
}
function componentKindMark(kind: string): string {
return isDanmakuKind(kind)
? translate('components.danmaku_mark')
: isSongRequestKind(kind)
? translate('components.song_mark')
: isGiftEffectKind(kind)
? translate('components.gift_mark')
: isGiftMenuKind(kind)
? translate('components.gift_menu_mark')
: translate('components.generic_mark')
}
type Flash = { kind: 'success' | 'error'; text: string } | undefined
function Panel({
@@ -2048,11 +2074,28 @@ function ComponentList({
components,
selectedId,
onSelect,
onCreate,
creating,
}: {
components: ComponentSummary[]
selectedId?: string
onSelect: (component: ComponentSummary) => void
onCreate: (kind: string, name: string) => Promise<boolean>
creating: boolean
}) {
const [kind, setKind] = useState<(typeof componentKinds)[number]>('danmaku_overlay')
const [name, setName] = useState('')
usePwaUpdateBlocker(
'component-create',
translate('components.create_blocker'),
creating || Boolean(name.trim()),
)
const submit = async (event: FormEvent) => {
event.preventDefault()
if (await onCreate(kind, name)) setName('')
}
return (
<aside className="component-sidebar jade-panel">
<div className="component-sidebar-heading">
@@ -2074,49 +2117,43 @@ function ComponentList({
key={component.id}
>
<span className="component-icon" aria-hidden="true">
{isDanmakuKind(component.kind)
? translate('components.danmaku_mark')
: isSongRequestKind(component.kind)
? translate('components.song_mark')
: isGiftEffectKind(component.kind)
? translate('components.gift_mark')
: isGiftMenuKind(component.kind)
? translate('components.gift_menu_mark')
: translate('components.generic_mark')}
{componentKindMark(component.kind)}
</span>
<span>
<b>
{isDanmakuKind(component.kind)
? translate('components.danmaku_type')
: isSongRequestKind(component.kind)
? translate('components.song_type')
: isGiftEffectKind(component.kind)
? translate('components.gift_type')
: isGiftMenuKind(component.kind)
? translate('components.gift_menu_type')
: component.name}
</b>
<small>
{isDanmakuKind(component.kind)
? translate('components.danmaku_type')
: isSongRequestKind(component.kind)
? translate('components.song_type')
: isGiftEffectKind(component.kind)
? translate('components.gift_type')
: isGiftMenuKind(component.kind)
? translate('components.gift_menu_type')
: component.kind}
</small>
<b>{component.name}</b>
<small>{componentKindLabel(component.kind)}</small>
</span>
<i className={component.enabled === false ? 'disabled' : 'enabled'} />
</button>
))}
</div>
)}
<div className="future-components">
<span>{translate('components.coming_soon')}</span>
<small>{translate('components.future')}</small>
</div>
<form className="component-create" onSubmit={event => void submit(event)}>
<b>{translate('components.add_instance')}</b>
<small>{translate('components.add_instance_description')}</small>
<select
aria-label={translate('components.instance_type')}
value={kind}
onChange={event => setKind(event.target.value as typeof kind)}
>
{componentKinds.map(componentKind => (
<option value={componentKind} key={componentKind}>
{componentKindLabel(componentKind)}
</option>
))}
</select>
<input
required
maxLength={80}
aria-label={translate('components.instance_name')}
placeholder={translate('components.instance_name_placeholder')}
value={name}
onChange={event => setName(event.target.value)}
/>
<button disabled={creating || !name.trim()}>
{translate(creating ? 'components.creating' : 'components.create_instance')}
</button>
</form>
</aside>
)
}
@@ -2134,6 +2171,8 @@ export function ComponentsPage({
const [settings, setSettings] = useState<ComponentSettings>()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [creating, setCreating] = useState(false)
const [deleting, setDeleting] = useState(false)
const [flash, setFlash] = useState<Flash>()
const selectedIdRef = useRef<string | undefined>(undefined)
const settingsRequestRef = useRef(0)
@@ -2147,7 +2186,7 @@ export function ComponentsPage({
usePwaUpdateBlocker(
'component-settings',
translate('components.settings_blocker'),
saving || settingsDirty,
saving || deleting || settingsDirty,
)
const loadComponentSettings = useCallback(async (component: ComponentSummary) => {
@@ -2225,6 +2264,12 @@ export function ComponentsPage({
}, [loadComponentSettings])
const choose = (component: ComponentSummary) => {
if (
component.id !== selectedId &&
settingsDirty &&
!window.confirm(translate('components.discard_changes_confirm'))
)
return
selectedIdRef.current = component.id
setSelectedId(component.id)
const url = new URL(location.href)
@@ -2233,6 +2278,76 @@ export function ComponentsPage({
void loadComponentSettings(component)
}
const createInstance = async (kind: string, name: string): Promise<boolean> => {
if (settingsDirty && !window.confirm(translate('components.discard_changes_confirm')))
return false
setCreating(true)
setFlash(undefined)
try {
const payload = await api<unknown>(
'/api/v1/components',
json('POST', { kind, name: name.trim() }),
)
const component = normalizeComponents(payload)[0]
if (!component) throw new Error(translate('components.create_failed'))
setComponents(current => [...current, component])
selectedIdRef.current = component.id
setSelectedId(component.id)
const url = new URL(location.href)
url.searchParams.set('component', component.id)
history.replaceState(null, '', url)
await loadComponentSettings(component)
setFlash({ kind: 'success', text: translate('components.created') })
return true
} catch (reason) {
setFlash({
kind: 'error',
text: errorMessage(reason, translate('components.create_failed')),
})
return false
} finally {
setCreating(false)
}
}
const deleteSelected = async () => {
if (
!selected ||
!window.confirm(translate('components.delete_confirm', { name: selected.name }))
)
return
setDeleting(true)
setFlash(undefined)
try {
await api(`/api/v1/components/${encodeURIComponent(selected.id)}`, json('DELETE'))
const remaining = components.filter(component => component.id !== selected.id)
setComponents(remaining)
const next = remaining.find(component => component.kind === selected.kind) ?? remaining[0]
settingsRequestRef.current += 1
savedSettingsRef.current = undefined
setSettings(undefined)
selectedIdRef.current = next?.id
setSelectedId(next?.id)
const url = new URL(location.href)
if (next) {
url.searchParams.set('component', next.id)
history.replaceState(null, '', url)
await loadComponentSettings(next)
} else {
url.searchParams.delete('component')
history.replaceState(null, '', url)
}
setFlash({ kind: 'success', text: translate('components.deleted') })
} catch (reason) {
setFlash({
kind: 'error',
text: errorMessage(reason, translate('components.delete_failed')),
})
} finally {
setDeleting(false)
}
}
const saveSettings = async () => {
if (!selected || !settings) return
const componentId = selected.id
@@ -2282,7 +2397,13 @@ export function ComponentsPage({
return (
<ControlLayout user={user} active="components" onLogout={onLogout}>
<div className="dashboard-grid">
<ComponentList components={components} selectedId={selectedId} onSelect={choose} />
<ComponentList
components={components}
selectedId={selectedId}
onSelect={choose}
onCreate={createInstance}
creating={creating}
/>
<div className="dashboard-content">
{loading && (
<div className="loading-panel jade-panel">{translate('components.loading')}</div>
@@ -2292,36 +2413,28 @@ export function ComponentsPage({
<>
<div className="page-heading">
<div>
<p className="eyebrow">
{isDanmakuKind(selected.kind)
? translate('components.danmaku_type')
: isSongRequestKind(selected.kind)
? translate('components.song_type')
: isGiftEffectKind(selected.kind)
? translate('components.gift_type')
: isGiftMenuKind(selected.kind)
? translate('components.gift_menu_type')
: selected.kind}
</p>
<h1>
{isDanmakuKind(selected.kind)
? translate('components.danmaku_type')
: isSongRequestKind(selected.kind)
? translate('components.song_type')
: isGiftEffectKind(selected.kind)
? translate('components.gift_type')
: isGiftMenuKind(selected.kind)
? translate('components.gift_menu_type')
: selected.name}
</h1>
<p className="eyebrow">{componentKindLabel(selected.kind)}</p>
<h1>{selected.name}</h1>
</div>
<div className="page-heading-actions">
<span
className={`status-chip ${selected.enabled === false ? 'offline' : 'online'}`}
>
{selected.enabled === false
? translate('common.disabled')
: translate('common.enabled')}
</span>
{components.filter(component => component.kind === selected.kind).length > 1 && (
<button
type="button"
className="danger"
disabled={deleting}
onClick={() => void deleteSelected()}
>
{translate(deleting ? 'components.deleting' : 'components.delete_instance')}
</button>
)}
</div>
<span
className={`status-chip ${selected.enabled === false ? 'offline' : 'online'}`}
>
{selected.enabled === false
? translate('common.disabled')
: translate('common.enabled')}
</span>
</div>
{isDanmakuKind(selected.kind) && settings ? (
<>