admin-ui: sync English localization

This commit is contained in:
A 2026-07-02 16:53:51 +08:00
parent 4570df5939
commit 44e8cde27f
27 changed files with 1016 additions and 290 deletions

View file

@ -0,0 +1,637 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
export type Language = "en" | "zh";
export type TranslationParams = Record<string, string | number | boolean>;
export type TFunction = (key: string, params?: TranslationParams) => string;
const storageKey = "telesrv.admin.lang";
const translations: Record<Language, Record<string, string>> = {
en: {
"app.adminConsole": "Admin Console",
"app.localAccess": "Local access",
"app.title": "telesrv admin",
"common.actions": "Actions",
"common.admins": "Admins",
"common.backToList": "Back to list",
"common.channel": "Channel",
"common.channelOrGroup": "Channel / Group",
"common.clear": "Clear",
"common.close": "Close",
"common.count": "Count",
"common.deleted": "Deleted",
"common.detail": "Details",
"common.device": "Device",
"common.disabled": "Disabled",
"common.enabled": "Enabled",
"common.fromPeer": "From Peer",
"common.group": "Group",
"common.id": "ID",
"common.limit": "Limit",
"common.loading": "Loading",
"common.member": "Member",
"common.members": "Members",
"common.messageId": "Message ID",
"common.name": "Name",
"common.no": "No",
"common.noResults": "No results",
"common.none": "None",
"common.normal": "Normal",
"common.operations": "Operations",
"common.owner": "Owner",
"common.platform": "Platform",
"common.refresh": "Refresh",
"common.search": "Search",
"common.sender": "Sender",
"common.status": "Status",
"common.survived": "Live",
"common.time": "Time",
"common.type": "Type",
"common.updatedAt": "Updated",
"common.username": "Username",
"common.valid": "Valid",
"common.verified": "Verified",
"common.views": "Views",
"common.yes": "Yes",
"route.accounts": "Accounts",
"route.accountsSubtitle": "Console / Accounts",
"route.channels": "Supergroups and Channels",
"route.channelsSubtitle": "Console / Channels",
"route.dashboard": "Operations Console",
"route.dashboardSubtitle": "Console / Overview",
"route.messages": "Message Audit",
"route.messagesSubtitle": "Console / Messages",
"layout.navigation": "Navigation",
"layout.primaryNav": "Primary navigation",
"layout.dashboard": "Overview",
"layout.accounts": "Accounts",
"layout.channels": "Supergroups / Channels",
"layout.messages": "Messages",
"layout.privateMessages": "Private",
"layout.groupMessages": "Groups",
"layout.runtime": "Runtime",
"layout.adminBackend": "Admin backend",
"layout.ready": "Ready",
"layout.pgRead": "PG read",
"layout.readOnly": "Read-only",
"layout.writeOps": "Write operations",
"layout.dryRun": "Dry-run",
"layout.actor": "Actor: {actor}",
"layout.logout": "Log out",
"language.en": "EN",
"language.zh": "中文",
"login.heading": "Operations Admin",
"login.body": "Enter credentials to open the console.",
"login.secret": "Admin password or token",
"login.submit": "Log in",
"login.submitting": "Logging in",
"dashboard.eyebrow": "Runtime Overview",
"dashboard.title": "Console Overview",
"dashboard.readPath": "Read path",
"dashboard.readPathValue": "PG read-only",
"dashboard.writePath": "Write path",
"dashboard.executionPolicy": "Execution policy",
"dashboard.dryRunFirst": "Dry-run first",
"dashboard.accountsText": "Account status, premium, verification, sessions.",
"dashboard.channelsText": "Public entities, member counts, verification state.",
"dashboard.messagesText": "Message boxes, updates, outbox state.",
"dashboard.strip.dryRun": "All dangerous actions start with dry-run",
"dashboard.strip.token": "Browser never stores internal tokens",
"dashboard.strip.pagination": "Lists use cursor pagination",
"dashboard.strip.snapshot": "Detail pages retain raw state snapshots",
"account.pageTitle": "Accounts",
"account.queryResults": "Search results",
"account.recentActive": "Recently active accounts",
"account.currentPage": "Accounts on page",
"account.onlineDevices": "Online device records",
"account.premium": "Premium",
"account.frozen": "Frozen",
"account.searchPlaceholder": "User ID / phone / username",
"account.userID": "User ID",
"account.phone": "Phone",
"account.lastActive": "Last active",
"account.notVerified": "Not verified",
"account.notPremium": "Not premium",
"account.premiumUntil": "Premium expires",
"account.activeSessions": "Authorized devices",
"account.accountFlags": "Account flags",
"account.restriction": "Restriction",
"account.restricted": "Restricted",
"account.createdAt": "Created",
"account.detailTitle": "Account #{id}",
"account.profile": "Account Profile",
"account.loadingDetail": "Loading account detail",
"account.waitingData": "Waiting for data",
"account.noUsername": "No username",
"account.noPhone": "No phone",
"account.sendFrozen": "Sending frozen",
"account.sendNormal": "Sending allowed",
"account.authorizationsTitle": "Authorized Devices",
"account.authorizationsCount": "{count} authorizations",
"account.recentAdminOps": "Recent Admin Actions",
"account.recent30Audit": "Last 30 audit rows",
"account.actionDock": "Account Actions",
"account.freezeSend": "Freeze sending",
"account.unfreezeSend": "Unfreeze sending",
"account.premiumMonths": "Premium duration (months)",
"account.premiumMonthsAria": "Set premium duration in months",
"account.setPremium": "Set premium",
"account.clearPremium": "Clear premium",
"account.setVerified": "Set verified",
"account.clearVerified": "Clear verified",
"channel.pageTitle": "Supergroups and Channels",
"channel.recentUpdated": "Recently updated",
"channel.currentPage": "Entities on page",
"channel.megagroups": "Supergroups",
"channel.broadcasts": "Channels",
"channel.verifiedCount": "Verified",
"channel.searchPlaceholder": "Channel ID / username / title",
"channel.channelID": "Channel ID",
"channel.kind": "Kind",
"channel.title": "Title",
"channel.pts": "PTS",
"channel.detailProfile": "Channel Profile",
"channel.loadingDetail": "Loading channel detail",
"channel.creator": "Creator {id}",
"channel.governance": "Moderation",
"channel.governanceValue": "Banned {banned} / Kicked {kicked}",
"channel.flags": "Channel flags",
"channel.rawRow": "Channel Raw Row",
"channel.rawRowText": "Database read-only snapshot",
"channel.actionDock": "Channel Actions",
"channel.setVerified": "Set verified",
"channel.clearVerified": "Clear verified",
"channel.kind.broadcast": "Channel",
"channel.kind.forum": "Supergroup / Forum",
"channel.kind.megagroup": "Supergroup",
"channel.kind.generic": "Channel / Group",
"messages.privateTitle": "Private Messages",
"messages.privateEyebrow": "Private message boxes",
"messages.groupTitle": "Group Messages",
"messages.groupEyebrow": "Supergroup / channel messages",
"messages.selectPrivatePeers": "Search and select the owner user and peer user first",
"messages.selectChannel": "Search and select a supergroup or channel first",
"messages.ownerUser": "Owner user",
"messages.peerUser": "Peer user",
"messages.beforeDatePlaceholder": "before_date cursor",
"messages.beforeIDPlaceholder": "before_msg_id cursor",
"messages.limitPlaceholder": "limit <= 100",
"messages.searchMessages": "Search messages",
"messages.nextPage": "Next page",
"messages.currentPage": "Messages on page",
"messages.deleted": "Deleted",
"messages.outgoing": "Outgoing",
"messages.incoming": "Incoming",
"messages.ownerPeer": "Owner / Peer",
"messages.deleteSelected": "Delete selected messages",
"messages.idsPlaceholder": "Message IDs, comma separated",
"messages.revoke": "Revoke for both sides",
"messages.previewDelete": "Dry-run delete",
"messages.clearHistory": "Clear private history",
"messages.maxIDPlaceholder": "max_id cutoff",
"messages.maxBatchesPlaceholder": "max_batches",
"messages.justClear": "Clear only this side",
"messages.previewClearHistory": "Dry-run clear history",
"messages.direction": "Direction",
"messages.body": "Body",
"messages.privateDetailTitle": "Message #{id}",
"messages.detailEyebrow": "Message Detail",
"messages.backPrivate": "Back to private messages",
"messages.backGroup": "Back to group messages",
"messages.ownerPeerTitle": "Owner {owner} · Peer {peer}",
"messages.senderSubtitle": "Sender {sender} · {date}",
"messages.boxID": "Message box ID",
"messages.privateMessageID": "Private message ID",
"messages.messageSender": "Message sender",
"messages.messageBox": "Message Box",
"messages.dialogRow": "Dialog Row",
"messages.privateRow": "Private Message Row",
"messages.channelMessageRow": "Channel Message Row",
"messages.channelRow": "Channel Row",
"messages.userUpdateEvents": "Update Events",
"messages.channelUpdateEvents": "Channel Update Events",
"messages.eventJson": "Event JSON",
"messages.dispatchOutbox": "Dispatch Queue",
"messages.messageBoxesSnapshot": "message_boxes read-only snapshot",
"messages.dialogSnapshot": "dialogs read-only snapshot",
"messages.privateSnapshot": "private_messages read-only snapshot",
"messages.channelMessagesSnapshot": "channel_messages read-only snapshot",
"messages.channelSnapshot": "channels read-only snapshot",
"messages.userEventsSource": "durable user_update_events",
"messages.channelEventsSource": "durable channel_update_events",
"messages.outboxSource": "online/offline dispatch_outbox",
"messages.attempts": "Attempts",
"messages.deleteThis": "Delete this message",
"messages.groupDetailTitle": "Group Message #{id}",
"messages.channelGroupTitle": "Channel / Group {id}",
"messages.mediaCount": "With media",
"messages.channelPosts": "Channel posts",
"messages.channelGroup": "Channel / Group",
"messages.pinned": "Pinned",
"messages.channelPost": "Channel post",
"messages.msgIDsInvalid": "Message IDs are invalid",
"auth.device": "Device",
"auth.platform": "Platform",
"auth.ip": "IP",
"auth.lastActive": "Last active",
"auth.revokeCurrent": "Revoke current",
"auth.keepCurrent": "Keep current",
"auth.revokeAll": "Revoke all devices",
"picker.userPlaceholder": "Search user_id / phone / username",
"picker.channelPlaceholder": "Search channel_id / username / title",
"picker.verified": "Verified",
"picker.regular": "Regular",
"action.reasonRequired": "Please enter an operation reason",
"action.flow": "Action Flow",
"action.close": "Close",
"action.stepReason": "Enter reason",
"action.stepDryRun": "Dry-run check",
"action.stepConfirm": "Confirm execution",
"action.reason": "Operation reason",
"action.reasonPlaceholder": "Describe why this operation is being performed",
"action.requestPreview": "Request preview",
"action.result": "Action result",
"action.commandID": "Command ID",
"action.status": "Status",
"action.dryRun": "Dry-run",
"action.runAgain": "Run dry-run again",
"action.runDry": "Run dry-run first",
"action.confirm": "Confirm execution",
"audit.id": "ID",
"audit.commandID": "Command ID",
"audit.action": "Action",
"audit.actor": "Actor",
"audit.status": "Status",
"audit.dryRun": "Dry-run",
"audit.reason": "Reason",
"audit.time": "Time"
},
zh: {
"app.adminConsole": "管理控制台",
"app.localAccess": "本地访问",
"app.title": "telesrv 管理后台",
"common.actions": "操作",
"common.admins": "管理员",
"common.backToList": "返回列表",
"common.channel": "频道",
"common.channelOrGroup": "频道/群",
"common.clear": "清除",
"common.close": "关闭",
"common.count": "数量",
"common.deleted": "已删除",
"common.detail": "详情",
"common.device": "设备",
"common.disabled": "已禁用",
"common.enabled": "已启用",
"common.fromPeer": "From Peer",
"common.group": "群组",
"common.id": "ID",
"common.limit": "条数",
"common.loading": "加载中",
"common.member": "成员",
"common.members": "成员",
"common.messageId": "消息 ID",
"common.name": "姓名",
"common.no": "否",
"common.noResults": "无结果",
"common.none": "无",
"common.normal": "正常",
"common.operations": "操作",
"common.owner": "所属",
"common.platform": "平台",
"common.refresh": "刷新",
"common.search": "查询",
"common.sender": "发送方",
"common.status": "状态",
"common.survived": "存活",
"common.time": "时间",
"common.type": "类型",
"common.updatedAt": "更新时间",
"common.username": "用户名",
"common.valid": "有效",
"common.verified": "已认证",
"common.views": "浏览",
"common.yes": "是",
"route.accounts": "账号管理",
"route.accountsSubtitle": "控制台 / 账号",
"route.channels": "超级群与频道",
"route.channelsSubtitle": "控制台 / 频道",
"route.dashboard": "运维控制台",
"route.dashboardSubtitle": "控制台 / 总览",
"route.messages": "消息审计",
"route.messagesSubtitle": "控制台 / 消息",
"layout.navigation": "导航",
"layout.primaryNav": "主导航",
"layout.dashboard": "总览",
"layout.accounts": "账号",
"layout.channels": "超级群/频道",
"layout.messages": "消息",
"layout.privateMessages": "私聊",
"layout.groupMessages": "群聊",
"layout.runtime": "运行状态",
"layout.adminBackend": "管理后台",
"layout.ready": "就绪",
"layout.pgRead": "PG 读取",
"layout.readOnly": "只读",
"layout.writeOps": "写操作",
"layout.dryRun": "预演",
"layout.actor": "操作者:{actor}",
"layout.logout": "退出",
"language.en": "EN",
"language.zh": "中文",
"login.heading": "运维后台",
"login.body": "输入凭据后进入控制台。",
"login.secret": "管理员密码或 token",
"login.submit": "登录",
"login.submitting": "登录中",
"dashboard.eyebrow": "运行总览",
"dashboard.title": "控制台总览",
"dashboard.readPath": "读路径",
"dashboard.readPathValue": "PG 只读",
"dashboard.writePath": "写路径",
"dashboard.executionPolicy": "执行策略",
"dashboard.dryRunFirst": "先预演",
"dashboard.accountsText": "账号状态、会员、认证、会话。",
"dashboard.channelsText": "公开实体、成员计数、认证状态。",
"dashboard.messagesText": "消息盒、update、outbox 状态。",
"dashboard.strip.dryRun": "所有危险操作先预演",
"dashboard.strip.token": "浏览器不持有内部 token",
"dashboard.strip.pagination": "列表使用游标分页",
"dashboard.strip.snapshot": "详情页保留原始状态快照",
"account.pageTitle": "账号",
"account.queryResults": "查询结果",
"account.recentActive": "最近活跃账号",
"account.currentPage": "当前页账号",
"account.onlineDevices": "在线设备记录",
"account.premium": "会员",
"account.frozen": "冻结",
"account.searchPlaceholder": "用户 ID / 手机号 / 用户名",
"account.userID": "用户 ID",
"account.phone": "手机号",
"account.lastActive": "最近活跃",
"account.notVerified": "未认证",
"account.notPremium": "非会员",
"account.premiumUntil": "会员到期",
"account.activeSessions": "授权设备",
"account.accountFlags": "账号标记",
"account.restriction": "限制状态",
"account.restricted": "已限制",
"account.createdAt": "创建时间",
"account.detailTitle": "账号 #{id}",
"account.profile": "账号档案",
"account.loadingDetail": "加载账号详情",
"account.waitingData": "等待数据",
"account.noUsername": "无用户名",
"account.noPhone": "无手机号",
"account.sendFrozen": "发消息冻结",
"account.sendNormal": "发送正常",
"account.authorizationsTitle": "授权设备",
"account.authorizationsCount": "共 {count} 个授权",
"account.recentAdminOps": "最近后台操作",
"account.recent30Audit": "最近 30 条审计",
"account.actionDock": "账号操作",
"account.freezeSend": "冻结发消息",
"account.unfreezeSend": "解冻发消息",
"account.premiumMonths": "会员时长(月)",
"account.premiumMonthsAria": "设置会员时长,单位月",
"account.setPremium": "设置会员",
"account.clearPremium": "取消会员",
"account.setVerified": "设置认证",
"account.clearVerified": "取消认证",
"channel.pageTitle": "超级群与频道",
"channel.recentUpdated": "最近更新",
"channel.currentPage": "当前页实体",
"channel.megagroups": "超级群",
"channel.broadcasts": "频道",
"channel.verifiedCount": "已认证",
"channel.searchPlaceholder": "频道 ID / 用户名 / 标题",
"channel.channelID": "频道 ID",
"channel.kind": "类型",
"channel.title": "标题",
"channel.pts": "PTS",
"channel.detailProfile": "频道档案",
"channel.loadingDetail": "加载频道详情",
"channel.creator": "创建者 {id}",
"channel.governance": "治理状态",
"channel.governanceValue": "封禁 {banned} / 踢出 {kicked}",
"channel.flags": "频道标记",
"channel.rawRow": "频道原始行",
"channel.rawRowText": "数据库只读快照",
"channel.actionDock": "频道操作",
"channel.setVerified": "设置认证",
"channel.clearVerified": "取消认证",
"channel.kind.broadcast": "频道",
"channel.kind.forum": "超级群/论坛",
"channel.kind.megagroup": "超级群",
"channel.kind.generic": "频道/群",
"messages.privateTitle": "私聊消息",
"messages.privateEyebrow": "私聊消息盒",
"messages.groupTitle": "群聊消息",
"messages.groupEyebrow": "超级群 / 频道消息",
"messages.selectPrivatePeers": "请先搜索并选择所属用户和对端用户",
"messages.selectChannel": "请先搜索并选择超级群或频道",
"messages.ownerUser": "所属用户",
"messages.peerUser": "对端用户",
"messages.beforeDatePlaceholder": "before_date 游标",
"messages.beforeIDPlaceholder": "before_msg_id 游标",
"messages.limitPlaceholder": "条数 <= 100",
"messages.searchMessages": "查询消息",
"messages.nextPage": "下一页",
"messages.currentPage": "当前页消息",
"messages.deleted": "已删除",
"messages.outgoing": "发出消息",
"messages.incoming": "收到",
"messages.ownerPeer": "所属 / 对端",
"messages.deleteSelected": "删除指定消息",
"messages.idsPlaceholder": "消息 ID,逗号分隔",
"messages.revoke": "同步撤回",
"messages.previewDelete": "预演删除",
"messages.clearHistory": "清空私聊历史",
"messages.maxIDPlaceholder": "max_id 截止消息",
"messages.maxBatchesPlaceholder": "max_batches 批次数",
"messages.justClear": "仅清本侧",
"messages.previewClearHistory": "预演清历史",
"messages.direction": "方向",
"messages.body": "正文",
"messages.privateDetailTitle": "消息 #{id}",
"messages.detailEyebrow": "消息详情",
"messages.backPrivate": "返回私聊消息",
"messages.backGroup": "返回群聊消息",
"messages.ownerPeerTitle": "所属 {owner} · 对端 {peer}",
"messages.senderSubtitle": "发送方 {sender} · {date}",
"messages.boxID": "消息盒 ID",
"messages.privateMessageID": "私聊消息 ID",
"messages.messageSender": "发送方",
"messages.messageBox": "消息盒",
"messages.dialogRow": "会话行",
"messages.privateRow": "私聊消息行",
"messages.channelMessageRow": "消息行",
"messages.channelRow": "频道行",
"messages.userUpdateEvents": "更新事件",
"messages.channelUpdateEvents": "频道更新事件",
"messages.eventJson": "事件 JSON",
"messages.dispatchOutbox": "分发队列",
"messages.messageBoxesSnapshot": "message_boxes 只读快照",
"messages.dialogSnapshot": "dialogs 只读快照",
"messages.privateSnapshot": "private_messages 只读快照",
"messages.channelMessagesSnapshot": "channel_messages 只读快照",
"messages.channelSnapshot": "channels 只读快照",
"messages.userEventsSource": "durable user_update_events",
"messages.channelEventsSource": "durable channel_update_events",
"messages.outboxSource": "在线/离线 dispatch_outbox",
"messages.attempts": "尝试",
"messages.deleteThis": "删除此消息",
"messages.groupDetailTitle": "群聊消息 #{id}",
"messages.channelGroupTitle": "频道/群 {id}",
"messages.mediaCount": "有媒体",
"messages.channelPosts": "频道帖子",
"messages.channelGroup": "频道 / 群",
"messages.pinned": "置顶",
"messages.channelPost": "频道帖子",
"messages.msgIDsInvalid": "消息 ID 无效",
"auth.device": "设备",
"auth.platform": "平台",
"auth.ip": "IP",
"auth.lastActive": "最近活跃",
"auth.revokeCurrent": "撤销当前",
"auth.keepCurrent": "保留当前",
"auth.revokeAll": "撤销全部设备",
"picker.userPlaceholder": "搜索 user_id / phone / username",
"picker.channelPlaceholder": "搜索 channel_id / username / title",
"picker.verified": "认证",
"picker.regular": "普通",
"action.reasonRequired": "请填写操作原因",
"action.flow": "操作流程",
"action.close": "关闭",
"action.stepReason": "填写原因",
"action.stepDryRun": "预演检查",
"action.stepConfirm": "确认执行",
"action.reason": "操作原因",
"action.reasonPlaceholder": "说明本次操作原因",
"action.requestPreview": "请求预览",
"action.result": "操作结果",
"action.commandID": "命令 ID",
"action.status": "状态",
"action.dryRun": "预演",
"action.runAgain": "重新预演",
"action.runDry": "先预演",
"action.confirm": "确认执行",
"audit.id": "ID",
"audit.commandID": "命令 ID",
"audit.action": "动作",
"audit.actor": "操作者",
"audit.status": "状态",
"audit.dryRun": "预演",
"audit.reason": "原因",
"audit.time": "时间"
}
};
type I18nContextValue = {
lang: Language;
setLang: (lang: Language) => void;
t: TFunction;
};
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLang] = useState<Language>(() => initialLanguage());
useEffect(() => {
try {
localStorage.setItem(storageKey, lang);
} catch {
// Language persistence is best-effort.
}
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
document.documentElement.dir = "ltr";
document.documentElement.setAttribute("translate", "no");
document.body.classList.add("notranslate");
document.title = translate(lang, "app.title");
}, [lang]);
const value = useMemo<I18nContextValue>(() => ({
lang,
setLang,
t: (key, params) => translate(lang, key, params)
}), [lang]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nContextValue {
const value = useContext(I18nContext);
if (!value) {
throw new Error("useI18n must be used inside I18nProvider");
}
return value;
}
export function LanguageSwitch() {
const { lang, setLang, t } = useI18n();
return (
<div className="language-switch" role="group" aria-label="Language">
{(["en", "zh"] as const).map((item) => (
<button
key={item}
className={lang === item ? "active" : ""}
type="button"
aria-pressed={lang === item}
onClick={() => setLang(item)}
>
{t(`language.${item}`)}
</button>
))}
</div>
);
}
export function translate(lang: Language, key: string, params?: TranslationParams): string {
const value = translations[lang][key] ?? translations.en[key] ?? key;
if (!params) {
return value;
}
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(params[name] ?? ""));
}
function initialLanguage(): Language {
try {
const requested = normalizeLanguage(new URLSearchParams(window.location.search).get("lang"));
if (requested) {
return requested;
}
} catch {
// URLSearchParams can fail in unusual embedded contexts.
}
try {
const saved = normalizeLanguage(localStorage.getItem(storageKey));
if (saved) {
return saved;
}
} catch {
// Storage is optional.
}
const browserLanguages = navigator.languages?.length ? navigator.languages : [navigator.language];
for (const item of browserLanguages) {
const normalized = normalizeLanguage(item);
if (normalized) {
return normalized;
}
}
return "en";
}
function normalizeLanguage(value: string | null | undefined): Language | null {
if (!value) {
return null;
}
const tag = value.trim().toLowerCase().replace("_", "-");
if (tag === "zh" || tag.startsWith("zh-")) {
return "zh";
}
if (tag === "en" || tag.startsWith("en-")) {
return "en";
}
return null;
}