From f79852d8e60b4e89199cfa53970fb637ddf9b54c Mon Sep 17 00:00:00 2001 From: felis Date: Sat, 18 Jul 2026 23:28:05 -0700 Subject: [PATCH] add account localization and switchable rooms Centralize control and OBS copy in a shared TOML catalog, persist the selected locale per account, and broadcast language changes to component streams. Allow account owners to atomically switch their Bilibili room and restart the shared listener without changing component URLs. --- Dockerfile | 2 + README.md | 11 +- apps/overlay/README.md | 7 + apps/overlay/index.html | 10 +- apps/overlay/package-lock.json | 14 + apps/overlay/package.json | 1 + .../public/control/manifest.webmanifest | 35 - apps/overlay/src/api.ts | 36 +- apps/overlay/src/auth.tsx | 120 +-- apps/overlay/src/control.css | 37 +- apps/overlay/src/control.tsx | 547 +++++++------ apps/overlay/src/i18n.tsx | 145 ++++ apps/overlay/src/main.tsx | 69 +- apps/overlay/src/overlay.tsx | 35 +- apps/overlay/src/pwa.tsx | 18 +- apps/overlay/src/songOverlay.tsx | 102 ++- apps/overlay/src/stream.ts | 11 +- apps/overlay/src/themes/jadeScroll.ts | 4 +- apps/overlay/src/themes/types.ts | 4 +- apps/overlay/src/vite-env.d.ts | 4 + apps/overlay/vite.config.ts | 47 ++ apps/server-rust/README.md | 1 + .../007_switchable_account_room.sql | 31 + .../migrations/008_account_language.sql | 23 + apps/server-rust/src/app.rs | 8 + apps/server-rust/src/auth.rs | 160 +++- apps/server-rust/src/http_api.rs | 93 ++- apps/server-rust/src/i18n.rs | 65 ++ apps/server-rust/src/lib.rs | 1 + apps/server-rust/src/repository.rs | 39 + config.toml.example | 4 +- docs/architecture.md | 6 +- docs/protocol.md | 7 +- docs/security.md | 2 + resources/i18n.toml | 719 ++++++++++++++++++ 35 files changed, 1946 insertions(+), 472 deletions(-) delete mode 100644 apps/overlay/public/control/manifest.webmanifest create mode 100644 apps/overlay/src/i18n.tsx create mode 100644 apps/server-rust/migrations/007_switchable_account_room.sql create mode 100644 apps/server-rust/migrations/008_account_language.sql create mode 100644 apps/server-rust/src/i18n.rs create mode 100644 resources/i18n.toml 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')}