feat(ui): translate nodes.ts view to Chinese

- Add import for i18n t() function
- Translate device pairing section (pending/paired/approve/reject)
- Translate exec node binding section
- Translate exec approvals section (target, tabs)
- Add comprehensive node-related translations to en-US and zh-TW locales
This commit is contained in:
Claude 2026-01-28 23:12:23 +00:00
parent eccf005580
commit edeb35dbcc
No known key found for this signature in database
3 changed files with 179 additions and 72 deletions

View File

@ -407,34 +407,87 @@ export const enUS = {
// Nodes page
nodes: {
title: "Nodes",
desc: "Connected execution nodes and device pairings.",
noNodes: "No nodes connected.",
desc: "Paired devices and live links.",
noNodes: "No nodes found.",
devices: "Devices",
devicesDesc: "Pairing requests + role tokens.",
noDevices: "No paired devices.",
approve: "Approve",
reject: "Reject",
revoke: "Revoke",
rotate: "Rotate",
pending: "Pending",
paired: "Paired",
approved: "Approved",
offline: "offline",
tokens: "Tokens",
tokensNone: "Tokens: none",
role: "role",
requested: "requested",
repair: "repair",
active: "active",
revoked: "revoked",
scopes: "scopes",
roles: "roles",
bindings: {
title: "Exec Bindings",
desc: "Bind agents to specific execution nodes.",
default: "Default Node",
title: "Exec node binding",
desc: "Pin agents to a specific node when using",
default: "Default binding",
defaultDesc: "Used when agents do not override a node binding.",
agent: "Agent",
node: "Node",
save: "Save Bindings",
anyNode: "Any node",
useDefault: "Use default",
save: "Save",
loadConfig: "Load config",
loadConfigNote: "Load config to edit bindings.",
noNodesAvailable: "No nodes with system.run available.",
defaultAgent: "default agent",
usesDefault: "uses default",
override: "override",
switchToForm: "Switch the Config tab to Form mode to edit bindings here.",
},
approvals: {
title: "Exec Approvals",
desc: "Pre-approve commands for agent execution.",
title: "Exec approvals",
desc: "Allowlist and approval policy for",
target: "Target",
targetDesc: "Gateway edits local approvals; node edits the selected node.",
host: "Host",
gateway: "Gateway",
selectAgent: "Select agent",
addRule: "Add Rule",
noRules: "No approval rules configured.",
selectNode: "Select node",
noNodesYet: "No nodes advertise exec approvals yet.",
scope: "Scope",
defaults: "Defaults",
security: "Security",
securityDesc: "Default security mode.",
mode: "Mode",
deny: "Deny",
allowlist: "Allowlist",
full: "Full",
ask: "Ask",
askDesc: "Default prompt policy.",
off: "Off",
onMiss: "On miss",
always: "Always",
askFallback: "Ask fallback",
askFallbackDesc: "Applied when the UI prompt is unavailable.",
fallback: "Fallback",
autoAllowSkills: "Auto-allow skill CLIs",
autoAllowSkillsDesc: "Allow skill executables listed by the Gateway.",
usingDefault: "Using default",
addPattern: "Add pattern",
allowlistTitle: "Allowlist",
allowlistDesc: "Case-insensitive glob patterns.",
noAllowlist: "No allowlist entries yet.",
pattern: "Pattern",
newPattern: "New pattern",
lastUsed: "Last used",
never: "never",
remove: "Remove",
loadApprovals: "Load approvals",
loadApprovalsNote: "Load exec approvals to edit allowlists.",
},
},

View File

@ -414,34 +414,87 @@ export const zhTW = {
// 節點頁面
nodes: {
title: "節點",
desc: "已連線的執行節點與裝置配對。",
noNodes: "沒有已連線的節點。",
desc: "已配對的裝置與即時連結。",
noNodes: "找不到節點。",
devices: "裝置",
devicesDesc: "配對請求與角色權杖。",
noDevices: "沒有已配對的裝置。",
approve: "核准",
reject: "拒絕",
revoke: "撤銷",
rotate: "輪換",
pending: "待處理",
paired: "已配對",
approved: "已核准",
offline: "離線",
tokens: "權杖",
tokensNone: "權杖:無",
role: "角色",
requested: "請求於",
repair: "修復",
active: "使用中",
revoked: "已撤銷",
scopes: "範圍",
roles: "角色",
bindings: {
title: "執行綁定",
desc: "將代理綁定到特定執行節點。",
default: "預設節點",
title: "執行節點綁定",
desc: "使用時將代理固定到特定節點",
default: "預設綁定",
defaultDesc: "當代理未覆寫節點綁定時使用。",
agent: "代理",
node: "節點",
save: "儲存綁定",
anyNode: "任意節點",
useDefault: "使用預設",
save: "儲存",
loadConfig: "載入組態",
loadConfigNote: "載入組態以編輯綁定。",
noNodesAvailable: "沒有支援 system.run 的節點。",
defaultAgent: "預設代理",
usesDefault: "使用預設",
override: "覆寫",
switchToForm: "請將「組態」分頁切換為「表單」模式以在此編輯綁定。",
},
approvals: {
title: "執行核准",
desc: "預先核准代理可執行的指令。",
desc: "許可清單與核准政策,適用於",
target: "目標",
targetDesc: "閘道器編輯本地核准;節點編輯選定的節點。",
host: "主機",
gateway: "閘道器",
selectAgent: "選擇代理",
addRule: "新增規則",
noRules: "尚未設定核准規則。",
selectNode: "選擇節點",
noNodesYet: "尚無節點公告執行核准。",
scope: "範圍",
defaults: "預設值",
security: "安全性",
securityDesc: "預設安全模式。",
mode: "模式",
deny: "拒絕",
allowlist: "許可清單",
full: "完整",
ask: "詢問",
askDesc: "預設提示政策。",
off: "關閉",
onMiss: "未命中時",
always: "總是",
askFallback: "詢問備援",
askFallbackDesc: "當 UI 提示不可用時套用。",
fallback: "備援",
autoAllowSkills: "自動允許技能 CLI",
autoAllowSkillsDesc: "允許閘道器列出的技能可執行檔。",
usingDefault: "使用預設",
addPattern: "新增模式",
allowlistTitle: "許可清單",
allowlistDesc: "不區分大小寫的 glob 模式。",
noAllowlist: "尚無許可清單項目。",
pattern: "模式",
newPattern: "新模式",
lastUsed: "上次使用",
never: "從未",
remove: "移除",
loadApprovals: "載入核准",
loadApprovalsNote: "載入執行核准以編輯許可清單。",
},
},

View File

@ -1,5 +1,6 @@
import { html, nothing } from "lit";
import { t } from "../../i18n";
import { clampText, formatAgo, formatList } from "../format";
import type {
ExecApprovalsAllowlistEntry,
@ -60,16 +61,16 @@ export function renderNodes(props: NodesProps) {
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Nodes</div>
<div class="card-sub">Paired devices and live links.</div>
<div class="card-title">${t("nodes.title")}</div>
<div class="card-sub">${t("nodes.desc")}</div>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onRefresh}>
${props.loading ? "Loading…" : "Refresh"}
${props.loading ? t("common.loading") : t("common.refresh")}
</button>
</div>
<div class="list" style="margin-top: 16px;">
${props.nodes.length === 0
? html`<div class="muted">No nodes found.</div>`
? html`<div class="muted">${t("nodes.noNodes")}</div>`
: props.nodes.map((n) => renderNode(n))}
</div>
</section>
@ -84,11 +85,11 @@ function renderDevices(props: NodesProps) {
<section class="card">
<div class="row" style="justify-content: space-between;">
<div>
<div class="card-title">Devices</div>
<div class="card-sub">Pairing requests + role tokens.</div>
<div class="card-title">${t("nodes.devices")}</div>
<div class="card-sub">${t("nodes.devicesDesc")}</div>
</div>
<button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
${props.devicesLoading ? "Loading…" : "Refresh"}
${props.devicesLoading ? t("common.loading") : t("common.refresh")}
</button>
</div>
${props.devicesError
@ -97,18 +98,18 @@ function renderDevices(props: NodesProps) {
<div class="list" style="margin-top: 16px;">
${pending.length > 0
? html`
<div class="muted" style="margin-bottom: 8px;">Pending</div>
<div class="muted" style="margin-bottom: 8px;">${t("nodes.pending")}</div>
${pending.map((req) => renderPendingDevice(req, props))}
`
: nothing}
${paired.length > 0
? html`
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">${t("nodes.paired")}</div>
${paired.map((device) => renderPairedDevice(device, props))}
`
: nothing}
${pending.length === 0 && paired.length === 0
? html`<div class="muted">No paired devices.</div>`
? html`<div class="muted">${t("nodes.noDevices")}</div>`
: nothing}
</div>
</section>
@ -117,9 +118,9 @@ function renderDevices(props: NodesProps) {
function renderPendingDevice(req: PendingDevice, props: NodesProps) {
const name = req.displayName?.trim() || req.deviceId;
const age = typeof req.ts === "number" ? formatAgo(req.ts) : "n/a";
const role = req.role?.trim() ? `role: ${req.role}` : "role: -";
const repair = req.isRepair ? " · repair" : "";
const age = typeof req.ts === "number" ? formatAgo(req.ts) : t("common.na");
const role = req.role?.trim() ? `${t("nodes.role")}: ${req.role}` : `${t("nodes.role")}: -`;
const repair = req.isRepair ? ` · ${t("nodes.repair")}` : "";
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
return html`
<div class="list-item">
@ -127,16 +128,16 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
<div class="list-title">${name}</div>
<div class="list-sub">${req.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">
${role} · requested ${age}${repair}
${role} · ${t("nodes.requested")} ${age}${repair}
</div>
</div>
<div class="list-meta">
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">
<button class="btn btn--sm primary" @click=${() => props.onDeviceApprove(req.requestId)}>
Approve
${t("nodes.approve")}
</button>
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}>
Reject
${t("nodes.reject")}
</button>
</div>
</div>
@ -147,8 +148,8 @@ function renderPendingDevice(req: PendingDevice, props: NodesProps) {
function renderPairedDevice(device: PairedDevice, props: NodesProps) {
const name = device.displayName?.trim() || device.deviceId;
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
const roles = `roles: ${formatList(device.roles)}`;
const scopes = `scopes: ${formatList(device.scopes)}`;
const roles = `${t("nodes.roles")}: ${formatList(device.roles)}`;
const scopes = `${t("nodes.scopes")}: ${formatList(device.scopes)}`;
const tokens = Array.isArray(device.tokens) ? device.tokens : [];
return html`
<div class="list-item">
@ -157,9 +158,9 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
<div class="list-sub">${device.deviceId}${ip}</div>
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
${tokens.length === 0
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
? html`<div class="muted" style="margin-top: 6px;">${t("nodes.tokensNone")}</div>`
: html`
<div class="muted" style="margin-top: 10px;">Tokens</div>
<div class="muted" style="margin-top: 10px;">${t("nodes.tokens")}</div>
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
</div>
@ -170,8 +171,8 @@ function renderPairedDevice(device: PairedDevice, props: NodesProps) {
}
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
const status = token.revokedAtMs ? "revoked" : "active";
const scopes = `scopes: ${formatList(token.scopes)}`;
const status = token.revokedAtMs ? t("nodes.revoked") : t("nodes.active");
const scopes = `${t("nodes.scopes")}: ${formatList(token.scopes)}`;
const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
return html`
<div class="row" style="justify-content: space-between; gap: 8px;">
@ -181,7 +182,7 @@ function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: Node
class="btn btn--sm"
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
>
Rotate
${t("nodes.rotate")}
</button>
${token.revokedAtMs
? nothing
@ -190,7 +191,7 @@ function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: Node
class="btn btn--sm danger"
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
>
Revoke
${t("nodes.revoke")}
</button>
`}
</div>
@ -436,9 +437,9 @@ function renderBindings(state: BindingState) {
<section class="card">
<div class="row" style="justify-content: space-between; align-items: center;">
<div>
<div class="card-title">Exec node binding</div>
<div class="card-title">${t("nodes.bindings.title")}</div>
<div class="card-sub">
Pin agents to a specific node when using <span class="mono">exec host=node</span>.
${t("nodes.bindings.desc")} <span class="mono">exec host=node</span>.
</div>
</div>
<button
@ -446,33 +447,33 @@ function renderBindings(state: BindingState) {
?disabled=${state.disabled || !state.configDirty}
@click=${state.onSave}
>
${state.configSaving ? "Saving…" : "Save"}
${state.configSaving ? t("common.saving") : t("nodes.bindings.save")}
</button>
</div>
${state.formMode === "raw"
? html`<div class="callout warn" style="margin-top: 12px;">
Switch the Config tab to <strong>Form</strong> mode to edit bindings here.
${t("nodes.bindings.switchToForm")}
</div>`
: nothing}
${!state.ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load config to edit bindings.</div>
<div class="muted">${t("nodes.bindings.loadConfigNote")}</div>
<button class="btn" ?disabled=${state.configLoading} @click=${state.onLoadConfig}>
${state.configLoading ? "Loading…" : "Load config"}
${state.configLoading ? t("common.loading") : t("nodes.bindings.loadConfig")}
</button>
</div>`
: html`
<div class="list" style="margin-top: 16px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Default binding</div>
<div class="list-sub">Used when agents do not override a node binding.</div>
<div class="list-title">${t("nodes.bindings.default")}</div>
<div class="list-sub">${t("nodes.bindings.defaultDesc")}</div>
</div>
<div class="list-meta">
<label class="field">
<span>Node</span>
<span>${t("nodes.bindings.node")}</span>
<select
?disabled=${state.disabled || !supportsBinding}
@change=${(event: Event) => {
@ -481,7 +482,7 @@ function renderBindings(state: BindingState) {
state.onBindDefault(value ? value : null);
}}
>
<option value="" ?selected=${defaultValue === ""}>Any node</option>
<option value="" ?selected=${defaultValue === ""}>${t("nodes.bindings.anyNode")}</option>
${state.nodes.map(
(node) =>
html`<option
@ -494,13 +495,13 @@ function renderBindings(state: BindingState) {
</select>
</label>
${!supportsBinding
? html`<div class="muted">No nodes with system.run available.</div>`
? html`<div class="muted">${t("nodes.bindings.noNodesAvailable")}</div>`
: nothing}
</div>
</div>
${state.agents.length === 0
? html`<div class="muted">No agents found.</div>`
? html`<div class="muted">${t("nodes.noNodes")}</div>`
: state.agents.map((agent) =>
renderAgentBinding(agent, state),
)}
@ -517,9 +518,9 @@ function renderExecApprovals(state: ExecApprovalsState) {
<section class="card">
<div class="row" style="justify-content: space-between; align-items: center;">
<div>
<div class="card-title">Exec approvals</div>
<div class="card-title">${t("nodes.approvals.title")}</div>
<div class="card-sub">
Allowlist and approval policy for <span class="mono">exec host=gateway/node</span>.
${t("nodes.approvals.desc")} <span class="mono">exec host=gateway/node</span>.
</div>
</div>
<button
@ -527,7 +528,7 @@ function renderExecApprovals(state: ExecApprovalsState) {
?disabled=${state.disabled || !state.dirty || !targetReady}
@click=${state.onSave}
>
${state.saving ? "Saving…" : "Save"}
${state.saving ? t("common.saving") : t("common.save")}
</button>
</div>
@ -535,9 +536,9 @@ function renderExecApprovals(state: ExecApprovalsState) {
${!ready
? html`<div class="row" style="margin-top: 12px; gap: 12px;">
<div class="muted">Load exec approvals to edit allowlists.</div>
<div class="muted">${t("nodes.approvals.loadApprovalsNote")}</div>
<button class="btn" ?disabled=${state.loading || !targetReady} @click=${state.onLoad}>
${state.loading ? "Loading…" : "Load approvals"}
${state.loading ? t("common.loading") : t("nodes.approvals.loadApprovals")}
</button>
</div>`
: html`
@ -558,14 +559,14 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
<div class="list" style="margin-top: 12px;">
<div class="list-item">
<div class="list-main">
<div class="list-title">Target</div>
<div class="list-title">${t("nodes.approvals.target")}</div>
<div class="list-sub">
Gateway edits local approvals; node edits the selected node.
${t("nodes.approvals.targetDesc")}
</div>
</div>
<div class="list-meta">
<label class="field">
<span>Host</span>
<span>${t("nodes.approvals.host")}</span>
<select
?disabled=${state.disabled}
@change=${(event: Event) => {
@ -579,14 +580,14 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
}
}}
>
<option value="gateway" ?selected=${state.target === "gateway"}>Gateway</option>
<option value="node" ?selected=${state.target === "node"}>Node</option>
<option value="gateway" ?selected=${state.target === "gateway"}>${t("nodes.approvals.gateway")}</option>
<option value="node" ?selected=${state.target === "node"}>${t("nodes.bindings.node")}</option>
</select>
</label>
${state.target === "node"
? html`
<label class="field">
<span>Node</span>
<span>${t("nodes.bindings.node")}</span>
<select
?disabled=${state.disabled || !hasNodes}
@change=${(event: Event) => {
@ -595,7 +596,7 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
state.onSelectTarget("node", value ? value : null);
}}
>
<option value="" ?selected=${nodeValue === ""}>Select node</option>
<option value="" ?selected=${nodeValue === ""}>${t("nodes.approvals.selectNode")}</option>
${state.targetNodes.map(
(node) =>
html`<option
@ -612,7 +613,7 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
</div>
</div>
${state.target === "node" && !hasNodes
? html`<div class="muted">No nodes advertise exec approvals yet.</div>`
? html`<div class="muted">${t("nodes.approvals.noNodesYet")}</div>`
: nothing}
</div>
`;
@ -621,13 +622,13 @@ function renderExecApprovalsTarget(state: ExecApprovalsState) {
function renderExecApprovalsTabs(state: ExecApprovalsState) {
return html`
<div class="row" style="margin-top: 12px; gap: 8px; flex-wrap: wrap;">
<span class="label">Scope</span>
<span class="label">${t("nodes.approvals.scope")}</span>
<div class="row" style="gap: 8px; flex-wrap: wrap;">
<button
class="btn btn--sm ${state.selectedScope === EXEC_APPROVALS_DEFAULT_SCOPE ? "active" : ""}"
@click=${() => state.onSelectScope(EXEC_APPROVALS_DEFAULT_SCOPE)}
>
Defaults
${t("nodes.approvals.defaults")}
</button>
${state.agents.map((agent) => {
const label = agent.name?.trim() ? `${agent.name} (${agent.id})` : agent.id;