完成配置界面和参数的汉化

This commit is contained in:
Xu, Jingrong 2026-01-31 00:41:52 +08:00
parent 5f6dc2d89b
commit fc8eae7426
5 changed files with 322 additions and 19 deletions

View File

@ -75,6 +75,235 @@ export const zhCN = {
authFailed: "身份验证失败。请重新复制包含令牌的 URL 或更新令牌,然后点击连接。",
insecureContext: "当前页面为 HTTP浏览器已禁用设备身份。请使用 HTTPS 或在网关主机上访问 localhost。",
},
nodes: {
approvalsTitle: "节点审批",
approvalsSubtitle: "配置对新节点的默认连接策略",
nodesTitle: "节点",
nodesSubtitle: "管理已连接的计算节点",
devicesTitle: "设备",
devicesSubtitle: "管理已配对的设备",
target: "目标",
targetHint: "允许连接的目标节点类型",
hostLabel: "主机",
gateway: "网关",
node: "节点",
selectNode: "选择节点...",
noApprovalsNodes: "未找到可审批的节点",
scope: "范围",
defaults: "默认",
security: "安全",
securityDefaultHint: "默认安全策略",
securityAgentHint: "当前设置: {security}",
modeLabel: "模式",
useDefault: "使用默认值 ({security})",
modelLabel: "模型",
securityOptions: {
deny: "拒绝",
allowlist: "白名单",
full: "完全访问",
},
ask: "询问",
askDefaultHint: "连接时的询问策略",
askOptions: {
off: "关闭",
onMiss: "仅缺失时",
always: "总是",
},
askFallback: "后备询问",
askFallbackHint: "后备询问策略",
fallbackLabel: "后备",
autoAllowSkills: "自动允许技能",
autoAllowSkillsHint: "自动允许已知技能运行",
bindingTitle: "绑定",
bindingSubtitle: "将代理绑定到特定节点",
save: "保存",
saving: "保存中...",
bindingRawWarn: "高级模式:请小心编辑 JSON",
loadConfigToEdit: "加载配置以编辑绑定",
loadConfig: "加载配置",
loadApprovalsToEdit: "加载审批配置以编辑",
loadApprovals: "加载审批配置",
defaultBinding: "默认绑定",
defaultBindingHint: "未指定绑定的代理将使用此节点",
nodeLabel: "节点",
anyNode: "任意节点 (自动调度)",
noRunNodes: "未发现可作为运行目标的节点",
noNodesFound: "未发现已连接的节点",
pending: "待批准",
paired: "已配对",
noPairedDevices: "暂无已配对设备",
approve: "批准",
reject: "拒绝",
tokens: "访问令牌",
tokensNone: "无令牌",
revoked: "已撤销",
active: "活跃",
rotate: "轮换",
revoke: "撤销",
},
debug: {
snapshotsTitle: "快照",
snapshotsSubtitle: "当前系统状态快照",
status: "状态",
health: "健康状况",
lastHeartbeat: "最近心跳",
securityAudit: "安全审计: {label}",
criticalIssues: "{count} 个严重问题",
warningIssues: "{count} 个警告",
infoIssues: "{count} 个提示",
noCriticalIssues: "无严重问题",
refreshing: "刷新中...",
manualRpcTitle: "手动 RPC",
manualRpcSubtitle: "手动调用远程过程",
methodLabel: "方法",
paramsLabel: "参数 (JSON)",
call: "调用",
modelsTitle: "模型",
modelsSubtitle: "已加载的模型",
eventLogTitle: "事件日志",
eventLogSubtitle: "最近的系统事件",
noEvents: "暂无事件。",
},
logs: {
logsTitle: "日志",
logsSubtitle: "实时系统日志流",
export: "导出",
exportFiltered: "导出筛选结果",
exportVisible: "导出可见日志",
noEntries: "暂无日志条目。",
filterLabel: "过滤",
searchPlaceholder: "搜索日志...",
autoFollow: "自动滚动",
fileLabel: "日志文件",
truncatedWarn: "日志已截断,仅显示最近的部分。",
trace: "Trace",
debug: "Debug",
info: "Info",
warn: "Warn",
error: "Error",
fatal: "Fatal",
},
config: {
subtitle: "全局配置编辑器",
searchPlaceholder: "搜索配置项...",
allSettings: "所有设置",
sections: {
env: "环境",
update: "更新",
agents: "代理",
auth: "认证",
channels: "渠道",
messages: "消息",
commands: "命令",
hooks: "钩子",
skills: "技能",
tools: "工具",
gateway: "网关",
wizard: "向导",
meta: "元数据",
diagnostics: "诊断",
logging: "日志",
browser: "浏览器",
ui: "界面",
models: "模型",
nodeHost: "节点主机",
bindings: "绑定",
broadcast: "广播",
audio: "音频",
media: "媒体",
approvals: "审批",
session: "会话",
cron: "定时任务",
web: "Web 服务",
discovery: "发现",
canvasHost: "画布主机",
talk: "语音",
plugins: "插件",
},
formMode: "表单模式",
rawMode: "原始模式",
reload: "重新加载",
save: "保存",
apply: "应用",
update: "更新",
noChanges: "没有更改",
schema: {
logging: {
label: "日志",
description: "日志级别和输出配置",
consoleLevel: { label: "控制台级别", description: "控制台日志的输出级别" },
consoleStyle: { label: "控制台样式", description: "控制台日志的格式 (plain/json)" },
file: { label: "文件日志", description: "日志文件路径" },
level: { label: "日志级别", description: "全局日志级别 (trace/debug/info/warn/error)" },
redactPatterns: { label: "脱敏模式", description: "用于在日志中隐藏敏感信息的正则表达式" },
redactSensitive: { label: "自动脱敏", description: "自动隐藏已知的敏感键值 (如 password, token)" },
},
diagnostics: {
label: "诊断",
description: "OpenTelemetry 与调试选项",
enabled: { label: "启用诊断", description: "开启系统诊断功能" },
flags: { label: "诊断标志", description: "启用特定模块的详细日志 (如 *telegram*)" },
cacheTrace: {
label: "缓存跟踪",
description: "配置缓存操作的跟踪详细程度",
enabled: { label: "启用缓存跟踪", description: "记录嵌入式代理运行的缓存跟踪快照" },
filePath: { label: "缓存跟踪文件路径", description: "缓存跟踪日志的 JSONL 输出路径" },
includeMessages: { label: "包含消息", description: "在跟踪输出中包含完整的消息负载" },
includePrompt: { label: "包含提示词", description: "在跟踪中包含提示词文本" },
includeSystem: { label: "包含系统提示", description: "在跟踪中包含系统提示词" },
},
otel: {
label: "OpenTelemetry",
description: "OTLP 导出配置",
enabled: { label: "启用 OpenTelemetry", description: "发送遥测数据到 OTLP 端点" },
endpoint: { label: "OTLP 端点", description: "OTLP 收集器 URL" },
flushInterval: { label: "刷新间隔 (ms)", description: "数据上报的频率" },
headers: { label: "请求头", description: "附加的 HTTP 请求头" },
logsEnabled: { label: "启用日志", description: "发送日志数据" },
metricsEnabled: { label: "启用指标", description: "发送指标数据" },
tracesEnabled: { label: "启用链路追踪", description: "发送链路追踪数据" },
protocol: { label: "协议", description: "传输协议 (http/grpc)" },
},
},
update: {
label: "更新",
description: "配置更新行为",
channel: { label: "更新渠道", description: "选择更新发布渠道 (main/beta/dev)" },
},
meta: {
label: "元数据",
description: "系统运行信息",
lastRunAt: { label: "上次运行时间", description: "" },
lastRunCommand: { label: "上次运行命令", description: "" },
lastRunCommit: { label: "上次运行提交", description: "" },
lastRunMode: { label: "上次运行模式", description: "" },
lastRunVersion: { label: "上次运行版本", description: "" },
},
auth: {
label: "认证",
description: "身份验证设置",
allowTailscale: { label: "允许 Tailscale", description: "允许 Tailscale 访问" },
mode: { label: "模式", description: "认证模式 (token/password)" },
password: { label: "网关密码", description: "Tailscale funnel 需要此密码" },
token: { label: "网关令牌", description: "访问网关所需的令牌" },
gatewayPassword: { label: "网关密码", description: "Tailscale funnel 需要此密码" },
gatewayToken: { label: "网关令牌", description: "访问网关所需的令牌" },
},
nodeHost: {
label: "节点主机",
description: "网关与节点通信配置",
allowTobacco: { label: "允许烟草内容", description: "是否允许涉及烟草的内容" },
password: { label: "网关密码", description: "连接网关所需的密码" },
token: { label: "网关令牌", description: "连接网关所需的令牌" },
mode: { label: "认证模式", description: "选择 token 或 password 认证" },
},
env: {
label: "环境变量",
description: "传递给网关进程的环境变量",
allSubsections: { label: "所有变量", description: "所有配置的环境变量列表" },
},
},
},
channels: {
title: "渠道",
subtitle: "管理消息渠道连接",
@ -309,6 +538,18 @@ export const zhCN = {
reasonAllowlist: "被白名单拦截",
enable: "启用",
disable: "禁用",
names: {
"1password": "1Password CLI",
"apple-notes": "备忘录 (Apple Notes)",
"apple-reminders": "提醒事项 (Apple Reminders)",
"github": "GitHub",
"openai-whisper": "OpenAI Whisper",
},
descriptions: {
"1password": "设置并使用 1Password CLI (op)。用于安装 CLI、启用桌面集成、登录账户或通过 op 访问机密。",
"apple-notes": "在 macOS 上通过 `memo` CLI 管理 Apple Notes (创建、查看、编辑、删除、搜索、移动和导出笔记)。",
"apple-reminders": "在 macOS 上通过 `remindctl` CLI 管理 Apple Reminders (列表、添加、编辑、完成、删除)。",
},
},
instances: {
title: "已连接实例",

View File

@ -27,6 +27,34 @@ function jsonValue(value: unknown): string {
}
}
function resolveNodeLabels(
path: Array<string | number>,
schema: JsonSchema,
hints: ConfigUiHints
): { label: string; help?: string } {
const hint = hintForPath(path, hints);
// Try translation
// Filter out numeric indices to handle array items if necessary,
// but for settings keys (which are object properties), we usually want the specific path.
// However, many array items might share the same schema.
// For now, we focus on the settings panel which is mostly object paths.
const strPath = path.join(".");
const schemaPath = `config.schema.${strPath}`;
const labelKey = `${schemaPath}.label`;
const descKey = `${schemaPath}.description`;
const translatedLabel = t(labelKey);
const translatedDesc = t(descKey);
const fallbackLabel = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const fallbackHelp = hint?.help ?? schema.description;
return {
label: translatedLabel && translatedLabel !== labelKey ? translatedLabel : fallbackLabel,
help: translatedDesc && translatedDesc !== descKey ? translatedDesc : fallbackHelp,
};
}
// SVG Icons as template literals
const icons = {
chevronDown: html`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
@ -49,9 +77,12 @@ export function renderNode(params: {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const type = schemaType(schema);
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const hint = hintForPath(path, hints); // Keep for sensitive check below?
// Actually resolveNodeLabels does `hintForPath` internally but we might need `hint` variable for other things like `sensitive`.
// `renderNode` doesn't use `hint` for anything else except label/help.
// `pathKey` is used below.
const { label, help } = resolveNodeLabels(path, schema, hints);
const key = pathKey(path);
if (unsupported.has(key)) {
@ -229,8 +260,7 @@ function renderTextInput(params: {
const { schema, value, path, hints, disabled, onPatch, inputType } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help } = resolveNodeLabels(path, schema, hints);
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
const placeholder =
hint?.placeholder ??
@ -297,8 +327,7 @@ function renderNumberInput(params: {
const { schema, value, path, hints, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help } = resolveNodeLabels(path, schema, hints);
const displayValue = value ?? schema.default ?? "";
const numValue = typeof displayValue === "number" ? displayValue : 0;
@ -348,8 +377,7 @@ function renderSelect(params: {
const { schema, value, path, hints, disabled, options, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help } = resolveNodeLabels(path, schema, hints);
const resolvedValue = value ?? schema.default;
const currentIndex = options.findIndex(
(opt) => opt === resolvedValue || String(opt) === String(resolvedValue),
@ -391,8 +419,7 @@ function renderObject(params: {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help } = resolveNodeLabels(path, schema, hints);
const fallback = value ?? schema.default;
const obj = fallback && typeof fallback === "object" && !Array.isArray(fallback)
@ -490,8 +517,7 @@ function renderArray(params: {
const { schema, value, path, hints, unsupported, disabled, onPatch } = params;
const showLabel = params.showLabel ?? true;
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
const { label, help } = resolveNodeLabels(path, schema, hints);
const itemsSchema = Array.isArray(schema.items) ? schema.items[0] : schema.items;
if (!itemsSchema) {

View File

@ -185,8 +185,16 @@ export function renderConfigForm(props: ConfigFormProps) {
? (() => {
const { sectionKey, subsectionKey, schema: node } = subsectionContext;
const hint = hintForPath([sectionKey, subsectionKey], props.uiHints);
const label = hint?.label ?? node.title ?? humanize(subsectionKey);
const description = hint?.help ?? node.description ?? "";
// Try translation first
const schemaPath = `config.schema.${sectionKey}.${subsectionKey}`;
const labelKey = `${schemaPath}.label`;
const descKey = `${schemaPath}.description`;
const translatedLabel = t(labelKey);
const translatedDesc = t(descKey);
const label = translatedLabel !== labelKey ? translatedLabel : (hint?.label ?? node.title ?? humanize(subsectionKey));
const description = translatedDesc !== descKey ? translatedDesc : (hint?.help ?? node.description ?? "");
const sectionValue = (value as Record<string, unknown>)[sectionKey];
const scopedValue =
sectionValue && typeof sectionValue === "object"
@ -221,14 +229,23 @@ export function renderConfigForm(props: ConfigFormProps) {
})()
: filteredEntries.map(([key, node]) => {
const meta = getSectionMeta(key);
// Try translation for top-level schema items if not in meta
const schemaPath = `config.schema.${key}`;
const labelKey = `${schemaPath}.label`;
const descKey = `${schemaPath}.description`;
const translatedLabel = t(labelKey);
const translatedDesc = t(descKey);
const label = translatedLabel !== labelKey ? translatedLabel : meta.label;
const description = translatedDesc !== descKey ? translatedDesc : meta.description;
return html`
<section class="config-section-card" id="config-section-${key}">
<div class="config-section-card__header">
<span class="config-section-card__icon">${getSectionIcon(key)}</span>
<div class="config-section-card__titles">
<h3 class="config-section-card__title">${meta.label}</h3>
${meta.description
<h3 class="config-section-card__title">${label}</h3>
${description
? html`<p class="config-section-card__desc">${meta.description}</p>`
: nothing}
</div>

View File

@ -88,6 +88,25 @@ const SECTIONS: Array<{ key: string; label: string }> = [
{ key: "tools", label: t("config.sections.tools") },
{ key: "gateway", label: t("config.sections.gateway") },
{ key: "wizard", label: t("config.sections.wizard") },
{ key: "meta", label: t("config.sections.meta") },
{ key: "diagnostics", label: t("config.sections.diagnostics") },
{ key: "logging", label: t("config.sections.logging") },
{ key: "browser", label: t("config.sections.browser") },
{ key: "ui", label: t("config.sections.ui") },
{ key: "models", label: t("config.sections.models") },
{ key: "nodeHost", label: t("config.sections.nodeHost") },
{ key: "bindings", label: t("config.sections.bindings") },
{ key: "broadcast", label: t("config.sections.broadcast") },
{ key: "audio", label: t("config.sections.audio") },
{ key: "media", label: t("config.sections.media") },
{ key: "approvals", label: t("config.sections.approvals") },
{ key: "session", label: t("config.sections.session") },
{ key: "cron", label: t("config.sections.cron") },
{ key: "web", label: t("config.sections.web") },
{ key: "discovery", label: t("config.sections.discovery") },
{ key: "canvasHost", label: t("config.sections.canvasHost") },
{ key: "talk", label: t("config.sections.talk") },
{ key: "plugins", label: t("config.sections.plugins") },
];
type SubsectionEntry = {

View File

@ -92,9 +92,9 @@ function renderSkill(skill: SkillStatusEntry, props: SkillsProps) {
<div class="list-item">
<div class="list-main">
<div class="list-title">
${skill.emoji ? `${skill.emoji} ` : ""}${skill.name}
${skill.emoji ? `${skill.emoji} ` : ""}${t(`skills.names.${skill.skillKey}`) !== `skills.names.${skill.skillKey}` ? t(`skills.names.${skill.skillKey}`) : skill.name}
</div>
<div class="list-sub">${clampText(skill.description, 140)}</div>
<div class="list-sub">${clampText((t(`skills.descriptions.${skill.skillKey}`) !== `skills.descriptions.${skill.skillKey}` ? t(`skills.descriptions.${skill.skillKey}`) : skill.description), 140)}</div>
<div class="chip-row" style="margin-top: 6px;">
<span class="chip">${skill.source}</span>
<span class="chip ${skill.eligible ? "chip-ok" : "chip-warn"}">