proper productionize project
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
import type {
|
||||
AuthUser,
|
||||
ComponentSummary,
|
||||
CookieCloudSource,
|
||||
Invitation,
|
||||
OverlaySettings,
|
||||
Session,
|
||||
TotpEnrollment,
|
||||
} from './types'
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly code?: string
|
||||
readonly fieldErrors?: Record<string, string>
|
||||
|
||||
constructor(status: number, message: string, code?: string, fieldErrors?: Record<string, string>) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.fieldErrors = fieldErrors
|
||||
}
|
||||
}
|
||||
|
||||
async function parseResponse(response: Response): Promise<unknown> {
|
||||
if (response.status === 204) return undefined
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (contentType.includes('application/json')) return response.json()
|
||||
const text = await response.text()
|
||||
return text ? { message: text } : undefined
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const method = (init.method ?? 'GET').toUpperCase()
|
||||
if (!navigator.onLine && !['GET', 'HEAD'].includes(method)) {
|
||||
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')
|
||||
headers.set('accept', 'application/json')
|
||||
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const payload = await parseResponse(response)
|
||||
if (!response.ok) {
|
||||
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> : {}
|
||||
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>
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
return payload as T
|
||||
}
|
||||
|
||||
export function json(method: string, body?: unknown): RequestInit {
|
||||
return {
|
||||
method,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
}
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return value != null && typeof value === 'object' && !Array.isArray(value)
|
||||
? 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 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
|
||||
return { user, setupRequired: root.setupRequired === true }
|
||||
}
|
||||
|
||||
export function normalizeEnrollment(value: unknown): TotpEnrollment {
|
||||
const root = object(value)
|
||||
const totp = object(root.totp ?? root.enrollment)
|
||||
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
|
||||
: 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,
|
||||
}
|
||||
}
|
||||
|
||||
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') : []
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
export function normalizeSettings(value: unknown): OverlaySettings {
|
||||
const root = object(value)
|
||||
return (root.settings ?? value) as 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 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),
|
||||
},
|
||||
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)
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown, fallback = '操作失败,请稍后再试'): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
if (window.isSecureContext && navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// Fall through to the compatibility path used by OBS' embedded browser.
|
||||
}
|
||||
}
|
||||
const input = document.createElement('textarea')
|
||||
input.value = text
|
||||
input.readOnly = true
|
||||
input.style.position = 'fixed'
|
||||
input.style.left = '-9999px'
|
||||
input.style.opacity = '0'
|
||||
document.body.appendChild(input)
|
||||
input.focus()
|
||||
input.select()
|
||||
let copied = false
|
||||
try {
|
||||
copied = document.execCommand('copy')
|
||||
} finally {
|
||||
input.remove()
|
||||
}
|
||||
return copied
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { FormEvent, ReactNode } from 'react'
|
||||
import {
|
||||
api,
|
||||
copyToClipboard,
|
||||
errorMessage,
|
||||
json,
|
||||
normalizeEnrollment,
|
||||
normalizeRecoveryCodes,
|
||||
} from './api'
|
||||
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
|
||||
import type { TotpEnrollment } from './types'
|
||||
|
||||
function AuthShell({ eyebrow, title, children, footer }: {
|
||||
eyebrow: string
|
||||
title: string
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<section className="auth-card jade-panel">
|
||||
<PwaControls />
|
||||
<div className="auth-mark" aria-hidden="true">星</div>
|
||||
<p className="eyebrow">{eyebrow}</p>
|
||||
<h1>{title}</h1>
|
||||
{children}
|
||||
{footer && <footer>{footer}</footer>}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
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)}`
|
||||
return undefined
|
||||
}, [enrollment.qrDataUrl, enrollment.qrSvg])
|
||||
|
||||
return (
|
||||
<div className="totp-enrollment">
|
||||
<div className="totp-qr">
|
||||
{source
|
||||
? <img src={source} alt="TOTP 验证器绑定二维码" />
|
||||
: <span>二维码暂不可用,请使用右侧密钥手工添加。</span>}
|
||||
</div>
|
||||
<div className="totp-copy">
|
||||
<h2>绑定动态验证器</h2>
|
||||
<ol>
|
||||
<li>使用 1Password、Aegis、Microsoft Authenticator 等应用扫描二维码。</li>
|
||||
<li>若无法扫码,选择“输入设置密钥”。</li>
|
||||
<li>输入应用中出现的 6 位动态码完成绑定。</li>
|
||||
</ol>
|
||||
<label>
|
||||
手工设置密钥
|
||||
<div className="secret-row">
|
||||
<code>{enrollment.manualKey || '未提供'}</code>
|
||||
{enrollment.manualKey && (
|
||||
<button type="button" className="text-button" onClick={() => void copyToClipboard(enrollment.manualKey)}>
|
||||
复制
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: () => void }) {
|
||||
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 url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = 'luoxingci-recovery-codes.txt'
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
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>}
|
||||
<div className="form-actions">
|
||||
{codes.length > 0 && (
|
||||
<>
|
||||
<button type="button" className="secondary" onClick={() => void copyToClipboard(text).then(setCopied)}>
|
||||
{copied ? '已复制' : '复制全部'}
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={download}>下载文本</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" onClick={onContinue}>我已妥善保存</button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoginPage({ onAuthenticated, setupRequired }: {
|
||||
onAuthenticated: () => Promise<void>
|
||||
setupRequired: boolean
|
||||
}) {
|
||||
const [username, setUsername] = useState('')
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [useRecoveryCode, setUseRecoveryCode] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
usePwaUpdateBlocker(
|
||||
'login-form',
|
||||
'完成或清空正在填写的登录表单',
|
||||
busy || Boolean(username || totpCode),
|
||||
)
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await api('/api/v1/auth/login', json('POST', { username: username.trim(), code: totpCode }))
|
||||
setTotpCode('')
|
||||
await onAuthenticated()
|
||||
location.assign('/control/')
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, '用户名或动态验证码不正确'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
eyebrow="洛星瓷直播组件"
|
||||
title="回到你的云台"
|
||||
footer={(
|
||||
<p>
|
||||
{setupRequired
|
||||
? <>首次部署?<a href={authRoute('setup')}>创建系统管理员</a></>
|
||||
: <>持有邀请码?<a href={authRoute('register')}>注册新账户</a></>}
|
||||
</p>
|
||||
)}
|
||||
>
|
||||
<p className="auth-lead">这是无密码账户。输入用户名与验证器中的动态验证码即可登录。</p>
|
||||
<form className="stack-form" onSubmit={submit}>
|
||||
<label>
|
||||
用户名
|
||||
<input
|
||||
autoFocus
|
||||
required
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={event => setUsername(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{useRecoveryCode ? '账户恢复码' : '6 位动态验证码'}
|
||||
<input
|
||||
required
|
||||
className={useRecoveryCode ? 'recovery-input' : 'otp-input'}
|
||||
inputMode={useRecoveryCode ? 'text' : 'numeric'}
|
||||
autoComplete={useRecoveryCode ? 'off' : 'one-time-code'}
|
||||
pattern={useRecoveryCode ? undefined : '[0-9]{6}'}
|
||||
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))}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-link"
|
||||
onClick={() => {
|
||||
setUseRecoveryCode(current => !current)
|
||||
setTotpCode('')
|
||||
}}
|
||||
>
|
||||
{useRecoveryCode ? '改用动态验证码' : '验证器不可用?改用恢复码'}
|
||||
</button>
|
||||
{error && <div className="notice error" role="alert">{error}</div>}
|
||||
<button disabled={busy}>{busy ? '正在验证…' : '安全登录'}</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
export function EnrollmentPage({ mode, onAuthenticated }: {
|
||||
mode: 'setup' | 'register'
|
||||
onAuthenticated: () => Promise<void>
|
||||
}) {
|
||||
const inviteFromFragment = new URLSearchParams(location.hash.slice(1)).get('invite') ?? ''
|
||||
const [inviteCode, setInviteCode] = useState(inviteFromFragment)
|
||||
const [username, setUsername] = useState('')
|
||||
const [bootstrapPassword, setBootstrapPassword] = useState('')
|
||||
const [enrollment, setEnrollment] = useState<TotpEnrollment>()
|
||||
const [totpCode, setTotpCode] = useState('')
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>()
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const isSetup = mode === 'setup'
|
||||
usePwaUpdateBlocker(
|
||||
'totp-enrollment',
|
||||
enrollment || recoveryCodes
|
||||
? '完成 TOTP 绑定并保存一次性恢复码'
|
||||
: '完成或清空正在填写的注册表单',
|
||||
busy || Boolean(inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes),
|
||||
)
|
||||
|
||||
const start = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const payload = await api<unknown>(
|
||||
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/start`,
|
||||
json('POST', {
|
||||
...(isSetup ? {} : { inviteCode: inviteCode.trim() }),
|
||||
username: username.trim(),
|
||||
...(isSetup ? { bootstrapPassword } : {}),
|
||||
}),
|
||||
)
|
||||
const next = normalizeEnrollment(payload)
|
||||
if (!next.enrollmentToken) throw new Error('服务端未返回注册流程标识')
|
||||
setEnrollment(next)
|
||||
setBootstrapPassword('')
|
||||
if (!isSetup) {
|
||||
setInviteCode('')
|
||||
history.replaceState(null, '', authRoute('register'))
|
||||
}
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, '无法开始安全注册流程'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirm = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (!enrollment) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const payload = await api<unknown>(
|
||||
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/confirm`,
|
||||
json('POST', { enrollmentToken: enrollment.enrollmentToken, code: totpCode }),
|
||||
)
|
||||
setTotpCode('')
|
||||
// Drop QR/manual-key material from React state as soon as enrollment is
|
||||
// committed; only the one-time recovery codes remain on screen.
|
||||
setEnrollment(undefined)
|
||||
setRecoveryCodes(normalizeRecoveryCodes(payload))
|
||||
} catch (reason) {
|
||||
setError(errorMessage(reason, '动态验证码无效或注册流程已过期'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const finish = async () => {
|
||||
await onAuthenticated()
|
||||
location.assign('/control/')
|
||||
}
|
||||
|
||||
if (recoveryCodes) return <RecoveryCodes codes={recoveryCodes} onContinue={() => void finish()} />
|
||||
|
||||
if (enrollment) {
|
||||
return (
|
||||
<AuthShell eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'} title="强制绑定 TOTP">
|
||||
<TotpQr enrollment={enrollment} />
|
||||
<form className="stack-form compact-form" onSubmit={confirm}>
|
||||
<label>
|
||||
验证器中的 6 位动态码
|
||||
<input
|
||||
required
|
||||
autoFocus
|
||||
className="otp-input"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
placeholder="000000"
|
||||
value={totpCode}
|
||||
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>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
eyebrow={isSetup ? '仅首次部署可用' : '仅限受邀用户'}
|
||||
title={isSetup ? '创建系统管理员' : '创建你的账户'}
|
||||
footer={<p>已有账户?<a href={authRoute('login')}>返回登录</a></p>}
|
||||
>
|
||||
<p className="auth-lead">
|
||||
{isSetup
|
||||
? '首位账户将拥有邀请码管理权限。旧管理员口令只授权这一次初始化,不会成为账户密码。'
|
||||
: '邀请码只用于注册;这是无密码账户,创建后每次登录都必须验证 TOTP。'}
|
||||
</p>
|
||||
<form className="stack-form" onSubmit={start}>
|
||||
{!isSetup && (
|
||||
<label>
|
||||
邀请码
|
||||
<input
|
||||
required
|
||||
autoFocus={!inviteFromFragment}
|
||||
autoComplete="off"
|
||||
value={inviteCode}
|
||||
onChange={event => setInviteCode(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
用户名
|
||||
<input
|
||||
required
|
||||
autoFocus={isSetup || Boolean(inviteFromFragment)}
|
||||
autoComplete="username"
|
||||
minLength={3}
|
||||
maxLength={32}
|
||||
value={username}
|
||||
onChange={event => setUsername(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{isSetup && (
|
||||
<label>
|
||||
一次性初始化口令
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={bootstrapPassword}
|
||||
onChange={event => setBootstrapPassword(event.target.value)}
|
||||
/>
|
||||
<small>填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。</small>
|
||||
</label>
|
||||
)}
|
||||
{error && <div className="notice error" role="alert">{error}</div>}
|
||||
<button disabled={busy}>{busy ? '正在准备 TOTP…' : '下一步:绑定验证器'}</button>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
+1031
-16
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,784 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { FormEvent, ReactNode } from 'react'
|
||||
import {
|
||||
ApiError,
|
||||
api,
|
||||
copyToClipboard,
|
||||
errorMessage,
|
||||
json,
|
||||
normalizeComponents,
|
||||
normalizeInvitations,
|
||||
normalizeSettings,
|
||||
normalizeSource,
|
||||
} from './api'
|
||||
import { Overlay } from './overlay'
|
||||
import { PwaControls, usePwaUpdateBlocker } from './pwa'
|
||||
import { defaultOverlaySettings } from './types'
|
||||
import type {
|
||||
AuthUser,
|
||||
ComponentSummary,
|
||||
CookieCloudSource,
|
||||
Invitation,
|
||||
OverlaySettings,
|
||||
} from './types'
|
||||
|
||||
const previewPresets = [
|
||||
{ label: '窄侧栏', width: 360, height: 600 },
|
||||
{ label: '竖屏', width: 440, height: 760 },
|
||||
{ label: '高清竖栏', width: 600, height: 1080 },
|
||||
{ label: '横向条', width: 720, height: 320 },
|
||||
]
|
||||
|
||||
function isDanmakuKind(kind: string): boolean {
|
||||
return kind === 'danmaku_overlay' || kind === 'danmaku'
|
||||
}
|
||||
|
||||
type Flash = { kind: 'success' | 'error'; text: string } | undefined
|
||||
|
||||
function Panel({ title, description, aside, children, className = '' }: {
|
||||
title: string
|
||||
description?: string
|
||||
aside?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section className={`dashboard-panel jade-panel ${className}`}>
|
||||
<header className="panel-header">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
{aside}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function FlashMessage({ flash }: { flash: Flash }) {
|
||||
if (!flash) return null
|
||||
return <div className={`notice ${flash.kind}`} role={flash.kind === 'error' ? 'alert' : 'status'}>{flash.text}</div>
|
||||
}
|
||||
|
||||
function ControlLayout({ user, active, onLogout, children }: {
|
||||
user: AuthUser
|
||||
active: 'components' | 'invitations'
|
||||
onLogout: () => Promise<void>
|
||||
children: ReactNode
|
||||
}) {
|
||||
const isAdmin = user.role === 'system_admin'
|
||||
return (
|
||||
<main className="dashboard-shell">
|
||||
<header className="dashboard-topbar">
|
||||
<a className="brand" href="/control/" aria-label="返回组件控制台">
|
||||
<span aria-hidden="true">星</span>
|
||||
<div><b>洛星瓷直播云台</b><small>OBS COMPONENT STUDIO</small></div>
|
||||
</a>
|
||||
<nav aria-label="控制台导航">
|
||||
<a className={active === 'components' ? 'active' : ''} href="/control/">我的组件</a>
|
||||
{isAdmin && <a className={active === 'invitations' ? 'active' : ''} href="/control/invitations">邀请码</a>}
|
||||
</nav>
|
||||
<div className="account-menu">
|
||||
<PwaControls />
|
||||
<div><b>{user.displayName || user.username}</b><small>{isAdmin ? '系统管理员' : '用户'}</small></div>
|
||||
<button type="button" className="ghost-button" onClick={() => void onLogout()}>退出</button>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsEditor({ settings, onChange, onSave, saving }: {
|
||||
settings: OverlaySettings
|
||||
onChange: (settings: OverlaySettings) => void
|
||||
onSave: () => Promise<void>
|
||||
saving: boolean
|
||||
}) {
|
||||
const edit = <K extends keyof OverlaySettings>(key: K, value: OverlaySettings[K]) => {
|
||||
onChange({ ...settings, [key]: value })
|
||||
}
|
||||
const eventToggles: Array<[keyof OverlaySettings, string]> = [
|
||||
['showDanmaku', '弹幕'],
|
||||
['showEnter', '进房'],
|
||||
['showGift', '礼物'],
|
||||
['showSuperchat', '醒目留言'],
|
||||
['showGuard', '舰长'],
|
||||
['showLike', '点赞'],
|
||||
['showShare', '分享'],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="settings-editor">
|
||||
<div className="slider-grid">
|
||||
<label>
|
||||
<span>字号 <output>{settings.fontScale}%</output></span>
|
||||
<input type="range" min="50" max="300" step="5" value={settings.fontScale} onChange={event => edit('fontScale', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>最大可见条数 <output>{settings.maxVisible}</output></span>
|
||||
<input type="range" min="1" max="12" value={settings.maxVisible} onChange={event => edit('maxVisible', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>自动收缩 <output>{settings.collapseAfterSeconds}s</output></span>
|
||||
<input type="range" min="2" max="120" value={settings.collapseAfterSeconds} onChange={event => edit('collapseAfterSeconds', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>卷轴展开时长 <output>{(settings.unfoldDurationMs / 1000).toFixed(1)}s</output></span>
|
||||
<input type="range" min="200" max="5000" step="100" value={settings.unfoldDurationMs} onChange={event => edit('unfoldDurationMs', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>动效强度 <output>{settings.motionIntensity}%</output></span>
|
||||
<input type="range" min="0" max="100" value={settings.motionIntensity} onChange={event => edit('motionIntensity', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>每卡粒子数量 <output>{settings.particleCount}</output></span>
|
||||
<input type="range" min="0" max="12" value={settings.particleCount} onChange={event => edit('particleCount', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>粒子动画速度 <output>{settings.particleSpeed}%</output></span>
|
||||
<input type="range" min="25" max="300" step="25" value={settings.particleSpeed} onChange={event => edit('particleSpeed', +event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset className="toggle-grid">
|
||||
<legend>显示事件</legend>
|
||||
{eventToggles.map(([key, label]) => (
|
||||
<label key={key}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(settings[key])}
|
||||
onChange={event => edit(key, event.target.checked as never)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
<label>
|
||||
<input type="checkbox" checked={settings.lowPerformanceMode} onChange={event => edit('lowPerformanceMode', event.target.checked)} />
|
||||
<span>低性能模式</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div className="field-grid two-columns">
|
||||
<label>
|
||||
高价值礼物阈值(厘)
|
||||
<input type="number" min="0" value={settings.highValueThreshold} onChange={event => edit('highValueThreshold', +event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
特别高价值阈值(厘)
|
||||
<input type="number" min="0" value={settings.featuredValueThreshold} onChange={event => edit('featuredValueThreshold', +event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-actions align-end">
|
||||
<button type="button" disabled={saving} onClick={() => void onSave()}>{saving ? '正在保存…' : '保存并实时同步'}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OverlayPreview({ settings }: { settings: OverlaySettings }) {
|
||||
const [preset, setPreset] = useState(previewPresets[1])
|
||||
return (
|
||||
<Panel title="自适应预览" description="预设只改变预览尺寸;OBS 浏览器源仍可使用任意宽高。" className="preview-panel">
|
||||
<div className="preset-buttons">
|
||||
{previewPresets.map(size => (
|
||||
<button
|
||||
type="button"
|
||||
className={size.label === preset.label ? 'active' : 'secondary'}
|
||||
onClick={() => setPreset(size)}
|
||||
key={size.label}
|
||||
>
|
||||
{size.label}<small>{size.width}×{size.height}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="preview-viewport">
|
||||
<div className="preview-frame" style={{ width: preset.width, height: preset.height }}>
|
||||
<Overlay preview previewSettings={settings} />
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function SourceEditor({ source, onSaved }: {
|
||||
source: CookieCloudSource
|
||||
onSaved: (source: CookieCloudSource) => void
|
||||
}) {
|
||||
const [roomId, setRoomId] = useState(source.roomId)
|
||||
const [host, setHost] = useState(source.cookieCloud.host)
|
||||
const [key, setKey] = useState(source.cookieCloud.key)
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [flash, setFlash] = useState<Flash>()
|
||||
const sourceDirty = roomId !== source.roomId
|
||||
|| host.trim() !== source.cookieCloud.host
|
||||
|| key !== source.cookieCloud.key
|
||||
|| Boolean(password)
|
||||
usePwaUpdateBlocker('live-source', '保存或还原直播源与 CookieCloud 设置', busy || sourceDirty)
|
||||
|
||||
useEffect(() => {
|
||||
setRoomId(source.roomId)
|
||||
setHost(source.cookieCloud.host)
|
||||
setKey(source.cookieCloud.key)
|
||||
setPassword('')
|
||||
}, [source])
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setFlash(undefined)
|
||||
try {
|
||||
const payload = await api<unknown>('/api/v1/source', json('PUT', {
|
||||
roomId: roomId.trim(),
|
||||
cookieCloud: {
|
||||
host: host.trim(),
|
||||
key: key.trim(),
|
||||
...(password ? { password } : {}),
|
||||
},
|
||||
}))
|
||||
const next = normalizeSource(payload)
|
||||
onSaved(next)
|
||||
setPassword('')
|
||||
setFlash({ kind: 'success', text: '直播源已保存,连接会使用新的隔离配置。' })
|
||||
} catch (reason) {
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '直播源保存失败') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Bilibili 直播源"
|
||||
description="该 CookieCloud 凭据仅属于当前账户,不会与其他用户共享。"
|
||||
aside={source.connected === undefined ? undefined : (
|
||||
<span className={`status-chip ${source.connected ? 'online' : 'offline'}`}>
|
||||
{source.connected ? '已连接' : '未连接'}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{source.detail && <p className="source-detail">{source.detail}</p>}
|
||||
<form className="field-grid two-columns" onSubmit={submit}>
|
||||
<label>
|
||||
邀请码绑定的直播间 ID
|
||||
<input required readOnly inputMode="numeric" value={roomId} onChange={event => setRoomId(event.target.value)} />
|
||||
<small>直播间由系统管理员签发邀请码时固定,用户不能自行切换。</small>
|
||||
</label>
|
||||
<label>
|
||||
CookieCloud 地址
|
||||
<input required type="url" placeholder="https://cookie.example.com" value={host} onChange={event => setHost(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
CookieCloud UUID / Key
|
||||
<input
|
||||
required={!source.cookieCloud.keyConfigured}
|
||||
autoComplete="off"
|
||||
placeholder={source.cookieCloud.keyConfigured ? '已设置;留空保持不变' : '请输入同步 UUID / Key'}
|
||||
value={key}
|
||||
onChange={event => setKey(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
CookieCloud 密码
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={source.cookieCloud.passwordConfigured ? '已设置;留空保持不变' : '请输入同步密码'}
|
||||
required={!source.cookieCloud.passwordConfigured}
|
||||
value={password}
|
||||
onChange={event => setPassword(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<FlashMessage flash={flash} />
|
||||
<div className="form-actions align-end span-all">
|
||||
<button disabled={busy}>{busy ? '正在验证并保存…' : '保存直播源'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
type TokenState = {
|
||||
publicId: string
|
||||
configured: boolean
|
||||
updatedAt?: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
function tokenState(value: unknown, fallbackPublicId: string): TokenState {
|
||||
const root = value && typeof value === 'object' ? value as Record<string, unknown> : {}
|
||||
const token = typeof root.token === 'string' ? root.token : undefined
|
||||
const publicId = String(root.publicId ?? fallbackPublicId)
|
||||
let address = typeof root.url === 'string'
|
||||
? root.url
|
||||
: typeof root.path === 'string'
|
||||
? root.path
|
||||
: undefined
|
||||
if (address) address = new URL(address, location.origin).toString()
|
||||
if (!address && token) {
|
||||
const url = new URL(`/obs/${encodeURIComponent(publicId)}`, location.origin)
|
||||
url.hash = new URLSearchParams({ token }).toString()
|
||||
address = url.toString()
|
||||
}
|
||||
return {
|
||||
publicId,
|
||||
configured: root.configured === true || root.hasToken === true || root.tokenConfigured === true || Boolean(token || address),
|
||||
updatedAt: typeof root.updatedAt === 'string' ? root.updatedAt : undefined,
|
||||
address,
|
||||
}
|
||||
}
|
||||
|
||||
function ObsAccessPanel({ component }: { component: ComponentSummary }) {
|
||||
const [state, setState] = useState<TokenState>({ publicId: component.publicId, configured: false })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [flash, setFlash] = useState<Flash>()
|
||||
usePwaUpdateBlocker(
|
||||
`obs-token:${component.id}`,
|
||||
'复制并妥善保存本次生成的 OBS 地址',
|
||||
busy || Boolean(state.address),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setState({ publicId: component.publicId, configured: false })
|
||||
setFlash(undefined)
|
||||
api<unknown>(`/api/v1/components/${encodeURIComponent(component.id)}/token`)
|
||||
.then(payload => { if (!cancelled) setState(tokenState(payload, component.publicId)) })
|
||||
.catch(reason => {
|
||||
if (cancelled) return
|
||||
setState({ publicId: component.publicId, configured: false })
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '无法读取当前组件的 OBS 令牌状态') })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [component.id, component.publicId])
|
||||
|
||||
const rotate = async () => {
|
||||
if (state.configured && !window.confirm('轮换后,所有使用旧地址的 OBS 浏览器源会立即失效。确定继续吗?')) return
|
||||
setBusy(true)
|
||||
setFlash(undefined)
|
||||
try {
|
||||
const payload = await api<unknown>(`/api/v1/components/${encodeURIComponent(component.id)}/token`, json('POST'))
|
||||
const next = tokenState(payload, component.publicId)
|
||||
setState(next)
|
||||
setFlash({ kind: 'success', text: '新 OBS 令牌已生成。请立即复制,离开本页后不会再次显示明文令牌。' })
|
||||
} catch (reason) {
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '无法轮换 OBS 令牌') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copy = async () => {
|
||||
if (!state.address) return
|
||||
const copied = await copyToClipboard(state.address)
|
||||
setFlash(copied
|
||||
? { kind: 'success', text: 'OBS 浏览器源地址已复制。' }
|
||||
: { kind: 'error', text: '浏览器阻止了复制,请手工选择下方地址。' })
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="OBS 只读访问" description="令牌仅能订阅这一组件;轮换不会影响账户登录或其他组件。">
|
||||
<div className="token-summary">
|
||||
<div>
|
||||
<small>访问状态</small>
|
||||
<b>{state.configured ? '已生成令牌' : '尚未生成'}</b>
|
||||
</div>
|
||||
<div>
|
||||
<small>组件公开标识</small>
|
||||
<code>{state.publicId}</code>
|
||||
</div>
|
||||
{state.updatedAt && <div><small>最近轮换</small><b>{formatDate(state.updatedAt)}</b></div>}
|
||||
</div>
|
||||
<FlashMessage flash={flash} />
|
||||
{state.address && (
|
||||
<label className="secret-address">
|
||||
本次生成的 OBS 地址
|
||||
<input readOnly value={state.address} onFocus={event => event.currentTarget.select()} />
|
||||
</label>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<button type="button" className={state.configured ? 'danger' : ''} disabled={busy} onClick={() => void rotate()}>
|
||||
{busy ? '正在生成…' : state.configured ? '轮换令牌' : '生成 OBS 地址'}
|
||||
</button>
|
||||
{state.address && <button type="button" className="secondary" onClick={() => void copy()}>复制地址</button>}
|
||||
</div>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function TestEvents({ componentId }: { componentId: string }) {
|
||||
const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift'>('danmaku')
|
||||
const [uid, setUid] = useState('test-viewer')
|
||||
const [name, setName] = useState('测试观众')
|
||||
const [text, setText] = useState('今天也要闪闪发光!')
|
||||
const [giftName, setGiftName] = useState('小花花')
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const [battery, setBattery] = useState(100)
|
||||
const [flash, setFlash] = useState<Flash>()
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setFlash(undefined)
|
||||
try {
|
||||
await api(`/api/v1/components/${encodeURIComponent(componentId)}/test-events`, json('POST', {
|
||||
kind,
|
||||
uid,
|
||||
name,
|
||||
...(kind === 'danmaku' ? { text } : {}),
|
||||
...(kind === 'gift' ? { giftName, quantity, battery } : {}),
|
||||
}))
|
||||
setFlash({ kind: 'success', text: '测试事件已发送到当前组件。' })
|
||||
} catch (reason) {
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '测试事件发送失败') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title="事件测试" description="模拟事件只进入当前用户、当前组件,不会向 Bilibili 发送消息。">
|
||||
<form className="field-grid two-columns" onSubmit={submit}>
|
||||
<label>
|
||||
事件类型
|
||||
<select value={kind} onChange={event => setKind(event.target.value as typeof kind)}>
|
||||
<option value="danmaku">弹幕</option>
|
||||
<option value="enter">进入直播间</option>
|
||||
<option value="gift">礼物</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>测试 UID<input required value={uid} onChange={event => setUid(event.target.value)} /></label>
|
||||
<label>测试昵称<input required value={name} onChange={event => setName(event.target.value)} /></label>
|
||||
{kind === 'danmaku' && <label>弹幕内容<input required value={text} onChange={event => setText(event.target.value)} /></label>}
|
||||
{kind === 'gift' && (
|
||||
<>
|
||||
<label>礼物名称<input required value={giftName} onChange={event => setGiftName(event.target.value)} /></label>
|
||||
<label>数量<input required type="number" min="1" value={quantity} onChange={event => setQuantity(+event.target.value)} /></label>
|
||||
<label>电池数<input required type="number" min="0" value={battery} onChange={event => setBattery(+event.target.value)} /></label>
|
||||
</>
|
||||
)}
|
||||
<FlashMessage flash={flash} />
|
||||
<div className="form-actions align-end span-all"><button disabled={busy}>{busy ? '正在发送…' : '触发测试事件'}</button></div>
|
||||
</form>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function ComponentList({ components, selectedId, onSelect }: {
|
||||
components: ComponentSummary[]
|
||||
selectedId?: string
|
||||
onSelect: (component: ComponentSummary) => void
|
||||
}) {
|
||||
return (
|
||||
<aside className="component-sidebar jade-panel">
|
||||
<div className="component-sidebar-heading">
|
||||
<p className="eyebrow">COMPONENTS</p>
|
||||
<h2>我的组件</h2>
|
||||
</div>
|
||||
{components.length === 0
|
||||
? <div className="empty-state"><b>还没有组件</b><p>账户初始化完成后,服务会为你创建默认弹幕姬。</p></div>
|
||||
: (
|
||||
<div className="component-list">
|
||||
{components.map(component => (
|
||||
<button
|
||||
type="button"
|
||||
className={component.id === selectedId ? 'selected' : ''}
|
||||
onClick={() => onSelect(component)}
|
||||
key={component.id}
|
||||
>
|
||||
<span className="component-icon" aria-hidden="true">{isDanmakuKind(component.kind) ? '弹' : '件'}</span>
|
||||
<span><b>{component.name}</b><small>{isDanmakuKind(component.kind) ? '直播弹幕姬' : component.kind}</small></span>
|
||||
<i className={component.enabled === false ? 'disabled' : 'enabled'} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="future-components">
|
||||
<span>即将支持</span>
|
||||
<small>礼物展示 · 点歌姬</small>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export function ComponentsPage({ user, onLogout }: { user: AuthUser; onLogout: () => Promise<void> }) {
|
||||
const [components, setComponents] = useState<ComponentSummary[]>([])
|
||||
const [selectedId, setSelectedId] = useState<string>()
|
||||
const [settings, setSettings] = useState<OverlaySettings>()
|
||||
const [source, setSource] = useState<CookieCloudSource>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [flash, setFlash] = useState<Flash>()
|
||||
const selectedIdRef = useRef<string | undefined>(undefined)
|
||||
const settingsRequestRef = useRef(0)
|
||||
const savedSettingsRef = useRef<string | undefined>(undefined)
|
||||
|
||||
const selected = useMemo(() => components.find(component => component.id === selectedId), [components, selectedId])
|
||||
const settingsDirty = Boolean(settings)
|
||||
&& JSON.stringify(settings) !== savedSettingsRef.current
|
||||
usePwaUpdateBlocker('component-settings', '保存或还原当前组件设置', saving || settingsDirty)
|
||||
|
||||
const loadComponentSettings = useCallback(async (component: ComponentSummary) => {
|
||||
const requestId = ++settingsRequestRef.current
|
||||
setSaving(false)
|
||||
setSettings(undefined)
|
||||
savedSettingsRef.current = undefined
|
||||
setFlash(undefined)
|
||||
try {
|
||||
const payload = await api<unknown>(`/api/v1/components/${encodeURIComponent(component.id)}/settings`)
|
||||
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== component.id) return
|
||||
const next = { ...defaultOverlaySettings, ...normalizeSettings(payload) }
|
||||
savedSettingsRef.current = JSON.stringify(next)
|
||||
setSettings(next)
|
||||
} catch (reason) {
|
||||
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== component.id) return
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '无法读取组件设置') })
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [componentPayload, sourcePayload] = await Promise.all([
|
||||
api<unknown>('/api/v1/components'),
|
||||
api<unknown>('/api/v1/source'),
|
||||
])
|
||||
if (cancelled) return
|
||||
const nextComponents = normalizeComponents(componentPayload)
|
||||
setComponents(nextComponents)
|
||||
setSource(normalizeSource(sourcePayload))
|
||||
const requested = new URLSearchParams(location.search).get('component')
|
||||
const first = nextComponents.find(component => component.id === requested)
|
||||
?? nextComponents.find(component => isDanmakuKind(component.kind))
|
||||
?? nextComponents[0]
|
||||
if (first) {
|
||||
selectedIdRef.current = first.id
|
||||
setSelectedId(first.id)
|
||||
await loadComponentSettings(first)
|
||||
}
|
||||
} catch (reason) {
|
||||
if (!cancelled) setFlash({ kind: 'error', text: errorMessage(reason, '控制台数据加载失败') })
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
settingsRequestRef.current += 1
|
||||
}
|
||||
}, [loadComponentSettings])
|
||||
|
||||
const choose = (component: ComponentSummary) => {
|
||||
selectedIdRef.current = component.id
|
||||
setSelectedId(component.id)
|
||||
const url = new URL(location.href)
|
||||
url.searchParams.set('component', component.id)
|
||||
history.replaceState(null, '', url)
|
||||
void loadComponentSettings(component)
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!selected || !settings) return
|
||||
const componentId = selected.id
|
||||
const requestId = settingsRequestRef.current
|
||||
setSaving(true)
|
||||
setFlash(undefined)
|
||||
try {
|
||||
const payload = await api<unknown>(
|
||||
`/api/v1/components/${encodeURIComponent(componentId)}/settings`,
|
||||
json('PUT', settings),
|
||||
)
|
||||
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return
|
||||
const next = { ...defaultOverlaySettings, ...normalizeSettings(payload) }
|
||||
savedSettingsRef.current = JSON.stringify(next)
|
||||
setSettings(next)
|
||||
setFlash({ kind: 'success', text: '组件设置已保存,并实时同步到已连接的 OBS。' })
|
||||
} catch (reason) {
|
||||
if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '组件设置保存失败') })
|
||||
} finally {
|
||||
if (requestId === settingsRequestRef.current && selectedIdRef.current === componentId) setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ControlLayout user={user} active="components" onLogout={onLogout}>
|
||||
<div className="dashboard-grid">
|
||||
<ComponentList components={components} selectedId={selectedId} onSelect={choose} />
|
||||
<div className="dashboard-content">
|
||||
{loading && <div className="loading-panel jade-panel">正在展开云台…</div>}
|
||||
<FlashMessage flash={flash} />
|
||||
{!loading && selected && (
|
||||
<>
|
||||
<div className="page-heading">
|
||||
<div><p className="eyebrow">{selected.kind.toUpperCase()}</p><h1>{selected.name}</h1></div>
|
||||
<span className={`status-chip ${selected.enabled === false ? 'offline' : 'online'}`}>
|
||||
{selected.enabled === false ? '已停用' : '运行中'}
|
||||
</span>
|
||||
</div>
|
||||
{isDanmakuKind(selected.kind) && settings
|
||||
? (
|
||||
<>
|
||||
<Panel title="弹幕姬设置" description="每一项都独立保存在当前用户的组件下。">
|
||||
<SettingsEditor settings={settings} onChange={setSettings} onSave={saveSettings} saving={saving} />
|
||||
</Panel>
|
||||
<OverlayPreview settings={settings} />
|
||||
</>
|
||||
)
|
||||
: <Panel title="组件设置"><div className="empty-state">该组件类型的设置编辑器尚未安装。</div></Panel>}
|
||||
<ObsAccessPanel component={selected} key={selected.id} />
|
||||
<TestEvents componentId={selected.id} />
|
||||
</>
|
||||
)}
|
||||
{!loading && !selected && <Panel title="欢迎来到直播云台"><div className="empty-state">当前账户还没有可配置的组件。</div></Panel>}
|
||||
{source && <SourceEditor source={source} onSaved={setSource} />}
|
||||
</div>
|
||||
</div>
|
||||
</ControlLayout>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(value?: string): string {
|
||||
if (!value) return '永久'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(date)
|
||||
}
|
||||
|
||||
function invitationStatus(invitation: Invitation): { label: string; className: string } {
|
||||
if (invitation.revokedAt) return { label: '已撤销', className: 'offline' }
|
||||
if (invitation.expiresAt && new Date(invitation.expiresAt).getTime() <= Date.now()) return { label: '已过期', className: 'offline' }
|
||||
if (invitation.consumedAt) return { label: '已使用', className: 'offline' }
|
||||
return { label: '可使用', className: 'online' }
|
||||
}
|
||||
|
||||
export function InvitationsPage({ user, onLogout }: { user: AuthUser; onLogout: () => Promise<void> }) {
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([])
|
||||
const [roomId, setRoomId] = useState('')
|
||||
const [expiresInHours, setExpiresInHours] = useState(24)
|
||||
const [newCode, setNewCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [flash, setFlash] = useState<Flash>()
|
||||
usePwaUpdateBlocker('invitation-code', '复制并妥善保存本次生成的一次性邀请码', busy || Boolean(newCode))
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const payload = await api<unknown>('/api/v1/invitations')
|
||||
setInvitations(normalizeInvitations(payload))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load().catch(reason => setFlash({ kind: 'error', text: errorMessage(reason, '邀请码读取失败') }))
|
||||
}, [load])
|
||||
|
||||
const create = async (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
setBusy(true)
|
||||
setFlash(undefined)
|
||||
setNewCode('')
|
||||
try {
|
||||
const payload = await api<unknown>('/api/v1/invitations', json('POST', { roomId: roomId.trim(), expiresInHours }))
|
||||
const root = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {}
|
||||
const invitation = root.invitation && typeof root.invitation === 'object'
|
||||
? root.invitation as Record<string, unknown>
|
||||
: root
|
||||
const code = String(invitation.code ?? root.code ?? '')
|
||||
setNewCode(code)
|
||||
setFlash({ kind: 'success', text: '邀请码已创建。明文只显示这一次。' })
|
||||
await load()
|
||||
} catch (reason) {
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '邀请码创建失败') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const revoke = async (invitation: Invitation) => {
|
||||
if (!window.confirm('确定撤销这个邀请码吗?尚未完成的注册会立即失效。')) return
|
||||
setFlash(undefined)
|
||||
try {
|
||||
await api(`/api/v1/invitations/${encodeURIComponent(invitation.id)}`, json('DELETE'))
|
||||
setFlash({ kind: 'success', text: '邀请码已撤销。' })
|
||||
await load()
|
||||
} catch (reason) {
|
||||
setFlash({ kind: 'error', text: errorMessage(reason, '邀请码撤销失败') })
|
||||
}
|
||||
}
|
||||
|
||||
const registerAddress = useMemo(() => {
|
||||
if (!newCode) return ''
|
||||
const url = new URL('/control/register', location.origin)
|
||||
url.hash = new URLSearchParams({ invite: newCode }).toString()
|
||||
return url.toString()
|
||||
}, [newCode])
|
||||
|
||||
return (
|
||||
<ControlLayout user={user} active="invitations" onLogout={onLogout}>
|
||||
<div className="admin-content">
|
||||
<div className="page-heading">
|
||||
<div><p className="eyebrow">SYSTEM ADMIN</p><h1>邀请码管理</h1></div>
|
||||
<span className="status-chip online">仅系统管理员</span>
|
||||
</div>
|
||||
<FlashMessage flash={flash} />
|
||||
<Panel title="创建邀请码" description="默认单次使用、24 小时有效;邀请码只用于注册,不能用于日常登录。">
|
||||
<form className="field-grid two-columns" onSubmit={create}>
|
||||
<label>绑定的 Bilibili 直播间 ID<input required inputMode="numeric" value={roomId} onChange={event => setRoomId(event.target.value)} /></label>
|
||||
<label>有效小时数<input type="number" min="1" max="720" value={expiresInHours} onChange={event => setExpiresInHours(+event.target.value)} /></label>
|
||||
<div className="form-actions align-end span-all"><button disabled={busy}>{busy ? '正在创建…' : '创建邀请码'}</button></div>
|
||||
</form>
|
||||
{newCode && (
|
||||
<div className="one-time-secret">
|
||||
<b>仅显示一次</b>
|
||||
<code>{newCode}</code>
|
||||
<input readOnly value={registerAddress} onFocus={event => event.currentTarget.select()} />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="secondary" onClick={() => void copyToClipboard(newCode)}>复制邀请码</button>
|
||||
<button type="button" className="secondary" onClick={() => void copyToClipboard(registerAddress)}>复制注册链接</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="历史邀请码" description="列表不包含邀请码明文,只显示可审计的状态和使用次数。">
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>直播间</th><th>邀请码前缀</th><th>创建时间</th><th>有效期</th><th>状态</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{invitations.map(invitation => {
|
||||
const status = invitationStatus(invitation)
|
||||
return (
|
||||
<tr key={invitation.id}>
|
||||
<td><code>{invitation.roomId}</code></td>
|
||||
<td><code>{invitation.codePrefix || '—'}</code></td>
|
||||
<td>{formatDate(invitation.createdAt)}</td>
|
||||
<td>{formatDate(invitation.expiresAt)}</td>
|
||||
<td><span className={`status-chip ${status.className}`}>{status.label}</span></td>
|
||||
<td>{status.className === 'online' && <button type="button" className="danger small" onClick={() => void revoke(invitation)}>撤销</button>}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{invitations.length === 0 && <tr><td colSpan={6}><div className="empty-state">尚未创建邀请码。</div></td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</ControlLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function ForbiddenPage({ user, onLogout }: { user: AuthUser; onLogout: () => Promise<void> }) {
|
||||
return (
|
||||
<ControlLayout user={user} active="components" onLogout={onLogout}>
|
||||
<div className="admin-content"><Panel title="没有访问权限"><p>邀请码管理仅对系统管理员开放。</p><a className="button-link" href="/control/">返回我的组件</a></Panel></div>
|
||||
</ControlLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export function isUnauthorized(error: unknown): boolean {
|
||||
return error instanceof ApiError && error.status === 401
|
||||
}
|
||||
+141
-60
@@ -1,68 +1,149 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
|
||||
import { EnrollmentPage, LoginPage } from './auth'
|
||||
import { ComponentsPage, ForbiddenPage, InvitationsPage } from './control'
|
||||
import { Overlay, tokenFromFragment } from './overlay'
|
||||
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
|
||||
import type { Session } from './types'
|
||||
import './style.css'
|
||||
import './control.css'
|
||||
|
||||
type Settings = { title:string; fontScale:number; showDanmaku:boolean; showEnter:boolean; showGift:boolean; showSuperchat:boolean; showGuard:boolean; showLike:boolean; showShare:boolean; maxVisible:number; collapseAfterSeconds:number; unfoldDurationMs:number; motionIntensity:number; particleCount:number; particleSpeed:number; lowPerformanceMode:boolean; highValueThreshold:number; featuredValueThreshold:number }
|
||||
type Envelope = { id:string; type:string; payload:any }
|
||||
type Item = Envelope & { key:string; received:number; decorVariant:number }
|
||||
type DanmakuSegment = { type:'text'; text:string } | { type:'emoticon'; text:string; unique?:string; url:string; width?:number; height?:number; isDynamic?:boolean; standalone?:boolean }
|
||||
const defaults: Settings = { title:'洛星瓷专用弹幕猪!', fontScale:140, showDanmaku:true, showEnter:true, showGift:true, showSuperchat:true, showGuard:true, showLike:false, showShare:false, maxVisible:5, collapseAfterSeconds:12, unfoldDurationMs:1000, motionIntensity:70, particleCount:8, particleSpeed:100, lowPerformanceMode:false, highValueThreshold:10000, featuredValueThreshold:100000 }
|
||||
const cardParticles=['star','floret','star','star','floret','star','floret','star','star','floret','star','floret'] as const
|
||||
const decorVariantCount=6
|
||||
function Redirect({ to }: { to: string }) {
|
||||
useEffect(() => {
|
||||
location.replace(to)
|
||||
}, [to])
|
||||
return <main className="route-loading">正在前往云台…</main>
|
||||
}
|
||||
|
||||
function stableHash(value:string) { let hash=2166136261; for(let index=0;index<value.length;index++){hash^=value.charCodeAt(index);hash=Math.imul(hash,16777619)} return hash>>>0 }
|
||||
function chooseDecorVariant(seed:string,previous?:number) { const hash=stableHash(seed); const base=hash%decorVariantCount; if(previous===undefined||base!==previous)return base; return (base+1+((hash>>>8)%(decorVariantCount-1)))%decorVariantCount }
|
||||
function CardDecor({count,variant}:{count:number;variant:number}) { const visible=Math.min(cardParticles.length,Math.max(0,Math.round(count||0))); const normalized=((variant%decorVariantCount)+decorVariantCount)%decorVariantCount; return <div className={`card-decor decor-v${normalized}`} aria-hidden="true"><i className="card-decor-surface"/><div className="card-particle-layer">{cardParticles.slice(0,visible).map((kind,index)=><i className={`card-particle ${kind}`} key={`${kind}-${index}`}/>)}</div></div> }
|
||||
function NotFoundPage() {
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<section className="auth-card jade-panel not-found">
|
||||
<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>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function wsUrl() { const p = new URLSearchParams(location.search); const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${location.host}/ws?token=${encodeURIComponent(p.get('token') || '')}` }
|
||||
function enabled(type:string, s:Settings) { return (type==='live.danmaku'&&s.showDanmaku)||(type==='live.enter'&&s.showEnter)||(type.startsWith('live.gift')&&s.showGift)||(type==='live.superchat'&&s.showSuperchat)||(type==='live.guard.buy'&&s.showGuard)||(type==='live.like'&&s.showLike)||(type==='live.share'&&s.showShare) }
|
||||
function useEvents(disabled=false) {
|
||||
const [settings,setSettings]=useState<Settings>(defaults); const [items,setItems]=useState<Item[]>([]); const [connected,setConnected]=useState(false); const settingsRef=useRef(settings)
|
||||
useEffect(()=>{settingsRef.current=settings},[settings])
|
||||
useEffect(()=>{ if(disabled)return; let dead=false; let socket:WebSocket|undefined; let timer=0
|
||||
const open=()=>{ socket=new WebSocket(wsUrl()); socket.onopen=()=>setConnected(true); socket.onclose=()=>{setConnected(false); if(!dead) timer=window.setTimeout(open,1500)}; socket.onmessage=e=>{ try { const x:Envelope=JSON.parse(e.data); if(x.type==='overlay.settings.snapshot'||x.type==='overlay.settings.updated'){setSettings(x.payload.settings);return} setItems(old=>{ const current=settingsRef.current; if(!enabled(x.type,current))return old; const combo=x.type==='live.gift.combo'&&x.payload.comboId; const key=combo?`combo:${combo}`:x.id; const existing=old.find(v=>v.key===key); const decorVariant=existing?.decorVariant??chooseDecorVariant(`${x.type}:${key}`,old[0]?.decorVariant); const next=[{...x,key,received:Date.now(),decorVariant},...old.filter(v=>v.key!==key)].slice(0,current.maxVisible); return next }) }catch{} } }
|
||||
open(); return()=>{dead=true;window.clearTimeout(timer);socket?.close()}
|
||||
},[disabled])
|
||||
useEffect(()=>{setItems(items=>items.slice(0,settings.maxVisible))},[settings.maxVisible])
|
||||
return {settings,items,connected,setItems}
|
||||
function App() {
|
||||
const [session, setSession] = useState<Session>()
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [online, setOnline] = useState(navigator.onLine)
|
||||
const path = location.pathname.replace(/\/+$/, '') || '/'
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
setLoadError('')
|
||||
try {
|
||||
const payload = await api<unknown>('/api/v1/auth/me')
|
||||
setSession(normalizeSession(payload))
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
setSession({ user: null, setupRequired: false })
|
||||
return
|
||||
}
|
||||
setLoadError(errorMessage(error, '无法连接认证服务'))
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshSession()
|
||||
}, [refreshSession])
|
||||
|
||||
useEffect(() => {
|
||||
const wentOnline = () => {
|
||||
setOnline(true)
|
||||
if (loadError) void refreshSession()
|
||||
}
|
||||
const wentOffline = () => setOnline(false)
|
||||
window.addEventListener('online', wentOnline)
|
||||
window.addEventListener('offline', wentOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', wentOnline)
|
||||
window.removeEventListener('offline', wentOffline)
|
||||
}
|
||||
}, [loadError, refreshSession])
|
||||
|
||||
useEffect(() => {
|
||||
const expired = () => {
|
||||
setSession({ user: null, setupRequired: false })
|
||||
if (location.pathname.startsWith('/control')) location.assign('/control/login')
|
||||
}
|
||||
window.addEventListener('lxc:session-expired', expired)
|
||||
return () => window.removeEventListener('lxc:session-expired', expired)
|
||||
}, [])
|
||||
|
||||
if (loadError) {
|
||||
const offline = !online
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<section className="auth-card jade-panel">
|
||||
<PwaControls />
|
||||
<p className="eyebrow">{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}</p>
|
||||
<h1>{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}</h1>
|
||||
{offline && <p className="auth-lead">应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。</p>}
|
||||
<div className="notice error">{loadError}</div>
|
||||
<button type="button" disabled={offline} onClick={() => void refreshSession()}>重新连接</button>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
if (!session) return <main className="route-loading">正在验证安全会话…</main>
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await api('/api/v1/auth/logout', json('POST'))
|
||||
} finally {
|
||||
setSession({ user: null, setupRequired: false })
|
||||
location.assign(authRoute('login'))
|
||||
}
|
||||
}
|
||||
|
||||
if (path === '/') return <Redirect to={session.user ? '/control/' : '/login'} />
|
||||
if (path === '/login' || path === '/control/login') {
|
||||
if (session.user) return <Redirect to="/control/" />
|
||||
return <LoginPage onAuthenticated={refreshSession} setupRequired={session.setupRequired} />
|
||||
}
|
||||
if (path === '/setup' || path === '/control/setup') {
|
||||
if (session.user) return <Redirect to="/control/" />
|
||||
if (!session.setupRequired) return <Redirect to={authRoute('login')} />
|
||||
return <EnrollmentPage mode="setup" onAuthenticated={refreshSession} />
|
||||
}
|
||||
if (path === '/register' || path === '/control/register') {
|
||||
if (session.user) return <Redirect to="/control/" />
|
||||
return <EnrollmentPage mode="register" onAuthenticated={refreshSession} />
|
||||
}
|
||||
if (path === '/control') {
|
||||
if (location.pathname === '/control') return <Redirect to="/control/" />
|
||||
if (!session.user) return <Redirect to="/control/login" />
|
||||
return <ComponentsPage user={session.user} onLogout={logout} />
|
||||
}
|
||||
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} />
|
||||
return <InvitationsPage user={session.user} onLogout={logout} />
|
||||
}
|
||||
return <NotFoundPage />
|
||||
}
|
||||
function giftTier(item:Item,s:Settings){const price=item.payload?.gift?.totalPrice||0;return price>=s.featuredValueThreshold?'featured':price>=s.highValueThreshold?'high':'normal'}
|
||||
const eventRenderers:Record<string,(payload:any)=>string>={
|
||||
'live.enter':()=> '踏入了云台','live.superchat':p=>p.message,
|
||||
'live.guard.buy':p=>`开通 ${p.guardName||'舰长'}`,'live.like':()=> '点亮了一颗星','live.share':()=> '分享了直播间'
|
||||
|
||||
const obsMatch = location.pathname.match(/^\/obs\/([^/]+)\/?$/)
|
||||
const root = createRoot(document.getElementById('root')!)
|
||||
if (obsMatch) {
|
||||
// A short-lived migration only: remove the root-scoped worker from early
|
||||
// development builds so it cannot keep controlling an OBS browser source.
|
||||
cleanupLegacyPwa()
|
||||
let publicId = ''
|
||||
try {
|
||||
publicId = decodeURIComponent(obsMatch[1])
|
||||
} catch {
|
||||
publicId = ''
|
||||
}
|
||||
root.render(<Overlay publicId={publicId} accessToken={tokenFromFragment()} />)
|
||||
} else {
|
||||
initializePwa()
|
||||
root.render(<App />)
|
||||
}
|
||||
function DanmakuEmoticon({segment}:{segment:Extract<DanmakuSegment,{type:'emoticon'}>}) { const [failed,setFailed]=useState(false); if(failed)return <>{segment.text}</>; return <img className={`danmaku-emoticon${segment.standalone?' standalone':''}`} src={segment.url} width={segment.width||undefined} height={segment.height||undefined} alt={segment.text} title={segment.text} referrerPolicy="no-referrer" decoding="async" draggable={false} onError={()=>setFailed(true)}/> }
|
||||
function DanmakuBody({payload}:{payload:any}) { 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>)}</> }
|
||||
function Card({item,settings,expanded}:{item:Item;settings:Settings;expanded:boolean}) { const p=item.payload||{}; const v=p.viewer||{}; const gift=p.gift; const isDanmaku=item.type==='live.danmaku'; const tier=gift?giftTier(item,settings):''; const body=gift?`献上 ${gift.name} ×${p.quantity||1}`:isDanmaku?<DanmakuBody payload={p}/>:eventRenderers[item.type]?.(p)||'送来了一份互动';
|
||||
return <article className={`card ${gift?'gift':''} ${item.type==='live.danmaku'?'danmaku':''} ${expanded?'expanded':'compact'} ${tier}`} key={item.key}>
|
||||
<CardDecor count={settings.particleCount} variant={item.decorVariant}/>
|
||||
{gift&&<div className="gift-art">{gift.animationUrl||gift.imageUrl?<img src={gift.animationUrl||gift.imageUrl} onError={e=>{const image=e.currentTarget;if(gift.imageUrl&&!image.src.endsWith(gift.imageUrl))image.src=gift.imageUrl;else image.style.display='none'}}/>:<span>✦</span>}</div>}
|
||||
<div className="copy"><b>{v.name||'直播间观众'}</b><span className={isDanmaku?'danmaku-content':undefined}>{body}</span>{gift?.priceCny>0&&<em>¥ {Number(gift.priceCny).toFixed(2)}</em>}</div>{tier==='featured'&&<div className="particles">✦ ✧ ✦</div>}
|
||||
</article> }
|
||||
function Overlay({preview=false,previewSettings}:{preview?:boolean;previewSettings?:Settings}) { const root=useRef<HTMLDivElement>(null); const events=useEvents(preview); const {items,connected,setItems}=events; const settings=previewSettings||events.settings; const [shape,setShape]=useState('standard'); const [expandedKey,setExpandedKey]=useState<string>(); const fontFactor=settings.fontScale/100
|
||||
useEffect(()=>{if(!root.current)return;const ob=new ResizeObserver(([entry])=>{const {width,height}=entry.contentRect;setShape(width<380?'narrow':height<420?'short':'standard')});ob.observe(root.current);return()=>ob.disconnect()},[])
|
||||
useEffect(()=>{if(preview&&!items.length)setItems([{id:'text-preview',key:'text-preview',received:Date.now(),decorVariant:0,type:'live.danmaku',payload:{viewer:{name:'青玉观众'},text:'今天也要闪闪发光!'}},{id:'gift-preview',key:'gift-preview',received:Date.now()-1000,decorVariant:3,type:'live.gift',payload:{viewer:{name:'星光旅人'},quantity:1,gift:{name:'甜蜜告白',totalPrice:12000,priceCny:12,imageUrl:'',animationUrl:''}}}])},[preview,items.length,setItems])
|
||||
useEffect(()=>{const newest=items[0];if(!newest){setExpandedKey(undefined);return}setExpandedKey(newest.key);const densityFactor=shape==='short'?.6:1;const timer=window.setTimeout(()=>setExpandedKey(key=>key===newest.key?undefined:key),settings.collapseAfterSeconds*1000*densityFactor);return()=>window.clearTimeout(timer)},[items[0]?.key,items[0]?.received,settings.collapseAfterSeconds,shape])
|
||||
return <main ref={root} className={`overlay ${shape} ${settings.lowPerformanceMode?'low-motion':''}`} style={{['--motion' as string]:`${settings.motionIntensity/100}`,['--unfold-duration' as string]:`${settings.unfoldDurationMs||defaults.unfoldDurationMs}ms`,['--particle-duration' as string]:`${400000/Math.min(300,Math.max(25,settings.particleSpeed||defaults.particleSpeed))}ms`,['--font-title' as string]:`${18*fontFactor}px`,['--font-body' as string]:`${18*fontFactor}px`,['--font-expanded' as string]:`${26*fontFactor}px`,['--font-compact' as string]:`${15*fontFactor}px`}}><section className={`wall ${items.length?'awake':''}`}><header><i className={connected?'online':''}/><span>{settings.title}</span></header><div className="cards">{items.map(item=><Card item={item} settings={settings} expanded={item.key===expandedKey} key={item.key}/>)}</div></section></main> }
|
||||
function api(url:string, init?:RequestInit){return fetch(url,{credentials:'same-origin',headers:{'content-type':'application/json',...(init?.headers||{})},...init})}
|
||||
async function copyToClipboard(text:string){
|
||||
if(window.isSecureContext&&navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(text);return true}catch{}}
|
||||
const input=document.createElement('textarea');input.value=text;input.readOnly=true;input.style.position='fixed';input.style.left='-9999px';input.style.opacity='0';document.body.appendChild(input);input.focus();input.select()
|
||||
let copied=false;try{copied=document.execCommand('copy')}finally{input.remove()}
|
||||
return copied
|
||||
}
|
||||
const previewPresets=[{label:'窄侧栏',width:360,height:600},{label:'竖屏',width:440,height:760},{label:'高清竖栏',width:600,height:1080},{label:'横向条',width:720,height:320}]
|
||||
function Control(){
|
||||
const [password,setPassword]=useState(''); const [settings,setSettings]=useState<Settings>(); const [error,setError]=useState(''); const [message,setMessage]=useState(''); const [obsAddress,setObsAddress]=useState(''); const [previewSize,setPreviewSize]=useState(previewPresets[1])
|
||||
const load=useCallback(async()=>{const r=await api('/api/admin/overlay-settings');if(!r.ok)throw new Error(r.status===401?'请输入管理员密码':'无法读取设置');setSettings(await r.json())},[])
|
||||
useEffect(()=>{load().catch(()=>{})},[load])
|
||||
const login=async(e:React.FormEvent)=>{e.preventDefault();const r=await api('/api/auth/login',{method:'POST',body:JSON.stringify({password})});if(!r.ok){setError('密码不正确');return}setError('');await load()}
|
||||
const save=async()=>{if(!settings)return;const r=await api('/api/admin/overlay-settings',{method:'PUT',body:JSON.stringify(settings)});if(!r.ok)setError('保存失败');else setSettings((await r.json()).settings)}
|
||||
const copy=async()=>{setMessage('');const r=await api('/api/admin/obs-url');if(!r.ok){setError('无法获取 OBS 地址,请重新登录');return}const {path}=await r.json();const address=new URL(path,location.origin).toString();setObsAddress(address);if(await copyToClipboard(address)){setError('');setMessage('OBS 地址已复制到剪贴板')}else{setError('浏览器阻止了自动复制,请在下方地址框中手动复制')}}
|
||||
if(!settings)return <main className="control login"><h1>弹幕猪控制台</h1><form onSubmit={login}><input type="password" autoFocus placeholder="管理员密码" value={password} onChange={e=>setPassword(e.target.value)}/><button>进入</button>{error&&<p>{error}</p>}</form></main>
|
||||
const edit=(key:keyof Settings,value:any)=>setSettings({...settings,[key]:value})
|
||||
const labels={showDanmaku:'弹幕',showEnter:'进房',showGift:'礼物',showSuperchat:'醒目留言',showGuard:'舰长',showLike:'点赞',showShare:'分享',lowPerformanceMode:'低性能模式'}
|
||||
return <main className="control"><section><h1>青玉弹幕姬</h1><p>改动会立即同步到所有 OBS 浏览器源。</p><label>标题<input value={settings.title} onChange={e=>edit('title',e.target.value)}/></label><label>字号 <input type="range" min="50" max="300" step="5" value={settings.fontScale} onChange={e=>edit('fontScale',+e.target.value)}/><output>{settings.fontScale}%</output></label><label>最大可见条数 <input type="range" min="1" max="12" value={settings.maxVisible} onChange={e=>edit('maxVisible',+e.target.value)}/><output>{settings.maxVisible}</output></label><label>自动收缩秒数 <input type="range" min="2" max="60" value={settings.collapseAfterSeconds} onChange={e=>edit('collapseAfterSeconds',+e.target.value)}/><output>{settings.collapseAfterSeconds}s</output></label><label>卷轴展开时长 <input type="range" min="200" max="5000" step="100" value={settings.unfoldDurationMs} onChange={e=>edit('unfoldDurationMs',+e.target.value)}/><output>{(settings.unfoldDurationMs/1000).toFixed(1)}s</output></label><label>动效强度 <input type="range" min="0" max="100" value={settings.motionIntensity} onChange={e=>edit('motionIntensity',+e.target.value)}/><output>{settings.motionIntensity}%</output></label><label>每卡粒子数量 <input type="range" min="0" max="12" step="1" value={settings.particleCount} onChange={e=>edit('particleCount',+e.target.value)}/><output>{settings.particleCount}</output></label><label>粒子动画速度 <input type="range" min="25" max="300" step="25" value={settings.particleSpeed} onChange={e=>edit('particleSpeed',+e.target.value)}/><output>{settings.particleSpeed}%</output></label><fieldset>{(Object.keys(labels) as (keyof typeof labels)[]).map(k=><label key={k}><input type="checkbox" checked={settings[k]} onChange={e=>edit(k,e.target.checked)}/>{labels[k]}</label>)}</fieldset><label>高价值礼物(厘)<input type="number" value={settings.highValueThreshold} onChange={e=>edit('highValueThreshold',+e.target.value)}/></label><label>特别高价值(厘)<input type="number" value={settings.featuredValueThreshold} onChange={e=>edit('featuredValueThreshold',+e.target.value)}/></label><div className="buttons"><button type="button" onClick={save}>保存并同步</button><button type="button" className="secondary" onClick={copy}>复制 OBS 地址</button></div>{obsAddress&&<label className="obs-address">OBS 浏览器源地址<input readOnly value={obsAddress} onFocus={e=>e.currentTarget.select()}/></label>}{message&&<p className="success">{message}</p>}{error&&<p>{error}</p>}</section><section className="preview"><h2>自适应预览</h2><p>选择常用尺寸后仍可拖拽预览框右下角;OBS 中也可使用任意宽高。</p><div className="preset-buttons">{previewPresets.map(size=><button type="button" className={size.label===previewSize.label?'active':'secondary'} onClick={()=>setPreviewSize(size)} key={size.label}>{size.label}<small>{size.width}×{size.height}</small></button>)}</div><div className="preview-viewport"><div className="preview-frame" style={{width:previewSize.width,height:previewSize.height}}><Overlay preview previewSettings={settings}/></div></div></section></main>
|
||||
}
|
||||
const isControl=location.pathname.startsWith('/control');createRoot(document.getElementById('root')!).render(isControl?<Control/>:<Overlay/>);
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { defaultOverlaySettings } from './types'
|
||||
import type { OverlaySettings } from './types'
|
||||
|
||||
type Envelope = {
|
||||
id: string
|
||||
type: string
|
||||
payload: LivePayload & {
|
||||
code?: string
|
||||
settings?: Partial<OverlaySettings>
|
||||
}
|
||||
}
|
||||
|
||||
type LivePayload = {
|
||||
viewer?: { uid?: string; name?: string }
|
||||
text?: string
|
||||
message?: string
|
||||
guardName?: string
|
||||
quantity?: number
|
||||
comboId?: string
|
||||
segments?: DanmakuSegment[]
|
||||
gift?: {
|
||||
name?: string
|
||||
totalPrice?: number
|
||||
priceCny?: number
|
||||
imageUrl?: string
|
||||
animationUrl?: string
|
||||
}
|
||||
}
|
||||
|
||||
type Item = Envelope & {
|
||||
key: string
|
||||
received: number
|
||||
decorVariant: number
|
||||
}
|
||||
|
||||
type DanmakuSegment =
|
||||
| { type: 'text'; text: string }
|
||||
| {
|
||||
type: 'emoticon'
|
||||
text: string
|
||||
unique?: string
|
||||
url: string
|
||||
width?: number
|
||||
height?: number
|
||||
isDynamic?: boolean
|
||||
standalone?: boolean
|
||||
}
|
||||
|
||||
type OverlayProps = {
|
||||
preview?: boolean
|
||||
previewSettings?: OverlaySettings
|
||||
publicId?: string
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
const cardParticles = ['star', 'floret', 'star', 'star', 'floret', 'star', 'floret', 'star', 'star', 'floret', 'star', 'floret'] as const
|
||||
const decorVariantCount = 6
|
||||
|
||||
function stableHash(value: string) {
|
||||
let hash = 2166136261
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index)
|
||||
hash = Math.imul(hash, 16777619)
|
||||
}
|
||||
return hash >>> 0
|
||||
}
|
||||
|
||||
function chooseDecorVariant(seed: string, previous?: number) {
|
||||
const hash = stableHash(seed)
|
||||
const base = hash % decorVariantCount
|
||||
if (previous === undefined || base !== previous) return base
|
||||
return (base + 1 + ((hash >>> 8) % (decorVariantCount - 1))) % decorVariantCount
|
||||
}
|
||||
|
||||
function CardDecor({ count, variant }: { count: number; variant: number }) {
|
||||
const visible = Math.min(cardParticles.length, Math.max(0, Math.round(count || 0)))
|
||||
const normalized = ((variant % decorVariantCount) + decorVariantCount) % decorVariantCount
|
||||
return (
|
||||
<div className={`card-decor decor-v${normalized}`} aria-hidden="true">
|
||||
<i className="card-decor-surface" />
|
||||
<div className="card-particle-layer">
|
||||
{cardParticles.slice(0, visible).map((kind, index) => (
|
||||
<i className={`card-particle ${kind}`} key={`${kind}-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function streamUrl(publicId: string) {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${location.host}/api/v1/components/${encodeURIComponent(publicId)}/stream`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function parseSettings(value: unknown): OverlaySettings {
|
||||
if (!value || typeof value !== 'object') return defaultOverlaySettings
|
||||
return { ...defaultOverlaySettings, ...value as Partial<OverlaySettings> }
|
||||
}
|
||||
|
||||
function useEvents(disabled: boolean, publicId?: string, accessToken?: string): {
|
||||
settings: OverlaySettings
|
||||
items: Item[]
|
||||
setItems: Dispatch<SetStateAction<Item[]>>
|
||||
connection: 'idle' | 'connecting' | 'connected' | 'denied'
|
||||
} {
|
||||
const [settings, setSettings] = useState<OverlaySettings>(defaultOverlaySettings)
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>('idle')
|
||||
const settingsRef = useRef(settings)
|
||||
|
||||
useEffect(() => {
|
||||
settingsRef.current = settings
|
||||
}, [settings])
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled || !publicId || !accessToken) {
|
||||
setConnection('idle')
|
||||
return
|
||||
}
|
||||
|
||||
let dead = false
|
||||
let socket: WebSocket | undefined
|
||||
let timer = 0
|
||||
let retries = 0
|
||||
|
||||
const open = () => {
|
||||
setConnection('connecting')
|
||||
socket = new WebSocket(streamUrl(publicId))
|
||||
socket.onopen = () => {
|
||||
retries = 0
|
||||
socket?.send(JSON.stringify({ type: 'authenticate', token: accessToken }))
|
||||
}
|
||||
socket.onclose = (event) => {
|
||||
if (dead) return
|
||||
if (event.code === 1008 || event.code === 4401 || event.code === 4403) {
|
||||
setConnection('denied')
|
||||
return
|
||||
}
|
||||
setConnection('connecting')
|
||||
const delay = Math.min(12_000, 1200 * 2 ** Math.min(retries, 3))
|
||||
retries += 1
|
||||
timer = window.setTimeout(open, delay)
|
||||
}
|
||||
socket.onmessage = event => {
|
||||
try {
|
||||
const envelope = JSON.parse(event.data) as Envelope
|
||||
if (envelope.type === 'authenticated' || envelope.type === 'stream.authenticated') {
|
||||
setConnection('connected')
|
||||
return
|
||||
}
|
||||
if (envelope.type === 'error' && envelope.payload?.code === 'UNAUTHORIZED') {
|
||||
setConnection('denied')
|
||||
socket?.close(1008, 'Unauthorized')
|
||||
return
|
||||
}
|
||||
setConnection('connected')
|
||||
if (envelope.type === 'overlay.settings.snapshot' || envelope.type === 'overlay.settings.updated') {
|
||||
setSettings(parseSettings(envelope.payload?.settings))
|
||||
return
|
||||
}
|
||||
setItems(old => {
|
||||
const current = settingsRef.current
|
||||
if (!enabled(envelope.type, current)) return old
|
||||
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)
|
||||
})
|
||||
} catch {
|
||||
// A malformed upstream event must not break a long-running OBS source.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open()
|
||||
return () => {
|
||||
dead = true
|
||||
window.clearTimeout(timer)
|
||||
socket?.close()
|
||||
}
|
||||
}, [accessToken, disabled, publicId])
|
||||
|
||||
useEffect(() => {
|
||||
setItems(current => current.slice(0, settings.maxVisible))
|
||||
}, [settings.maxVisible])
|
||||
|
||||
return { settings, items, setItems, connection }
|
||||
}
|
||||
|
||||
function giftTier(item: Item, settings: OverlaySettings) {
|
||||
const price = item.payload?.gift?.totalPrice || 0
|
||||
return price >= settings.featuredValueThreshold
|
||||
? 'featured'
|
||||
: price >= settings.highValueThreshold
|
||||
? 'high'
|
||||
: 'normal'
|
||||
}
|
||||
|
||||
const eventRenderers: Record<string, (payload: LivePayload) => string> = {
|
||||
'live.enter': () => '踏入了云台',
|
||||
'live.superchat': payload => payload.message || '',
|
||||
'live.guard.buy': payload => `开通 ${payload.guardName || '舰长'}`,
|
||||
'live.like': () => '点亮了一颗星',
|
||||
'live.share': () => '分享了直播间',
|
||||
}
|
||||
|
||||
function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type: 'emoticon' }> }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
if (failed) return <>{segment.text}</>
|
||||
return (
|
||||
<img
|
||||
className={`danmaku-emoticon${segment.standalone ? ' standalone' : ''}`}
|
||||
src={segment.url}
|
||||
width={segment.width || undefined}
|
||||
height={segment.height || undefined}
|
||||
alt={segment.text}
|
||||
title={segment.text}
|
||||
referrerPolicy="no-referrer"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DanmakuBody({ payload }: { payload: LivePayload }) {
|
||||
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>)}</>
|
||||
}
|
||||
|
||||
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) || '送来了一份互动'
|
||||
|
||||
return (
|
||||
<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>}
|
||||
</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>}
|
||||
</div>
|
||||
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function Overlay({ preview = false, previewSettings, publicId, accessToken }: OverlayProps) {
|
||||
const root = useRef<HTMLDivElement>(null)
|
||||
const events = useEvents(preview, publicId, accessToken)
|
||||
const { items, setItems } = events
|
||||
const settings = previewSettings || events.settings
|
||||
const [shape, setShape] = useState('standard')
|
||||
const [expandedKey, setExpandedKey] = useState<string>()
|
||||
const fontFactor = settings.fontScale / 100
|
||||
|
||||
useEffect(() => {
|
||||
if (!root.current) return
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect
|
||||
setShape(width < 380 ? 'narrow' : height < 420 ? 'short' : 'standard')
|
||||
})
|
||||
observer.observe(root.current)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (preview && !items.length) {
|
||||
setItems([
|
||||
{
|
||||
id: 'text-preview',
|
||||
key: 'text-preview',
|
||||
received: Date.now(),
|
||||
decorVariant: 0,
|
||||
type: 'live.danmaku',
|
||||
payload: { viewer: { name: '青玉观众' }, text: '今天也要闪闪发光!' },
|
||||
},
|
||||
{
|
||||
id: 'gift-preview',
|
||||
key: 'gift-preview',
|
||||
received: Date.now() - 1000,
|
||||
decorVariant: 3,
|
||||
type: 'live.gift',
|
||||
payload: {
|
||||
viewer: { name: '星光旅人' },
|
||||
quantity: 1,
|
||||
gift: { name: '甜蜜告白', totalPrice: 12_000, priceCny: 12, imageUrl: '', animationUrl: '' },
|
||||
},
|
||||
},
|
||||
])
|
||||
}
|
||||
}, [items.length, preview, setItems])
|
||||
|
||||
useEffect(() => {
|
||||
const newest = items[0]
|
||||
if (!newest) {
|
||||
setExpandedKey(undefined)
|
||||
return
|
||||
}
|
||||
setExpandedKey(newest.key)
|
||||
const densityFactor = shape === 'short' ? 0.6 : 1
|
||||
const timer = window.setTimeout(
|
||||
() => setExpandedKey(key => key === newest.key ? undefined : key),
|
||||
settings.collapseAfterSeconds * 1000 * densityFactor,
|
||||
)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [items, settings.collapseAfterSeconds, shape])
|
||||
|
||||
const missingAccess = !preview && (!publicId || !accessToken)
|
||||
return (
|
||||
<main
|
||||
ref={root}
|
||||
className={`overlay ${shape} ${settings.lowPerformanceMode ? 'low-motion' : ''}`}
|
||||
data-connection={events.connection}
|
||||
style={{
|
||||
['--motion' as string]: `${settings.motionIntensity / 100}`,
|
||||
['--unfold-duration' as string]: `${settings.unfoldDurationMs || defaultOverlaySettings.unfoldDurationMs}ms`,
|
||||
['--particle-duration' as string]: `${400000 / Math.min(300, Math.max(25, settings.particleSpeed || defaultOverlaySettings.particleSpeed))}ms`,
|
||||
['--font-body' as string]: `${18 * fontFactor}px`,
|
||||
['--font-expanded' as string]: `${26 * fontFactor}px`,
|
||||
['--font-compact' as string]: `${15 * fontFactor}px`,
|
||||
}}
|
||||
>
|
||||
<section className="wall">
|
||||
{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} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export function tokenFromFragment(): string {
|
||||
const hash = location.hash.startsWith('#') ? location.hash.slice(1) : location.hash
|
||||
return new URLSearchParams(hash).get('token') ?? ''
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useEffect, useSyncExternalStore } from 'react'
|
||||
|
||||
interface InstallChoice {
|
||||
outcome: 'accepted' | 'dismissed'
|
||||
platform: string
|
||||
}
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
readonly platforms: string[]
|
||||
readonly userChoice: Promise<InstallChoice>
|
||||
prompt(): Promise<void>
|
||||
}
|
||||
|
||||
interface PwaSnapshot {
|
||||
online: boolean
|
||||
standalone: boolean
|
||||
installPrompt?: BeforeInstallPromptEvent
|
||||
registration?: ServiceWorkerRegistration
|
||||
waitingWorker?: ServiceWorker
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
const updateBlockers = new Map<string, string>()
|
||||
let snapshot: PwaSnapshot = {
|
||||
online: navigator.onLine,
|
||||
standalone: isStandalone(),
|
||||
}
|
||||
let initialized = false
|
||||
let reloadForUpdate = false
|
||||
|
||||
function isStandalone(): boolean {
|
||||
const iosNavigator = navigator as Navigator & { standalone?: boolean }
|
||||
return window.matchMedia('(display-mode: standalone)').matches || iosNavigator.standalone === true
|
||||
}
|
||||
|
||||
function emit(patch: Partial<PwaSnapshot>) {
|
||||
snapshot = { ...snapshot, ...patch }
|
||||
listeners.forEach(listener => listener())
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function addManifest() {
|
||||
if (document.head.querySelector('link[rel="manifest"]')) return
|
||||
const manifest = document.createElement('link')
|
||||
manifest.rel = 'manifest'
|
||||
manifest.href = '/control/manifest.webmanifest'
|
||||
document.head.append(manifest)
|
||||
}
|
||||
|
||||
function observeRegistration(registration: ServiceWorkerRegistration) {
|
||||
emit({
|
||||
registration,
|
||||
waitingWorker: registration.waiting && navigator.serviceWorker.controller
|
||||
? registration.waiting
|
||||
: snapshot.waitingWorker,
|
||||
})
|
||||
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const worker = registration.installing
|
||||
if (!worker) return
|
||||
worker.addEventListener('statechange', () => {
|
||||
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
emit({ waitingWorker: worker })
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
}))
|
||||
|
||||
const cacheNames = await caches.keys()
|
||||
await Promise.all(cacheNames
|
||||
.filter(name => name.startsWith('lxc-control-shell-'))
|
||||
.map(name => caches.delete(name)))
|
||||
}
|
||||
|
||||
export function cleanupLegacyPwa() {
|
||||
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
|
||||
void removeLegacyRootWorker().catch(error => {
|
||||
console.warn('旧版 PWA 清理失败', error)
|
||||
})
|
||||
}
|
||||
|
||||
async function registerWorker() {
|
||||
try {
|
||||
await removeLegacyRootWorker()
|
||||
const registration = await navigator.serviceWorker.register(
|
||||
`/control/sw.js?v=${encodeURIComponent(__PWA_BUILD_ID__)}`,
|
||||
{ scope: '/control/', updateViaCache: 'none' },
|
||||
)
|
||||
observeRegistration(registration)
|
||||
} catch (error) {
|
||||
// A failed PWA registration must never stop the online control console.
|
||||
console.warn('控制台 PWA 注册失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
export function initializePwa() {
|
||||
if (initialized || location.pathname === '/obs' || location.pathname.startsWith('/obs/')) return
|
||||
initialized = true
|
||||
addManifest()
|
||||
|
||||
const displayMode = window.matchMedia('(display-mode: standalone)')
|
||||
const updateConnection = () => emit({ online: navigator.onLine })
|
||||
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
|
||||
if (modernListener) modernListener.call(displayMode, 'change', 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 }))
|
||||
|
||||
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
|
||||
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (!reloadForUpdate) return
|
||||
reloadForUpdate = false
|
||||
location.reload()
|
||||
})
|
||||
|
||||
const start = () => void registerWorker()
|
||||
if (document.readyState === 'complete') start()
|
||||
else window.addEventListener('load', start, { once: true })
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && navigator.onLine) {
|
||||
void snapshot.registration?.update()
|
||||
}
|
||||
})
|
||||
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/')
|
||||
return inControlScope ? `/control/${name}` : `/${name}`
|
||||
}
|
||||
|
||||
export function usePwaUpdateBlocker(key: string, reason: string, active: boolean) {
|
||||
useEffect(() => {
|
||||
if (active) updateBlockers.set(key, reason)
|
||||
else updateBlockers.delete(key)
|
||||
return () => { updateBlockers.delete(key) }
|
||||
}, [active, key, reason])
|
||||
}
|
||||
|
||||
async function requestInstall() {
|
||||
const prompt = snapshot.installPrompt
|
||||
if (!prompt) return
|
||||
await prompt.prompt()
|
||||
await prompt.userChoice
|
||||
emit({ installPrompt: undefined, standalone: isStandalone() })
|
||||
}
|
||||
|
||||
function applyUpdate() {
|
||||
const worker = snapshot.waitingWorker
|
||||
if (!worker) return
|
||||
const reasons = [...new Set(updateBlockers.values())]
|
||||
if (reasons.length > 0) {
|
||||
window.alert(`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`)
|
||||
return
|
||||
}
|
||||
const confirmed = window.confirm('更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?')
|
||||
if (!confirmed) return
|
||||
reloadForUpdate = true
|
||||
worker.postMessage({ type: 'SKIP_WAITING' })
|
||||
}
|
||||
|
||||
export function PwaControls() {
|
||||
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
||||
const canInstall = Boolean(state.installPrompt) && !state.standalone
|
||||
if (state.online && !state.waitingWorker && !canInstall) return null
|
||||
|
||||
return (
|
||||
<div className="pwa-controls" aria-live="polite">
|
||||
{!state.online && <span className="pwa-state offline"><i aria-hidden="true" />离线</span>}
|
||||
{state.waitingWorker && (
|
||||
<button type="button" className="pwa-action update" onClick={applyUpdate}>
|
||||
更新可用
|
||||
</button>
|
||||
)}
|
||||
{canInstall && (
|
||||
<button type="button" className="pwa-action install" onClick={() => void requestInstall()}>
|
||||
安装到设备
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
*{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))}.wall header{align-self:flex-end;display:flex;gap:8px;align-items:center;padding:8px 14px;border:1px solid rgba(144,255,240,.33);border-radius:999px;background:linear-gradient(110deg,rgba(13,47,63,.72),rgba(29,115,107,.44));backdrop-filter:blur(12px);letter-spacing:.08em;font-size:clamp(12px,2.5vw,17px);transition:.5s}.wall:not(.awake) header{opacity:.72}.wall header i{width:7px;height:7px;border-radius:50%;background:#55736f}.wall header i.online{background:#74ffd9;box-shadow:0 0 10px #4bffc7}.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,.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}}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
export type OverlaySettings = {
|
||||
fontScale: number
|
||||
showDanmaku: boolean
|
||||
showEnter: boolean
|
||||
showGift: boolean
|
||||
showSuperchat: boolean
|
||||
showGuard: boolean
|
||||
showLike: boolean
|
||||
showShare: boolean
|
||||
maxVisible: number
|
||||
collapseAfterSeconds: number
|
||||
unfoldDurationMs: number
|
||||
motionIntensity: number
|
||||
particleCount: number
|
||||
particleSpeed: number
|
||||
lowPerformanceMode: boolean
|
||||
highValueThreshold: number
|
||||
featuredValueThreshold: number
|
||||
}
|
||||
|
||||
export const defaultOverlaySettings: OverlaySettings = {
|
||||
fontScale: 140,
|
||||
showDanmaku: true,
|
||||
showEnter: true,
|
||||
showGift: true,
|
||||
showSuperchat: true,
|
||||
showGuard: true,
|
||||
showLike: false,
|
||||
showShare: false,
|
||||
maxVisible: 5,
|
||||
collapseAfterSeconds: 12,
|
||||
unfoldDurationMs: 1000,
|
||||
motionIntensity: 70,
|
||||
particleCount: 8,
|
||||
particleSpeed: 100,
|
||||
lowPerformanceMode: false,
|
||||
highValueThreshold: 10_000,
|
||||
featuredValueThreshold: 100_000,
|
||||
}
|
||||
|
||||
export type UserRole = 'system_admin' | 'user' | string
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
username: string
|
||||
roomId?: string
|
||||
displayName?: string
|
||||
role: UserRole
|
||||
totpEnabled?: boolean
|
||||
}
|
||||
|
||||
export type Session = {
|
||||
user: AuthUser | null
|
||||
setupRequired: boolean
|
||||
}
|
||||
|
||||
export type ComponentSummary = {
|
||||
id: string
|
||||
publicId: string
|
||||
kind: string
|
||||
name: string
|
||||
enabled?: boolean
|
||||
settings?: OverlaySettings
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type CookieCloudSource = {
|
||||
roomId: string
|
||||
cookieCloud: {
|
||||
host: string
|
||||
key: string
|
||||
keyConfigured?: boolean
|
||||
password?: string
|
||||
passwordConfigured?: boolean
|
||||
}
|
||||
connected?: boolean
|
||||
detail?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type Invitation = {
|
||||
id: string
|
||||
code?: string
|
||||
codePrefix?: string
|
||||
roomId: string
|
||||
createdBy?: string
|
||||
createdAt?: string
|
||||
expiresAt?: string
|
||||
consumedAt?: string | null
|
||||
revokedAt?: string | null
|
||||
}
|
||||
|
||||
export type TotpEnrollment = {
|
||||
enrollmentToken: string
|
||||
qrSvg?: string
|
||||
qrDataUrl?: string
|
||||
otpauthUri?: string
|
||||
manualKey: string
|
||||
expiresAt?: string
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __PWA_BUILD_ID__: string
|
||||
Reference in New Issue
Block a user