diff --git a/Dockerfile b/Dockerfile index f78e7e5..b46c05b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ FROM node:22-bookworm-slim AS web-build WORKDIR /app/apps/overlay COPY apps/overlay/package.json apps/overlay/package-lock.json ./ RUN npm ci +COPY resources /app/resources COPY apps/overlay ./ RUN npm run build @@ -14,6 +15,7 @@ COPY apps/server-rust/Cargo.toml apps/server-rust/Cargo.lock ./apps/server-rust/ COPY --from=libilibili . /libilibili COPY apps/server-rust/src ./apps/server-rust/src COPY apps/server-rust/migrations ./apps/server-rust/migrations +COPY resources ./resources RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/app/target-cache \ CARGO_TARGET_DIR=/app/target-cache cargo build --manifest-path apps/server-rust/Cargo.toml --release && \ diff --git a/README.md b/README.md index 87a83d8..15379a0 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ crate。它负责 Cookie/WBI API、直播 WebSocket 认证、心跳以及 JSON、zlib、brotli 包解析;本项目的 provider adapter 只负责把强类型 Bilibili 命令转换成稳定的领域事件。crate 尚未建模的少量兼容命令只在 provider 边界解析,原始包不会进入组件协议。 +控制台和 OBS 展示的所有文案统一来自 +`resources/i18n.toml`。匿名页面可以先使用浏览器偏好,登录后语言保存为账户级偏好,并实时同步到该账户的所有 OBS 组件。目前支持简体中文和英语。 + ## 文档索引 - [总体架构与事件流](docs/architecture.md) @@ -66,7 +69,8 @@ Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--c `[database].url` 指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL RLS 约束,HTTP -API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。点歌队列、评分和完整历史同样保存在租户隔离表中。 +API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。点歌队列、评分和完整历史同样保存在租户隔离表中。账户语言偏好也保存在强制 RLS 的 +`account_preferences` 表中。 `security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP Secret 与每个用户的 CookieCloud @@ -113,8 +117,9 @@ https://danmaku.luoxingci.com/control/setup - 会话使用随机令牌,数据库只保存摘要,可在服务端到期或撤销。 系统管理员可在 `/control/invitations` -创建和撤销邀请码。每个邀请码只能使用一次,并在创建时固定绑定一个尚未占用的 Bilibili -`room_id`;注册者不能修改该房间,账户创建后房间绑定也不可更改。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。 +创建和撤销邀请码。每个邀请码只能使用一次,并指定一个尚未占用的初始 Bilibili +`room_id`。受邀用户完成注册后,可以在 `/control/account` +自行切换到其他尚未被账户占用的直播间;切换不会改变组件、队列或 OBS 地址。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。 每个用户在 `/control/account` 配置账户级直播监听和自己的 CookieCloud 同步 UUID/Key 与密码。该配置只创建一条上游连接,并供账户下全部现有和未来组件使用。地址必须位于部署管理员配置的 diff --git a/apps/overlay/README.md b/apps/overlay/README.md index 5be6a7a..12fd947 100644 --- a/apps/overlay/README.md +++ b/apps/overlay/README.md @@ -27,6 +27,7 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域 | `src/overlay.tsx` | 弹幕、礼物/表情和 OBS 自适应渲染 | | `src/songOverlay.tsx` | 点歌快照 reducer、revision 校验与往返滚动 | | `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker | +| `src/i18n.tsx` | TOML 语言资源、浏览器回退和运行时切换 | | `src/types.ts` | sanitized API view model 与 overlay settings | | `pwa/control-sw.js` | `/control/` 静态壳层的缓存策略 | @@ -45,6 +46,12 @@ manifest、start URL 与 Service Worker scope 均为 `/control/`。worker 使用 HTML 和 cache-first build assets;`/api/*`、`/obs/*`、WebSocket 和用户数据永不缓存。每次 Vite build 把同一个 build ID 注入浏览器 bundle 与 worker,发现更新后等待用户确认激活。 +## 语言资源 + +`../../resources/i18n.toml` 是控制台、OBS 组件、API 错误和 PWA +metadata 的唯一文案来源。Vite 在构建时解析并注入它,Rust 则嵌入同一文件来验证允许保存的 locale。增加文案键时必须为每个 locale 提供值;增加语言时同时增加完整 +`[locales."".messages]` 段。业务命令如“点歌”是 Bilibili 输入协议,不属于 UI 翻译。 + ## 格式化与构建 ```bash diff --git a/apps/overlay/index.html b/apps/overlay/index.html index 45d4ac8..4127d46 100644 --- a/apps/overlay/index.html +++ b/apps/overlay/index.html @@ -1,18 +1,18 @@ - + - - + + - + - 洛星瓷直播云台 + __APP_NAME__
diff --git a/apps/overlay/package-lock.json b/apps/overlay/package-lock.json index 9e1a799..f362bec 100644 --- a/apps/overlay/package-lock.json +++ b/apps/overlay/package-lock.json @@ -16,6 +16,7 @@ "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.2", "prettier": "3.9.5", + "smol-toml": "1.4.2", "typescript": "5.9.2", "vite": "7.3.6" } @@ -1682,6 +1683,19 @@ "semver": "bin/semver.js" } }, + "node_modules/smol-toml": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz", + "integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/apps/overlay/package.json b/apps/overlay/package.json index f94a6b8..f29370a 100644 --- a/apps/overlay/package.json +++ b/apps/overlay/package.json @@ -18,6 +18,7 @@ "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.2", "prettier": "3.9.5", + "smol-toml": "1.4.2", "typescript": "5.9.2", "vite": "7.3.6" } diff --git a/apps/overlay/public/control/manifest.webmanifest b/apps/overlay/public/control/manifest.webmanifest deleted file mode 100644 index d0ef263..0000000 --- a/apps/overlay/public/control/manifest.webmanifest +++ /dev/null @@ -1,35 +0,0 @@ -{ - "id": "/control", - "name": "洛星瓷直播云台", - "short_name": "直播云台", - "description": "洛星瓷直播组件与 OBS 浏览器源控制台", - "lang": "zh-CN", - "dir": "auto", - "start_url": "/control/?source=pwa", - "scope": "/control/", - "display": "standalone", - "orientation": "any", - "background_color": "#03121a", - "theme_color": "#0a302f", - "categories": ["utilities", "productivity"], - "icons": [ - { - "src": "/pwa/icon-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/pwa/icon-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "any" - }, - { - "src": "/pwa/icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/apps/overlay/src/api.ts b/apps/overlay/src/api.ts index 8253f8a..c21a77b 100644 --- a/apps/overlay/src/api.ts +++ b/apps/overlay/src/api.ts @@ -21,22 +21,26 @@ import type { } from './types' import { normalizeThemeId } from './themes' import { defaultOverlaySettings, defaultSongRequestSettings } from './types' +import { currentLanguage, hasTranslation, translate } from './i18n' export class ApiError extends Error { readonly status: number readonly code?: string + readonly messageKey?: string readonly fieldErrors?: Record constructor( status: number, message: string, code?: string, + messageKey?: string, fieldErrors?: Record, ) { super(message) this.name = 'ApiError' this.status = status this.code = code + this.messageKey = messageKey this.fieldErrors = fieldErrors } } @@ -52,12 +56,13 @@ async function parseResponse(response: Response): Promise { export async function api(path: string, init: RequestInit = {}): Promise { const method = (init.method ?? 'GET').toUpperCase() if (!navigator.onLine && !['GET', 'HEAD'].includes(method)) { - throw new ApiError(0, '当前处于离线状态,操作没有提交;联网后请重试。', 'offline') + throw new ApiError(0, translate('api.offline'), 'offline', 'api.error.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') + headers.set('accept-language', currentLanguage()) const response = await fetch(path, { ...init, @@ -70,10 +75,24 @@ export async function api(path: string, init: RequestInit = {}): Promise { window.dispatchEvent(new Event('lxc:session-expired')) } const error = payload && typeof payload === 'object' ? (payload as Record) : {} + const code = typeof error.code === 'string' ? error.code : undefined + const messageKey = + typeof error.messageKey === 'string' + ? error.messageKey + : typeof error.message_key === 'string' + ? error.message_key + : code + ? `api.error.${code}` + : undefined + const localized = messageKey && hasTranslation(messageKey) ? translate(messageKey) : undefined throw new ApiError( response.status, - String(error.message ?? error.error ?? `请求失败(HTTP ${response.status})`), - typeof error.code === 'string' ? error.code : undefined, + localized ?? + String( + error.message ?? error.error ?? translate('api.http_error', { status: response.status }), + ), + code, + messageKey, error.fieldErrors && typeof error.fieldErrors === 'object' ? (error.fieldErrors as Record) : undefined, @@ -166,7 +185,7 @@ export function normalizeComponents(value: unknown): ComponentSummary[] { id: String(item.id ?? ''), publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''), kind: String(item.kind ?? item.type ?? 'danmaku'), - name: String(item.name ?? '弹幕姬'), + name: String(item.name ?? translate('components.danmaku_type')), enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined, settings: item.settings as OverlaySettings | undefined, updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined, @@ -204,7 +223,10 @@ export function normalizeSongRequestItem(value: unknown): SongRequestItem | unde return { id: item.id, title: item.title, - requester: { uid: String(requester.uid ?? ''), name: String(requester.name ?? '直播间观众') }, + requester: { + uid: String(requester.uid ?? ''), + name: String(requester.name ?? translate('common.viewer')), + }, status: status as SongRequestItem['status'], queuePosition: Number(item.queuePosition ?? 0), requestedAt: String(item.requestedAt ?? ''), @@ -308,7 +330,9 @@ export function normalizeInvitations(value: unknown): Invitation[] { .filter(item => item.id) } -export function errorMessage(error: unknown, fallback = '操作失败,请稍后再试'): string { +export function errorMessage(error: unknown, fallback = translate('common.failed')): string { + if (error instanceof ApiError && error.messageKey && hasTranslation(error.messageKey)) + return translate(error.messageKey) return error instanceof Error && error.message ? error.message : fallback } diff --git a/apps/overlay/src/auth.tsx b/apps/overlay/src/auth.tsx index f31d9b6..1b53ced 100644 --- a/apps/overlay/src/auth.tsx +++ b/apps/overlay/src/auth.tsx @@ -17,6 +17,7 @@ import { normalizeRecoveryCodes, } from './api' import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa' +import { LanguageSelect, useI18n } from './i18n' import type { TotpEnrollment } from './types' function AuthShell({ @@ -30,12 +31,16 @@ function AuthShell({ children: ReactNode footer?: ReactNode }) { + const { t } = useI18n() return (
- +
+ + +

{eyebrow}

{title}

@@ -47,6 +52,7 @@ function AuthShell({ } function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) { + const { t } = useI18n() const source = useMemo(() => { if (enrollment.qrDataUrl) return enrollment.qrDataUrl if (enrollment.qrSvg) @@ -58,29 +64,29 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
{source ? ( - TOTP 验证器绑定二维码 + {t('auth.totp_qr_alt')} ) : ( - 二维码暂不可用,请使用右侧密钥手工添加。 + {t('auth.qr_unavailable')} )}
-

绑定动态验证器

+

{t('auth.bind_authenticator')}

    -
  1. 使用 1Password、Aegis、Microsoft Authenticator 等应用扫描二维码。
  2. -
  3. 若无法扫码,选择“输入设置密钥”。
  4. -
  5. 输入应用中出现的 6 位动态码完成绑定。
  6. +
  7. {t('auth.totp_step_scan')}
  8. +
  9. {t('auth.totp_step_manual')}
  10. +
  11. {t('auth.totp_step_confirm')}
)} - + ) diff --git a/apps/overlay/src/control.css b/apps/overlay/src/control.css index ea4181f..912e98b 100644 --- a/apps/overlay/src/control.css +++ b/apps/overlay/src/control.css @@ -1226,12 +1226,44 @@ select { .account-menu { display: flex; + flex-wrap: wrap; justify-content: flex-end; gap: 12px; align-items: center; text-align: right; } +.language-select { + display: grid; + gap: 3px; + min-width: 112px; + color: var(--app-muted); + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 10px; + text-align: left; +} + +.language-select select { + min-height: 32px; + padding: 5px 28px 5px 9px; + border: 1px solid rgba(95, 215, 193, 0.24); + border-radius: 8px; + color: #dffff7; + background: rgba(4, 30, 38, 0.9); +} + +.auth-toolbar { + margin: -6px 64px 18px 0; + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: flex-end; +} + +.auth-toolbar .pwa-controls { + min-height: 32px; +} + .pwa-controls { display: flex; gap: 7px; @@ -1242,11 +1274,6 @@ select { justify-content: flex-end; } -.auth-card > .pwa-controls { - margin: -4px 64px 18px 0; - justify-content: flex-start; -} - .auth-page .pwa-action { min-height: 34px; padding: 6px 11px; diff --git a/apps/overlay/src/control.tsx b/apps/overlay/src/control.tsx index 602b862..691a3a9 100644 --- a/apps/overlay/src/control.tsx +++ b/apps/overlay/src/control.tsx @@ -25,6 +25,7 @@ import { Overlay } from './overlay' import { PwaControls, usePwaUpdateBlocker } from './pwa' import { SongRequestOverlay } from './songOverlay' import { getOverlayTheme, overlayThemes } from './themes' +import { currentLanguage, LanguageSelect, translate, useI18n } from './i18n' import { defaultOverlaySettings, defaultSongRequestSettings } from './types' import type { AuthUser, @@ -39,10 +40,10 @@ import type { } from './types' const previewPresets = [ - { label: '窄侧栏', width: 360, height: 600 }, - { label: '竖屏', width: 440, height: 760 }, - { label: '高清竖栏', width: 600, height: 1080 }, - { label: '横向条', width: 720, height: 320 }, + { id: 'narrow', labelKey: 'preview.narrow', width: 360, height: 600 }, + { id: 'portrait', labelKey: 'preview.portrait', width: 440, height: 760 }, + { id: 'hd-portrait', labelKey: 'preview.hd_portrait', width: 600, height: 1080 }, + { id: 'horizontal', labelKey: 'preview.horizontal', width: 720, height: 320 }, ] function isDanmakuKind(kind: string): boolean { @@ -103,37 +104,48 @@ function ControlLayout({ children: ReactNode }) { const isAdmin = user.role === 'system_admin' + const { language, setLanguage, t } = useI18n() + const saveLanguage = async (nextLanguage: string) => { + const previous = language + try { + await api('/api/v1/account/preferences', json('PUT', { language: nextLanguage })) + } catch (reason) { + setLanguage(previous) + window.alert(errorMessage(reason, t('language.save_failed'))) + } + } return (
- - + +
- 洛星瓷直播云台 - OBS COMPONENT STUDIO + {t('brand.name')} + {t('brand.tagline')}
-
@@ -157,13 +169,13 @@ function SettingsEditor({ onChange({ ...settings, [key]: value }) } const eventToggles: Array<[keyof OverlaySettings, string]> = [ - ['showDanmaku', '弹幕'], - ['showEnter', '进房'], - ['showGift', '礼物'], - ['showSuperchat', '醒目留言'], - ['showGuard', '舰长'], - ['showLike', '点赞'], - ['showShare', '分享'], + ['showDanmaku', 'settings.event.danmaku'], + ['showEnter', 'settings.event.enter'], + ['showGift', 'settings.event.gift'], + ['showSuperchat', 'settings.event.superchat'], + ['showGuard', 'settings.event.guard'], + ['showLike', 'settings.event.like'], + ['showShare', 'settings.event.share'], ] const selectedTheme = getOverlayTheme(settings.themeId) @@ -171,24 +183,24 @@ function SettingsEditor({
@@ -477,19 +491,19 @@ function SongRequestPreview({ settings }: { settings: SongRequestSettings }) { const [preset, setPreset] = useState(previewPresets[3]) return (
{previewPresets.map(size => ( +
@@ -658,7 +689,7 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) { const [flash, setFlash] = useState() usePwaUpdateBlocker( `obs-token:${component.id}`, - '复制并妥善保存本次生成的 OBS 地址', + translate('obs.blocker'), busy || Boolean(state.address), ) @@ -673,7 +704,7 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) { .catch(reason => { if (cancelled) return setState({ publicId: component.publicId, configured: false }) - setFlash({ kind: 'error', text: errorMessage(reason, '无法读取当前组件的 OBS 令牌状态') }) + setFlash({ kind: 'error', text: errorMessage(reason, translate('obs.status_failed')) }) }) return () => { cancelled = true @@ -681,11 +712,7 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) { }, [component.id, component.publicId]) const rotate = async () => { - if ( - state.configured && - !window.confirm('轮换后,所有使用旧地址的 OBS 浏览器源会立即失效。确定继续吗?') - ) - return + if (state.configured && !window.confirm(translate('obs.rotate_confirm'))) return setBusy(true) setFlash(undefined) try { @@ -697,10 +724,10 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) { setState(next) setFlash({ kind: 'success', - text: '新 OBS 令牌已生成。请立即复制,离开本页后不会再次显示明文令牌。', + text: translate('obs.token_created'), }) } catch (reason) { - setFlash({ kind: 'error', text: errorMessage(reason, '无法轮换 OBS 令牌') }) + setFlash({ kind: 'error', text: errorMessage(reason, translate('obs.rotate_failed')) }) } finally { setBusy(false) } @@ -711,28 +738,25 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) { const copied = await copyToClipboard(state.address) setFlash( copied - ? { kind: 'success', text: 'OBS 浏览器源地址已复制。' } - : { kind: 'error', text: '浏览器阻止了复制,请手工选择下方地址。' }, + ? { kind: 'success', text: translate('obs.address_copied') } + : { kind: 'error', text: translate('obs.copy_blocked') }, ) } return ( - +
- 访问状态 - {state.configured ? '已生成令牌' : '尚未生成'} + {translate('obs.access_status')} + {state.configured ? translate('obs.token_ready') : translate('obs.token_missing')}
- 组件公开标识 + {translate('obs.public_id')} {state.publicId}
{state.updatedAt && (
- 最近轮换 + {translate('obs.last_rotated')} {formatDate(state.updatedAt)}
)} @@ -740,7 +764,7 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) { {state.address && ( )} @@ -751,11 +775,15 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) { disabled={busy} onClick={() => void rotate()} > - {busy ? '正在生成…' : state.configured ? '轮换令牌' : '生成 OBS 地址'} + {busy + ? translate('obs.generating') + : state.configured + ? translate('obs.rotate') + : translate('obs.generate')} {state.address && ( )}
@@ -766,9 +794,9 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) { 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 [name, setName] = useState(() => translate('test.default_viewer')) + const [text, setText] = useState(() => translate('test.default_text')) + const [giftName, setGiftName] = useState(() => translate('test.default_gift')) const [quantity, setQuantity] = useState(1) const [battery, setBattery] = useState(100) const [flash, setFlash] = useState() @@ -789,46 +817,43 @@ function TestEvents({ componentId }: { componentId: string }) { ...(kind === 'gift' ? { giftName, quantity, battery } : {}), }), ) - setFlash({ kind: 'success', text: '测试事件已发送到当前组件。' }) + setFlash({ kind: 'success', text: translate('test.sent') }) } catch (reason) { - setFlash({ kind: 'error', text: errorMessage(reason, '测试事件发送失败') }) + setFlash({ kind: 'error', text: errorMessage(reason, translate('test.failed')) }) } finally { setBusy(false) } } return ( - +
{kind === 'danmaku' && ( )} {kind === 'gift' && ( <>
@@ -878,13 +905,13 @@ function ComponentList({ return ( ) @@ -932,6 +965,7 @@ export function ComponentsPage({ user: AuthUser onLogout: () => Promise }) { + useI18n() const [components, setComponents] = useState([]) const [selectedId, setSelectedId] = useState() const [settings, setSettings] = useState() @@ -947,7 +981,11 @@ export function ComponentsPage({ [components, selectedId], ) const settingsDirty = Boolean(settings) && JSON.stringify(settings) !== savedSettingsRef.current - usePwaUpdateBlocker('component-settings', '保存或还原当前组件设置', saving || settingsDirty) + usePwaUpdateBlocker( + 'component-settings', + translate('components.settings_blocker'), + saving || settingsDirty, + ) const loadComponentSettings = useCallback(async (component: ComponentSummary) => { const requestId = ++settingsRequestRef.current @@ -967,7 +1005,10 @@ export function ComponentsPage({ setSettings(next) } catch (reason) { if (requestId !== settingsRequestRef.current || selectedIdRef.current !== component.id) return - setFlash({ kind: 'error', text: errorMessage(reason, '无法读取组件设置') }) + setFlash({ + kind: 'error', + text: errorMessage(reason, translate('components.settings_load_failed')), + }) } }, []) @@ -992,7 +1033,10 @@ export function ComponentsPage({ } } catch (reason) { if (!cancelled) - setFlash({ kind: 'error', text: errorMessage(reason, '控制台数据加载失败') }) + setFlash({ + kind: 'error', + text: errorMessage(reason, translate('components.load_failed')), + }) } finally { if (!cancelled) setLoading(false) } @@ -1030,10 +1074,13 @@ export function ComponentsPage({ : { ...defaultOverlaySettings, ...normalizeSettings(payload) } savedSettingsRef.current = JSON.stringify(next) setSettings(next) - setFlash({ kind: 'success', text: '组件设置已保存,并实时同步到已连接的 OBS。' }) + setFlash({ kind: 'success', text: translate('components.settings_saved') }) } catch (reason) { if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return - setFlash({ kind: 'error', text: errorMessage(reason, '组件设置保存失败') }) + setFlash({ + kind: 'error', + text: errorMessage(reason, translate('components.settings_save_failed')), + }) } finally { if (requestId === settingsRequestRef.current && selectedIdRef.current === componentId) setSaving(false) @@ -1045,24 +1092,43 @@ export function ComponentsPage({
- {loading &&
正在展开云台…
} + {loading && ( +
{translate('components.loading')}
+ )} {!loading && selected && ( <>
-

{selected.kind.toUpperCase()}

-

{selected.name}

+

+ {isDanmakuKind(selected.kind) + ? translate('components.danmaku_type') + : isSongRequestKind(selected.kind) + ? translate('components.song_type') + : selected.kind} +

+

+ {isDanmakuKind(selected.kind) + ? translate('components.danmaku_type') + : isSongRequestKind(selected.kind) + ? translate('components.song_type') + : selected.name} +

- {selected.enabled === false ? '已停用' : '运行中'} + {selected.enabled === false + ? translate('common.disabled') + : translate('common.enabled')}
{isDanmakuKind(selected.kind) && settings ? ( <> - + setSettings(next)} @@ -1075,8 +1141,8 @@ export function ComponentsPage({ ) : isSongRequestKind(selected.kind) && settings ? ( <> - 打开点歌统计 + {translate('components.open_song_stats')} } > @@ -1107,8 +1173,8 @@ export function ComponentsPage({ ) : ( - -
该组件类型的设置编辑器尚未安装。
+ +
{translate('components.no_editor')}
)} @@ -1116,8 +1182,8 @@ export function ComponentsPage({ )} {!loading && !selected && ( - -
当前账户还没有可配置的组件。
+ +
{translate('components.none_configurable')}
)}
@@ -1135,6 +1201,7 @@ export function AccountLiveSourcePage({ user: AuthUser onLogout: () => Promise }) { + useI18n() const [source, setSource] = useState() const [loading, setLoading] = useState(true) const [flash, setFlash] = useState() @@ -1147,7 +1214,7 @@ export function AccountLiveSourcePage({ }) .catch(reason => { if (!cancelled) - setFlash({ kind: 'error', text: errorMessage(reason, '无法读取账户直播源') }) + setFlash({ kind: 'error', text: errorMessage(reason, translate('account.load_failed')) }) }) .finally(() => { if (!cancelled) setLoading(false) @@ -1162,13 +1229,13 @@ export function AccountLiveSourcePage({
-

ACCOUNT LIVE STREAM

-

直播账户

-

配置一次直播间与 CookieCloud,供当前账户下所有现有及未来组件使用。

+

{translate('account.eyebrow')}

+

{translate('account.title')}

+

{translate('account.description')}

- {loading &&
正在读取账户直播源…
} + {loading &&
{translate('account.loading')}
} {source && }
@@ -1200,7 +1267,7 @@ async function loadCompleteSongQueue(componentId: string): Promise= 0) if (consistent && result) return { ...result, items } } - throw new Error('点歌队列正在频繁变化,请稍后重试') + throw new Error(translate('song.queue_changing')) } /** Session-protected queue operations and long-term song request statistics. */ @@ -1213,6 +1280,7 @@ export function SongRequestsPage({ componentId: string onLogout: () => Promise }) { + useI18n() const [active, setActive] = useState() const [historyPage, setHistoryPage] = useState() const [flash, setFlash] = useState() @@ -1232,7 +1300,7 @@ export function SongRequestsPage({ setActive(activePage) setHistoryPage(normalizeSongRequestPage(historyPayload)) } catch (reason) { - setFlash({ kind: 'error', text: errorMessage(reason, '点歌队列读取失败') }) + setFlash({ kind: 'error', text: errorMessage(reason, translate('song.queue_load_failed')) }) } finally { loadingRef.current = false } @@ -1256,7 +1324,11 @@ export function SongRequestsPage({ }, [load]) const mutate = async (item: SongRequestItem, action: 'promote' | 'complete' | 'cancel') => { - if (action === 'cancel' && !window.confirm(`确定取消「${item.title}」吗?`)) return + if ( + action === 'cancel' && + !window.confirm(translate('song.cancel_confirm', { title: item.title })) + ) + return setBusyId(item.id) setFlash(undefined) try { @@ -1266,16 +1338,17 @@ export function SongRequestsPage({ ) setFlash({ kind: 'success', - text: + text: translate( action === 'promote' - ? '已移到下一首。' + ? 'song.promoted' : action === 'complete' - ? '已完成并自动切换下一首。' - : '已取消点歌。', + ? 'song.completed' + : 'song.cancelled', + ), }) await load() } catch (reason) { - setFlash({ kind: 'error', text: errorMessage(reason, '队列操作失败') }) + setFlash({ kind: 'error', text: errorMessage(reason, translate('song.action_failed')) }) } finally { setBusyId('') } @@ -1287,23 +1360,23 @@ export function SongRequestsPage({
-

SONG REQUEST MANAGER

-

点歌统计与队列

+

{translate('song.stats_eyebrow')}

+

{translate('song.stats_title')}

- 返回组件设置 + {translate('song.back_settings')}
{[ - ['活动点歌', summary?.activeCount ?? 0], - ['已经完成', summary?.completedCount ?? 0], - ['已经取消', summary?.cancelledCount ?? 0], - ['累计评分', summary?.ratingCount ?? 0], + [translate('song.stat.active'), summary?.activeCount ?? 0], + [translate('song.stat.completed'), summary?.completedCount ?? 0], + [translate('song.stat.cancelled'), summary?.cancelledCount ?? 0], + [translate('song.stat.ratings'), summary?.ratingCount ?? 0], ].map(([label, value]) => (
{label} @@ -1311,7 +1384,10 @@ export function SongRequestsPage({
))}
- + {active?.current ? (
@@ -1321,8 +1397,11 @@ export function SongRequestsPage({

{active.current.title}

{active.current.ratingCount - ? `平均 ${active.current.averageScore?.toFixed(2)} 分 · ${active.current.ratingCount} 人评分` - : '尚无评分'} + ? translate('song.average_score', { + score: active.current.averageScore?.toFixed(2) ?? '—', + count: active.current.ratingCount, + }) + : translate('song.no_rating')}

@@ -1331,7 +1410,7 @@ export function SongRequestsPage({ disabled={busyId === active.current.id} onClick={() => void mutate(active.current!, 'complete')} > - 完成当前歌曲 + {translate('song.complete_current')}
) : ( -
当前没有正在演唱的歌曲。
+
{translate('song.no_current')}
)}
{active?.items.map((item, index) => ( @@ -1368,7 +1447,7 @@ export function SongRequestsPage({ disabled={busyId === item.id || index === 0} onClick={() => void mutate(item, 'promote')} > - 移到下一首 + {translate('song.promote')}
))} {active && active.items.length === 0 && ( -
待唱队列为空。
+
{translate('song.queue_empty')}
)} - {!active &&
正在读取完整队列…
} + {!active &&
{translate('song.queue_loading')}
}
- +
- - - - - + + + + + @@ -1410,7 +1492,11 @@ export function SongRequestsPage({ - {item.status === 'completed' ? '已完成' : '已取消'} + {translate( + item.status === 'completed' + ? 'song.status.completed' + : 'song.status.cancelled', + )} )} @@ -1438,18 +1524,23 @@ export function SongRequestsPage({ } function formatDate(value?: string): string { - if (!value) return '永久' + if (!value) return translate('common.permanent') const date = new Date(value) if (Number.isNaN(date.getTime())) return value - return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(date) + return new Intl.DateTimeFormat(currentLanguage(), { + dateStyle: 'medium', + timeStyle: 'short', + }).format(date) } function invitationStatus(invitation: Invitation): { label: string; className: string } { - if (invitation.revokedAt) return { label: '已撤销', className: 'offline' } + if (invitation.revokedAt) + return { label: translate('invitation.status.revoked'), 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' } + return { label: translate('invitation.status.expired'), className: 'offline' } + if (invitation.consumedAt) + return { label: translate('invitation.status.used'), className: 'offline' } + return { label: translate('invitation.status.available'), className: 'online' } } export function InvitationsPage({ @@ -1459,17 +1550,14 @@ export function InvitationsPage({ user: AuthUser onLogout: () => Promise }) { + useI18n() const [invitations, setInvitations] = useState([]) const [roomId, setRoomId] = useState('') const [expiresInHours, setExpiresInHours] = useState(24) const [newCode, setNewCode] = useState('') const [busy, setBusy] = useState(false) const [flash, setFlash] = useState() - usePwaUpdateBlocker( - 'invitation-code', - '复制并妥善保存本次生成的一次性邀请码', - busy || Boolean(newCode), - ) + usePwaUpdateBlocker('invitation-code', translate('invitation.blocker'), busy || Boolean(newCode)) const load = useCallback(async () => { const payload = await api('/api/v1/invitations') @@ -1478,7 +1566,7 @@ export function InvitationsPage({ useEffect(() => { void load().catch(reason => - setFlash({ kind: 'error', text: errorMessage(reason, '邀请码读取失败') }), + setFlash({ kind: 'error', text: errorMessage(reason, translate('invitation.load_failed')) }), ) }, [load]) @@ -1500,24 +1588,24 @@ export function InvitationsPage({ : root const code = String(invitation.code ?? root.code ?? '') setNewCode(code) - setFlash({ kind: 'success', text: '邀请码已创建。明文只显示这一次。' }) + setFlash({ kind: 'success', text: translate('invitation.created') }) await load() } catch (reason) { - setFlash({ kind: 'error', text: errorMessage(reason, '邀请码创建失败') }) + setFlash({ kind: 'error', text: errorMessage(reason, translate('invitation.create_failed')) }) } finally { setBusy(false) } } const revoke = async (invitation: Invitation) => { - if (!window.confirm('确定撤销这个邀请码吗?尚未完成的注册会立即失效。')) return + if (!window.confirm(translate('invitation.revoke_confirm'))) return setFlash(undefined) try { await api(`/api/v1/invitations/${encodeURIComponent(invitation.id)}`, json('DELETE')) - setFlash({ kind: 'success', text: '邀请码已撤销。' }) + setFlash({ kind: 'success', text: translate('invitation.revoked') }) await load() } catch (reason) { - setFlash({ kind: 'error', text: errorMessage(reason, '邀请码撤销失败') }) + setFlash({ kind: 'error', text: errorMessage(reason, translate('invitation.revoke_failed')) }) } } @@ -1533,19 +1621,19 @@ export function InvitationsPage({
-

SYSTEM ADMIN

-

邀请码管理

+

{translate('invitation.eyebrow')}

+

{translate('invitation.title')}

- 仅系统管理员 + {translate('invitation.admin_only')}
- +
{newCode && (
- 仅显示一次 + {translate('invitation.one_time')} {newCode} void copyToClipboard(newCode)} > - 复制邀请码 + {translate('invitation.copy_code')}
)}
歌曲点歌用户结果评分结束时间{translate('song.column.title')}{translate('song.column.requester')}{translate('song.column.result')}{translate('song.column.rating')}{translate('song.column.finished')}
@@ -1424,7 +1510,7 @@ export function SongRequestsPage({ {historyPage && historyPage.items.length === 0 && (
-
尚无历史记录。
+
{translate('song.no_history')}
- - - - - + + + + + @@ -1634,7 +1724,7 @@ export function InvitationsPage({ className="danger small" onClick={() => void revoke(invitation)} > - 撤销 + {translate('common.revoke')} )} @@ -1644,7 +1734,7 @@ export function InvitationsPage({ {invitations.length === 0 && ( )} @@ -1664,13 +1754,14 @@ export function ForbiddenPage({ user: AuthUser onLogout: () => Promise }) { + useI18n() return (
- -

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

+ +

{translate('forbidden.description')}

- 返回我的组件 + {translate('forbidden.back')}
diff --git a/apps/overlay/src/i18n.tsx b/apps/overlay/src/i18n.tsx new file mode 100644 index 0000000..cf266f6 --- /dev/null +++ b/apps/overlay/src/i18n.tsx @@ -0,0 +1,145 @@ +/** Runtime localization backed exclusively by `resources/i18n.toml`. */ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' + +type Messages = Record +type LocaleDefinition = { name: string; messages: Messages } +type Parameters = Record + +const resource = __I18N_RESOURCE__ +const storageKey = 'lxc-stream-language' +const localeEntries = Object.entries(resource.locales) + +function supported(value: string | null | undefined): boolean { + return Boolean(value && resource.locales[value]) +} + +function browserLanguage(): string { + let saved: string | null = null + try { + saved = localStorage.getItem(storageKey) + } catch { + // Some embedded browsers disable storage. Locale selection still works + // for the current document and authenticated accounts remain authoritative. + } + if (saved && supported(saved)) return saved + for (const candidate of navigator.languages) { + if (supported(candidate)) return candidate + const prefix = candidate.split('-')[0].toLowerCase() + const match = localeEntries.find(([code]) => code.split('-')[0].toLowerCase() === prefix) + if (match) return match[0] + } + return resource.default_language +} + +let activeLanguage = browserLanguage() + +function format(message: string, parameters?: Parameters): string { + if (!parameters) return message + return message.replace(/\{([a-zA-Z0-9_]+)\}/g, (placeholder, key: string) => + Object.hasOwn(parameters, key) ? String(parameters[key]) : placeholder, + ) +} + +export function translate(key: string, parameters?: Parameters, language = activeLanguage): string { + const locale = resource.locales[language] ?? resource.locales[resource.default_language] + const fallback = resource.locales[resource.default_language] + return format(locale?.messages[key] ?? fallback?.messages[key] ?? key, parameters) +} + +export function currentLanguage(): string { + return activeLanguage +} + +export function hasTranslation(key: string): boolean { + return localeEntries.some(([, locale]) => Object.hasOwn(locale.messages, key)) +} + +type I18nContextValue = { + language: string + locales: Array<{ code: string; name: string }> + setLanguage: (language: string) => void + t: (key: string, parameters?: Parameters) => string +} + +const I18nContext = createContext(undefined) + +export function I18nProvider({ children }: { children: ReactNode }) { + const [language, setLanguageState] = useState(activeLanguage) + + const setLanguage = useCallback((next: string) => { + if (!supported(next)) return + activeLanguage = next + try { + localStorage.setItem(storageKey, next) + } catch { + // Storage is a pre-login convenience, not a requirement for switching. + } + document.documentElement.lang = next + setLanguageState(next) + }, []) + + useEffect(() => { + document.documentElement.lang = language + document.title = translate('pwa.manifest.name', undefined, language) + const localizedMetadata: Array<[string, string]> = [ + ['meta[name="description"]', 'pwa.manifest.description'], + ['meta[name="application-name"]', 'pwa.manifest.name'], + ['meta[name="apple-mobile-web-app-title"]', 'pwa.manifest.short_name'], + ] + for (const [selector, key] of localizedMetadata) { + document + .querySelector(selector) + ?.setAttribute('content', translate(key, undefined, language)) + } + }, [language]) + + const value = useMemo( + () => ({ + language, + locales: localeEntries.map(([code, locale]) => ({ + code, + name: (locale as LocaleDefinition).name, + })), + setLanguage, + t: (key, parameters) => translate(key, parameters, language), + }), + [language, setLanguage], + ) + return {children} +} + +export function useI18n(): I18nContextValue { + const context = useContext(I18nContext) + if (!context) throw new Error('I18nProvider is missing') + return context +} + +export function LanguageSelect({ + onChange, + className = '', +}: { + onChange?: (language: string) => void | Promise + className?: string +}) { + const { language, locales, setLanguage, t } = useI18n() + return ( + + ) +} diff --git a/apps/overlay/src/main.tsx b/apps/overlay/src/main.tsx index 47d65fa..f0e1abc 100644 --- a/apps/overlay/src/main.tsx +++ b/apps/overlay/src/main.tsx @@ -21,6 +21,7 @@ import { Overlay, tokenFromFragment } from './overlay' import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa' import { SongRequestOverlay } from './songOverlay' import { useComponentStream } from './stream' +import { I18nProvider, translate, useI18n } from './i18n' import type { Session } from './types' import './style.css' import './control.css' @@ -28,35 +29,41 @@ import './song.css' function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken: string }) { const stream = useComponentStream(false, publicId, accessToken) + const { setLanguage, t } = useI18n() + useEffect(() => { + if (stream.language) setLanguage(stream.language) + }, [setLanguage, stream.language]) if (!publicId || !accessToken) - return
OBS 地址缺少组件标识或访问令牌
+ return
{t('main.obs_missing_credentials')}
if (stream.connection === 'denied') - return
OBS 访问令牌无效或已经轮换
+ return
{t('main.obs_invalid_token')}
if (stream.componentKind === 'song_request') return if (stream.componentKind === 'danmaku_overlay' || stream.componentKind === 'danmaku') return - return
正在连接组件…
+ return
{t('main.obs_connecting')}
} function Redirect({ to }: { to: string }) { + const { t } = useI18n() useEffect(() => { location.replace(to) }, [to]) - return
正在前往云台…
+ return
{t('main.redirecting')}
} function NotFoundPage() { + const { t } = useI18n() return (
-

404 · LOST IN THE CLOUDS

-

这里没有组件

-

地址可能已经失效,或者组件已被所属用户删除。

+

{t('main.not_found_eyebrow')}

+

{t('main.not_found_title')}

+

{t('main.not_found_description')}

- 返回控制台 + {t('main.back_to_console')}
@@ -64,6 +71,7 @@ function NotFoundPage() { } function App() { + const { setLanguage, t } = useI18n() const [session, setSession] = useState() const [loadError, setLoadError] = useState('') const [online, setOnline] = useState(navigator.onLine) @@ -73,15 +81,24 @@ function App() { setLoadError('') try { const payload = await api('/api/v1/auth/me') - setSession(normalizeSession(payload)) + const next = normalizeSession(payload) + setSession(next) + if (next.user) { + try { + const preferences = await api<{ language?: string }>('/api/v1/account/preferences') + if (preferences.language) setLanguage(preferences.language) + } catch { + // The local pre-login choice remains usable if preferences cannot load. + } + } } catch (error) { if (error instanceof ApiError && error.status === 401) { setSession({ user: null, setupRequired: false }) return } - setLoadError(errorMessage(error, '无法连接认证服务')) + setLoadError(errorMessage(error, translate('main.auth_unavailable'))) } - }, []) + }, [setLanguage]) useEffect(() => { void refreshSession() @@ -116,22 +133,20 @@ function App() {
-

{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}

-

{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}

- {offline && ( -

- 应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。 -

- )} +

+ {t(offline ? 'main.offline_eyebrow' : 'main.connection_error_eyebrow')} +

+

{offline ? t('main.offline_title') : t('main.unavailable_title')}

+ {offline &&

{t('main.offline_description')}

}
{loadError}
) } - if (!session) return
正在验证安全会话…
+ if (!session) return
{t('main.verifying_session')}
const logout = async () => { try { @@ -197,8 +212,16 @@ if (obsMatch) { } catch { publicId = '' } - root.render() + root.render( + + + , + ) } else { initializePwa() - root.render() + root.render( + + + , + ) } diff --git a/apps/overlay/src/overlay.tsx b/apps/overlay/src/overlay.tsx index 44028e1..1039776 100644 --- a/apps/overlay/src/overlay.tsx +++ b/apps/overlay/src/overlay.tsx @@ -10,6 +10,7 @@ import { useEffect, useRef, useState } from 'react' import type { CSSProperties } from 'react' import type { ComponentStream } from './stream' +import { translate, useI18n } from './i18n' import { getOverlayTheme, normalizeThemeId, themeCssVariables } from './themes' import type { OverlayThemeDefinition } from './themes' import { defaultOverlaySettings } from './types' @@ -195,11 +196,12 @@ function giftTier(item: Item, settings: OverlaySettings) { } const eventRenderers: Record string> = { - 'live.enter': () => '踏入了云台', + 'live.enter': () => translate('overlay.enter'), 'live.superchat': payload => payload.message || '', - 'live.guard.buy': payload => `开通 ${payload.guardName || '舰长'}`, - 'live.like': () => '点亮了一颗星', - 'live.share': () => '分享了直播间', + 'live.guard.buy': payload => + translate('overlay.guard', { guard: payload.guardName || translate('common.guard') }), + 'live.like': () => translate('overlay.like'), + 'live.share': () => translate('overlay.share'), } function DanmakuEmoticon({ segment }: { segment: Extract }) { @@ -258,11 +260,14 @@ function Card({ const isDanmaku = item.type === 'live.danmaku' const tier = gift ? giftTier(item, settings) : '' const body = gift ? ( - `献上 ${gift.name || '礼物'} ×${payload.quantity || 1}` + translate('overlay.gift', { + gift: gift.name || translate('common.gift'), + quantity: payload.quantity || 1, + }) ) : isDanmaku ? ( ) : ( - eventRenderers[item.type]?.(payload) || '送来了一份互动' + eventRenderers[item.type]?.(payload) || translate('overlay.interaction') ) return ( @@ -275,7 +280,7 @@ function Card({ {gift.animationUrl || gift.imageUrl ? ( {gift.name { const image = event.currentTarget if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl @@ -288,7 +293,7 @@ function Card({ )}
- {viewer.name || '直播间观众'} + {viewer.name || translate('common.viewer')} {body} {typeof gift?.priceCny === 'number' && gift.priceCny > 0 && ( ¥ {gift.priceCny.toFixed(2)} @@ -300,6 +305,7 @@ function Card({ } export function Overlay({ preview = false, previewSettings, stream }: OverlayProps) { + const { language } = useI18n() const root = useRef(null) const events = useEvents(preview, stream) const { items, setItems } = events @@ -320,7 +326,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro }, []) useEffect(() => { - if (preview && !items.length) { + if (preview) { setItems([ { id: 'text-preview', @@ -328,7 +334,10 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro received: Date.now(), decorVariant: 0, type: 'live.danmaku', - payload: { viewer: { name: '青玉观众' }, text: '今天也要闪闪发光!' }, + payload: { + viewer: { name: translate('overlay.preview_viewer') }, + text: translate('overlay.preview_message'), + }, }, { id: 'gift-preview', @@ -337,10 +346,10 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro decorVariant: 3, type: 'live.gift', payload: { - viewer: { name: '星光旅人' }, + viewer: { name: translate('overlay.preview_gift_viewer') }, quantity: 1, gift: { - name: '甜蜜告白', + name: translate('overlay.preview_gift'), totalPrice: 12_000, priceCny: 12, imageUrl: '', @@ -350,7 +359,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro }, ]) } - }, [items.length, preview, setItems]) + }, [items.length, language, preview, setItems]) useEffect(() => { const newest = items[0] diff --git a/apps/overlay/src/pwa.tsx b/apps/overlay/src/pwa.tsx index abb5d85..2eeb4b9 100644 --- a/apps/overlay/src/pwa.tsx +++ b/apps/overlay/src/pwa.tsx @@ -8,6 +8,7 @@ * one-time-secret or dirty-form blocker has cleared. */ import { useEffect, useSyncExternalStore } from 'react' +import { translate, useI18n } from './i18n' interface InstallChoice { outcome: 'accepted' | 'dismissed' @@ -108,7 +109,7 @@ async function removeLegacyRootWorker() { export function cleanupLegacyPwa() { if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return void removeLegacyRootWorker().catch(error => { - console.warn('旧版 PWA 清理失败', error) + console.warn('Legacy PWA cleanup failed', error) }) } @@ -122,7 +123,7 @@ async function registerWorker() { observeRegistration(registration) } catch (error) { // A failed PWA registration must never stop the online control console. - console.warn('控制台 PWA 注册失败', error) + console.warn('Console PWA registration failed', error) } } @@ -211,19 +212,18 @@ function applyUpdate() { const reasons = [...new Set(updateBlockers.values())] if (reasons.length > 0) { window.alert( - `暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`, + translate('pwa.blocked', { reasons: reasons.map(reason => `• ${reason}`).join('\n') }), ) return } - const confirmed = window.confirm( - '更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?', - ) + const confirmed = window.confirm(translate('pwa.confirm_update')) if (!confirmed) return reloadForUpdate = true worker.postMessage({ type: 'SKIP_WAITING' }) } export function PwaControls() { + const { t } = useI18n() const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) const canInstall = Boolean(state.installPrompt) && !state.standalone if (state.online && !state.waitingWorker && !canInstall) return null @@ -233,17 +233,17 @@ export function PwaControls() { {!state.online && (
直播间邀请码前缀创建时间有效期状态{translate('invitation.column.room')}{translate('invitation.column.prefix')}{translate('invitation.column.created')}{translate('invitation.column.expires')}{translate('invitation.column.status')}
-
尚未创建邀请码。
+
{translate('invitation.empty')}