formatting and comments

This commit is contained in:
2026-07-16 00:12:26 -07:00
parent edb6d2b5b4
commit 994854d104
45 changed files with 2514 additions and 628 deletions
+61
View File
@@ -0,0 +1,61 @@
# React 控制台与 OBS 前端
Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域托管。生产容器不包含 Node。
## 路由
| 路由 | 权限 | 作用 |
| ---------------------- | --------------- | ------------------------------------ |
| `/control/` | 登录用户 | 直播源、组件、测试、设置和 OBS token |
| `/control/invitations` | system admin | 创建/撤销绑定房间的邀请码 |
| `/control/login` | 匿名 | 用户名 + TOTP/恢复码登录 |
| `/control/register` | 匿名受邀用户 | 邀请码注册与 TOTP enrollment |
| `/control/setup` | 首次部署 | 创建唯一 system admin |
| `/obs/:publicId` | component token | 透明 OBS 浏览器源 |
`main.tsx` 在初始化控制台前先识别 OBS 路由,因此 OBS 不会注册 PWA 或请求账户 session。
## 文件职责
| 文件 | 职责 |
| ------------------- | ---------------------------------------------------- |
| `src/api.ts` | same-origin fetch、错误模型和兼容性 normalizer |
| `src/auth.tsx` | passwordless login、TOTP QR 与恢复码 |
| `src/control.tsx` | tenant component studio 和 system-admin 邀请码页面 |
| `src/overlay.tsx` | WebSocket、消息队列、礼物/表情和 OBS 自适应渲染 |
| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker |
| `src/types.ts` | sanitized API view model 与 overlay settings |
| `pwa/control-sw.js` | `/control/` 静态壳层的缓存策略 |
## Secret 与状态
- TOTP Secret、恢复码、邀请码和新 OBS 地址只保存在当前 React state。
- API normalizer 不把未知对象直接传播到组件。
- API mutation 离线时立即失败,不进入 Background Sync。
- PWA 更新在 dirty form 或一次性 secret 可见时被阻止。
- OBS token 从 URL fragment 读取,只在 WebSocket 第一帧发送。
- OBS 收到坏 JSON、坏图片或未知事件时局部降级,不让浏览器源崩溃。
## PWA
manifest、start URL 与 Service Worker scope 均为 `/control/`。worker 使用 network-first
HTML 和 cache-first build assets;`/api/*`、`/obs/*`、WebSocket 和用户数据永不缓存。每次 Vite
build 把同一个 build ID 注入浏览器 bundle 与 worker,发现更新后等待用户确认激活。
## 格式化与构建
```bash
npm run format
npm run format:check
npm run docs:check
npm run build
```
`npm run format`
使用仓库根目录的 Prettier 配置,同时格式化 TypeScript、TSX、CSS、HTML、JSON、Markdown、Compose
YAML 和项目文档。
组件协议与弹幕姬行为分别见:
- [实时协议](../../docs/protocol.md)
- [弹幕姬组件](../../docs/components/danmaku-overlay.md)
+17
View File
@@ -15,6 +15,7 @@
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.2",
"prettier": "3.9.5",
"typescript": "5.9.2",
"vite": "7.3.6"
}
@@ -1573,6 +1574,22 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prettier": {
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
+19 -3
View File
@@ -2,7 +2,23 @@
"name": "lxc-stream-overlay",
"private": true,
"version": "0.1.0",
"scripts": { "build": "tsc -b && vite build", "dev": "vite" },
"dependencies": { "react": "19.1.1", "react-dom": "19.1.1" },
"devDependencies": { "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.2", "typescript": "5.9.2", "vite": "7.3.6" }
"scripts": {
"build": "tsc -b && vite build",
"dev": "vite",
"docs:check": "node scripts/check-doc-links.mjs ../..",
"format": "prettier --ignore-path ../../.prettierignore --write . ../server-rust/README.md ../../README.md ../../docs ../../compose.yaml",
"format:check": "prettier --ignore-path ../../.prettierignore --check . ../server-rust/README.md ../../README.md ../../docs ../../compose.yaml"
},
"dependencies": {
"react": "19.1.1",
"react-dom": "19.1.1"
},
"devDependencies": {
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.2",
"prettier": "3.9.5",
"typescript": "5.9.2",
"vite": "7.3.6"
}
}
+4 -6
View File
@@ -7,8 +7,7 @@
- License: CC0 1.0 / public domain
- Retrieved: 2026-07-15
`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by
Paul Bürck”.
`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by Paul Bürck”.
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_04_element_by_Paul_B%C3%BCrck.svg
- Original artist: Paul Bürck, 1899
@@ -16,8 +15,7 @@ Paul Bürck”.
- License: original public domain; SVG adaptation CC0 1.0
- Retrieved: 2026-07-15
`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by
Paul Bürck”.
`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by Paul Bürck”.
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_01_corner_by_Paul_B%C3%BCrck.svg
- Original artist: Paul Bürck, 1899
@@ -25,5 +23,5 @@ Paul Bürck”.
- License: original public domain; SVG adaptation CC0 1.0
- Retrieved: 2026-07-15
The overlay uses these SVGs as recolorable CSS masks inside message cards and
bundles them locally, so OBS never needs to load the source websites.
The overlay uses these SVGs as recolorable CSS masks inside message cards and bundles them locally,
so OBS never needs to load the source websites.
+37 -19
View File
@@ -1,4 +1,11 @@
/* __PWA_BUILD_ID__ is replaced by the Vite build before this file is emitted. */
/**
* Network policy for the `/control/` application shell.
*
* `__PWA_BUILD_ID__` is replaced by Vite before emission. Navigations use a
* network-first strategy so fresh HTML can reference the newest hashed bundle;
* known static assets use cache-first. The worker never handles API, WebSocket
* or OBS routes because they are outside both this fetch policy and its scope.
*/
const buildId = '__PWA_BUILD_ID__'
const cacheName = `lxc-control-v2-shell-${buildId}`
const shellPath = '/control/'
@@ -24,8 +31,9 @@ async function precacheShell() {
const html = await response.clone().text()
await Promise.all(shellRoutes.map(route => cache.put(route, response.clone())))
const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)]
.map(match => match[1])
const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)].map(
match => match[1],
)
await cache.addAll([...new Set([...stableAssets, ...generatedAssets])])
}
@@ -34,13 +42,17 @@ self.addEventListener('install', event => {
})
self.addEventListener('activate', event => {
event.waitUntil((async () => {
const names = await caches.keys()
await Promise.all(names
.filter(name => name.startsWith('lxc-control-v2-shell-') && name !== cacheName)
.map(name => caches.delete(name)))
await self.clients.claim()
})())
event.waitUntil(
(async () => {
const names = await caches.keys()
await Promise.all(
names
.filter(name => name.startsWith('lxc-control-v2-shell-') && name !== cacheName)
.map(name => caches.delete(name)),
)
await self.clients.claim()
})(),
)
})
self.addEventListener('message', event => {
@@ -52,9 +64,11 @@ function isControlShell(pathname) {
}
function isStaticAsset(pathname) {
return pathname.startsWith('/assets/')
|| pathname.startsWith('/pwa/')
|| pathname === '/control/manifest.webmanifest'
return (
pathname.startsWith('/assets/') ||
pathname.startsWith('/pwa/') ||
pathname === '/control/manifest.webmanifest'
)
}
async function navigationResponse(request) {
@@ -62,16 +76,20 @@ async function navigationResponse(request) {
const pathname = new URL(request.url).pathname
try {
const response = await fetch(request)
if (shellRoutes.includes(pathname)
&& response.ok
&& response.headers.get('content-type')?.includes('text/html')) {
if (
shellRoutes.includes(pathname) &&
response.ok &&
response.headers.get('content-type')?.includes('text/html')
) {
await cache.put(pathname, response.clone())
}
return response
} catch {
return (await cache.match(request, { ignoreSearch: true }))
|| (await cache.match(shellPath))
|| Response.error()
return (
(await cache.match(request, { ignoreSearch: true })) ||
(await cache.match(shellPath)) ||
Response.error()
)
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Verify repository-local Markdown links without making network requests.
*
* External URLs are intentionally skipped: CI should not fail because an
* upstream website is temporarily unavailable. Local documentation links are
* deterministic and catch renamed component guides or incorrect relative paths.
*/
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { dirname, extname, resolve } from 'node:path'
const root = resolve(process.argv[2] ?? new URL('../../..', import.meta.url).pathname)
const ignoredDirectories = new Set([
'.agents',
'.codex',
'.git',
'dist',
'node_modules',
'target',
'vendor',
])
const markdownFiles = []
function collect(directory) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue
const path = resolve(directory, entry.name)
if (entry.isDirectory()) collect(path)
else if (extname(entry.name).toLowerCase() === '.md') markdownFiles.push(path)
}
}
collect(root)
const failures = []
const markdownLink = /\[[^\]]*\]\(([^)]+)\)/g
for (const file of markdownFiles) {
const source = readFileSync(file, 'utf8')
for (const match of source.matchAll(markdownLink)) {
const rawTarget = match[1].trim().replace(/^<|>$/g, '')
if (!rawTarget || rawTarget.startsWith('#') || /^[a-z][a-z+.-]*:/i.test(rawTarget)) continue
// The project does not currently use titled local links. Splitting here
// still handles the conventional `(path "title")` form if one is added.
const target = decodeURI(rawTarget.split(/\s+["']/)[0].split('#')[0])
if (!target) continue
const destination = resolve(dirname(file), target)
if (!existsSync(destination)) failures.push(`${file}: ${rawTarget}`)
}
}
if (failures.length > 0) {
console.error(`Broken local documentation links:\n${failures.join('\n')}`)
process.exitCode = 1
} else {
console.log(`Checked ${markdownFiles.length} Markdown files; local links are valid.`)
}
+120 -71
View File
@@ -1,3 +1,12 @@
/**
* Same-origin API client and defensive wire-format normalizers.
*
* The backend is authoritative and may evolve response wrappers independently
* from a deployed frontend. Normalizers accept those compatible wrappers while
* producing strict UI models. Mutations are refused while offline and are never
* queued for background replay, which avoids applying an old user's action
* after logout or tenant switching.
*/
import type {
AuthUser,
ComponentSummary,
@@ -13,7 +22,12 @@ export class ApiError extends Error {
readonly code?: string
readonly fieldErrors?: Record<string, string>
constructor(status: number, message: string, code?: string, fieldErrors?: Record<string, string>) {
constructor(
status: number,
message: string,
code?: string,
fieldErrors?: Record<string, string>,
) {
super(message)
this.name = 'ApiError'
this.status = status
@@ -36,7 +50,8 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
throw new ApiError(0, '当前处于离线状态,操作没有提交;联网后请重试。', 'offline')
}
const headers = new Headers(init.headers)
if (init.body != null && !headers.has('content-type')) headers.set('content-type', 'application/json')
if (init.body != null && !headers.has('content-type'))
headers.set('content-type', 'application/json')
headers.set('accept', 'application/json')
const response = await fetch(path, {
@@ -49,13 +64,13 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
if (response.status === 401 && !path.startsWith('/api/v1/auth/')) {
window.dispatchEvent(new Event('lxc:session-expired'))
}
const error = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {}
const error = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
throw new ApiError(
response.status,
String(error.message ?? error.error ?? `请求失败(HTTP ${response.status})`),
typeof error.code === 'string' ? error.code : undefined,
error.fieldErrors && typeof error.fieldErrors === 'object'
? error.fieldErrors as Record<string, string>
? (error.fieldErrors as Record<string, string>)
: undefined,
)
}
@@ -71,24 +86,29 @@ export function json(method: string, body?: unknown): RequestInit {
function object(value: unknown): Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
? (value as Record<string, unknown>)
: {}
}
export function normalizeSession(value: unknown): Session {
const root = object(value)
const candidate = root.user
?? (root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string' ? root : null)
const candidate =
root.user ??
(root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string'
? root
: null)
const raw = object(candidate)
const hasUser = typeof raw.id === 'string' || typeof raw.username === 'string'
const user: AuthUser | null = hasUser ? {
id: String(raw.id ?? ''),
username: String(raw.username ?? ''),
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
role: typeof raw.role === 'string' ? raw.role : 'user',
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
} : null
const user: AuthUser | null = hasUser
? {
id: String(raw.id ?? ''),
username: String(raw.username ?? ''),
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
role: typeof raw.role === 'string' ? raw.role : 'user',
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
}
: null
return { user, setupRequired: root.setupRequired === true }
}
@@ -98,19 +118,26 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment {
const enrollmentToken = root.enrollmentToken ?? totp.enrollmentToken ?? root.flowId ?? root.id
return {
enrollmentToken: String(enrollmentToken ?? ''),
qrSvg: typeof totp.qrSvg === 'string' ? totp.qrSvg : typeof root.qrSvg === 'string' ? root.qrSvg : undefined,
qrDataUrl: typeof totp.qrDataUrl === 'string'
? totp.qrDataUrl
: typeof totp.qrCodeDataUrl === 'string'
? totp.qrCodeDataUrl
: typeof root.qrDataUrl === 'string'
? root.qrDataUrl
qrSvg:
typeof totp.qrSvg === 'string'
? totp.qrSvg
: typeof root.qrSvg === 'string'
? root.qrSvg
: undefined,
qrDataUrl:
typeof totp.qrDataUrl === 'string'
? totp.qrDataUrl
: typeof totp.qrCodeDataUrl === 'string'
? totp.qrCodeDataUrl
: typeof root.qrDataUrl === 'string'
? root.qrDataUrl
: undefined,
otpauthUri:
typeof totp.otpauthUri === 'string'
? totp.otpauthUri
: typeof root.otpauthUri === 'string'
? root.otpauthUri
: undefined,
otpauthUri: typeof totp.otpauthUri === 'string'
? totp.otpauthUri
: typeof root.otpauthUri === 'string'
? root.otpauthUri
: undefined,
manualKey: String(totp.manualKey ?? totp.secret ?? root.manualKey ?? root.secret ?? ''),
expiresAt: typeof root.expiresAt === 'string' ? root.expiresAt : undefined,
}
@@ -119,24 +146,28 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment {
export function normalizeRecoveryCodes(value: unknown): string[] {
const root = object(value)
const codes = root.recoveryCodes ?? object(root.recovery).codes
return Array.isArray(codes) ? codes.filter((code): code is string => typeof code === 'string') : []
return Array.isArray(codes)
? codes.filter((code): code is string => typeof code === 'string')
: []
}
export function normalizeComponents(value: unknown): ComponentSummary[] {
const root = object(value)
const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : []
return list.map((entry) => {
const item = object(entry)
return {
id: String(item.id ?? ''),
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
kind: String(item.kind ?? item.type ?? 'danmaku'),
name: String(item.name ?? '弹幕姬'),
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
settings: item.settings as OverlaySettings | undefined,
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined,
}
}).filter(item => item.id)
return list
.map(entry => {
const item = object(entry)
return {
id: String(item.id ?? ''),
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
kind: String(item.kind ?? item.type ?? 'danmaku'),
name: String(item.name ?? '弹幕姬'),
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
settings: item.settings as OverlaySettings | undefined,
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined,
}
})
.filter(item => item.id)
}
export function normalizeSettings(value: unknown): OverlaySettings {
@@ -147,50 +178,68 @@ export function normalizeSettings(value: unknown): OverlaySettings {
export function normalizeSource(value: unknown): CookieCloudSource {
const root = object(value)
const source = object(root.source ?? root)
const cookieCloud = object(source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud)
const cookieCloud = object(
source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud,
)
const status = object(source.status)
return {
roomId: String(source.roomId ?? root.roomId ?? ''),
cookieCloud: {
host: String(cookieCloud.host ?? ''),
key: '',
keyConfigured: cookieCloud.keyConfigured === true
|| (typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
passwordConfigured: cookieCloud.passwordConfigured === true
|| cookieCloud.configured === true
|| (typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
keyConfigured:
cookieCloud.keyConfigured === true ||
(typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
passwordConfigured:
cookieCloud.passwordConfigured === true ||
cookieCloud.configured === true ||
(typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
},
connected: typeof source.connected === 'boolean'
? source.connected
: typeof status.connected === 'boolean'
? status.connected
: undefined,
detail: typeof source.detail === 'string'
? source.detail
: typeof status.detail === 'string'
? status.detail
: undefined,
connected:
typeof source.connected === 'boolean'
? source.connected
: typeof status.connected === 'boolean'
? status.connected
: undefined,
detail:
typeof source.detail === 'string'
? source.detail
: typeof status.detail === 'string'
? status.detail
: undefined,
updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : undefined,
}
}
export function normalizeInvitations(value: unknown): Invitation[] {
const root = object(value)
const list = Array.isArray(value) ? value : Array.isArray(root.invitations) ? root.invitations : []
return list.map((entry) => {
const item = object(entry)
return {
id: String(item.id ?? ''),
code: typeof item.code === 'string' ? item.code : undefined,
codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined,
roomId: String(item.roomId ?? ''),
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
consumedAt: typeof item.consumedAt === 'string' || item.consumedAt === null ? item.consumedAt : undefined,
revokedAt: typeof item.revokedAt === 'string' || item.revokedAt === null ? item.revokedAt : undefined,
}
}).filter(item => item.id)
const list = Array.isArray(value)
? value
: Array.isArray(root.invitations)
? root.invitations
: []
return list
.map(entry => {
const item = object(entry)
return {
id: String(item.id ?? ''),
code: typeof item.code === 'string' ? item.code : undefined,
codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined,
roomId: String(item.roomId ?? ''),
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
consumedAt:
typeof item.consumedAt === 'string' || item.consumedAt === null
? item.consumedAt
: undefined,
revokedAt:
typeof item.revokedAt === 'string' || item.revokedAt === null
? item.revokedAt
: undefined,
}
})
.filter(item => item.id)
}
export function errorMessage(error: unknown, fallback = '操作失败,请稍后再试'): string {
+113 -37
View File
@@ -1,3 +1,11 @@
/**
* Passwordless login and one-time TOTP enrollment screens.
*
* QR material, manual keys and recovery codes exist only in React memory and
* are dropped as soon as their enrollment phase completes. PWA activation is
* blocked while those values or partially completed forms are visible so an
* update cannot erase information that the server will not reveal again.
*/
import { useMemo, useState } from 'react'
import type { FormEvent, ReactNode } from 'react'
import {
@@ -11,7 +19,12 @@ import {
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
import type { TotpEnrollment } from './types'
function AuthShell({ eyebrow, title, children, footer }: {
function AuthShell({
eyebrow,
title,
children,
footer,
}: {
eyebrow: string
title: string
children: ReactNode
@@ -21,7 +34,9 @@ function AuthShell({ eyebrow, title, children, footer }: {
<main className="auth-page">
<section className="auth-card jade-panel">
<PwaControls />
<div className="auth-mark" aria-hidden="true">星</div>
<div className="auth-mark" aria-hidden="true">
星
</div>
<p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
{children}
@@ -34,16 +49,19 @@ function AuthShell({ eyebrow, title, children, footer }: {
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
const source = useMemo(() => {
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
if (enrollment.qrSvg) return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}`
if (enrollment.qrSvg)
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}`
return undefined
}, [enrollment.qrDataUrl, enrollment.qrSvg])
return (
<div className="totp-enrollment">
<div className="totp-qr">
{source
? <img src={source} alt="TOTP 验证器绑定二维码" />
: <span>二维码暂不可用,请使用右侧密钥手工添加。</span>}
{source ? (
<img src={source} alt="TOTP 验证器绑定二维码" />
) : (
<span>二维码暂不可用,请使用右侧密钥手工添加。</span>
)}
</div>
<div className="totp-copy">
<h2>绑定动态验证器</h2>
@@ -57,7 +75,11 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
<div className="secret-row">
<code>{enrollment.manualKey || '未提供'}</code>
{enrollment.manualKey && (
<button type="button" className="text-button" onClick={() => void copyToClipboard(enrollment.manualKey)}>
<button
type="button"
className="text-button"
onClick={() => void copyToClipboard(enrollment.manualKey)}
>
复制
</button>
)}
@@ -72,12 +94,10 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
const [copied, setCopied] = useState(false)
const text = codes.join('\n')
const download = () => {
const blob = new Blob([
'洛星瓷直播组件恢复码\n',
'每个恢复码只能使用一次,请离线妥善保存。\n\n',
text,
'\n',
], { type: 'text/plain;charset=utf-8' })
const blob = new Blob(
['洛星瓷直播组件恢复码\n', '每个恢复码只能使用一次,请离线妥善保存。\n\n', text, '\n'],
{ type: 'text/plain;charset=utf-8' },
)
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
@@ -88,26 +108,45 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
return (
<AuthShell eyebrow="安全设置完成" title="保存账户恢复码">
<p className="auth-lead">手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。</p>
{codes.length > 0
? <div className="recovery-grid">{codes.map(code => <code key={code}>{code}</code>)}</div>
: <div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>}
<p className="auth-lead">
手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。
</p>
{codes.length > 0 ? (
<div className="recovery-grid">
{codes.map(code => (
<code key={code}>{code}</code>
))}
</div>
) : (
<div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>
)}
<div className="form-actions">
{codes.length > 0 && (
<>
<button type="button" className="secondary" onClick={() => void copyToClipboard(text).then(setCopied)}>
<button
type="button"
className="secondary"
onClick={() => void copyToClipboard(text).then(setCopied)}
>
{copied ? '已复制' : '复制全部'}
</button>
<button type="button" className="secondary" onClick={download}>下载文本</button>
<button type="button" className="secondary" onClick={download}>
下载文本
</button>
</>
)}
<button type="button" onClick={onContinue}>我已妥善保存</button>
<button type="button" onClick={onContinue}>
我已妥善保存
</button>
</div>
</AuthShell>
)
}
export function LoginPage({ onAuthenticated, setupRequired }: {
export function LoginPage({
onAuthenticated,
setupRequired,
}: {
onAuthenticated: () => Promise<void>
setupRequired: boolean
}) {
@@ -142,13 +181,19 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
<AuthShell
eyebrow="洛星瓷直播组件"
title="回到你的云台"
footer={(
footer={
<p>
{setupRequired
? <>首次部署?<a href={authRoute('setup')}>创建系统管理员</a></>
: <>持有邀请码?<a href={authRoute('register')}>注册新账户</a></>}
{setupRequired ? (
<>
首次部署?<a href={authRoute('setup')}>创建系统管理员</a>
</>
) : (
<>
持有邀请码?<a href={authRoute('register')}>注册新账户</a>
</>
)}
</p>
)}
}
>
<p className="auth-lead">这是无密码账户。输入用户名与验证器中的动态验证码即可登录。</p>
<form className="stack-form" onSubmit={submit}>
@@ -173,9 +218,13 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
maxLength={useRecoveryCode ? 64 : 6}
placeholder={useRecoveryCode ? '输入一个尚未使用的恢复码' : '000000'}
value={totpCode}
onChange={event => setTotpCode(useRecoveryCode
? event.target.value.trimStart().slice(0, 64)
: event.target.value.replace(/\D/g, '').slice(0, 6))}
onChange={event =>
setTotpCode(
useRecoveryCode
? event.target.value.trimStart().slice(0, 64)
: event.target.value.replace(/\D/g, '').slice(0, 6),
)
}
/>
</label>
<button
@@ -188,14 +237,21 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
>
{useRecoveryCode ? '改用动态验证码' : '验证器不可用?改用恢复码'}
</button>
{error && <div className="notice error" role="alert">{error}</div>}
{error && (
<div className="notice error" role="alert">
{error}
</div>
)}
<button disabled={busy}>{busy ? '正在验证…' : '安全登录'}</button>
</form>
</AuthShell>
)
}
export function EnrollmentPage({ mode, onAuthenticated }: {
export function EnrollmentPage({
mode,
onAuthenticated,
}: {
mode: 'setup' | 'register'
onAuthenticated: () => Promise<void>
}) {
@@ -214,7 +270,10 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
enrollment || recoveryCodes
? '完成 TOTP 绑定并保存一次性恢复码'
: '完成或清空正在填写的注册表单',
busy || Boolean(inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes),
busy ||
Boolean(
inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes,
),
)
const start = async (event: FormEvent) => {
@@ -276,7 +335,10 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
if (enrollment) {
return (
<AuthShell eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'} title="强制绑定 TOTP">
<AuthShell
eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'}
title="强制绑定 TOTP"
>
<TotpQr enrollment={enrollment} />
<form className="stack-form compact-form" onSubmit={confirm}>
<label>
@@ -294,8 +356,14 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
onChange={event => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</label>
{error && <div className="notice error" role="alert">{error}</div>}
<button disabled={busy || totpCode.length !== 6}>{busy ? '正在确认…' : '确认绑定并创建账户'}</button>
{error && (
<div className="notice error" role="alert">
{error}
</div>
)}
<button disabled={busy || totpCode.length !== 6}>
{busy ? '正在确认…' : '确认绑定并创建账户'}
</button>
</form>
</AuthShell>
)
@@ -305,7 +373,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
<AuthShell
eyebrow={isSetup ? '仅首次部署可用' : '仅限受邀用户'}
title={isSetup ? '创建系统管理员' : '创建你的账户'}
footer={<p>已有账户?<a href={authRoute('login')}>返回登录</a></p>}
footer={
<p>
已有账户?<a href={authRoute('login')}>返回登录</a>
</p>
}
>
<p className="auth-lead">
{isSetup
@@ -350,7 +422,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
<small>填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。</small>
</label>
)}
{error && <div className="notice error" role="alert">{error}</div>}
{error && (
<div className="notice error" role="alert">
{error}
</div>
)}
<button disabled={busy}>{busy ? '正在准备 TOTP…' : '下一步:绑定验证器'}</button>
</form>
</AuthShell>
+299 -179
View File
@@ -7,7 +7,9 @@ html,
body,
#root,
.copy {
font-family: "Noto Serif SC", "Microsoft YaHei", "Noto Color Emoji", "Segoe UI Emoji", "Apple Color Emoji", serif;
font-family:
'Noto Serif SC', 'Microsoft YaHei', 'Noto Color Emoji', 'Segoe UI Emoji', 'Apple Color Emoji',
serif;
}
.wall {
@@ -19,19 +21,33 @@ body,
}
.card-decor {
--decor-primary-image: url("/assets/floral-divider.svg");
--decor-primary-image: url('/assets/floral-divider.svg');
--decor-primary-position: center 44%;
--decor-primary-size: 92% auto;
--decor-primary-transform: none;
--decor-primary-opacity: .11;
--decor-primary-color: linear-gradient(90deg, #e7d0f4 4%, #75e6cc 35%, #d5fff1 50%, #6adcc6 68%, #f4bfd4 96%);
--decor-secondary-image: url("/assets/floral-cluster.svg");
--decor-primary-opacity: 0.11;
--decor-primary-color: linear-gradient(
90deg,
#e7d0f4 4%,
#75e6cc 35%,
#d5fff1 50%,
#6adcc6 68%,
#f4bfd4 96%
);
--decor-secondary-image: url('/assets/floral-cluster.svg');
--decor-secondary-position: right -10px bottom -22px;
--decor-secondary-size: 34% auto;
--decor-secondary-transform: none;
--decor-secondary-opacity: .065;
--decor-secondary-opacity: 0.065;
--decor-secondary-color: linear-gradient(135deg, #e9c7f1, #79ead3 62%, #fff0bc);
--decor-surface: radial-gradient(ellipse at 50% 0, rgba(180, 255, 237, .09), transparent 58%), linear-gradient(90deg, rgba(230, 203, 241, .04), transparent 18% 82%, rgba(244, 190, 214, .04));
--decor-surface:
radial-gradient(ellipse at 50% 0, rgba(180, 255, 237, 0.09), transparent 58%),
linear-gradient(
90deg,
rgba(230, 203, 241, 0.04),
transparent 18% 82%,
rgba(244, 190, 214, 0.04)
);
position: absolute;
inset: 0;
z-index: 0;
@@ -43,7 +59,7 @@ body,
.card-decor::before,
.card-decor::after {
content: "";
content: '';
position: absolute;
inset: 0;
z-index: 1;
@@ -59,7 +75,7 @@ body,
opacity: var(--decor-primary-opacity);
transform: var(--decor-primary-transform);
transform-origin: center;
filter: drop-shadow(0 0 6px rgba(105, 255, 224, .28));
filter: drop-shadow(0 0 6px rgba(105, 255, 224, 0.28));
}
.card-decor::after {
@@ -78,12 +94,12 @@ body,
position: absolute;
inset: 2px;
z-index: 0;
border: 1px solid rgba(157, 255, 232, .14);
border: 1px solid rgba(157, 255, 232, 0.14);
border-radius: inherit;
background: var(--decor-surface);
box-shadow:
inset 0 1px rgba(225, 255, 247, .1),
inset 0 -1px rgba(82, 224, 198, .08);
inset 0 1px rgba(225, 255, 247, 0.1),
inset 0 -1px rgba(82, 224, 198, 0.08);
}
.decor-v1 {
@@ -94,67 +110,82 @@ body,
--decor-secondary-position: right -12px bottom -20px;
--decor-secondary-size: 31% auto;
--decor-secondary-transform: rotate(180deg);
--decor-secondary-opacity: .075;
--decor-secondary-opacity: 0.075;
--decor-secondary-color: linear-gradient(145deg, #ffe9aa, #83e9d2 62%, #efd0f5);
--decor-surface: radial-gradient(ellipse at 52% 100%, rgba(255, 226, 163, .08), transparent 56%), linear-gradient(90deg, rgba(112, 232, 208, .045), transparent 34% 76%, rgba(236, 200, 243, .04));
--decor-surface:
radial-gradient(ellipse at 52% 100%, rgba(255, 226, 163, 0.08), transparent 56%),
linear-gradient(
90deg,
rgba(112, 232, 208, 0.045),
transparent 34% 76%,
rgba(236, 200, 243, 0.04)
);
}
.decor-v2 {
--decor-primary-image: url("/assets/floral-vine.svg");
--decor-primary-image: url('/assets/floral-vine.svg');
--decor-primary-position: left -12px bottom -13px;
--decor-primary-size: 73% auto;
--decor-primary-opacity: .105;
--decor-primary-opacity: 0.105;
--decor-primary-color: linear-gradient(110deg, #83ead5, #d8fff3 54%, #cbb9e9);
--decor-secondary-image: url("/assets/floral-divider.svg");
--decor-secondary-image: url('/assets/floral-divider.svg');
--decor-secondary-position: right -22px top -18px;
--decor-secondary-size: 62% auto;
--decor-secondary-opacity: .055;
--decor-secondary-opacity: 0.055;
--decor-secondary-color: linear-gradient(90deg, #f5c9dc, #8be9d7 68%, #fff1bd);
--decor-surface: radial-gradient(ellipse at 0 74%, rgba(105, 231, 206, .09), transparent 54%), linear-gradient(105deg, rgba(217, 198, 241, .045), transparent 58%);
--decor-surface:
radial-gradient(ellipse at 0 74%, rgba(105, 231, 206, 0.09), transparent 54%),
linear-gradient(105deg, rgba(217, 198, 241, 0.045), transparent 58%);
}
.decor-v3 {
--decor-primary-image: url("/assets/floral-vine.svg");
--decor-primary-image: url('/assets/floral-vine.svg');
--decor-primary-position: left -15px bottom -15px;
--decor-primary-size: 77% auto;
--decor-primary-transform: scaleX(-1);
--decor-primary-opacity: .115;
--decor-primary-opacity: 0.115;
--decor-primary-color: linear-gradient(100deg, #f2bfd7, #8aead8 48%, #d6fff0);
--decor-secondary-position: right -12px bottom -20px;
--decor-secondary-size: 32% auto;
--decor-secondary-opacity: .07;
--decor-secondary-opacity: 0.07;
--decor-secondary-color: linear-gradient(145deg, #d9c1ed, #6fe0c9 58%, #ffe8ad);
--decor-surface: radial-gradient(ellipse at 100% 24%, rgba(235, 190, 218, .075), transparent 54%), linear-gradient(270deg, rgba(103, 228, 204, .05), transparent 64%);
--decor-surface:
radial-gradient(ellipse at 100% 24%, rgba(235, 190, 218, 0.075), transparent 54%),
linear-gradient(270deg, rgba(103, 228, 204, 0.05), transparent 64%);
}
.decor-v4 {
--decor-primary-image: url("/assets/floral-cluster.svg");
--decor-primary-image: url('/assets/floral-cluster.svg');
--decor-primary-position: left -18px bottom -28px;
--decor-primary-size: 45% auto;
--decor-primary-transform: rotate(-5deg);
--decor-primary-opacity: .09;
--decor-primary-opacity: 0.09;
--decor-primary-color: linear-gradient(135deg, #9cecd8, #fff0bd 57%, #edc5e4);
--decor-secondary-position: left -15px bottom -25px;
--decor-secondary-size: 37% auto;
--decor-secondary-transform: rotate(180deg);
--decor-secondary-opacity: .065;
--decor-secondary-opacity: 0.065;
--decor-secondary-color: linear-gradient(145deg, #dec4ee, #72dfc7 66%, #fff0b6);
--decor-surface: radial-gradient(ellipse at 18% 100%, rgba(110, 232, 207, .085), transparent 47%), radial-gradient(ellipse at 84% 0, rgba(239, 199, 223, .06), transparent 44%);
--decor-surface:
radial-gradient(ellipse at 18% 100%, rgba(110, 232, 207, 0.085), transparent 47%),
radial-gradient(ellipse at 84% 0, rgba(239, 199, 223, 0.06), transparent 44%);
}
.decor-v5 {
--decor-primary-image: url("/assets/floral-vine.svg");
--decor-primary-image: url('/assets/floral-vine.svg');
--decor-primary-position: center top -17px;
--decor-primary-size: 90% auto;
--decor-primary-transform: scaleY(-1);
--decor-primary-opacity: .095;
--decor-primary-opacity: 0.095;
--decor-primary-color: linear-gradient(90deg, #d9c4ed, #82e5d2 44%, #f8e7b5 78%, #efc2d8);
--decor-secondary-image: url("/assets/floral-divider.svg");
--decor-secondary-image: url('/assets/floral-divider.svg');
--decor-secondary-position: left 18% top -15px;
--decor-secondary-size: 55% auto;
--decor-secondary-opacity: .06;
--decor-secondary-opacity: 0.06;
--decor-secondary-color: linear-gradient(90deg, #fff0bc, #86e7d5 64%, #e4c6f0);
--decor-surface: radial-gradient(ellipse at 50% 50%, rgba(207, 247, 236, .065), transparent 56%), linear-gradient(90deg, rgba(232, 199, 239, .04), transparent 52%, rgba(255, 229, 168, .035));
--decor-surface:
radial-gradient(ellipse at 50% 50%, rgba(207, 247, 236, 0.065), transparent 56%),
linear-gradient(90deg, rgba(232, 199, 239, 0.04), transparent 52%, rgba(255, 229, 168, 0.035));
}
.card::before {
@@ -173,8 +204,8 @@ body,
position: absolute;
inset: 0;
z-index: 2;
opacity: .5;
transition: opacity .3s ease;
opacity: 0.5;
transition: opacity 0.3s ease;
}
.decor-v1 .card-particle-layer,
@@ -208,7 +239,7 @@ body,
.card-particle.star {
background: linear-gradient(135deg, #fff7ca, #b7fff0 58%, #f5d4ff);
clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%);
filter: drop-shadow(0 0 4px rgba(190, 255, 240, .9));
filter: drop-shadow(0 0 4px rgba(190, 255, 240, 0.9));
}
.card-particle.floret {
@@ -219,30 +250,99 @@ body,
radial-gradient(circle at 50% 83%, #ffd7e5 0 19%, transparent 22%),
radial-gradient(circle at 18% 50%, #ffd7e5 0 19%, transparent 22%),
radial-gradient(circle at 50% 50%, #fff0a9 0 18%, transparent 21%);
filter: drop-shadow(0 0 4px rgba(255, 202, 226, .72));
filter: drop-shadow(0 0 4px rgba(255, 202, 226, 0.72));
}
.card-particle:nth-child(1) { left: 2%; top: 14%; animation-delay: -.35s; }
.card-particle:nth-child(2) { right: 3%; top: 16%; --particle-size: 10px; animation-delay: -1.15s; }
.card-particle:nth-child(3) { left: 4%; bottom: 13%; --particle-size: 7px; animation-delay: -2.4s; }
.card-particle:nth-child(4) { right: 2%; bottom: 15%; --particle-size: 8px; animation-delay: -.75s; }
.card-particle:nth-child(5) { left: 22%; top: 5%; --particle-size: 9px; animation-delay: -3.15s; }
.card-particle:nth-child(6) { right: 24%; top: 8%; --particle-size: 6px; animation-delay: -1.65s; }
.card-particle:nth-child(7) { left: 47%; bottom: 4%; --particle-size: 8px; animation-delay: -2.75s; }
.card-particle:nth-child(8) { right: 43%; top: 4%; --particle-size: 7px; animation-delay: -.15s; }
.card-particle:nth-child(9) { left: 34%; top: 46%; --particle-size: 6px; animation-delay: -1.95s; }
.card-particle:nth-child(10) { right: 32%; bottom: 30%; --particle-size: 9px; animation-delay: -3.55s; }
.card-particle:nth-child(11) { left: 12%; top: 48%; --particle-size: 7px; animation-delay: -.95s; }
.card-particle:nth-child(12) { right: 13%; top: 52%; --particle-size: 6px; animation-delay: -2.2s; }
.card-particle:nth-child(1) {
left: 2%;
top: 14%;
animation-delay: -0.35s;
}
.card-particle:nth-child(2) {
right: 3%;
top: 16%;
--particle-size: 10px;
animation-delay: -1.15s;
}
.card-particle:nth-child(3) {
left: 4%;
bottom: 13%;
--particle-size: 7px;
animation-delay: -2.4s;
}
.card-particle:nth-child(4) {
right: 2%;
bottom: 15%;
--particle-size: 8px;
animation-delay: -0.75s;
}
.card-particle:nth-child(5) {
left: 22%;
top: 5%;
--particle-size: 9px;
animation-delay: -3.15s;
}
.card-particle:nth-child(6) {
right: 24%;
top: 8%;
--particle-size: 6px;
animation-delay: -1.65s;
}
.card-particle:nth-child(7) {
left: 47%;
bottom: 4%;
--particle-size: 8px;
animation-delay: -2.75s;
}
.card-particle:nth-child(8) {
right: 43%;
top: 4%;
--particle-size: 7px;
animation-delay: -0.15s;
}
.card-particle:nth-child(9) {
left: 34%;
top: 46%;
--particle-size: 6px;
animation-delay: -1.95s;
}
.card-particle:nth-child(10) {
right: 32%;
bottom: 30%;
--particle-size: 9px;
animation-delay: -3.55s;
}
.card-particle:nth-child(11) {
left: 12%;
top: 48%;
--particle-size: 7px;
animation-delay: -0.95s;
}
.card-particle:nth-child(12) {
right: 13%;
top: 52%;
--particle-size: 6px;
animation-delay: -2.2s;
}
@keyframes card-sparkle {
0%, 100% { opacity: .04; transform: translate3d(0, 4px, 0) rotate(0) scale(.45); }
38% { opacity: .82; transform: translate3d(2px, -2px, 0) rotate(38deg) scale(1.08); }
68% { opacity: .22; transform: translate3d(-1px, -7px, 0) rotate(72deg) scale(.7); }
0%,
100% {
opacity: 0.04;
transform: translate3d(0, 4px, 0) rotate(0) scale(0.45);
}
38% {
opacity: 0.82;
transform: translate3d(2px, -2px, 0) rotate(38deg) scale(1.08);
}
68% {
opacity: 0.22;
transform: translate3d(-1px, -7px, 0) rotate(72deg) scale(0.7);
}
}
.narrow .card-particle-layer > :nth-child(n+9),
.short .card-particle-layer > :nth-child(n+7) {
.narrow .card-particle-layer > :nth-child(n + 9),
.short .card-particle-layer > :nth-child(n + 7) {
display: none;
}
@@ -279,9 +379,9 @@ body,
width: auto;
height: 1.4em;
max-width: 6em;
margin-inline: .08em;
margin-inline: 0.08em;
object-fit: contain;
vertical-align: -.32em;
vertical-align: -0.32em;
opacity: 1;
filter: none;
}
@@ -292,7 +392,7 @@ body,
height: auto;
max-width: 100%;
max-height: 4.8em;
margin: .12em 0;
margin: 0.12em 0;
vertical-align: top;
}
@@ -300,8 +400,8 @@ body,
display: inline-block;
max-width: 5em;
max-height: 2.2em;
margin: 0 .1em;
vertical-align: -.55em;
margin: 0 0.1em;
vertical-align: -0.55em;
}
.copy b,
@@ -316,46 +416,52 @@ body,
.card.danmaku {
isolation: isolate;
transform-origin: center center;
transition: min-height .24s ease, padding .24s ease, border-radius .24s ease, background .24s ease;
transition:
min-height 0.24s ease,
padding 0.24s ease,
border-radius 0.24s ease,
background 0.24s ease;
}
.card.danmaku.expanded {
min-height: 92px;
padding: 14px;
border-color: rgba(133, 255, 232, .58);
border-color: rgba(133, 255, 232, 0.58);
background:
linear-gradient(90deg, rgba(91, 224, 199, .13), transparent 14% 86%, rgba(91, 224, 199, .13)),
linear-gradient(115deg, rgba(4, 43, 59, .94), rgba(9, 82, 81, .78));
linear-gradient(90deg, rgba(91, 224, 199, 0.13), transparent 14% 86%, rgba(91, 224, 199, 0.13)),
linear-gradient(115deg, rgba(4, 43, 59, 0.94), rgba(9, 82, 81, 0.78));
box-shadow:
inset 12px 0 18px -15px rgba(142, 255, 230, .95),
inset -12px 0 18px -15px rgba(142, 255, 230, .95),
0 10px 28px rgba(0, 15, 25, .32);
animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both;
inset 12px 0 18px -15px rgba(142, 255, 230, 0.95),
inset -12px 0 18px -15px rgba(142, 255, 230, 0.95),
0 10px 28px rgba(0, 15, 25, 0.32);
animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95) both;
}
.card.danmaku.expanded::after {
content: "";
content: '';
position: absolute;
inset-block: 4px;
inset-inline: -1px;
z-index: 2;
pointer-events: none;
border-inline: 4px solid rgba(123, 238, 214, .72);
border-inline: 4px solid rgba(123, 238, 214, 0.72);
border-radius: 11px;
background:
linear-gradient(90deg,
rgba(201, 255, 240, .32) 0,
rgba(63, 180, 165, .26) 5px,
transparent 5px,
transparent calc(100% - 5px),
rgba(63, 180, 165, .26) calc(100% - 5px),
rgba(201, 255, 240, .32) 100%);
background: linear-gradient(
90deg,
rgba(201, 255, 240, 0.32) 0,
rgba(63, 180, 165, 0.26) 5px,
transparent 5px,
transparent calc(100% - 5px),
rgba(63, 180, 165, 0.26) calc(100% - 5px),
rgba(201, 255, 240, 0.32) 100%
);
box-shadow:
inset 6px 0 7px -7px rgba(216, 255, 246, .72),
inset -6px 0 7px -7px rgba(216, 255, 246, .72),
-2px 0 0 rgba(7, 46, 53, .72),
2px 0 0 rgba(7, 46, 53, .72);
animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both;
inset 6px 0 7px -7px rgba(216, 255, 246, 0.72),
inset -6px 0 7px -7px rgba(216, 255, 246, 0.72),
-2px 0 0 rgba(7, 46, 53, 0.72),
2px 0 0 rgba(7, 46, 53, 0.72);
animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95)
both;
}
.card.danmaku.expanded .copy {
@@ -370,8 +476,8 @@ body,
.card.danmaku.expanded .copy b {
color: #f0fffb;
font-size: .78em;
letter-spacing: .06em;
font-size: 0.78em;
letter-spacing: 0.06em;
}
.card.danmaku.expanded .copy span {
@@ -386,7 +492,7 @@ body,
min-height: 38px;
padding: 5px 10px;
border-radius: 10px;
background: linear-gradient(115deg, rgba(4, 34, 49, .72), rgba(8, 69, 72, .48));
background: linear-gradient(115deg, rgba(4, 34, 49, 0.72), rgba(8, 69, 72, 0.48));
}
.card.danmaku.compact .copy {
@@ -413,7 +519,7 @@ body,
white-space: normal;
}
.short .cards .card:nth-child(n+4) {
.short .cards .card:nth-child(n + 4) {
display: flex;
}
@@ -428,7 +534,7 @@ body,
@keyframes scroll-rails-open {
from {
transform: scaleX(.025);
transform: scaleX(0.025);
}
to {
transform: scaleX(1);
@@ -436,11 +542,17 @@ body,
}
@keyframes arrive {
from { transform: translateX(38px) scale(.96); }
to { transform: none; }
from {
transform: translateX(38px) scale(0.96);
}
to {
transform: none;
}
}
.overlay.narrow { padding: 6px; }
.overlay.narrow {
padding: 6px;
}
.narrow .card.danmaku.expanded {
min-height: 82px;
@@ -478,11 +590,11 @@ body,
.preset-buttons small {
font-weight: normal;
opacity: .72;
opacity: 0.72;
}
.preset-buttons .active {
box-shadow: 0 0 0 2px rgba(137, 255, 226, .28);
box-shadow: 0 0 0 2px rgba(137, 255, 226, 0.28);
}
.control .obs-address {
@@ -503,7 +615,7 @@ body,
max-height: 75vh;
overflow: auto;
margin-top: 14px;
border: 1px solid rgba(89, 183, 173, .25);
border: 1px solid rgba(89, 183, 173, 0.25);
background: #04131b;
}
@@ -515,7 +627,9 @@ body,
}
@media (max-width: 720px) {
.preview-viewport { max-height: 560px; }
.preview-viewport {
max-height: 560px;
}
}
/* Multi-user application shell ------------------------------------------------ */
@@ -523,10 +637,10 @@ body,
:root {
color-scheme: dark;
--app-bg: #041219;
--app-panel: rgba(7, 32, 42, .88);
--app-panel-strong: rgba(7, 39, 48, .96);
--app-line: rgba(110, 224, 205, .22);
--app-line-strong: rgba(117, 244, 218, .48);
--app-panel: rgba(7, 32, 42, 0.88);
--app-panel-strong: rgba(7, 39, 48, 0.96);
--app-line: rgba(110, 224, 205, 0.22);
--app-line-strong: rgba(117, 244, 218, 0.48);
--app-text: #dcfff7;
--app-muted: #83b8b0;
--app-accent: #67e6c5;
@@ -544,20 +658,20 @@ select {
position: relative;
border: 1px solid var(--app-line);
background:
radial-gradient(ellipse at 100% 0, rgba(94, 220, 194, .08), transparent 48%),
linear-gradient(135deg, rgba(8, 39, 50, .94), rgba(5, 25, 35, .91));
radial-gradient(ellipse at 100% 0, rgba(94, 220, 194, 0.08), transparent 48%),
linear-gradient(135deg, rgba(8, 39, 50, 0.94), rgba(5, 25, 35, 0.91));
box-shadow:
inset 0 1px rgba(210, 255, 245, .055),
0 18px 48px rgba(0, 8, 15, .22);
inset 0 1px rgba(210, 255, 245, 0.055),
0 18px 48px rgba(0, 8, 15, 0.22);
backdrop-filter: blur(18px);
}
.jade-panel::after {
content: "";
content: '';
position: absolute;
inset: 5px;
z-index: 0;
border: 1px solid rgba(122, 237, 216, .06);
border: 1px solid rgba(122, 237, 216, 0.06);
border-radius: inherit;
pointer-events: none;
}
@@ -573,7 +687,7 @@ select {
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 11px;
font-weight: 700;
letter-spacing: .19em;
letter-spacing: 0.19em;
text-transform: uppercase;
}
@@ -585,15 +699,15 @@ select {
place-items: center;
overflow: auto;
background:
radial-gradient(circle at 12% 12%, rgba(50, 145, 139, .2), transparent 30%),
radial-gradient(circle at 86% 82%, rgba(67, 105, 143, .18), transparent 33%),
radial-gradient(circle at 12% 12%, rgba(50, 145, 139, 0.2), transparent 30%),
radial-gradient(circle at 86% 82%, rgba(67, 105, 143, 0.18), transparent 33%),
linear-gradient(145deg, #04131b, #082b31 55%, #041119);
color: var(--app-text);
}
.route-loading {
color: #93cfc4;
letter-spacing: .12em;
letter-spacing: 0.12em;
}
.auth-card {
@@ -607,13 +721,13 @@ select {
color: #dffff7;
font-size: clamp(28px, 5vw, 44px);
font-weight: 600;
letter-spacing: .04em;
letter-spacing: 0.04em;
}
.auth-card footer {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid rgba(109, 212, 196, .14);
border-top: 1px solid rgba(109, 212, 196, 0.14);
}
.auth-card footer p {
@@ -634,11 +748,11 @@ select {
height: 54px;
display: grid;
place-items: center;
border: 1px solid rgba(124, 239, 216, .32);
border: 1px solid rgba(124, 239, 216, 0.32);
border-radius: 50%;
color: rgba(190, 255, 241, .8);
background: radial-gradient(circle, rgba(62, 173, 155, .23), transparent 72%);
box-shadow: 0 0 30px rgba(80, 216, 191, .14);
color: rgba(190, 255, 241, 0.8);
background: radial-gradient(circle, rgba(62, 173, 155, 0.23), transparent 72%);
box-shadow: 0 0 30px rgba(80, 216, 191, 0.14);
}
.auth-lead {
@@ -676,12 +790,14 @@ select {
width: 100%;
min-height: 44px;
padding: 10px 12px;
border: 1px solid rgba(75, 157, 151, .55);
border: 1px solid rgba(75, 157, 151, 0.55);
border-radius: 10px;
outline: none;
color: #edfffb;
background: rgba(3, 18, 26, .82);
transition: border-color .18s ease, box-shadow .18s ease;
background: rgba(3, 18, 26, 0.82);
transition:
border-color 0.18s ease,
box-shadow 0.18s ease;
}
.stack-form input:focus,
@@ -690,14 +806,14 @@ select {
.secret-address input:focus,
.one-time-secret input:focus {
border-color: #6ee8ce;
box-shadow: 0 0 0 3px rgba(92, 225, 198, .12);
box-shadow: 0 0 0 3px rgba(92, 225, 198, 0.12);
}
.otp-input {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important;
font-size: 22px !important;
font-weight: 700;
letter-spacing: .35em;
letter-spacing: 0.35em;
text-align: center;
}
@@ -710,12 +826,15 @@ select {
border-radius: 9px;
color: #05241f;
background: linear-gradient(135deg, #86f2d8, #50cdb2);
box-shadow: 0 5px 18px rgba(40, 168, 145, .16);
box-shadow: 0 5px 18px rgba(40, 168, 145, 0.16);
font-weight: 700;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: filter .16s ease, transform .16s ease, border-color .16s ease;
transition:
filter 0.16s ease,
transform 0.16s ease,
border-color 0.16s ease;
}
.auth-page button:hover:not(:disabled),
@@ -728,23 +847,23 @@ select {
.auth-page button:disabled,
.dashboard-shell button:disabled {
cursor: wait;
filter: saturate(.45);
opacity: .65;
filter: saturate(0.45);
opacity: 0.65;
}
.auth-page button.secondary,
.dashboard-shell button.secondary,
.dashboard-shell .ghost-button {
border-color: rgba(91, 189, 176, .26);
border-color: rgba(91, 189, 176, 0.26);
color: #cffff4;
background: rgba(16, 67, 76, .62);
background: rgba(16, 67, 76, 0.62);
box-shadow: none;
}
.dashboard-shell button.danger {
border-color: rgba(255, 138, 156, .3);
border-color: rgba(255, 138, 156, 0.3);
color: #ffd7dd;
background: rgba(102, 32, 48, .58);
background: rgba(102, 32, 48, 0.58);
box-shadow: none;
}
@@ -773,32 +892,32 @@ select {
.recovery-input {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important;
letter-spacing: .08em;
letter-spacing: 0.08em;
}
.notice {
padding: 11px 13px;
border: 1px solid rgba(121, 222, 205, .24);
border: 1px solid rgba(121, 222, 205, 0.24);
border-radius: 10px;
color: #c9f8ee;
background: rgba(17, 73, 76, .42);
background: rgba(17, 73, 76, 0.42);
line-height: 1.45;
}
.notice.error {
border-color: rgba(255, 133, 151, .36);
border-color: rgba(255, 133, 151, 0.36);
color: #ffd6dc;
background: rgba(103, 31, 47, .45);
background: rgba(103, 31, 47, 0.45);
}
.notice.warning {
border-color: rgba(255, 213, 127, .34);
border-color: rgba(255, 213, 127, 0.34);
color: #ffe4aa;
background: rgba(93, 67, 24, .4);
background: rgba(93, 67, 24, 0.4);
}
.notice.success {
border-color: rgba(105, 239, 196, .34);
border-color: rgba(105, 239, 196, 0.34);
color: #baffea;
}
@@ -849,10 +968,10 @@ select {
min-width: 0;
padding: 8px 10px;
overflow-wrap: anywhere;
border: 1px solid rgba(106, 216, 199, .2);
border: 1px solid rgba(106, 216, 199, 0.2);
border-radius: 8px;
color: #e8fff9;
background: rgba(0, 12, 19, .66);
background: rgba(0, 12, 19, 0.66);
}
.compact-form {
@@ -869,12 +988,12 @@ select {
.recovery-grid code {
padding: 11px;
border: 1px solid rgba(104, 219, 200, .2);
border: 1px solid rgba(104, 219, 200, 0.2);
border-radius: 8px;
color: #e8fff9;
background: rgba(0, 13, 20, .7);
background: rgba(0, 13, 20, 0.7);
text-align: center;
letter-spacing: .08em;
letter-spacing: 0.08em;
}
.form-actions {
@@ -899,9 +1018,8 @@ select {
overflow: auto;
color: var(--app-text);
background:
radial-gradient(circle at 88% 8%, rgba(30, 119, 114, .17), transparent 28%),
radial-gradient(circle at 8% 80%, rgba(58, 83, 126, .12), transparent 32%),
var(--app-bg);
radial-gradient(circle at 88% 8%, rgba(30, 119, 114, 0.17), transparent 28%),
radial-gradient(circle at 8% 80%, rgba(58, 83, 126, 0.12), transparent 32%), var(--app-bg);
}
.dashboard-topbar {
@@ -914,8 +1032,8 @@ select {
grid-template-columns: minmax(210px, 1fr) auto minmax(210px, 1fr);
gap: 20px;
align-items: center;
border-bottom: 1px solid rgba(84, 178, 166, .16);
background: rgba(3, 18, 26, .84);
border-bottom: 1px solid rgba(84, 178, 166, 0.16);
background: rgba(3, 18, 26, 0.84);
backdrop-filter: blur(18px);
}
@@ -933,9 +1051,9 @@ select {
height: 38px;
display: grid;
place-items: center;
border: 1px solid rgba(111, 232, 210, .35);
border: 1px solid rgba(111, 232, 210, 0.35);
border-radius: 50%;
background: rgba(34, 116, 107, .22);
background: rgba(34, 116, 107, 0.22);
}
.brand div,
@@ -949,16 +1067,16 @@ select {
color: #689b94;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 9px;
letter-spacing: .12em;
letter-spacing: 0.12em;
}
.dashboard-topbar nav {
display: flex;
gap: 5px;
padding: 4px;
border: 1px solid rgba(89, 181, 169, .14);
border: 1px solid rgba(89, 181, 169, 0.14);
border-radius: 11px;
background: rgba(3, 20, 28, .54);
background: rgba(3, 20, 28, 0.54);
}
.dashboard-topbar nav a {
@@ -970,7 +1088,7 @@ select {
.dashboard-topbar nav a.active {
color: #e4fff9;
background: rgba(49, 132, 120, .35);
background: rgba(49, 132, 120, 0.35);
}
.account-menu {
@@ -999,9 +1117,9 @@ select {
.auth-page .pwa-action {
min-height: 34px;
padding: 6px 11px;
border-color: rgba(95, 215, 193, .28);
border-color: rgba(95, 215, 193, 0.28);
color: #cffff4;
background: rgba(16, 67, 76, .72);
background: rgba(16, 67, 76, 0.72);
box-shadow: none;
font-size: 12px;
}
@@ -1012,10 +1130,10 @@ select {
display: inline-flex;
gap: 6px;
align-items: center;
border: 1px solid rgba(255, 202, 112, .3);
border: 1px solid rgba(255, 202, 112, 0.3);
border-radius: 999px;
color: #ffe2a7;
background: rgba(83, 55, 20, .48);
background: rgba(83, 55, 20, 0.48);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 12px;
white-space: nowrap;
@@ -1026,29 +1144,31 @@ select {
height: 7px;
border-radius: 50%;
background: #ffca70;
box-shadow: 0 0 9px rgba(255, 202, 112, .68);
box-shadow: 0 0 9px rgba(255, 202, 112, 0.68);
}
.dashboard-shell .pwa-action {
min-height: 30px;
padding: 5px 10px;
border-color: rgba(95, 215, 193, .28);
border-color: rgba(95, 215, 193, 0.28);
color: #cffff4;
background: rgba(16, 67, 76, .72);
background: rgba(16, 67, 76, 0.72);
box-shadow: none;
font-size: 12px;
white-space: nowrap;
}
.dashboard-shell .pwa-action.update {
border-color: rgba(255, 222, 143, .4);
border-color: rgba(255, 222, 143, 0.4);
color: #ffe8b7;
background: rgba(91, 65, 25, .58);
background: rgba(91, 65, 25, 0.58);
animation: pwa-update-glow 2.2s ease-in-out infinite;
}
@keyframes pwa-update-glow {
50% { box-shadow: 0 0 15px rgba(255, 215, 123, .16); }
50% {
box-shadow: 0 0 15px rgba(255, 215, 123, 0.16);
}
}
@media (display-mode: standalone) {
@@ -1110,8 +1230,8 @@ select {
}
.component-list button.selected {
border-color: rgba(91, 211, 190, .23);
background: linear-gradient(110deg, rgba(38, 126, 114, .35), rgba(17, 69, 76, .3));
border-color: rgba(91, 211, 190, 0.23);
background: linear-gradient(110deg, rgba(38, 126, 114, 0.35), rgba(17, 69, 76, 0.3));
}
.component-list button > span:nth-child(2) {
@@ -1137,10 +1257,10 @@ select {
height: 38px;
display: grid;
place-items: center;
border: 1px solid rgba(106, 225, 203, .2);
border: 1px solid rgba(106, 225, 203, 0.2);
border-radius: 10px;
color: #a8f0df;
background: rgba(45, 135, 121, .2);
background: rgba(45, 135, 121, 0.2);
}
.component-list i {
@@ -1152,7 +1272,7 @@ select {
.component-list i.enabled {
background: #72ebc9;
box-shadow: 0 0 9px rgba(86, 232, 197, .7);
box-shadow: 0 0 9px rgba(86, 232, 197, 0.7);
}
.future-components {
@@ -1160,7 +1280,7 @@ select {
padding-top: 15px;
display: grid;
gap: 4px;
border-top: 1px solid rgba(94, 187, 173, .12);
border-top: 1px solid rgba(94, 187, 173, 0.12);
color: #567d78;
}
@@ -1188,24 +1308,24 @@ select {
.status-chip {
width: max-content;
padding: 5px 9px;
border: 1px solid rgba(112, 197, 186, .2);
border: 1px solid rgba(112, 197, 186, 0.2);
border-radius: 99px;
color: #91bcb6;
background: rgba(17, 60, 66, .44);
background: rgba(17, 60, 66, 0.44);
font-size: 12px;
white-space: nowrap;
}
.status-chip.online {
border-color: rgba(99, 236, 199, .3);
border-color: rgba(99, 236, 199, 0.3);
color: #99f3d8;
background: rgba(31, 105, 87, .35);
background: rgba(31, 105, 87, 0.35);
}
.status-chip.offline {
border-color: rgba(240, 151, 159, .25);
border-color: rgba(240, 151, 159, 0.25);
color: #e9aeb4;
background: rgba(91, 39, 48, .34);
background: rgba(91, 39, 48, 0.34);
}
.dashboard-panel {
@@ -1251,7 +1371,7 @@ select {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.slider-grid input[type="range"] {
.slider-grid input[type='range'] {
width: 100%;
accent-color: #63ddc1;
}
@@ -1262,7 +1382,7 @@ select {
display: flex;
flex-wrap: wrap;
gap: 9px;
border: 1px solid rgba(89, 179, 167, .18);
border: 1px solid rgba(89, 179, 167, 0.18);
border-radius: 12px;
}
@@ -1381,9 +1501,9 @@ select {
padding: 17px;
display: grid;
gap: 11px;
border: 1px solid rgba(255, 215, 127, .28);
border: 1px solid rgba(255, 215, 127, 0.28);
border-radius: 12px;
background: rgba(86, 64, 24, .24);
background: rgba(86, 64, 24, 0.24);
}
.one-time-secret > b {
@@ -1408,7 +1528,7 @@ table {
th,
td {
padding: 12px 10px;
border-bottom: 1px solid rgba(85, 169, 158, .12);
border-bottom: 1px solid rgba(85, 169, 158, 0.12);
color: #a9d3cc;
text-align: left;
white-space: nowrap;
@@ -1426,10 +1546,10 @@ td:last-child {
.obs-configuration-error {
padding: 10px 12px;
border: 1px solid rgba(255, 143, 157, .42);
border: 1px solid rgba(255, 143, 157, 0.42);
border-radius: 10px;
color: #ffe1e5;
background: rgba(72, 24, 37, .82);
background: rgba(72, 24, 37, 0.82);
font-size: clamp(13px, 3vw, 17px);
}
File diff suppressed because it is too large Load Diff
+24 -5
View File
@@ -1,3 +1,11 @@
/**
* Browser entry point and intentionally small client-side router.
*
* `/obs/:publicId` is selected before the control application is initialized.
* That early split is a security and reliability boundary: OBS sources never
* register the control-console PWA or execute authenticated dashboard requests.
* All other routes share the session bootstrap and passwordless auth flow.
*/
import { useCallback, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
@@ -20,11 +28,15 @@ function NotFoundPage() {
return (
<main className="auth-page">
<section className="auth-card jade-panel not-found">
<div className="auth-mark" aria-hidden="true">云</div>
<div className="auth-mark" aria-hidden="true">
云
</div>
<p className="eyebrow">404 · LOST IN THE CLOUDS</p>
<h1>这里没有组件</h1>
<p className="auth-lead">地址可能已经失效,或者组件已被所属用户删除。</p>
<a className="button-link" href="/control/">返回控制台</a>
<a className="button-link" href="/control/">
返回控制台
</a>
</section>
</main>
)
@@ -85,9 +97,15 @@ function App() {
<PwaControls />
<p className="eyebrow">{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}</p>
<h1>{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}</h1>
{offline && <p className="auth-lead">应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。</p>}
{offline && (
<p className="auth-lead">
应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。
</p>
)}
<div className="notice error">{loadError}</div>
<button type="button" disabled={offline} onClick={() => void refreshSession()}>重新连接</button>
<button type="button" disabled={offline} onClick={() => void refreshSession()}>
重新连接
</button>
</section>
</main>
)
@@ -124,7 +142,8 @@ function App() {
}
if (path === '/control/invitations') {
if (!session.user) return <Redirect to="/control/login" />
if (session.user.role !== 'system_admin') return <ForbiddenPage user={session.user} onLogout={logout} />
if (session.user.role !== 'system_admin')
return <ForbiddenPage user={session.user} onLogout={logout} />
return <InvitationsPage user={session.user} onLogout={logout} />
}
return <NotFoundPage />
+124 -47
View File
@@ -1,3 +1,12 @@
/**
* Transparent OBS renderer and component WebSocket client.
*
* The bearer token is read from the URL fragment, then sent as the first
* WebSocket frame; fragments never reach Nginx access logs or the initial HTTP
* request. Incoming events are treated as untrusted display data, bounded by
* component settings, and malformed frames are ignored without terminating a
* long-running browser source.
*/
import { useEffect, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { defaultOverlaySettings } from './types'
@@ -55,7 +64,20 @@ type OverlayProps = {
accessToken?: string
}
const cardParticles = ['star', 'floret', 'star', 'star', 'floret', 'star', 'floret', 'star', 'star', 'floret', 'star', 'floret'] as const
const cardParticles = [
'star',
'floret',
'star',
'star',
'floret',
'star',
'floret',
'star',
'star',
'floret',
'star',
'floret',
] as const
const decorVariantCount = 6
function stableHash(value: string) {
@@ -95,21 +117,27 @@ function streamUrl(publicId: string) {
}
function enabled(type: string, settings: OverlaySettings) {
return (type === 'live.danmaku' && settings.showDanmaku)
|| (type === 'live.enter' && settings.showEnter)
|| (type.startsWith('live.gift') && settings.showGift)
|| (type === 'live.superchat' && settings.showSuperchat)
|| (type === 'live.guard.buy' && settings.showGuard)
|| (type === 'live.like' && settings.showLike)
|| (type === 'live.share' && settings.showShare)
return (
(type === 'live.danmaku' && settings.showDanmaku) ||
(type === 'live.enter' && settings.showEnter) ||
(type.startsWith('live.gift') && settings.showGift) ||
(type === 'live.superchat' && settings.showSuperchat) ||
(type === 'live.guard.buy' && settings.showGuard) ||
(type === 'live.like' && settings.showLike) ||
(type === 'live.share' && settings.showShare)
)
}
function parseSettings(value: unknown): OverlaySettings {
if (!value || typeof value !== 'object') return defaultOverlaySettings
return { ...defaultOverlaySettings, ...value as Partial<OverlaySettings> }
return { ...defaultOverlaySettings, ...(value as Partial<OverlaySettings>) }
}
function useEvents(disabled: boolean, publicId?: string, accessToken?: string): {
function useEvents(
disabled: boolean,
publicId?: string,
accessToken?: string,
): {
settings: OverlaySettings
items: Item[]
setItems: Dispatch<SetStateAction<Item[]>>
@@ -117,7 +145,9 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
} {
const [settings, setSettings] = useState<OverlaySettings>(defaultOverlaySettings)
const [items, setItems] = useState<Item[]>([])
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>('idle')
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>(
'idle',
)
const settingsRef = useRef(settings)
useEffect(() => {
@@ -142,7 +172,7 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
retries = 0
socket?.send(JSON.stringify({ type: 'authenticate', token: accessToken }))
}
socket.onclose = (event) => {
socket.onclose = event => {
if (dead) return
if (event.code === 1008 || event.code === 4401 || event.code === 4403) {
setConnection('denied')
@@ -166,7 +196,10 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
return
}
setConnection('connected')
if (envelope.type === 'overlay.settings.snapshot' || envelope.type === 'overlay.settings.updated') {
if (
envelope.type === 'overlay.settings.snapshot' ||
envelope.type === 'overlay.settings.updated'
) {
setSettings(parseSettings(envelope.payload?.settings))
return
}
@@ -176,10 +209,13 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
const combo = envelope.type === 'live.gift.combo' && envelope.payload?.comboId
const key = combo ? `combo:${combo}` : envelope.id
const existing = old.find(item => item.key === key)
const decorVariant = existing?.decorVariant
?? chooseDecorVariant(`${envelope.type}:${key}`, old[0]?.decorVariant)
return [{ ...envelope, key, received: Date.now(), decorVariant }, ...old.filter(item => item.key !== key)]
.slice(0, current.maxVisible)
const decorVariant =
existing?.decorVariant ??
chooseDecorVariant(`${envelope.type}:${key}`, old[0]?.decorVariant)
return [
{ ...envelope, key, received: Date.now(), decorVariant },
...old.filter(item => item.key !== key),
].slice(0, current.maxVisible)
})
} catch {
// A malformed upstream event must not break a long-running OBS source.
@@ -239,49 +275,75 @@ function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type:
}
function DanmakuBody({ payload }: { payload: LivePayload }) {
const segments = Array.isArray(payload.segments) ? payload.segments as DanmakuSegment[] : undefined
const segments = Array.isArray(payload.segments)
? (payload.segments as DanmakuSegment[])
: undefined
if (!segments?.length) return <>{payload.text || ''}</>
return <>{segments.map((segment, index) => segment.type === 'emoticon' && segment.url
? <DanmakuEmoticon segment={segment} key={`${segment.unique || segment.url}:${index}`} />
: <span className="danmaku-text" key={`text:${index}`}>{segment.text}</span>)}</>
return (
<>
{segments.map((segment, index) =>
segment.type === 'emoticon' && segment.url ? (
<DanmakuEmoticon segment={segment} key={`${segment.unique || segment.url}:${index}`} />
) : (
<span className="danmaku-text" key={`text:${index}`}>
{segment.text}
</span>
),
)}
</>
)
}
function Card({ item, settings, expanded }: { item: Item; settings: OverlaySettings; expanded: boolean }) {
function Card({
item,
settings,
expanded,
}: {
item: Item
settings: OverlaySettings
expanded: boolean
}) {
const payload = item.payload || {}
const viewer = payload.viewer || {}
const gift = payload.gift
const isDanmaku = item.type === 'live.danmaku'
const tier = gift ? giftTier(item, settings) : ''
const body = gift
? `献上 ${gift.name || '礼物'} ×${payload.quantity || 1}`
: isDanmaku
? <DanmakuBody payload={payload} />
: eventRenderers[item.type]?.(payload) || '送来了一份互动'
const body = gift ? (
`献上 ${gift.name || '礼物'} ×${payload.quantity || 1}`
) : isDanmaku ? (
<DanmakuBody payload={payload} />
) : (
eventRenderers[item.type]?.(payload) || '送来了一份互动'
)
return (
<article className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}>
<article
className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}
>
<CardDecor count={settings.particleCount} variant={item.decorVariant} />
{gift && (
<div className="gift-art">
{gift.animationUrl || gift.imageUrl
? (
<img
src={gift.animationUrl || gift.imageUrl}
alt={gift.name || '礼物'}
onError={event => {
const image = event.currentTarget
if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl
else image.style.display = 'none'
}}
/>
)
: <span>✦</span>}
{gift.animationUrl || gift.imageUrl ? (
<img
src={gift.animationUrl || gift.imageUrl}
alt={gift.name || '礼物'}
onError={event => {
const image = event.currentTarget
if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl
else image.style.display = 'none'
}}
/>
) : (
<span>✦</span>
)}
</div>
)}
<div className="copy">
<b>{viewer.name || '直播间观众'}</b>
<span className={isDanmaku ? 'danmaku-content' : undefined}>{body}</span>
{typeof gift?.priceCny === 'number' && gift.priceCny > 0 && <em>¥ {gift.priceCny.toFixed(2)}</em>}
{typeof gift?.priceCny === 'number' && gift.priceCny > 0 && (
<em>¥ {gift.priceCny.toFixed(2)}</em>
)}
</div>
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
</article>
@@ -327,7 +389,13 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
payload: {
viewer: { name: '星光旅人' },
quantity: 1,
gift: { name: '甜蜜告白', totalPrice: 12_000, priceCny: 12, imageUrl: '', animationUrl: '' },
gift: {
name: '甜蜜告白',
totalPrice: 12_000,
priceCny: 12,
imageUrl: '',
animationUrl: '',
},
},
},
])
@@ -343,7 +411,7 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
setExpandedKey(newest.key)
const densityFactor = shape === 'short' ? 0.6 : 1
const timer = window.setTimeout(
() => setExpandedKey(key => key === newest.key ? undefined : key),
() => setExpandedKey(key => (key === newest.key ? undefined : key)),
settings.collapseAfterSeconds * 1000 * densityFactor,
)
return () => window.clearTimeout(timer)
@@ -365,11 +433,20 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
}}
>
<section className="wall">
{missingAccess && <div className="obs-configuration-error">OBS 地址不完整,请从控制台重新复制。</div>}
{!missingAccess && events.connection === 'denied' && <div className="obs-configuration-error">OBS 访问令牌已失效。</div>}
{missingAccess && (
<div className="obs-configuration-error">OBS 地址不完整,请从控制台重新复制。</div>
)}
{!missingAccess && events.connection === 'denied' && (
<div className="obs-configuration-error">OBS 访问令牌已失效。</div>
)}
<div className="cards">
{items.map(item => (
<Card item={item} settings={settings} expanded={item.key === expandedKey} key={item.key} />
<Card
item={item}
settings={settings}
expanded={item.key === expandedKey}
key={item.key}
/>
))}
</div>
</section>
+64 -27
View File
@@ -1,3 +1,12 @@
/**
* Install, offline and explicit-update lifecycle for the control-console PWA.
*
* Registration is restricted to `/control/`; `/obs/*` is deliberately outside
* the scope. The worker caches only the public application shell and static
* assets. API responses, WebSockets and secrets remain network-only. A waiting
* worker activates only after user confirmation and after every registered
* one-time-secret or dirty-form blocker has cleared.
*/
import { useEffect, useSyncExternalStore } from 'react'
interface InstallChoice {
@@ -58,9 +67,10 @@ function addManifest() {
function observeRegistration(registration: ServiceWorkerRegistration) {
emit({
registration,
waitingWorker: registration.waiting && navigator.serviceWorker.controller
? registration.waiting
: snapshot.waitingWorker,
waitingWorker:
registration.waiting && navigator.serviceWorker.controller
? registration.waiting
: snapshot.waitingWorker,
})
registration.addEventListener('updatefound', () => {
@@ -76,18 +86,23 @@ function observeRegistration(registration: ServiceWorkerRegistration) {
async function removeLegacyRootWorker() {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map(async registration => {
const scopePath = new URL(registration.scope).pathname
const workers = [registration.installing, registration.waiting, registration.active]
const isLegacy = scopePath === '/'
&& workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
if (isLegacy) await registration.unregister()
}))
await Promise.all(
registrations.map(async registration => {
const scopePath = new URL(registration.scope).pathname
const workers = [registration.installing, registration.waiting, registration.active]
const isLegacy =
scopePath === '/' &&
workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
if (isLegacy) await registration.unregister()
}),
)
const cacheNames = await caches.keys()
await Promise.all(cacheNames
.filter(name => name.startsWith('lxc-control-shell-'))
.map(name => caches.delete(name)))
await Promise.all(
cacheNames
.filter(name => name.startsWith('lxc-control-shell-'))
.map(name => caches.delete(name)),
)
}
export function cleanupLegacyPwa() {
@@ -121,18 +136,25 @@ export function initializePwa() {
const updateDisplayMode = () => emit({ standalone: isStandalone() })
window.addEventListener('online', updateConnection)
window.addEventListener('offline', updateConnection)
const modernListener = (displayMode as unknown as {
addEventListener?: (type: 'change', listener: () => void) => void
}).addEventListener
const modernListener = (
displayMode as unknown as {
addEventListener?: (type: 'change', listener: () => void) => void
}
).addEventListener
if (modernListener) modernListener.call(displayMode, 'change', updateDisplayMode)
else (displayMode as unknown as { addListener?: (listener: () => void) => void })
.addListener?.call(displayMode, updateDisplayMode)
else
(displayMode as unknown as { addListener?: (listener: () => void) => void }).addListener?.call(
displayMode,
updateDisplayMode,
)
window.addEventListener('beforeinstallprompt', event => {
event.preventDefault()
emit({ installPrompt: event as BeforeInstallPromptEvent })
})
window.addEventListener('appinstalled', () => emit({ installPrompt: undefined, standalone: true }))
window.addEventListener('appinstalled', () =>
emit({ installPrompt: undefined, standalone: true }),
)
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
@@ -151,13 +173,17 @@ export function initializePwa() {
void snapshot.registration?.update()
}
})
window.setInterval(() => {
if (navigator.onLine) void snapshot.registration?.update()
}, 60 * 60 * 1000)
window.setInterval(
() => {
if (navigator.onLine) void snapshot.registration?.update()
},
60 * 60 * 1000,
)
}
export function authRoute(name: 'login' | 'register' | 'setup'): string {
const inControlScope = location.pathname === '/control' || location.pathname.startsWith('/control/')
const inControlScope =
location.pathname === '/control' || location.pathname.startsWith('/control/')
return inControlScope ? `/control/${name}` : `/${name}`
}
@@ -165,7 +191,9 @@ export function usePwaUpdateBlocker(key: string, reason: string, active: boolean
useEffect(() => {
if (active) updateBlockers.set(key, reason)
else updateBlockers.delete(key)
return () => { updateBlockers.delete(key) }
return () => {
updateBlockers.delete(key)
}
}, [active, key, reason])
}
@@ -182,10 +210,14 @@ function applyUpdate() {
if (!worker) return
const reasons = [...new Set(updateBlockers.values())]
if (reasons.length > 0) {
window.alert(`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`)
window.alert(
`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`,
)
return
}
const confirmed = window.confirm('更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?')
const confirmed = window.confirm(
'更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?',
)
if (!confirmed) return
reloadForUpdate = true
worker.postMessage({ type: 'SKIP_WAITING' })
@@ -198,7 +230,12 @@ export function PwaControls() {
return (
<div className="pwa-controls" aria-live="polite">
{!state.online && <span className="pwa-state offline"><i aria-hidden="true" />离线</span>}
{!state.online && (
<span className="pwa-state offline">
<i aria-hidden="true" />
离线
</span>
)}
{state.waitingWorker && (
<button type="button" className="pwa-action update" onClick={applyUpdate}>
更新可用
+274 -1
View File
@@ -1 +1,274 @@
*{box-sizing:border-box}html,body,#root{margin:0;width:100%;height:100%;font-family:"Noto Serif SC","Microsoft YaHei",serif}body{background:transparent;color:#dcfffa}.overlay{width:100%;height:100%;padding:clamp(8px,2vw,22px);display:flex;align-items:center;justify-content:flex-end;overflow:hidden;background:radial-gradient(ellipse at 100% 50%,rgba(21,94,96,.2),transparent 62%)}.wall{width:min(100%,480px);display:flex;flex-direction:column;gap:9px;filter:drop-shadow(0 10px 28px rgba(0,11,19,.36))}.cards{display:flex;flex-direction:column;gap:8px}.card{position:relative;min-height:58px;padding:11px 14px;border:1px solid rgba(101,226,211,.28);border-radius:14px;overflow:hidden;display:flex;align-items:center;gap:10px;background:linear-gradient(115deg,rgba(4,34,49,.82),rgba(8,69,72,.61));backdrop-filter:blur(15px);animation:arrive calc(.35s + .35s*var(--motion)) cubic-bezier(.19,.9,.3,1) both}.card:before{content:"";position:absolute;inset:0;background:linear-gradient(105deg,transparent 25%,rgba(155,255,232,.14),transparent 65%);transform:translateX(-120%);animation:sheen calc(2.4s - 1.3s*var(--motion)) ease-in-out .25s both}.copy{position:relative;min-width:0;display:flex;flex-direction:column;gap:3px;font-size:clamp(12px,2.7vw,17px)}.copy b{color:#e6fff9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy span{color:#a9dbd4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy em{font-style:normal;color:#ffdc89;font-size:.84em}.gift-art{z-index:1;width:48px;height:48px;flex:none;border-radius:12px;background:radial-gradient(circle,#2e9f9e,transparent 66%);display:grid;place-items:center}.gift-art img{width:100%;height:100%;object-fit:contain}.gift.high{min-height:72px;border-color:rgba(102,255,229,.65);background:linear-gradient(105deg,rgba(5,59,78,.92),rgba(14,119,103,.7))}.gift.featured{min-height:128px;border-color:#b6ffdc;background:radial-gradient(circle at 20% 50%,rgba(69,236,190,.4),transparent 35%),linear-gradient(118deg,rgba(7,63,84,.97),rgba(12,105,89,.85));animation:featured calc(2.8s - 1.5s*var(--motion)) ease-in-out infinite}.gift.featured .gift-art{width:92px;height:92px}.particles{position:absolute;right:9px;top:6px;color:#ceffe4;letter-spacing:9px;animation:float 2.2s ease-in-out infinite}.narrow .overlay{padding:6px}.narrow .wall{gap:5px}.narrow .card{min-height:45px;padding:7px 9px;border-radius:10px}.narrow .gift-art{width:34px;height:34px}.narrow .gift.featured{min-height:90px;align-items:flex-start}.narrow .gift.featured .gift-art{width:58px;height:58px}.short .cards .card:nth-child(n+4){display:none}.low-motion *{animation:none!important}.control{min-height:100%;padding:clamp(20px,5vw,56px);display:grid;grid-template-columns:minmax(300px,520px) minmax(280px,1fr);gap:36px;background:linear-gradient(135deg,#061923,#092c32 55%,#06151f);color:#d9fff6}.control h1{margin:0;color:#bffff0}.control h2{margin:0}.control p{color:#88bdb5}.control label{display:block;margin:14px 0;color:#bcebe3}.control input:not([type=checkbox]):not([type=range]){width:100%;padding:10px;margin-top:5px;border:1px solid #357e79;border-radius:8px;background:#071d28;color:#e6fff9}.control input[type=range]{margin-left:10px;accent-color:#5ae7c6}.control output{margin-left:8px}.control fieldset{border:1px solid #28645f;border-radius:10px;display:flex;flex-wrap:wrap;gap:3px 12px}.control fieldset label{margin:8px 0}.buttons{display:flex;gap:10px;margin-top:20px}.control button{padding:10px 14px;border:0;border-radius:8px;background:#55dcb9;color:#06211e;font-weight:bold;cursor:pointer}.control button.secondary{background:#173e4b;color:#d4fff7}.preview-frame{height:600px;resize:both;overflow:auto;border:1px dashed #4dafa4;background:linear-gradient(135deg,rgba(71,190,172,.12),transparent)}.login{display:grid;place-content:center;grid-template-columns:360px;background:#071923}.login form{display:flex;flex-direction:column;gap:12px}@keyframes arrive{from{opacity:0;transform:translateX(38px) scale(.96)}to{opacity:1;transform:none}}@keyframes sheen{to{transform:translateX(135%)}}@keyframes featured{50%{filter:brightness(1.18);transform:scale(1.015)}}@keyframes float{50%{transform:translateY(-8px);opacity:.5}}@media(max-width:720px){.control{grid-template-columns:1fr}.preview-frame{height:480px}}
* {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
width: 100%;
height: 100%;
font-family: 'Noto Serif SC', 'Microsoft YaHei', serif;
}
body {
background: transparent;
color: #dcfffa;
}
.overlay {
width: 100%;
height: 100%;
padding: clamp(8px, 2vw, 22px);
display: flex;
align-items: center;
justify-content: flex-end;
overflow: hidden;
background: radial-gradient(ellipse at 100% 50%, rgba(21, 94, 96, 0.2), transparent 62%);
}
.wall {
width: min(100%, 480px);
display: flex;
flex-direction: column;
gap: 9px;
filter: drop-shadow(0 10px 28px rgba(0, 11, 19, 0.36));
}
.cards {
display: flex;
flex-direction: column;
gap: 8px;
}
.card {
position: relative;
min-height: 58px;
padding: 11px 14px;
border: 1px solid rgba(101, 226, 211, 0.28);
border-radius: 14px;
overflow: hidden;
display: flex;
align-items: center;
gap: 10px;
background: linear-gradient(115deg, rgba(4, 34, 49, 0.82), rgba(8, 69, 72, 0.61));
backdrop-filter: blur(15px);
animation: arrive calc(0.35s + 0.35s * var(--motion)) cubic-bezier(0.19, 0.9, 0.3, 1) both;
}
.card:before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(105deg, transparent 25%, rgba(155, 255, 232, 0.14), transparent 65%);
transform: translateX(-120%);
animation: sheen calc(2.4s - 1.3s * var(--motion)) ease-in-out 0.25s both;
}
.copy {
position: relative;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
font-size: clamp(12px, 2.7vw, 17px);
}
.copy b {
color: #e6fff9;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy span {
color: #a9dbd4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copy em {
font-style: normal;
color: #ffdc89;
font-size: 0.84em;
}
.gift-art {
z-index: 1;
width: 48px;
height: 48px;
flex: none;
border-radius: 12px;
background: radial-gradient(circle, #2e9f9e, transparent 66%);
display: grid;
place-items: center;
}
.gift-art img {
width: 100%;
height: 100%;
object-fit: contain;
}
.gift.high {
min-height: 72px;
border-color: rgba(102, 255, 229, 0.65);
background: linear-gradient(105deg, rgba(5, 59, 78, 0.92), rgba(14, 119, 103, 0.7));
}
.gift.featured {
min-height: 128px;
border-color: #b6ffdc;
background:
radial-gradient(circle at 20% 50%, rgba(69, 236, 190, 0.4), transparent 35%),
linear-gradient(118deg, rgba(7, 63, 84, 0.97), rgba(12, 105, 89, 0.85));
animation: featured calc(2.8s - 1.5s * var(--motion)) ease-in-out infinite;
}
.gift.featured .gift-art {
width: 92px;
height: 92px;
}
.particles {
position: absolute;
right: 9px;
top: 6px;
color: #ceffe4;
letter-spacing: 9px;
animation: float 2.2s ease-in-out infinite;
}
.narrow .overlay {
padding: 6px;
}
.narrow .wall {
gap: 5px;
}
.narrow .card {
min-height: 45px;
padding: 7px 9px;
border-radius: 10px;
}
.narrow .gift-art {
width: 34px;
height: 34px;
}
.narrow .gift.featured {
min-height: 90px;
align-items: flex-start;
}
.narrow .gift.featured .gift-art {
width: 58px;
height: 58px;
}
.short .cards .card:nth-child(n + 4) {
display: none;
}
.low-motion * {
animation: none !important;
}
.control {
min-height: 100%;
padding: clamp(20px, 5vw, 56px);
display: grid;
grid-template-columns: minmax(300px, 520px) minmax(280px, 1fr);
gap: 36px;
background: linear-gradient(135deg, #061923, #092c32 55%, #06151f);
color: #d9fff6;
}
.control h1 {
margin: 0;
color: #bffff0;
}
.control h2 {
margin: 0;
}
.control p {
color: #88bdb5;
}
.control label {
display: block;
margin: 14px 0;
color: #bcebe3;
}
.control input:not([type='checkbox']):not([type='range']) {
width: 100%;
padding: 10px;
margin-top: 5px;
border: 1px solid #357e79;
border-radius: 8px;
background: #071d28;
color: #e6fff9;
}
.control input[type='range'] {
margin-left: 10px;
accent-color: #5ae7c6;
}
.control output {
margin-left: 8px;
}
.control fieldset {
border: 1px solid #28645f;
border-radius: 10px;
display: flex;
flex-wrap: wrap;
gap: 3px 12px;
}
.control fieldset label {
margin: 8px 0;
}
.buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.control button {
padding: 10px 14px;
border: 0;
border-radius: 8px;
background: #55dcb9;
color: #06211e;
font-weight: bold;
cursor: pointer;
}
.control button.secondary {
background: #173e4b;
color: #d4fff7;
}
.preview-frame {
height: 600px;
resize: both;
overflow: auto;
border: 1px dashed #4dafa4;
background: linear-gradient(135deg, rgba(71, 190, 172, 0.12), transparent);
}
.login {
display: grid;
place-content: center;
grid-template-columns: 360px;
background: #071923;
}
.login form {
display: flex;
flex-direction: column;
gap: 12px;
}
@keyframes arrive {
from {
opacity: 0;
transform: translateX(38px) scale(0.96);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes sheen {
to {
transform: translateX(135%);
}
}
@keyframes featured {
50% {
filter: brightness(1.18);
transform: scale(1.015);
}
}
@keyframes float {
50% {
transform: translateY(-8px);
opacity: 0.5;
}
}
@media (max-width: 720px) {
.control {
grid-template-columns: 1fr;
}
.preview-frame {
height: 480px;
}
}
+7
View File
@@ -1,3 +1,10 @@
/**
* Shared control-console view models.
*
* These types describe sanitized API responses and editable settings, not raw
* database rows or Bilibili packets. Secret fields are optional because the
* server normally returns only `*Configured` flags after initial submission.
*/
export type OverlaySettings = {
fontScale: number
showDanmaku: boolean
+16 -1
View File
@@ -1,4 +1,19 @@
{
"compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2022"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" },
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+7
View File
@@ -1,3 +1,10 @@
/**
* Vite build configuration and PWA asset emission.
*
* The same per-build identifier is compiled into the browser bundle and the
* service worker. This makes an hourly/visibility update check discover a new
* deployment while still leaving activation under explicit user control.
*/
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { readFileSync } from 'node:fs'
+50
View File
@@ -0,0 +1,50 @@
# Rust 后端
这是多租户直播组件服务的可复用核心和唯一运行时进程。`src/main.rs` 只处理进程启动;主要行为由 library
crate 导出。
## 模块职责
| 模块 | 职责 |
| ------------- | -------------------------------------------------------- |
| `app` | 依赖组装、迁移、事件队列、源启动与旧配置导入 |
| `auth` | 邀请码、TOTP、恢复码、会话、secret 加密和组件 token |
| `components` | 组件定义、设置版本、订阅、projection 与 handler registry |
| `config` | TOML 解析、部署策略校验和旧单用户兼容字段 |
| `credentials` | CookieCloud URL 边界与 Bilibili Cookie 提取 |
| `db` | PostgreSQL pool、迁移和 RLS tenant context |
| `domain` | provider-independent event 与 WebSocket envelope |
| `http_api` | REST/WS、会话、权限、same-origin、静态资源与安全响应头 |
| `live` | provider trait、Bilibili adapter 与 source supervisor |
| `overlay` | 弹幕姬设置及礼物/表情目录 |
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
| `realtime` | source event routing 与 component-scoped fanout |
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
## 重要不变量
- handler 不能信任请求体中的 owner;owner 必须来自 session 或 source context。
- tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。
- provider 只能输出 canonical、bounded、sanitized `LiveEvent`。
- projection 无副作用;可靠业务动作必须使用幂等 handler。
- token、邀请码和恢复码只存摘要,TOTP/CookieCloud Secret 只存认证加密密文。
- EventHub 的 channel key 是 component ID,不允许增加无权限的全局 receiver。
## 本地质量检查
在仓库根目录执行:
```bash
cargo fmt --manifest-path apps/server-rust/Cargo.toml --check
cargo clippy --manifest-path apps/server-rust/Cargo.toml --all-targets --no-deps -- -D warnings
cargo test --manifest-path apps/server-rust/Cargo.toml --all-targets
```
第三方 `vendor/blivedm` 不作为本项目风格重写目标;项目只维护保留原始 JSON 所需的小补丁。
更多设计说明:
- [总体架构](../../docs/architecture.md)
- [组件开发](../../docs/components/README.md)
- [实时协议](../../docs/protocol.md)
- [安全模型](../../docs/security.md)
+4
View File
@@ -0,0 +1,4 @@
edition = "2024"
max_width = 100
newline_style = "Unix"
use_small_heuristics = "Default"
+7
View File
@@ -1,3 +1,10 @@
//! Application composition root and process-wide dependencies.
//!
//! [`AppState::build`] wires the database, authentication service, component
//! registry, live-source supervisor and realtime router in dependency order.
//! Tenant-owned runtime state stays in PostgreSQL or the source supervisor;
//! this module only owns cloneable handles shared by Axum handlers.
use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
+8
View File
@@ -1,3 +1,11 @@
//! Passwordless authentication, enrollment and secret-storage domain service.
//!
//! This module owns invite redemption, TOTP replay protection, recovery codes,
//! session issuance and tenant credential encryption. Raw sessions, recovery
//! codes and component tokens are returned only at creation time; persistent
//! rows contain hashes or authenticated ciphertext. HTTP-specific cookie and
//! origin policy deliberately live in `http_api`, not here.
use std::{fmt, sync::Arc, time::Duration};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
+7
View File
@@ -1,3 +1,10 @@
//! Extensible component registry and event-processing contracts.
//!
//! A component kind supplies a versioned settings definition, a pure browser
//! projection and optional durable handlers. The live provider and router know
//! only these traits, so adding a gift wall or song-request component does not
//! require branching on component kinds in the ingestion pipeline.
use std::{
collections::{BTreeSet, HashMap},
error::Error,
+7
View File
@@ -1,3 +1,10 @@
//! TOML configuration loading, validation and legacy bootstrap compatibility.
//!
//! Deployment-wide policy (bind address, encryption key, allowed CookieCloud
//! hosts and timeouts) remains in [`Config`]. Room IDs, credentials and component
//! settings become tenant-owned database records after enrollment; the legacy
//! TOML fields are import inputs and must not be treated as global live state.
use std::{env, fs, net::IpAddr, path::PathBuf};
use base64::{Engine, engine::general_purpose::STANDARD};
+7
View File
@@ -1,3 +1,10 @@
//! CookieCloud boundary and Bilibili cookie extraction.
//!
//! Tenant input reaches an outbound HTTP client only after canonical URL
//! validation and exact allow-list matching in the caller. Redirects are
//! disabled by the shared client, the synchronization key is encoded as one
//! path segment, and only the minimum Bilibili cookie fields leave this module.
use reqwest::{
Url,
header::{REFERER, USER_AGENT},
+7
View File
@@ -1,3 +1,10 @@
//! PostgreSQL pool, migrations and low-level tenant-scoped queries.
//!
//! Multi-tenant tables use PostgreSQL row-level security in addition to owner
//! columns and composite foreign keys. Every tenant query must execute inside a
//! transaction after [`Db::set_tenant`], which uses `SET LOCAL` so pooled
//! connections cannot retain the previous request's identity.
use std::{fmt, str::FromStr};
use deadpool_postgres::{Manager, ManagerConfig, Object, Pool, RecyclingMethod, Runtime};
+7
View File
@@ -1,3 +1,10 @@
//! Provider-independent live events and the versioned component wire envelope.
//!
//! Provider adapters normalize platform packets into [`LiveEvent`]. Components
//! subscribe to stable [`LiveEventKind`] values and project them to
//! [`ComponentMessage`], keeping Bilibili command names and raw payloads out of
//! browser contracts and future provider implementations.
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
+14 -6
View File
@@ -1,3 +1,11 @@
//! Axum HTTP/WebSocket adapter and public trust boundary.
//!
//! Handlers derive the tenant from a server-side session cookie; owner IDs from
//! request bodies are never trusted. This module also enforces same-origin
//! mutation checks, response cache policy, component-token WebSocket
//! authentication, bounded request bodies and security headers for the static
//! control console and OBS entry point.
use std::{sync::Arc, time::Duration};
use axum::{
@@ -326,7 +334,7 @@ async fn login(
Ok(result) => result,
Err(error) => {
state.login_limiter.failure(&body.username, &ip).await;
return Err(ApiError::from(error).as_generic_login());
return Err(ApiError::from(error).into_generic_login());
}
};
state.login_limiter.success(&body.username, &ip).await;
@@ -1146,7 +1154,7 @@ impl ApiError {
self
}
fn as_generic_login(mut self) -> Self {
fn into_generic_login(mut self) -> Self {
if self.status != StatusCode::TOO_MANY_REQUESTS {
self.status = StatusCode::UNAUTHORIZED;
self.code = "invalid_credentials";
@@ -1170,10 +1178,10 @@ impl IntoResponse for ApiError {
}),
)
.into_response();
if let Some(retry_after) = self.retry_after {
if let Ok(value) = HeaderValue::from_str(&retry_after.as_secs().max(1).to_string()) {
response.headers_mut().insert(header::RETRY_AFTER, value);
}
if let Some(retry_after) = self.retry_after
&& let Ok(value) = HeaderValue::from_str(&retry_after.as_secs().max(1).to_string())
{
response.headers_mut().insert(header::RETRY_AFTER, value);
}
response
}
+30 -25
View File
@@ -1,3 +1,10 @@
//! Bilibili `blivedm_rs` adapter and raw-command normalization.
//!
//! The adapter authenticates with a CookieCloud-derived cookie, refreshes gift
//! and emoticon catalogs, and converts supported Bilibili commands into bounded
//! domain payloads. Unknown commands expose only sanitized metadata—never the
//! original unbounded packet or authentication material.
use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
@@ -241,11 +248,11 @@ impl LiveProvider for BilibiliProvider {
});
}
while let Ok(message) = upstream_receiver.try_recv() {
if let Some(message) = normalize(message) {
if raw_sender.blocking_send(message).is_err() {
client.close();
return Ok(());
}
if let Some(message) = normalize(message)
&& raw_sender.blocking_send(message).is_err()
{
client.close();
return Ok(());
}
}
}
@@ -534,12 +541,12 @@ fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec<EmoticonHint> {
json_object(object.get("extra")?)
});
if let Some(extra) = extra {
if let Some(emoticons) = extra.get("emots").and_then(json_object) {
if let Some(emoticons) = emoticons.as_object() {
for (token, metadata) in emoticons {
if let Some(hint) = emoticon_hint(metadata, token, false) {
hints.push(hint);
}
if let Some(emoticons) = extra.get("emots").and_then(json_object)
&& let Some(emoticons) = emoticons.as_object()
{
for (token, metadata) in emoticons {
if let Some(hint) = emoticon_hint(metadata, token, false) {
hints.push(hint);
}
}
}
@@ -547,22 +554,20 @@ fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec<EmoticonHint> {
.get("emoticon_unique")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
{
if !hints
&& !hints
.iter()
.any(|hint| hint.unique.as_deref() == Some(unique))
{
hints.push(EmoticonHint {
text: text.to_owned(),
unique: Some(unique.to_owned()),
url: None,
width: None,
height: None,
is_dynamic: false,
bulge_display: value_is_truthy(extra.get("bulge_display")),
standalone: extra.get("dm_type").and_then(Value::as_i64) == Some(1),
});
}
{
hints.push(EmoticonHint {
text: text.to_owned(),
unique: Some(unique.to_owned()),
url: None,
width: None,
height: None,
is_dynamic: false,
bulge_display: value_is_truthy(extra.get("bulge_display")),
standalone: extra.get("dm_type").and_then(Value::as_i64) == Some(1),
});
}
}
hints
+7
View File
@@ -1,3 +1,10 @@
//! Live-provider abstraction and source lifecycle types.
//!
//! A provider receives trusted tenant/source context and emits canonical
//! [`LiveEvent`] values plus observable connection status. Cancellation and
//! restart ownership live in [`supervisor::SourceSupervisor`], keeping provider
//! implementations focused on one upstream connection.
pub mod bilibili;
pub mod supervisor;
+6
View File
@@ -1,3 +1,9 @@
//! Per-source task ownership, restart and cancellation.
//!
//! The supervisor guarantees at most one provider generation for a source ID.
//! Reconfiguration cancels the old task before a replacement starts, preventing
//! duplicate Bilibili listeners and duplicate downstream events.
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
+6
View File
@@ -1,3 +1,9 @@
//! Executable entry point for the livestream component host.
//!
//! Keep this file limited to process concerns: configuration, logging, socket
//! binding and graceful shutdown. All application behavior belongs in the
//! library crate so it can be exercised without starting a real HTTP server.
use lxc_stream_server::{app::AppState, config::Config, http_api};
use tracing::{info, warn};
+35 -21
View File
@@ -1,3 +1,10 @@
//! Danmaku-overlay settings plus Bilibili gift and emoticon metadata caches.
//!
//! Settings are sanitized before persistence or projection. Catalog refreshes
//! replace the in-memory snapshot only after a complete valid response, so a
//! transient upstream failure preserves the last known gift images, prices and
//! emoticon URLs instead of breaking live rendering.
use std::{collections::HashMap, sync::Arc};
use serde::{Deserialize, Serialize};
@@ -123,16 +130,30 @@ pub struct EmoticonCatalog {
by_emoji: Arc<RwLock<HashMap<String, EmoticonMeta>>>,
}
// Parser return aliases keep the atomic cache-replacement contract visible:
// both lookup maps and their source count are produced before either lock is
// updated, so readers never observe a half-refreshed catalog.
type GiftCatalogSnapshot = (HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize);
type EmoticonCatalogSnapshot = (
HashMap<String, EmoticonMeta>,
HashMap<String, EmoticonMeta>,
usize,
);
impl EmoticonCatalog {
pub async fn len(&self) -> usize {
self.by_unique.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.len().await == 0
}
pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option<EmoticonMeta> {
if let Some(unique) = unique.filter(|value| !value.is_empty()) {
if let Some(emoticon) = self.by_unique.read().await.get(unique).cloned() {
return Some(emoticon);
}
if let Some(unique) = unique.filter(|value| !value.is_empty())
&& let Some(emoticon) = self.by_unique.read().await.get(unique).cloned()
{
return Some(emoticon);
}
self.by_emoji.read().await.get(emoji).cloned()
}
@@ -184,11 +205,15 @@ impl GiftCatalog {
self.by_id.read().await.len()
}
pub async fn is_empty(&self) -> bool {
self.len().await == 0
}
pub async fn get(&self, id: Option<i64>, name: &str) -> Option<GiftMeta> {
if let Some(id) = id {
if let Some(gift) = self.by_id.read().await.get(&id).cloned() {
return Some(gift);
}
if let Some(id) = id
&& let Some(gift) = self.by_id.read().await.get(&id).cloned()
{
return Some(gift);
}
self.by_name
.read()
@@ -231,9 +256,7 @@ impl GiftCatalog {
}
}
fn parse_catalog(
payload: &Value,
) -> Result<(HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize), String> {
fn parse_catalog(payload: &Value) -> Result<GiftCatalogSnapshot, String> {
let list = payload
.pointer("/data/gift_config/base_config/list")
.and_then(Value::as_array)
@@ -282,16 +305,7 @@ fn string_field(value: &Value, name: &str) -> Option<String> {
.map(ToOwned::to_owned)
}
fn parse_emoticon_catalog(
payload: &Value,
) -> Result<
(
HashMap<String, EmoticonMeta>,
HashMap<String, EmoticonMeta>,
usize,
),
String,
> {
fn parse_emoticon_catalog(payload: &Value) -> Result<EmoticonCatalogSnapshot, String> {
let packages = payload
.pointer("/data/data")
.and_then(Value::as_array)
+6
View File
@@ -1,3 +1,9 @@
//! In-memory abuse limits for anonymous authentication and enrollment routes.
//!
//! Limits are evaluated across both account and network-derived keys while
//! returning one generic result to callers. This reduces brute-force attempts
//! without turning timing or error messages into a username-enumeration API.
use std::{
collections::{HashMap, VecDeque},
sync::Arc,
+7
View File
@@ -1,3 +1,10 @@
//! Tenant-aware event routing and component-scoped realtime fanout.
//!
//! The router resolves enabled instances by owner and source, validates their
//! settings/subscriptions, runs durable handlers, then publishes passive
//! projections. Each component owns a separate broadcast channel; there is no
//! global receiver that could accidentally observe another tenant's events.
use std::{
collections::HashMap,
error::Error,
+7
View File
@@ -1,3 +1,10 @@
//! Repository facade for tenant components, settings and the routing cache.
//!
//! PostgreSQL is authoritative. Successful writes are reflected into the
//! in-process [`InMemoryComponentStore`] used by the hot event path; startup and
//! source restarts hydrate that cache from tenant-scoped rows before events are
//! routed.
use std::{fmt, sync::Arc};
use chrono::{DateTime, Utc};