dsh-maoying 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -53,21 +53,23 @@ window.__ModuleLoader__.load({
53
53
  var CSS_KEY = "maoying-creator/base";
54
54
  var WS_STORAGE_KEY = "maoying.workspace";
55
55
  var API_TIMEOUT_MS = 60000;
56
+ // 一键升级单独放宽:服务端 pnpm add 超时上限 180s,统一 60s 会先掐断,
57
+ // 故 self-update 请求走 200s(留 20s 余量),其余请求仍用 API_TIMEOUT_MS。
58
+ var SELF_UPDATE_TIMEOUT_MS = 200000;
56
59
 
57
60
  var STATUS_FLOW = ["pending", "writing", "review", "accepted"];
58
- var STATUS_LABEL = { pending: "待写", writing: "写作中", review: "审校中", accepted: "已定稿" };
59
61
  var FS_ORDER = ["planted", "progressing", "resolved", "abandoned"];
60
62
  var FS_FLOW = { planted: "progressing", progressing: "resolved" };
61
- var FS_LABEL = { planted: "埋设", progressing: "推进中", resolved: "已回收", abandoned: "已弃用" };
62
- var PRI_LABEL = { high: "高", normal: "中", low: "低" };
63
63
  var PHASES = ["idea", "setting", "outline", "writing", "review", "consistency", "export"];
64
- var PHASE_LABEL = { idea: "立意", setting: "设定", outline: "大纲", writing: "写作", review: "审校", consistency: "一致性", export: "导出" };
65
- var SCI_LABEL = { known: "已确证", extrapolation: "推演", hypothesis: "假说" };
66
- var CLUE_KIND_LABEL = { clue: "线索", red_herring: "红鲱鱼", inference: "推理", question: "疑问" };
64
+ // clue.* 词典键是小驼峰,数据里的 kind 按契约(templates/novel/README.md、
65
+ // docs/spec.md)是 clue|red-herring|deduction|question——此前键表误按
66
+ // snake_case + inference 写,红鲱鱼/推理两组永远查不中、只能裸显英文原值。
67
+ // 尾两个旧键是历史手写数据的容错别名,契约值优先。
68
+ var CLUE_KIND_KEYS = { clue: "clue", "red-herring": "redHerring", deduction: "inference", question: "question", red_herring: "redHerring", inference: "inference" };
67
69
  var TABS = [
68
- ["overview", "概览"], ["chapters", "章节"], ["characters", "角色"],
69
- ["foreshadows", "伏笔"], ["relations", "关系图"], ["timeline", "时间线"],
70
- ["world", "设定"], ["index", "索引板"], ["settings", "小说设置"],
70
+ ["overview", "tabs.overview"], ["chapters", "tabs.chapters"], ["characters", "tabs.characters"],
71
+ ["foreshadows", "tabs.foreshadows"], ["relations", "tabs.relations"], ["timeline", "tabs.timeline"],
72
+ ["world", "tabs.world"], ["index", "tabs.index"], ["settings", "tabs.settings"],
71
73
  ];
72
74
 
73
75
  // 角色徽章四色(role 是自由文本):按关键词归 tone,命中即返。
@@ -85,16 +87,16 @@ window.__ModuleLoader__.load({
85
87
  }
86
88
  function statusBadge(status) {
87
89
  var s = STATUS_FLOW.indexOf(status) !== -1 ? status : "pending";
88
- return h("span", { className: "myt-badge", "data-status": s }, STATUS_LABEL[s] + (status && s !== status ? "(" + status + ")" : ""));
90
+ return h("span", { className: "myt-badge", "data-status": s }, tt("status." + s) + (status && s !== status ? tf("status.rawSuffix", { raw: status }) : ""));
89
91
  }
90
92
  function phasePill(phase) {
91
93
  var p = PHASES.indexOf(phase) !== -1 ? phase : null;
92
- if (!p) return h("span", { className: "myt-phase", "data-phase": "idea", title: phase || "" }, phase ? String(phase) : "未设阶段");
93
- return h("span", { className: "myt-phase", "data-phase": p }, PHASE_LABEL[p]);
94
+ if (!p) return h("span", { className: "myt-phase", "data-phase": "idea", title: phase || "" }, phase ? String(phase) : tt("phase.unset"));
95
+ return h("span", { className: "myt-phase", "data-phase": p }, tt("phase." + p));
94
96
  }
95
97
  function priDot(pri) {
96
98
  var p = pri === "high" || pri === "low" ? pri : "normal";
97
- return h("span", { className: "myt-priDot", "data-pri": p, title: "优先级:" + (PRI_LABEL[p] || pri || "中"), "aria-label": "优先级" + (PRI_LABEL[p] || "") });
99
+ return h("span", { className: "myt-priDot", "data-pri": p, title: tf("common.priorityTitle", { pri: tt("pri." + p) }), "aria-label": tf("common.priorityAria", { pri: tt("pri." + p) }) });
98
100
  }
99
101
 
100
102
  // ── 格式化小工具 ─────────────────────────────────────────────────────
@@ -114,7 +116,7 @@ window.__ModuleLoader__.load({
114
116
  // 章节「卷-章」定位:volume 缺省只显示第 N 章。
115
117
  function chWhere(ch) {
116
118
  if (!ch) return "";
117
- var num = typeof ch.num === "number" ? "第 " + ch.num + " 章" : "";
119
+ var num = typeof ch.num === "number" ? tf("common.chapterNum", { n: ch.num }) : "";
118
120
  return ch.volume ? String(ch.volume) + " · " + num : num;
119
121
  }
120
122
  function chFullTitle(ch) {
@@ -160,7 +162,9 @@ window.__ModuleLoader__.load({
160
162
 
161
163
  // ── 服务端 API 助手(同源 /api/maoying/*,非 2xx 抛错)─────────────────
162
164
  // 错误统一 toast(读路由调用方另留卡内错误态);总超时防按钮永久转圈。
163
- function maoyingApi(path, body) {
165
+ function maoyingApi(path, body, timeoutMs) {
166
+ // timeoutMs 缺省用统一 60s;一键升级单独放宽到 200s(见常量处注释)。
167
+ var limit = typeof timeoutMs === "number" && timeoutMs > 0 ? timeoutMs : API_TIMEOUT_MS;
164
168
  var opts = body === undefined ? {} : {
165
169
  method: "POST",
166
170
  headers: { "content-type": "application/json" },
@@ -170,19 +174,25 @@ window.__ModuleLoader__.load({
170
174
  var timer = null;
171
175
  if (ctrl) {
172
176
  opts.signal = ctrl.signal;
173
- timer = setTimeout(function () { ctrl.abort(); }, API_TIMEOUT_MS);
177
+ timer = setTimeout(function () { ctrl.abort(); }, limit);
174
178
  }
175
179
  return fetch(API_BASE + path, opts).then(function (r) {
176
180
  // 先取全文再 JSON.parse:代理错误页 / 空体不再抛 SyntaxError。
177
181
  return r.text().then(function (raw) {
178
182
  var d = null;
179
183
  try { d = raw ? JSON.parse(raw) : null; } catch (err) { d = null; }
180
- if (!r.ok) throw new Error((d && d.error) || ("HTTP " + r.status));
181
- if (!d) throw new Error("响应不是 JSON(HTTP " + r.status + ")");
184
+ if (!r.ok) {
185
+ // 错误对象带状态码:调用方按码分流(如设置保存只在 409 外部改动
186
+ // 冲突时重置表单,其余失败保留用户正在编辑的内容)。
187
+ var e = new Error((d && d.error) || ("HTTP " + r.status));
188
+ e.status = r.status;
189
+ throw e;
190
+ }
191
+ if (!d) throw new Error(tf("api.notJson", { code: r.status }));
182
192
  return d;
183
193
  });
184
194
  }).catch(function (err) {
185
- if (ctrl && err && err.name === "AbortError") throw new Error("请求超时(" + API_TIMEOUT_MS / 1000 + " 秒)");
195
+ if (ctrl && err && err.name === "AbortError") throw new Error(tf("api.timeout", { s: Math.round(limit / 1000) }));
186
196
  throw err;
187
197
  }).finally(function () {
188
198
  if (timer !== null) clearTimeout(timer);
@@ -196,7 +206,7 @@ window.__ModuleLoader__.load({
196
206
  qs = usp.toString();
197
207
  }
198
208
  return maoyingApi(path + (qs ? (path.indexOf("?") === -1 ? "?" : "&") + qs : "")).catch(function (err) {
199
- toast.err("毛颖创作台:" + err.message);
209
+ toast.err(tf("toast.prefix", { msg: err.message }));
200
210
  throw err;
201
211
  });
202
212
  }
@@ -205,17 +215,17 @@ window.__ModuleLoader__.load({
205
215
  toast.ok(apiPostOkText(path, d));
206
216
  return d;
207
217
  }, function (err) {
208
- toast.err("毛颖创作台:" + err.message);
218
+ toast.err(tf("toast.prefix", { msg: err.message }));
209
219
  throw err;
210
220
  });
211
221
  }
212
222
  // 写操作成功提示按路由分流(server 返回 chapters 计数时带上)。
213
223
  function apiPostOkText(path, d) {
214
- if (path === "rebuild-index") return "索引已重建(" + fmtNum(d && d.chapters) + " 章)";
215
- if (path === "chapter-status") return "章节状态已更新";
216
- if (path === "foreshadow-status") return "伏笔状态已更新";
217
- if (path === "phase") return "阶段已推进到「" + (PHASE_LABEL[d && d.phase] || (d && d.phase) || "—") + "」";
218
- return "操作成功";
224
+ if (path === "rebuild-index") return tf("toast.indexRebuilt", { n: fmtNum(d && d.chapters) });
225
+ if (path === "chapter-status") return tt("toast.chapterStatus");
226
+ if (path === "foreshadow-status") return tt("toast.foreshadowStatus");
227
+ if (path === "phase") return tf("toast.phaseAdvanced", { phase: d && d.phase && PHASES.indexOf(d.phase) !== -1 ? tt("phase." + d.phase) : (d && d.phase) || "—" });
228
+ return tt("toast.success");
219
229
  }
220
230
 
221
231
  // ── Toast 控制器(模块级单例;ok 4s / warn 6s 自动消失,err 手动关)────
@@ -267,7 +277,7 @@ window.__ModuleLoader__.load({
267
277
  },
268
278
  h("div", { className: "myt-toastText" }, it.text),
269
279
  h("button", {
270
- type: "button", className: "myt-toastClose", "aria-label": "关闭提示",
280
+ type: "button", className: "myt-toastClose", "aria-label": tt("common.closeTip"),
271
281
  onClick: function () { toast.dismiss(it.id); },
272
282
  }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")));
273
283
  }));
@@ -446,14 +456,14 @@ window.__ModuleLoader__.load({
446
456
 
447
457
  // ── 引导卡(无 novel 工作区 / 未初始化):maoying init 提示 + 复制 ──────
448
458
  function GuideCard(props) {
449
- var title = props.title || "该工作区还没有小说项目";
450
- var desc = props.desc || "在下面给出的命令里初始化目录骨架(或把已有 novel 目录放进工作区根),然后刷新本面板。";
459
+ var title = props.title || tt("guide.defaultTitle");
460
+ var desc = props.desc || tt("guide.defaultDesc");
451
461
  var _b = useState(false), busy = _b[0], setBusy = _b[1];
452
462
  function doCopy() {
453
463
  setBusy(true);
454
464
  copyText("maoying init").then(function (ok) {
455
- if (ok) toast.ok("已复制 maoying init");
456
- else toast.warn("复制失败,请手动选择命令文本");
465
+ if (ok) toast.ok(tt("guide.copied"));
466
+ else toast.warn(tt("guide.copyFailed"));
457
467
  }).finally(function () { setBusy(false); });
458
468
  }
459
469
  return h("div", { className: "myt-card myt-guide" },
@@ -461,7 +471,7 @@ window.__ModuleLoader__.load({
461
471
  h("p", { className: "myt-guideText" }, desc),
462
472
  h("div", { className: "myt-guideCode" },
463
473
  h("code", { className: "myt-guideCodeText" }, "maoying init"),
464
- h("button", { type: "button", className: "myt-btn myt-copyBtn", disabled: busy, onClick: doCopy }, busy ? "复制中…" : "复制命令")));
474
+ h("button", { type: "button", className: "myt-btn myt-copyBtn", disabled: busy, onClick: doCopy }, busy ? tt("guide.copying") : tt("guide.copyCmd"))));
465
475
  }
466
476
 
467
477
  // ── 骨架 / 错误 / 空态小件 ──────────────────────────────────────────────
@@ -474,9 +484,9 @@ window.__ModuleLoader__.load({
474
484
  return h("div", { className: "myt-strip myt-stripError" },
475
485
  h("span", { className: "myt-stripIcon" }, ic("IconWarningOutlineRegular", 14)),
476
486
  h("span", { className: "myt-stripText" }, props.text),
477
- props.onRetry ? h("button", { type: "button", className: "myt-btn", style: { marginLeft: "auto" }, onClick: props.onRetry }, "重试") : null);
487
+ props.onRetry ? h("button", { type: "button", className: "myt-btn", style: { marginLeft: "auto" }, onClick: props.onRetry }, tt("common.retry")) : null);
478
488
  }
479
- function Empty(props) { return h("div", { className: "myt-empty" }, props.text || "暂无数据"); }
489
+ function Empty(props) { return h("div", { className: "myt-empty" }, props.text || tt("common.empty")); }
480
490
 
481
491
  // tab 内容错误边界:任何 tab / 阅读页渲染抛错都降级为卡内错误态(错误摘要
482
492
  // + 重试按钮),不再把整个面板炸成白屏。错误边界只有 class 组件能做
@@ -501,12 +511,12 @@ window.__ModuleLoader__.load({
501
511
  render() {
502
512
  var err = this.state.err;
503
513
  if (!err) return this.props.children;
504
- var msg = String((err && err.message) || err || "未知错误");
514
+ var msg = String((err && err.message) || err || tt("common.unknownErr"));
505
515
  return h("div", { className: "myt-card" },
506
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "该分区渲染出错")),
507
- h("div", { className: "myt-muted", style: { overflowWrap: "anywhere" } }, "错误摘要:" + truncate(msg, 300)),
516
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("errBoundary.title"))),
517
+ h("div", { className: "myt-muted", style: { overflowWrap: "anywhere" } }, tf("errBoundary.summary", { msg: truncate(msg, 300) })),
508
518
  h("div", { className: "myt-heroMeta" },
509
- h("button", { type: "button", className: "myt-btn", onClick: this.retry }, "重试")));
519
+ h("button", { type: "button", className: "myt-btn", onClick: this.retry }, tt("common.retry"))));
510
520
  }
511
521
  }
512
522
 
@@ -526,7 +536,18 @@ window.__ModuleLoader__.load({
526
536
  var _rd = useState(null), readerFile = _rd[0], setReaderFile = _rd[1];
527
537
  // 系统设置:字号档位持久化 + 面板整体缩放(portal 节点读同一 CSS 变量)。
528
538
  var _sm = useState(false), showSettings = _sm[0], setShowSettings = _sm[1];
529
- var _fs = useState(function () { return lsGet(FONT_SCALE_KEY) || "1"; }), fontScale = _fs[0], setFontScale = _fs[1];
539
+ var _fs = useState(function () {
540
+ var stored = lsGet(FONT_SCALE_KEY) || "1";
541
+ return FONT_SCALES.some(function (o) { return o[0] === stored; }) ? stored : "1"; // 旧档位(1.8/2)回落 100%
542
+ }), fontScale = _fs[0], setFontScale = _fs[1];
543
+ var _lg = useState(function () {
544
+ var stored = lsGet(LANG_KEY) || "zh";
545
+ return UI_LANGS.some(function (o) { return o[0] === stored; }) ? stored : "zh";
546
+ }), lang = _lg[0], setLang = _lg[1];
547
+ useEffect(function () {
548
+ UI_LANG = lang;
549
+ lsSet(LANG_KEY, lang);
550
+ }, [lang]);
530
551
  useEffect(function () {
531
552
  if (typeof document !== "undefined" && document.documentElement) {
532
553
  document.documentElement.style.setProperty("--myt-zoom", fontScale);
@@ -594,6 +615,35 @@ window.__ModuleLoader__.load({
594
615
  if (d && d.current) setVer(d); // 常态也显示当前版本号;有新版再亮徽章
595
616
  }).catch(function () {});
596
617
  }, []);
618
+ // 一键升级状态机:""(可点)→ "installing"(安装中,按钮禁用)→
619
+ // "installed"(已装完待重启,按钮永久禁用、新版徽章收起)。可预期失败
620
+ // (dev-link / pnpm-missing / pnpm 出错)回落 "" 允许重试。
621
+ var _up = useState(""), upState = _up[0], setUpState = _up[1];
622
+ function runSelfUpdate() {
623
+ if (upState !== "") return;
624
+ setUpState("installing");
625
+ // 不走 apiPost:成功提示要带新版本号、失败按 kind 分流文案,且超时
626
+ // 单独放宽(服务端 pnpm 最长 180s,统一 60s 会先掐断,见常量处注释)。
627
+ maoyingApi("self-update", {}, SELF_UPDATE_TIMEOUT_MS).then(function (d) {
628
+ if (d && d.ok) {
629
+ setUpState("installed");
630
+ // 硬约束照实说:装完只是磁盘换新,运行中的进程仍是旧代码。
631
+ toast.ok(tf("toast.selfUpdate.ok", { ver: (d && d.to) || "?" }));
632
+ } else if (d && d.kind === "dev-link") {
633
+ setUpState("");
634
+ toast.warn(tt("toast.selfUpdate.devLink"));
635
+ } else if (d && d.kind === "pnpm-missing") {
636
+ setUpState("");
637
+ toast.warn(tt("toast.selfUpdate.pnpmMissing"));
638
+ } else {
639
+ setUpState("");
640
+ toast.err(tf("toast.prefix", { msg: (d && d.error) || tt("common.unknownErr") }));
641
+ }
642
+ }).catch(function (err) {
643
+ setUpState("");
644
+ toast.err(tf("toast.prefix", { msg: err.message }));
645
+ });
646
+ }
597
647
  useEffect(function () { loadWorkspaces(); }, [loadWorkspaces]);
598
648
  useEffect(function () { loadState(effectiveWs); }, [effectiveWs, loadState]);
599
649
  // 校正后的选择回写记忆(首次打开 / 记忆失效时自动落位)。
@@ -618,19 +668,19 @@ window.__ModuleLoader__.load({
618
668
  } else if (!effectiveWs) {
619
669
  // 全无 novel 工作区:引导卡(maoying init + 复制)。
620
670
  body = h("div", { className: "myt-stack" },
621
- wsErr ? ErrorStrip({ text: "工作区列表拉取失败:" + wsErr, onRetry: loadWorkspaces }) : null,
671
+ wsErr ? ErrorStrip({ text: tf("shell.wsListFailed", { msg: wsErr }), onRetry: loadWorkspaces }) : null,
622
672
  h(GuideCard, {
623
- title: "还没有可用的小说工作区",
624
- desc: "任一工作区根目录下出现 novel/project.yml 即自动入选;先用命令初始化目录骨架:",
673
+ title: tt("guide.noWsTitle"),
674
+ desc: tt("guide.noWsDesc"),
625
675
  }));
626
676
  } else if (loading && !snap) {
627
677
  body = h(SkelPage);
628
678
  } else if (stateErr) {
629
- body = h(ErrorStrip, { text: "快照拉取失败:" + stateErr, onRetry: reload });
679
+ body = h(ErrorStrip, { text: tf("shell.stateFailed", { msg: stateErr }), onRetry: reload });
630
680
  } else if (snap && snap.found === false) {
631
681
  body = h(GuideCard, {
632
- title: "该工作区尚未初始化小说项目",
633
- desc: "工作区根目录下没有 novel/project.yml。在该工作区根目录执行:",
682
+ title: tt("guide.notInitTitle"),
683
+ desc: tt("guide.notInitDesc"),
634
684
  });
635
685
  } else if (snap) {
636
686
  var tabProps = { snap: snap, reload: reload, wsPath: effectiveWs };
@@ -650,37 +700,51 @@ window.__ModuleLoader__.load({
650
700
  h("header", { className: "myt-header" },
651
701
  h("div", null,
652
702
  h("h1", { className: "myt-title" }, "毛颖创作台",
653
- ver && ver.current ? h("span", { className: "myt-verTag", title: "当前版本" }, ver.current) : null,
703
+ ver && ver.current ? h("span", { className: "myt-verTag", title: tt("header.curVerTitle") }, ver.current) : null,
654
704
  ver && ver.updateAvailable
655
- ? h("a", { className: "myt-verBadge", href: ver.releaseUrl || "#", target: "_blank", rel: "noreferrer", title: "当前 " + ver.current + ",点击打开新版发布页" },
656
- "有新版 " + ver.latest + " ↗")
705
+ ? [
706
+ // 徽章(跳发布页)+「一键升级」按钮双元素;装完(installed)
707
+ // 徽章收起——磁盘已是新版,别再引导去发布页——按钮变
708
+ // 「已安装,待重启」保持禁用,重启 DeepSeek 换新进程后才消失。
709
+ upState !== "installed"
710
+ ? h("a", { key: "b", className: "myt-verBadge", href: ver.releaseUrl || "#", target: "_blank", rel: "noreferrer", title: tf("header.upgradeTip", { cur: ver.current }) },
711
+ tf("header.newVersion", { ver: ver.latest }))
712
+ : null,
713
+ h("button", {
714
+ key: "u", type: "button", className: "myt-verUpgrade",
715
+ disabled: upState !== "",
716
+ title: upState === "installed" ? tt("header.upgradedTitle") : tt("header.upgradeTitle"),
717
+ onClick: runSelfUpdate,
718
+ },
719
+ upState === "installing" ? tt("header.upgrading") : upState === "installed" ? tt("header.upgraded") : tt("header.upgradeBtn")),
720
+ ]
657
721
  : null),
658
- h("p", { className: "myt-subtitle" }, "网文项目可视化与状态流转 · 只读正文,写状态")),
722
+ h("p", { className: "myt-subtitle" }, tt("header.subtitle"))),
659
723
  h("div", { className: "myt-headActions" },
660
724
  workspaces && novelWorkspaces.length
661
- ? [h("span", { className: "myt-wsLabel", key: "l" }, "工作区"),
662
- h("select", { className: "myt-wsSelect", key: "s", value: effectiveWs, onChange: chooseWs, "aria-label": "选择工作区" },
725
+ ? [h("span", { className: "myt-wsLabel", key: "l" }, tt("header.workspace")),
726
+ h("select", { className: "myt-wsSelect", key: "s", value: effectiveWs, onChange: chooseWs, "aria-label": tt("header.wsAria") },
663
727
  workspaces.map(function (w, i) {
664
728
  return h("option", { key: w.path || i, value: w.path || "", disabled: !w.hasNovel },
665
- (w.hasNovel ? "" : "(无 novel)") + (w.name || w.path || "未命名"));
729
+ (w.hasNovel ? "" : tt("header.noNovel")) + (w.name || w.path || tt("header.untitled")));
666
730
  }))]
667
731
  : null,
668
- h("button", { type: "button", className: "myt-btn myt-btnIcon", title: "系统设置", "aria-label": "系统设置", onClick: function () { setShowSettings(true); } }, ic("IconSettingsOutlineRegular", 14)),
669
- h("button", { type: "button", className: "myt-btn myt-btnIcon", title: "重新拉取", "aria-label": "重新拉取", disabled: loading, onClick: reload }, ic("IconRefreshOutlineRegular", 14)),
670
- h("button", { type: "button", className: "myt-closeBtn", "aria-label": "关闭创作台", title: "关闭", onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")))),
671
- readerFile ? null : h("nav", { className: "myt-tabs", "aria-label": "创作台分区" },
732
+ h("button", { type: "button", className: "myt-btn myt-btnIcon", title: tt("header.settings"), "aria-label": tt("header.settings"), onClick: function () { setShowSettings(true); } }, ic("IconSettingsOutlineRegular", 14)),
733
+ h("button", { type: "button", className: "myt-btn myt-btnIcon", title: tt("header.refresh"), "aria-label": tt("header.refresh"), disabled: loading, onClick: reload }, ic("IconRefreshOutlineRegular", 14)),
734
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("header.closeAria"), title: tt("header.closeTitle"), onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")))),
735
+ readerFile ? null : h("nav", { className: "myt-tabs", "aria-label": tt("tabs.aria") },
672
736
  TABS.map(function (t) {
673
737
  return h("button", {
674
738
  key: t[0], type: "button", className: "myt-tab", "data-active": tab === t[0] ? "true" : undefined,
675
739
  onClick: function () { gotoTab(t[0]); },
676
- }, t[1]);
740
+ }, tt(t[1]));
677
741
  })),
678
742
  h("div", { className: "myt-body" },
679
743
  h("div", { className: "myt-page" },
680
744
  snap && Array.isArray(snap.warnings) && snap.warnings.length ? WarningsStrip(snap.warnings) : null,
681
745
  // 错误边界包住 tab 内容:key 随 tab / 阅读页变化重挂,切分区自动清错误态。
682
746
  h(TabErrorBoundary, { key: readerFile ? "reader:" + readerFile : tab, onRetry: reload }, body))),
683
- showSettings ? h(SettingsModal, { scale: fontScale, onPick: setFontScale, onClose: function () { setShowSettings(false); } }) : null,
747
+ showSettings ? h(SettingsModal, { scale: fontScale, onPick: setFontScale, lang: lang, onPickLang: setLang, onClose: function () { setShowSettings(false); } }) : null,
684
748
  h(ToastHost));
685
749
  }
686
750
 
@@ -689,21 +753,54 @@ window.__ModuleLoader__.load({
689
753
  return h("div", { className: "myt-strip myt-stripWarn" },
690
754
  h("span", { className: "myt-stripIcon" }, ic("IconWarningOutlineRegular", 14)),
691
755
  h("span", { className: "myt-stripText" },
692
- h("div", null, h("strong", null, "部分文件解析异常(已跳过):")),
756
+ h("div", null, h("strong", null, tt("warnings.title"))),
693
757
  warnings.map(function (w, i) { return h("div", { key: i }, "· " + String(w)); })));
694
758
  }
695
759
 
760
+ // 静态体检黄条:checkProject 的原始问题清单(快照侧会把越枚举 status
761
+ // 归一成 pending,人工改坏在快照里看不出来,这里照 CLI check 同源透出)。
762
+ // 含 error 级问题时叠加强调 class(纯 CSS,见 base.css)。
763
+ function CheckStrip(problems) {
764
+ var errs = 0;
765
+ for (var i = 0; i < problems.length; i++) { if (problems[i] && problems[i].level === "error") errs++; }
766
+ return h("div", { className: "myt-strip myt-stripWarn" + (errs > 0 ? " myt-stripCheckErr" : "") },
767
+ h("span", { className: "myt-stripIcon" }, ic("IconWarningOutlineRegular", 14)),
768
+ h("span", { className: "myt-stripText" },
769
+ h("div", null, h("strong", null, tf("check.title", { n: fmtNum(problems.length), err: fmtNum(errs), warn: fmtNum(problems.length - errs) }))),
770
+ problems.slice(0, 8).map(function (p, i) {
771
+ return h("div", { key: i }, "· [" + String((p && p.level) || "") + "] " + String((p && p.file) || "") + ": " + String((p && p.message) || ""));
772
+ }),
773
+ h("div", null, tt("check.fullHint"))));
774
+ }
775
+
696
776
  // ── 概览 tab:project 卡 + 统计卡 + 最近更新 ───────────────────────────
697
777
  function OverviewTab(props) {
698
778
  var snap = props.snap;
699
779
  var reload = props.reload;
700
780
  var gotoTab = props.gotoTab;
781
+ var wsPath = props.wsPath;
701
782
  var project = snap.project || {};
702
783
  var totals = snap.totals || {};
703
784
  var byStatus = totals.byStatus || {};
704
785
  var _b = useState(false), phaseBusy = _b[0], setPhaseBusy = _b[1];
705
786
  var phaseIdx = PHASES.indexOf(project.phase);
706
787
  var nextPhase = phaseIdx !== -1 && phaseIdx < PHASES.length - 1 ? PHASES[phaseIdx + 1] : null;
788
+ // 静态体检黄条:仅 found 时拉 check 路由(未初始化没有可查的东西)。
789
+ // 工作区切换与每次 reload 都换新 snap → effect 重跑即重查;请求序号防
790
+ // 过期响应(快速切工作区时在途旧响应不得落进新工作区的黄条)。
791
+ var _ck = useState(null), checkProblems = _ck[0], setCheckProblems = _ck[1];
792
+ var checkSeqRef = useRef(0);
793
+ useEffect(function () {
794
+ if (!snap || !snap.found || !wsPath) { setCheckProblems(null); return; }
795
+ var seq = ++checkSeqRef.current; // 先自增再发请求;早退分支也作废在途旧响应
796
+ apiGet("check", { path: wsPath }).then(function (d) {
797
+ if (seq !== checkSeqRef.current) return; // 过期响应:已有更新的请求在途,丢弃
798
+ setCheckProblems(d && d.ok && Array.isArray(d.problems) ? d.problems : []);
799
+ }).catch(function () {
800
+ if (seq !== checkSeqRef.current) return;
801
+ setCheckProblems(null); // 拉取失败不显示黄条(toast 已由 apiGet 统一提示)
802
+ });
803
+ }, [snap, wsPath]);
707
804
  function advancePhase() {
708
805
  if (!nextPhase || phaseBusy) return;
709
806
  setPhaseBusy(true);
@@ -711,46 +808,47 @@ window.__ModuleLoader__.load({
711
808
  }
712
809
  var recent = (snap.chapters || []).slice().sort(function (a, b) { return (b.num || 0) - (a.num || 0); }).slice(0, 5);
713
810
  return h("div", { className: "myt-stack" },
811
+ checkProblems && checkProblems.length ? CheckStrip(checkProblems) : null,
714
812
  h("div", { className: "myt-card myt-hero" },
715
813
  h("div", { className: "myt-heroMeta" },
716
- project.author ? h("span", { className: "myt-muted" }, "作者:" + project.author) : null,
814
+ project.author ? h("span", { className: "myt-muted" }, tf("overview.author", { name: project.author })) : null,
717
815
  project.genre ? h("span", { className: "myt-badge" }, project.genre) : null,
718
816
  project.length ? h("span", { className: "myt-badge" }, project.length) : null,
719
817
  project.language ? h("span", { className: "myt-badge" }, project.language) : null),
720
818
  h("h2", { className: "myt-heroTitle" }, orDash(project.title)),
721
819
  h("div", { className: "myt-heroMeta" },
722
820
  phasePill(project.phase),
723
- nextPhase ? h("button", { type: "button", className: "myt-btn", disabled: phaseBusy, onClick: advancePhase, title: "推进到「" + PHASE_LABEL[nextPhase] + "」" }, phaseBusy ? "推进中…" : "推进阶段") : h("span", { className: "myt-muted" }, project.phase ? "已是最终阶段" : null)),
821
+ nextPhase ? h("button", { type: "button", className: "myt-btn", disabled: phaseBusy, onClick: advancePhase, title: tf("phase.advanceTitle", { next: tt("phase." + nextPhase) }) }, phaseBusy ? tt("phase.advancing") : tt("phase.advance")) : h("span", { className: "myt-muted" }, project.phase ? tt("phase.final") : null)),
724
822
  project.description ? h("p", { className: "myt-heroDesc" }, project.description) : null),
725
823
  h("div", { className: "myt-statRow" },
726
- h("div", { className: "myt-card myt-stat" }, h("span", { className: "myt-statNum" }, fmtNum(totals.words)), h("span", { className: "myt-statLabel" }, "总字数(CJK)")),
727
- h("div", { className: "myt-card myt-stat" }, h("span", { className: "myt-statNum" }, fmtNum(totals.chapters)), h("span", { className: "myt-statLabel" }, "章节总数")),
824
+ h("div", { className: "myt-card myt-stat" }, h("span", { className: "myt-statNum" }, fmtNum(totals.words)), h("span", { className: "myt-statLabel" }, tt("overview.totalWords"))),
825
+ h("div", { className: "myt-card myt-stat" }, h("span", { className: "myt-statNum" }, fmtNum(totals.chapters)), h("span", { className: "myt-statLabel" }, tt("overview.totalChapters"))),
728
826
  h("div", { className: "myt-card myt-stat" },
729
827
  h("span", { className: "myt-statNum" }, fmtNum((snap.characters || []).length)),
730
- h("span", { className: "myt-statLabel" }, "角色数")),
828
+ h("span", { className: "myt-statLabel" }, tt("overview.characters"))),
731
829
  h("div", { className: "myt-card myt-stat" },
732
830
  h("span", { className: "myt-statNum" }, fmtNum((snap.foreshadows || []).filter(function (f) { return f.status === "planted" || f.status === "progressing"; }).length)),
733
- h("span", { className: "myt-statLabel" }, "未回收伏笔"))),
831
+ h("span", { className: "myt-statLabel" }, tt("overview.openForeshadows")))),
734
832
  h("div", { className: "myt-card" },
735
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "章节状态分布")),
833
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("overview.statusDist"))),
736
834
  h("div", { className: "myt-distBar", "aria-hidden": "true" }, STATUS_FLOW.map(function (s) {
737
835
  var n = byStatus[s] || 0;
738
836
  return n > 0 ? h("div", { key: s, className: "myt-distSeg", "data-status": s, style: { flexGrow: n } }) : null;
739
837
  })),
740
838
  h("div", { className: "myt-heroMeta" }, STATUS_FLOW.map(function (s) {
741
- return h("span", { key: s, className: "myt-muted" }, STATUS_LABEL[s] + " " + (byStatus[s] || 0));
839
+ return h("span", { key: s, className: "myt-muted" }, tt("status." + s) + " " + (byStatus[s] || 0));
742
840
  }))),
743
841
  h("div", { className: "myt-card" },
744
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "最近章节")),
842
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("overview.recent"))),
745
843
  recent.length
746
844
  ? h("div", { className: "myt-recentList" }, recent.map(function (ch) {
747
845
  return h("button", { key: ch.file, type: "button", className: "myt-recentRow", onClick: function () { gotoTab("chapters", ch.file); } },
748
846
  statusBadge(ch.status),
749
847
  h("span", { className: "myt-chTitle" }, orDash(ch.title)),
750
848
  h("span", { className: "myt-chPath" }, chWhere(ch)),
751
- h("span", { className: "myt-wordsCell" }, fmtNum(ch.words) + " 字"));
849
+ h("span", { className: "myt-wordsCell" }, tf("common.charCount", { n: fmtNum(ch.words) })));
752
850
  }))
753
- : h(Empty, { text: "chapters/ 目录还没有章节文件" })));
851
+ : h(Empty, { text: tt("chapters.empty") })));
754
852
  }
755
853
 
756
854
  // ── 轻量 Markdown 渲染(零依赖):先整体 HTML 转义再施加 md 变换,杜绝
@@ -857,28 +955,216 @@ window.__ModuleLoader__.load({
857
955
 
858
956
  // ── 系统设置:字号五档(面板整体 zoom,抽屉/弹层 portal 节点同缩放)──────
859
957
  var FONT_SCALE_KEY = "maoying.fontScale";
860
- var FONT_SCALES = [["1", "默认(100%)"], ["1.2", "大(120%)"], ["1.5", "更大(150%)"], ["1.8", "超大(180%)"], ["2", "巨大(200%)"]];
958
+ var FONT_SCALES = [["1", "100%"], ["1.1", "110%"], ["1.2", "120%"], ["1.3", "130%"], ["1.4", "140%"], ["1.5", "150%"]];
959
+ // ── 界面语言(系统设置可选中文/English):UI_LANG 为模块级当前语言,
960
+ // PageShell 在 lang state 变化时改写它并整树重渲。tt/tf 是唯一取词口:
961
+ // zh 词典的值必须与历史硬编码文案逐字一致(中文界面零回归),en 词典
962
+ // 不留中文;词典缺键回落 key 本身(便于渐进迁移,一眼看出漏网串)。 ──
963
+ var LANG_KEY = "maoying.lang";
964
+ var UI_LANGS = [["zh", "中文"], ["en", "English"]];
965
+ var UI_LANG = "zh";
966
+ var I18N = {
967
+ zh: {
968
+ "shell.title": "毛颖创作台", "header.subtitle": "网文项目可视化与状态流转 · 只读正文,写状态",
969
+ "header.curVerTitle": "当前版本", "header.upgradeTip": "当前 {cur},点击打开新版发布页", "header.newVersion": "有新版 {ver} ↗",
970
+ "header.upgradeBtn": "一键升级", "header.upgrading": "安装中…", "header.upgraded": "已安装,待重启",
971
+ "header.upgradeTitle": "用 pnpm 安装 npm 最新版(最长约 3 分钟),装完需重启 DeepSeek 生效",
972
+ "header.upgradedTitle": "已安装新版,重启 DeepSeek 后生效",
973
+ "toast.selfUpdate.ok": "已安装 {ver},重启 DeepSeek 后生效",
974
+ "toast.selfUpdate.devLink": "开发目录安装,请 git pull 更新", "toast.selfUpdate.pnpmMissing": "未找到 pnpm,请手动安装",
975
+ "header.workspace": "工作区", "header.wsAria": "选择工作区", "header.noNovel": "(无 novel)", "header.untitled": "未命名",
976
+ "header.settings": "系统设置", "header.refresh": "重新拉取", "header.closeAria": "关闭创作台", "header.closeTitle": "关闭",
977
+ "tabs.aria": "创作台分区",
978
+ "tabs.overview": "概览", "tabs.chapters": "章节", "tabs.characters": "角色", "tabs.foreshadows": "伏笔", "tabs.relations": "关系图",
979
+ "tabs.timeline": "时间线", "tabs.world": "设定", "tabs.index": "索引板", "tabs.settings": "小说设置",
980
+ "shell.wsListFailed": "工作区列表拉取失败:{msg}", "shell.stateFailed": "快照拉取失败:{msg}",
981
+ "guide.noWsTitle": "还没有可用的小说工作区", "guide.noWsDesc": "任一工作区根目录下出现 novel/project.yml 即自动入选;先用命令初始化目录骨架:",
982
+ "guide.notInitTitle": "该工作区尚未初始化小说项目", "guide.notInitDesc": "工作区根目录下没有 novel/project.yml。在该工作区根目录执行:",
983
+ "guide.defaultTitle": "该工作区还没有小说项目", "guide.defaultDesc": "在下面给出的命令里初始化目录骨架(或把已有 novel 目录放进工作区根),然后刷新本面板。",
984
+ "guide.copied": "已复制 maoying init", "guide.copyFailed": "复制失败,请手动选择命令文本", "guide.copying": "复制中…", "guide.copyCmd": "复制命令",
985
+ "common.retry": "重试", "common.empty": "暂无数据", "common.uncategorized": "未分类", "common.charCount": "{n} 字",
986
+ "common.closeTip": "关闭提示", "common.unknownErr": "未知错误", "common.priorityTitle": "优先级:{pri}", "common.priorityAria": "优先级{pri}",
987
+ "common.chapterNum": "第 {n} 章", "pri.high": "高", "pri.normal": "中", "pri.low": "低",
988
+ "toast.prefix": "毛颖创作台:{msg}", "toast.success": "操作成功", "toast.indexRebuilt": "索引已重建({n} 章)",
989
+ "toast.chapterStatus": "章节状态已更新", "toast.foreshadowStatus": "伏笔状态已更新", "toast.phaseAdvanced": "阶段已推进到「{phase}」",
990
+ "api.notJson": "响应不是 JSON(HTTP {code})", "api.timeout": "请求超时({s} 秒)",
991
+ "status.pending": "待写", "status.writing": "写作中", "status.review": "审校中", "status.accepted": "已定稿", "status.rawSuffix": "({raw})",
992
+ "phase.idea": "立意", "phase.setting": "设定", "phase.outline": "大纲", "phase.writing": "写作", "phase.review": "审校",
993
+ "phase.consistency": "一致性", "phase.export": "导出", "phase.unset": "未设阶段",
994
+ "phase.advanceTitle": "推进到「{next}」", "phase.advance": "推进阶段", "phase.advancing": "推进中…", "phase.final": "已是最终阶段",
995
+ "foreshadow.planted": "埋设", "foreshadow.progressing": "推进中", "foreshadow.resolved": "已回收", "foreshadow.abandoned": "已弃用",
996
+ "foreshadow.advance": "推进", "foreshadow.resolve": "回收", "foreshadow.abandon": "弃用", "foreshadow.advancing": "推进中…",
997
+ "foreshadow.plantedUnmarked": "埋设章未标", "foreshadow.resolvedAt": "回收于 {ch}", "foreshadow.priorityLabel": "优先级 {pri}",
998
+ "foreshadow.backfill": "回填", "foreshadow.backfillTip": "从作者正文回填", "foreshadow.missingStatus": "缺 status",
999
+ "foreshadow.noIdTip": "该条目缺 id,无法写状态",
1000
+ "foreshadow.noStatusTip": "该条目缺 status 行,服务端会拒绝流转(409);请先在 foreshadows.yml 补上 status",
1001
+ "foreshadow.empty": "foreshadows.yml 还没有伏笔条目", "foreshadow.colEmpty": "空",
1002
+ "clue.clue": "线索", "clue.redHerring": "红鲱鱼", "clue.inference": "推理", "clue.question": "疑问",
1003
+ "sci.known": "已确证", "sci.extrapolation": "推演", "sci.hypothesis": "假说", "world.unlabeled": "未标级",
1004
+ "warnings.title": "部分文件解析异常(已跳过):",
1005
+ "check.title": "静态体检:{n} 项(错误 {err} / 警告 {warn})", "check.fullHint": "全量结果请在终端运行 maoying check 查看",
1006
+ "index.title": "索引板(clue-board / memories)", "index.rebuild": "重建索引", "index.rebuilding": "重建中…",
1007
+ "index.rebuildTip": "按 CLI index 同一逻辑重建 index.yml", "index.clueBoard": "线索板", "index.clueEmpty": "clue-board.yml 还没有条目",
1008
+ "index.memBoard": "记忆板", "index.memEmpty": "memories.yml 还没有条目", "index.promise": "承诺线",
1009
+ "timeline.empty": "timeline.yml 还没有时间线条目", "timeline.importance": "重要度 {n}", "timeline.linkedChapter": "关联章节:{ch}",
1010
+ "world.title": "世界观条目", "world.empty": "world/ 目录还没有设定条目", "world.science": "科学设定",
1011
+ "world.scienceEmpty": "science/ 目录还没有科学设定", "world.research": "资料检索", "world.ref": "参考 {n}:{r}",
1012
+ "relations.title": "角色关系图", "relations.counts": "{n} 节点 · {m} 关系",
1013
+ "relations.help": "拖节点固定位置 · 滚轮缩放 · 双击复位 · 悬停边看关系描述",
1014
+ "relations.unlinkedTitle": "未入图角色(无关系边)", "relations.noData": "没有角色或关系数据,无法绘图",
1015
+ "relations.edgeTipPrefix": "{type}:", "relations.noDesc": "(无描述)",
1016
+ "char.empty": "characters/ 目录还没有角色卡",
1017
+ "char.field.age": "年龄", "char.field.gender": "性别", "char.field.appearance": "外貌", "char.field.personality": "性格",
1018
+ "char.field.background": "背景", "char.field.motivation": "动机", "char.field.arc": "成长弧",
1019
+ "char.years": "{n} 岁", "char.linePersonality": "性格 ", "char.lineMotivation": "动机 ",
1020
+ "char.modalAria": "角色详情", "char.closeAria": "关闭角色卡", "char.markers": "标记", "char.file": "文件",
1021
+ "overview.author": "作者:{name}", "overview.totalWords": "总字数(CJK)", "overview.totalChapters": "章节总数",
1022
+ "overview.characters": "角色数", "overview.openForeshadows": "未回收伏笔", "overview.statusDist": "章节状态分布", "overview.recent": "最近章节",
1023
+ "chapters.empty": "chapters/ 目录还没有章节文件", "chapters.writeQueue": "按单写作", "chapters.rowTip": "点击打开该章",
1024
+ "chapters.outlineWords": "章纲 {n} 字", "chapters.all": "全部", "chapters.thVolume": "卷 · 章", "chapters.thTitle": "标题",
1025
+ "chapters.thStatus": "状态", "chapters.thWords": "字数", "chapters.thSummary": "梗概", "chapters.rowReadTip": "点击阅读正文",
1026
+ "chapters.emptyFiltered": "该状态下没有章节",
1027
+ "reader.back": "返回", "reader.backTip": "返回章节列表", "reader.copyPath": "复制路径", "reader.copyPathTip": "复制章节文件路径",
1028
+ "reader.copied": "已复制章节文件路径", "reader.copyFailed": "复制失败,请手动选择路径文本", "reader.loadFailed": "正文读取失败:{msg}",
1029
+ "reader.noOutline": "暂无章纲", "reader.emptyBody": "(空章节)", "reader.finalState": "已定稿(终态)", "reader.statusFlow": "状态流转:",
1030
+ "reader.flowing": "流转中…", "reader.advanceTo": "推进到「{next}」",
1031
+ "outline.title": "章纲对照", "outline.empty": "(章纲为空)", "outline.collapseTip": "收起章纲", "outline.expandTip": "展开章纲",
1032
+ "novel.metaTitle": "书稿元信息", "novel.metaNote": "对应 novel/project.yml · 行尾注释会原样保留",
1033
+ "novel.fTitle": "书名", "novel.titlePh": "如:长夜余火", "novel.fAuthor": "作者", "novel.authorPh": "笔名",
1034
+ "novel.fGenre": "题材", "novel.genrePh": "如:东方玄幻 / 都市异能 / 历史架空", "novel.fLength": "预期篇幅",
1035
+ "novel.lengthPh": "如:约100万字 / 300章", "novel.fLanguage": "正文语言", "novel.fPhase": "工作流阶段", "novel.fMode": "共创模式",
1036
+ "novel.fDesc": "一句话简介", "novel.descPh": "写封面文案时可直接引用", "novel.titleRequired": "书名不能为空",
1037
+ "mode.aiDraft": "AI 代笔(AI 写正文)", "mode.coWrite": "逐章共创(按章指派谁写)", "mode.authorDraft": "作者亲笔(AI 只备料审稿)",
1038
+ "novel.saving": "保存中…", "novel.save": "保存设置",
1039
+ "settings.title": "系统设置", "settings.closeAria": "关闭设置", "settings.language": "语言",
1040
+ "settings.languageHint": "界面文字随语言选项切换,立即生效并记忆。", "settings.fontScale": "字号",
1041
+ "settings.fontScaleHint": "按当前字号的百分比缩放整个创作台,立即生效并记忆。",
1042
+ "errBoundary.title": "该分区渲染出错", "errBoundary.summary": "错误摘要:{msg}",
1043
+ "dim.cognitiveFrame": "认知框架", "dim.emotionalAnchor": "情感锚点", "dim.worldTexture": "世界质感",
1044
+ "dim.suspense": "悬念", "dim.interpersonalTension": "人际张力",
1045
+ },
1046
+ en: {
1047
+ "shell.title": "Maoying Studio", "header.subtitle": "Web-novel project dashboard & status flow · read-only text, writable status",
1048
+ "header.curVerTitle": "Current version", "header.upgradeTip": "Current {cur} — click to open the release page", "header.newVersion": "New version {ver} ↗",
1049
+ "header.upgradeBtn": "Upgrade now", "header.upgrading": "Installing…", "header.upgraded": "Installed — restart pending",
1050
+ "header.upgradeTitle": "Install the latest npm release with pnpm (up to ~3 min); takes effect after restarting DeepSeek",
1051
+ "header.upgradedTitle": "New version installed — takes effect after restarting DeepSeek",
1052
+ "toast.selfUpdate.ok": "Installed {ver} — takes effect after restarting DeepSeek",
1053
+ "toast.selfUpdate.devLink": "Installed from a dev directory — update with git pull", "toast.selfUpdate.pnpmMissing": "pnpm not found — install it manually",
1054
+ "header.workspace": "Workspace", "header.wsAria": "Select workspace", "header.noNovel": "(no novel)", "header.untitled": "Untitled",
1055
+ "header.settings": "Settings", "header.refresh": "Refresh", "header.closeAria": "Close Maoying Studio", "header.closeTitle": "Close",
1056
+ "tabs.aria": "Studio sections",
1057
+ "tabs.overview": "Overview", "tabs.chapters": "Chapters", "tabs.characters": "Characters", "tabs.foreshadows": "Foreshadowing", "tabs.relations": "Relations",
1058
+ "tabs.timeline": "Timeline", "tabs.world": "Worldbuilding", "tabs.index": "Index Board", "tabs.settings": "Novel Settings",
1059
+ "shell.wsListFailed": "Failed to load workspace list: {msg}", "shell.stateFailed": "Failed to load snapshot: {msg}",
1060
+ "guide.noWsTitle": "No novel workspace available yet", "guide.noWsDesc": "A workspace is picked up automatically once novel/project.yml exists at its root; initialize the directory scaffold with this command first:",
1061
+ "guide.notInitTitle": "No novel project in this workspace yet", "guide.notInitDesc": "novel/project.yml is missing at the workspace root. Run this at the workspace root:",
1062
+ "guide.defaultTitle": "No novel project in this workspace yet", "guide.defaultDesc": "Initialize the directory scaffold with the command below (or drop an existing novel/ directory into the workspace root), then refresh this panel.",
1063
+ "guide.copied": "Copied maoying init", "guide.copyFailed": "Copy failed — select the command text manually", "guide.copying": "Copying…", "guide.copyCmd": "Copy command",
1064
+ "common.retry": "Retry", "common.empty": "No data", "common.uncategorized": "Uncategorized", "common.charCount": "{n} chars",
1065
+ "common.closeTip": "Dismiss notification", "common.unknownErr": "Unknown error", "common.priorityTitle": "Priority: {pri}", "common.priorityAria": "Priority {pri}",
1066
+ "common.chapterNum": "Ch. {n}", "pri.high": "High", "pri.normal": "Normal", "pri.low": "Low",
1067
+ "toast.prefix": "Maoying Studio: {msg}", "toast.success": "Success", "toast.indexRebuilt": "Index rebuilt ({n} chapters)",
1068
+ "toast.chapterStatus": "Chapter status updated", "toast.foreshadowStatus": "Foreshadowing status updated", "toast.phaseAdvanced": "Phase advanced to {phase}",
1069
+ "api.notJson": "Response is not JSON (HTTP {code})", "api.timeout": "Request timed out ({s} s)",
1070
+ "status.pending": "Pending", "status.writing": "Writing", "status.review": "Review", "status.accepted": "Accepted", "status.rawSuffix": "({raw})",
1071
+ "phase.idea": "Idea", "phase.setting": "Setting", "phase.outline": "Outline", "phase.writing": "Writing", "phase.review": "Review",
1072
+ "phase.consistency": "Consistency", "phase.export": "Export", "phase.unset": "No phase",
1073
+ "phase.advanceTitle": "Advance to {next}", "phase.advance": "Advance phase", "phase.advancing": "Advancing…", "phase.final": "Final phase",
1074
+ "foreshadow.planted": "Planted", "foreshadow.progressing": "In Progress", "foreshadow.resolved": "Resolved", "foreshadow.abandoned": "Abandoned",
1075
+ "foreshadow.advance": "Advance", "foreshadow.resolve": "Resolve", "foreshadow.abandon": "Abandon", "foreshadow.advancing": "Advancing…",
1076
+ "foreshadow.plantedUnmarked": "Planting chapter unmarked", "foreshadow.resolvedAt": "Resolved in {ch}", "foreshadow.priorityLabel": "Priority {pri}",
1077
+ "foreshadow.backfill": "Backfill", "foreshadow.backfillTip": "Backfilled from the author's manuscript", "foreshadow.missingStatus": "missing status",
1078
+ "foreshadow.noIdTip": "This entry has no id — its status cannot be written",
1079
+ "foreshadow.noStatusTip": "This entry has no status line, the server will reject the transition (409); add a status in foreshadows.yml first",
1080
+ "foreshadow.empty": "No entries in foreshadows.yml yet", "foreshadow.colEmpty": "Empty",
1081
+ "clue.clue": "Clue", "clue.redHerring": "Red herring", "clue.inference": "Inference", "clue.question": "Question",
1082
+ "sci.known": "Confirmed", "sci.extrapolation": "Extrapolation", "sci.hypothesis": "Hypothesis", "world.unlabeled": "Unrated",
1083
+ "warnings.title": "Some files failed to parse (skipped):",
1084
+ "check.title": "Static check: {n} issue(s) ({err} errors / {warn} warnings)", "check.fullHint": "Run maoying check in a terminal for the full report",
1085
+ "index.title": "Index Board (clue-board / memories)", "index.rebuild": "Rebuild index", "index.rebuilding": "Rebuilding…",
1086
+ "index.rebuildTip": "Rebuild index.yml with the same logic as the CLI index command", "index.clueBoard": "Clue board", "index.clueEmpty": "No entries in clue-board.yml yet",
1087
+ "index.memBoard": "Memory board", "index.memEmpty": "No entries in memories.yml yet", "index.promise": "Promise thread",
1088
+ "timeline.empty": "No entries in timeline.yml yet", "timeline.importance": "Importance {n}", "timeline.linkedChapter": "Related chapter: {ch}",
1089
+ "world.title": "Worldbuilding entries", "world.empty": "No worldbuilding entries in world/ yet", "world.science": "Science settings",
1090
+ "world.scienceEmpty": "No science entries in science/ yet", "world.research": "Research", "world.ref": "Ref {n}: {r}",
1091
+ "relations.title": "Character relations", "relations.counts": "{n} nodes · {m} relations",
1092
+ "relations.help": "Drag a node to pin it · scroll to zoom · double-click to reset · hover an edge for its description",
1093
+ "relations.unlinkedTitle": "Unlinked characters (no relation edges)", "relations.noData": "No character or relation data to draw",
1094
+ "relations.edgeTipPrefix": "{type}: ", "relations.noDesc": "(no description)",
1095
+ "char.empty": "No character cards in characters/ yet",
1096
+ "char.field.age": "Age", "char.field.gender": "Gender", "char.field.appearance": "Appearance", "char.field.personality": "Personality",
1097
+ "char.field.background": "Background", "char.field.motivation": "Motivation", "char.field.arc": "Character arc",
1098
+ "char.years": "Age {n}", "char.linePersonality": "Personality ", "char.lineMotivation": "Motivation ",
1099
+ "char.modalAria": "Character details", "char.closeAria": "Close character card", "char.markers": "Tags", "char.file": "File",
1100
+ "overview.author": "Author: {name}", "overview.totalWords": "Total characters (CJK)", "overview.totalChapters": "Chapters",
1101
+ "overview.characters": "Characters", "overview.openForeshadows": "Unresolved foreshadowing", "overview.statusDist": "Chapter status breakdown", "overview.recent": "Recent chapters",
1102
+ "chapters.empty": "No chapter files in chapters/ yet", "chapters.writeQueue": "Writing Queue", "chapters.rowTip": "Click to open this chapter",
1103
+ "chapters.outlineWords": "Outline {n} chars", "chapters.all": "All", "chapters.thVolume": "Vol · Ch", "chapters.thTitle": "Title",
1104
+ "chapters.thStatus": "Status", "chapters.thWords": "Words", "chapters.thSummary": "Summary", "chapters.rowReadTip": "Click to read",
1105
+ "chapters.emptyFiltered": "No chapters with this status",
1106
+ "reader.back": "Back", "reader.backTip": "Back to chapter list", "reader.copyPath": "Copy path", "reader.copyPathTip": "Copy chapter file path",
1107
+ "reader.copied": "Copied chapter file path", "reader.copyFailed": "Copy failed — select the path text manually", "reader.loadFailed": "Failed to load chapter text: {msg}",
1108
+ "reader.noOutline": "No chapter outline", "reader.emptyBody": "(empty chapter)", "reader.finalState": "Accepted (final)", "reader.statusFlow": "Status:",
1109
+ "reader.flowing": "Transitioning…", "reader.advanceTo": "Advance to {next}",
1110
+ "outline.title": "Outline", "outline.empty": "(outline is empty)", "outline.collapseTip": "Collapse outline", "outline.expandTip": "Expand outline",
1111
+ "novel.metaTitle": "Manuscript metadata", "novel.metaNote": "Maps to novel/project.yml · trailing comments are preserved",
1112
+ "novel.fTitle": "Title", "novel.titlePh": "e.g. The Long Night", "novel.fAuthor": "Author", "novel.authorPh": "Pen name",
1113
+ "novel.fGenre": "Genre", "novel.genrePh": "e.g. Eastern fantasy / Urban powers / Alternate history", "novel.fLength": "Planned length",
1114
+ "novel.lengthPh": "e.g. ~1M characters / 300 chapters", "novel.fLanguage": "Text language", "novel.fPhase": "Workflow phase", "novel.fMode": "Co-writing mode",
1115
+ "novel.fDesc": "One-line synopsis", "novel.descPh": "Can be quoted directly in cover copy", "novel.titleRequired": "Title is required",
1116
+ "mode.aiDraft": "AI ghostwriting (AI drafts the prose)", "mode.coWrite": "Chapter-by-chapter co-writing (assign a writer per chapter)", "mode.authorDraft": "Author writes (AI only preps and reviews)",
1117
+ "novel.saving": "Saving…", "novel.save": "Save",
1118
+ "settings.title": "Settings", "settings.closeAria": "Close settings", "settings.language": "Language",
1119
+ "settings.languageHint": "UI text follows the language option; it applies immediately and is remembered.", "settings.fontScale": "Font size",
1120
+ "settings.fontScaleHint": "Scales the whole studio as a percentage of the current font size; applies immediately and is remembered.",
1121
+ "errBoundary.title": "This section failed to render", "errBoundary.summary": "Error summary: {msg}",
1122
+ "dim.cognitiveFrame": "Cognitive Frame", "dim.emotionalAnchor": "Emotional Anchor", "dim.worldTexture": "World Texture",
1123
+ "dim.suspense": "Suspense", "dim.interpersonalTension": "Interpersonal Tension",
1124
+ },
1125
+ };
1126
+ function tt(key) {
1127
+ var d = I18N[UI_LANG] || I18N.zh;
1128
+ var v = d[key];
1129
+ if (v === undefined) v = I18N.zh[key];
1130
+ return v === undefined ? key : v;
1131
+ }
1132
+ function tf(key, vars) {
1133
+ var s = tt(key);
1134
+ if (vars) for (var k in vars) s = s.split("{" + k + "}").join(String(vars[k]));
1135
+ return s;
1136
+ }
861
1137
  function SettingsModal(props) {
862
1138
  var scale = props.scale;
863
1139
  var onPick = props.onPick;
1140
+ var lang = props.lang;
1141
+ var onPickLang = props.onPickLang;
864
1142
  var onClose = props.onClose;
865
1143
  return h(MaskPortal, {
866
1144
  className: "myt-settingsMask",
867
1145
  onMouseDown: function (e) { if (e.target === e.currentTarget) onClose(); },
868
1146
  },
869
- h("div", { className: "myt-settings", role: "dialog", "aria-modal": "true", "aria-label": "系统设置" },
1147
+ h("div", { className: "myt-settings", role: "dialog", "aria-modal": "true", "aria-label": tt("settings.title") },
870
1148
  h("div", { className: "myt-settingsHead" },
871
- h("h3", { className: "myt-settingsTitle" }, "系统设置"),
872
- h("button", { type: "button", className: "myt-closeBtn", "aria-label": "关闭设置", onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
1149
+ h("h3", { className: "myt-settingsTitle" }, tt("settings.title")),
1150
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("settings.closeAria"), onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
873
1151
  h("div", { className: "myt-settingsBody" },
874
- h("div", { className: "myt-settingsLabel" }, "字号"),
1152
+ h("div", { className: "myt-settingsLabel" }, tt("settings.language")),
1153
+ h("div", { className: "myt-setRow" }, UI_LANGS.map(function (o) {
1154
+ return h("button", {
1155
+ key: o[0], type: "button", className: "myt-setOpt", "data-active": lang === o[0] ? "true" : undefined,
1156
+ onClick: function () { onPickLang(o[0]); },
1157
+ }, o[1]);
1158
+ })),
1159
+ h("div", { className: "myt-settingsHint" }, tt("settings.languageHint")),
1160
+ h("div", { className: "myt-settingsLabel" }, tt("settings.fontScale")),
875
1161
  h("div", { className: "myt-setRow" }, FONT_SCALES.map(function (o) {
876
1162
  return h("button", {
877
1163
  key: o[0], type: "button", className: "myt-setOpt", "data-active": scale === o[0] ? "true" : undefined,
878
1164
  onClick: function () { onPick(o[0]); },
879
1165
  }, o[1]);
880
1166
  })),
881
- h("div", { className: "myt-settingsHint" }, "按当前字号的百分比缩放整个创作台,立即生效并记忆。"))));
1167
+ h("div", { className: "myt-settingsHint" }, tt("settings.fontScaleHint")))));
882
1168
  }
883
1169
 
884
1170
  // ── 章节阅读页(整页视图,替代右侧抽屉):返回按钮 + 章纲对照 + 正文 +
@@ -922,32 +1208,32 @@ window.__ModuleLoader__.load({
922
1208
  }
923
1209
  function copyChapterPath() {
924
1210
  copyText(chapterAbsPath(snap && snap.novelDir, file)).then(function (ok) {
925
- if (ok) toast.ok("已复制章节文件路径");
926
- else toast.warn("复制失败,请手动选择路径文本");
1211
+ if (ok) toast.ok(tt("reader.copied"));
1212
+ else toast.warn(tt("reader.copyFailed"));
927
1213
  });
928
1214
  }
929
1215
  return h("div", { className: "myt-reader" },
930
1216
  h("div", { className: "myt-readerBar" },
931
- h("button", { type: "button", className: "myt-btn", onClick: onBack, title: "返回章节列表" },
932
- ic("IconChevronRightOutlineRegular", 13), "返回"),
1217
+ h("button", { type: "button", className: "myt-btn", onClick: onBack, title: tt("reader.backTip") },
1218
+ ic("IconChevronRightOutlineRegular", 13), tt("reader.back")),
933
1219
  h("h2", { className: "myt-readerTitle" }, ch ? chFullTitle(ch) : file),
934
1220
  ch ? statusBadge(ch.status) : null,
935
1221
  h("div", { className: "myt-readerBarActions" },
936
1222
  h("button", {
937
1223
  type: "button", className: "myt-btn myt-copyBtn",
938
- title: "复制章节文件路径", "aria-label": "复制章节文件路径",
1224
+ title: tt("reader.copyPathTip"), "aria-label": tt("reader.copyPathTip"),
939
1225
  onClick: copyChapterPath,
940
- }, ic("IconCopyOutlineRegular", 13), "复制路径"))),
1226
+ }, ic("IconCopyOutlineRegular", 13), tt("reader.copyPath")))),
941
1227
  h("div", { className: "myt-readerBody" },
942
1228
  !detail || detail.loading ? h("div", { className: "myt-stack" }, h("div", { className: "myt-skel myt-skelRow" }), h("div", { className: "myt-skel myt-skelRow", style: { width: "80%" } }), h("div", { className: "myt-skel myt-skelRow", style: { width: "64%" } }))
943
- : detail.err ? h(ErrorStrip, { text: "正文读取失败:" + detail.err, onRetry: function () { loadDetail(file); } })
1229
+ : detail.err ? h(ErrorStrip, { text: tf("reader.loadFailed", { msg: detail.err }), onRetry: function () { loadDetail(file); } })
944
1230
  : [
945
1231
  (function () {
946
1232
  var ol = detail.data && detail.data.outline;
947
1233
  return ol ? h(OutlinePanel, { key: "outline", outline: ol })
948
- : h("div", { key: "outline-none", className: "myt-outlineEmpty" }, "暂无章纲");
1234
+ : h("div", { key: "outline-none", className: "myt-outlineEmpty" }, tt("reader.noOutline"));
949
1235
  })(),
950
- h("pre", { key: "body", className: "myt-chapterBody" }, (detail.data && detail.data.body) || "(空章节)"),
1236
+ h("pre", { key: "body", className: "myt-chapterBody" }, (detail.data && detail.data.body) || tt("reader.emptyBody")),
951
1237
  ],
952
1238
  ch ? h("div", { className: "myt-dimGrid" }, FIVE_DIMS.map(function (dim) {
953
1239
  return h("div", { key: dim[0], className: "myt-dim" },
@@ -958,43 +1244,84 @@ window.__ModuleLoader__.load({
958
1244
  (function () {
959
1245
  var idx = STATUS_FLOW.indexOf(ch.status);
960
1246
  var next = idx !== -1 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null;
961
- if (!next) return h("span", { className: "myt-muted" }, "已定稿(终态)");
962
- return [h("span", { className: "myt-muted", key: "t" }, "状态流转:"),
1247
+ if (!next) return h("span", { className: "myt-muted" }, tt("reader.finalState"));
1248
+ return [h("span", { className: "myt-muted", key: "t" }, tt("reader.statusFlow")),
963
1249
  h("button", { key: "b", type: "button", className: "myt-btn myt-btnPrimary", disabled: flowBusy, onClick: advance },
964
- flowBusy ? "流转中…" : "推进到「" + STATUS_LABEL[next] + "」")];
1250
+ flowBusy ? tt("reader.flowing") : tf("reader.advanceTo", { next: tt("status." + next) }))];
965
1251
  })()) : null);
966
1252
  }
967
1253
 
968
1254
  // ── 小说设置 tab:可视化 project.yml 平键并保存。mode/phase 走枚举
969
1255
  // 下拉,自由文本走输入框;保存走 project-save 路由(服务端校验 + 保
970
1256
  // 注释改写),成功后重拉快照让概览同步。 ──
1257
+ // 表单初值(与磁盘真值同源):首次载入与 409 重置共用同一形状。
1258
+ function settingsFormOf(project) {
1259
+ project = project || {};
1260
+ return {
1261
+ title: project.title || "", author: project.author || "", genre: project.genre || "",
1262
+ length: project.length || "", language: project.language || "zh",
1263
+ phase: PHASES.indexOf(project.phase) !== -1 ? project.phase : "idea",
1264
+ mode: project.mode || "ai-draft",
1265
+ description: project.description || "",
1266
+ };
1267
+ }
1268
+ // 快照 root 与工作区 path 同源但形态可能有差(尾斜杠等),比对前归一。
1269
+ function normRoot(s) { return String(s || "").replace(/[\\/]+$/, ""); }
971
1270
  function SettingsTab(props) {
972
1271
  var snap = props.snap;
973
1272
  var wsPath = props.wsPath;
974
1273
  var reload = props.reload;
975
1274
  var project = snap.project || {};
976
- var _f = useState(function () {
977
- return {
978
- title: project.title || "", author: project.author || "", genre: project.genre || "",
979
- length: project.length || "", language: project.language || "zh",
980
- phase: PHASES.indexOf(project.phase) !== -1 ? project.phase : "idea",
981
- mode: project.mode || "ai-draft",
982
- description: project.description || "",
983
- };
984
- }), form = _f[0], setForm = _f[1];
1275
+ var _f = useState(function () { return settingsFormOf(project); }), form = _f[0], setForm = _f[1];
1276
+ // 保存基线:表单载入时的 project.yml 指纹(snap.project_hash)。服务端
1277
+ // 拿它检测表单载入后的外部改动(作者手改 / agent 推进 phase),命中即
1278
+ // 409 拒写——没有它,保存会静默覆盖同名键、丢掉人工改动。
1279
+ var baseHashRef = useRef(snap && typeof snap.project_hash === "string" ? snap.project_hash : null);
1280
+ // 409 后的一次性重置标记:reload 带回新快照时把表单与基线放回磁盘真值。
1281
+ // 只在保存失败路径置位——顶栏刷新等其他 reload 不碰正在编辑的表单。
1282
+ var pendingResetRef = useRef(false);
1283
+ useEffect(function () {
1284
+ var hash = snap && typeof snap.project_hash === "string" ? snap.project_hash : null;
1285
+ if (pendingResetRef.current) {
1286
+ pendingResetRef.current = false;
1287
+ setForm(settingsFormOf(snap && snap.project));
1288
+ baseHashRef.current = hash;
1289
+ return;
1290
+ }
1291
+ // 空闲跟随:快照确属当前工作区(root 对得上,跨工作区切换的在途旧
1292
+ // 快照不认)且表单未被用户动过(与磁盘逐键同值)时,把基线对齐本快照
1293
+ // ——本组件不随工作区切换重挂,基线若还攥着旧工作区的 hash,保存必被
1294
+ // 自家 409 拦住(假冲突)。用户已编辑则不打扰,外部改动由保存 409 兜底。
1295
+ if (!snap || !snap.found || normRoot(snap.root) !== normRoot(wsPath)) return;
1296
+ var disk = settingsFormOf(snap.project);
1297
+ for (var k in disk) {
1298
+ if (String(form[k]) !== String(disk[k])) return;
1299
+ }
1300
+ baseHashRef.current = hash;
1301
+ }, [snap, wsPath]);
985
1302
  var _b = useState(false), busy = _b[0], setBusy = _b[1];
986
1303
  var _e = useState(""), saveErr = _e[0], setSaveErr = _e[1];
987
1304
  function set(k, v) { setForm(Object.assign({}, form, (function () { var o = {}; o[k] = v; return o; })())); }
988
1305
  function save() {
989
1306
  if (busy) return;
990
- if (!String(form.title || "").trim()) { setSaveErr("书名不能为空"); return; }
1307
+ if (!String(form.title || "").trim()) { setSaveErr(tt("novel.titleRequired")); return; }
991
1308
  setBusy(true); setSaveErr("");
992
1309
  // 成败提示统一走 apiPost 的 toast 通道(这里不再另弹,避免双 toast);
993
1310
  // 卡内 saveErr 只承担表单旁的错误态。
994
- apiPost("project-save", { path: wsPath, values: form }).then(function () {
1311
+ apiPost("project-save", { path: wsPath, values: form, base_hash: baseHashRef.current }).then(function (res) {
1312
+ // 写后指纹即下一轮基线:不等 reload 快照就先接住——连续两次保存时
1313
+ // 第二次若还回传载入时的旧指纹,会被自家 409 拦住(假冲突)。
1314
+ if (res && typeof res.project_hash === "string") baseHashRef.current = res.project_hash;
995
1315
  return reload();
996
1316
  }).catch(function (err) {
997
1317
  setSaveErr(err.message);
1318
+ // 409 = project.yml 在表单载入后被外部改过:重拉快照并一次性把表单
1319
+ // 放回磁盘真值,别让用户在过期基线上继续盲改。其他失败(网络抖动
1320
+ // 等)不动表单,保留正在编辑的内容。
1321
+ if (err && err.status === 409) {
1322
+ pendingResetRef.current = true;
1323
+ reload();
1324
+ }
998
1325
  }).finally(function () { setBusy(false); });
999
1326
  }
1000
1327
  function field(label, key, placeholder, textarea) {
@@ -1017,27 +1344,27 @@ window.__ModuleLoader__.load({
1017
1344
  return h("div", { className: "myt-stack" },
1018
1345
  h("div", { className: "myt-card" },
1019
1346
  h("div", { className: "myt-cardHead" },
1020
- h("h3", { className: "myt-cardTitle" }, "书稿元信息"),
1021
- h("span", { className: "myt-muted" }, "对应 novel/project.yml · 行尾注释会原样保留")),
1347
+ h("h3", { className: "myt-cardTitle" }, tt("novel.metaTitle")),
1348
+ h("span", { className: "myt-muted" }, tt("novel.metaNote"))),
1022
1349
  h("div", { className: "myt-setGrid" },
1023
- field("书名", "title", "如:长夜余火"),
1024
- field("作者", "author", "笔名"),
1025
- field("题材", "genre", "如:东方玄幻 / 都市异能 / 历史架空"),
1026
- field("预期篇幅", "length", "如:约100万字 / 300章"),
1027
- picker("正文语言", "language", [["zh", "中文"], ["en", "English"], ["ja", "日本語"]]),
1028
- picker("工作流阶段", "phase", PHASES.map(function (p) { return [p, PHASE_LABEL[p] || p]; })),
1029
- picker("共创模式", "mode", [["ai-draft", "AI 代笔(AI 写正文)"], ["co-write", "逐章共创(按章指派谁写)"], ["author-draft", "作者亲笔(AI 只备料审稿)"]]),
1030
- field("一句话简介", "description", "写封面文案时可直接引用", true))),
1350
+ field(tt("novel.fTitle"), "title", tt("novel.titlePh")),
1351
+ field(tt("novel.fAuthor"), "author", tt("novel.authorPh")),
1352
+ field(tt("novel.fGenre"), "genre", tt("novel.genrePh")),
1353
+ field(tt("novel.fLength"), "length", tt("novel.lengthPh")),
1354
+ picker(tt("novel.fLanguage"), "language", [["zh", "中文"], ["en", "English"], ["ja", "日本語"]]),
1355
+ picker(tt("novel.fPhase"), "phase", PHASES.map(function (p) { return [p, tt("phase." + p)]; })),
1356
+ picker(tt("novel.fMode"), "mode", [["ai-draft", tt("mode.aiDraft")], ["co-write", tt("mode.coWrite")], ["author-draft", tt("mode.authorDraft")]]),
1357
+ field(tt("novel.fDesc"), "description", tt("novel.descPh"), true))),
1031
1358
  saveErr ? h(ErrorStrip, { text: saveErr }) : null,
1032
1359
  h("div", { className: "myt-setActions" },
1033
1360
  h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save },
1034
- busy ? "保存中…" : "保存设置")));
1361
+ busy ? tt("novel.saving") : tt("novel.save"))));
1035
1362
  }
1036
1363
 
1037
1364
  // ── 章节 tab:状态筛选 pills + 表格 + 行抽屉(只读正文 + 五维 + 流转)────
1038
1365
  var FIVE_DIMS = [
1039
- ["cognitive_frame", "认知框架"], ["emotional_anchor", "情感锚点"], ["world_texture", "世界质感"],
1040
- ["suspense", "悬念"], ["interpersonal_tension", "人际张力"],
1366
+ ["cognitive_frame", tt("dim.cognitiveFrame")], ["emotional_anchor", tt("dim.emotionalAnchor")], ["world_texture", tt("dim.worldTexture")],
1367
+ ["suspense", tt("dim.suspense")], ["interpersonal_tension", tt("dim.interpersonalTension")],
1041
1368
  ];
1042
1369
  // 章纲对照栏(五期):outline 非空时渲染可折叠原文(默认展开,只读,样式
1043
1370
  // 与正文区分);共享常量避免重渲染时折叠态意外重置——组件随抽屉挂载,
@@ -1045,16 +1372,16 @@ window.__ModuleLoader__.load({
1045
1372
  function OutlinePanel(props) {
1046
1373
  var outline = props.outline;
1047
1374
  var _op = useState(true), open = _op[0], setOpen = _op[1];
1048
- var body = outline && typeof outline.body === "string" && outline.body.trim() !== "" ? outline.body : "(章纲为空)";
1375
+ var body = outline && typeof outline.body === "string" && outline.body.trim() !== "" ? outline.body : tt("outline.empty");
1049
1376
  return h("div", { className: "myt-outlinePanel" },
1050
1377
  h("button", {
1051
1378
  type: "button", className: "myt-outlineHead",
1052
1379
  "aria-expanded": open ? "true" : "false",
1053
- title: open ? "收起章纲" : "展开章纲",
1380
+ title: open ? tt("outline.collapseTip") : tt("outline.expandTip"),
1054
1381
  onClick: function () { setOpen(!open); },
1055
1382
  },
1056
- h("span", { className: "myt-outlineTitle" }, "章纲对照"),
1057
- typeof (outline && outline.words) === "number" ? h("span", { className: "myt-outlineWords" }, fmtNum(outline.words) + " 字") : null,
1383
+ h("span", { className: "myt-outlineTitle" }, tt("outline.title")),
1384
+ typeof (outline && outline.words) === "number" ? h("span", { className: "myt-outlineWords" }, tf("common.charCount", { n: fmtNum(outline.words) })) : null,
1058
1385
  h("span", { className: "myt-outlineChevron", "aria-hidden": "true" },
1059
1386
  ic(open ? "IconChevronDownOutlineRegular" : "IconChevronRightOutlineRegular", 14))),
1060
1387
  open ? h(MdView, { text: body, className: "myt-outlineBody" }) : null);
@@ -1093,14 +1420,14 @@ window.__ModuleLoader__.load({
1093
1420
  return h("div", { className: "myt-stack" },
1094
1421
  writeOrder.length ? h("div", { className: "myt-card myt-writeOrder" },
1095
1422
  h("div", { className: "myt-cardHead" },
1096
- h("h3", { className: "myt-cardTitle" }, "按单写作"),
1423
+ h("h3", { className: "myt-cardTitle" }, tt("chapters.writeQueue")),
1097
1424
  h("span", { className: "myt-boardCount" }, writeOrder.length)),
1098
1425
  h("div", { className: "myt-writeOrderList" }, writeOrder.map(function (item) {
1099
1426
  var ch = item.ch;
1100
1427
  var ol = item.ol;
1101
1428
  return h("button", {
1102
1429
  key: ch.file, type: "button", className: "myt-writeOrderRow",
1103
- title: "点击打开该章",
1430
+ title: tt("chapters.rowTip"),
1104
1431
  onClick: function () { onOpen(ch.file); },
1105
1432
  },
1106
1433
  h("span", { className: "myt-chPath" }, chWhere(ch) || ch.file),
@@ -1108,11 +1435,11 @@ window.__ModuleLoader__.load({
1108
1435
  statusBadge(ch.status),
1109
1436
  ol && typeof ol.excerpt === "string" && ol.excerpt !== ""
1110
1437
  ? h("span", { className: "myt-writeOrderExcerpt", title: ol.excerpt }, truncate(ol.excerpt, 60)) : null,
1111
- ol && ol.words ? h("span", { className: "myt-wordsCell" }, "章纲 " + fmtNum(ol.words) + " 字") : null);
1438
+ ol && ol.words ? h("span", { className: "myt-wordsCell" }, tf("chapters.outlineWords", { n: fmtNum(ol.words) })) : null);
1112
1439
  }))) : null,
1113
1440
  h("div", { className: "myt-filterRow" },
1114
- [{ k: "all", label: "全部", n: chapters.length }].concat(STATUS_FLOW.map(function (s) {
1115
- return { k: s, label: STATUS_LABEL[s], n: counts[s] || 0 };
1441
+ [{ k: "all", label: tt("chapters.all"), n: chapters.length }].concat(STATUS_FLOW.map(function (s) {
1442
+ return { k: s, label: tt("status." + s), n: counts[s] || 0 };
1116
1443
  })).map(function (f) {
1117
1444
  return h("button", {
1118
1445
  key: f.k, type: "button", className: "myt-filterPill",
@@ -1123,22 +1450,22 @@ window.__ModuleLoader__.load({
1123
1450
  rows.length ? h("div", { className: "myt-tableWrap" },
1124
1451
  h("table", { className: "myt-table" },
1125
1452
  h("thead", null, h("tr", null,
1126
- h("th", null, "卷 · 章"), h("th", null, "标题"), h("th", null, "状态"), h("th", null, "字数"), h("th", null, "梗概"))),
1453
+ h("th", null, tt("chapters.thVolume")), h("th", null, tt("chapters.thTitle")), h("th", null, tt("chapters.thStatus")), h("th", null, tt("chapters.thWords")), h("th", null, tt("chapters.thSummary")))),
1127
1454
  h("tbody", null, rows.map(function (ch) {
1128
- return h("tr", { key: ch.file, onClick: function () { onOpen(ch.file); }, title: "点击阅读正文" },
1455
+ return h("tr", { key: ch.file, onClick: function () { onOpen(ch.file); }, title: tt("chapters.rowReadTip") },
1129
1456
  h("td", null, h("span", { className: "myt-chPath" }, chWhere(ch) || "—")),
1130
1457
  h("td", null, h("span", { className: "myt-chTitle" }, orDash(ch.title))),
1131
1458
  h("td", null, statusBadge(ch.status)),
1132
1459
  h("td", null, h("span", { className: "myt-wordsCell" }, fmtNum(ch.words))),
1133
1460
  h("td", null, h("div", { className: "myt-summaryCell myt-muted" }, orDash(ch.summary))));
1134
1461
  }))))
1135
- : (chapters.length ? h(Empty, { text: "该状态下没有章节" }) : h(Empty, { text: "chapters/ 目录还没有章节文件" })));
1462
+ : (chapters.length ? h(Empty, { text: tt("chapters.emptyFiltered") }) : h(Empty, { text: tt("chapters.empty") })));
1136
1463
  }
1137
1464
 
1138
1465
  // ── 角色 tab:卡墙 + 全卡弹层 ───────────────────────────────────────────
1139
1466
  var CHAR_FULL_ROWS = [
1140
- ["age", "年龄"], ["gender", "性别"], ["appearance", "外貌"], ["personality", "性格"],
1141
- ["background", "背景"], ["motivation", "动机"], ["arc", "成长弧"],
1467
+ ["age", tt("char.field.age")], ["gender", tt("char.field.gender")], ["appearance", tt("char.field.appearance")], ["personality", tt("char.field.personality")],
1468
+ ["background", tt("char.field.background")], ["motivation", tt("char.field.motivation")], ["arc", tt("char.field.arc")],
1142
1469
  ];
1143
1470
  function CharactersTab(props) {
1144
1471
  var characters = props.snap.characters || [];
@@ -1152,20 +1479,20 @@ window.__ModuleLoader__.load({
1152
1479
  h("span", { className: "myt-charName" }, orDash(c.name)),
1153
1480
  roleBadge(c.role)),
1154
1481
  h("div", { className: "myt-charMeta" },
1155
- [c.age !== null && c.age !== undefined ? c.age + " 岁" : null, c.gender || null].filter(Boolean).join(" · ") || "—"),
1156
- c.personality ? h("div", { className: "myt-charLine" }, h("span", { className: "myt-charLabel" }, "性格 "), truncate(c.personality, 60)) : null,
1157
- c.motivation ? h("div", { className: "myt-charLine" }, h("span", { className: "myt-charLabel" }, "动机 "), truncate(c.motivation, 60)) : null);
1158
- })) : h(Empty, { text: "characters/ 目录还没有角色卡" }),
1482
+ [c.age !== null && c.age !== undefined ? tf("char.years", { n: c.age }) : null, c.gender || null].filter(Boolean).join(" · ") || "—"),
1483
+ c.personality ? h("div", { className: "myt-charLine" }, h("span", { className: "myt-charLabel" }, tt("char.linePersonality")), truncate(c.personality, 60)) : null,
1484
+ c.motivation ? h("div", { className: "myt-charLine" }, h("span", { className: "myt-charLabel" }, tt("char.lineMotivation")), truncate(c.motivation, 60)) : null);
1485
+ })) : h(Empty, { text: tt("char.empty") }),
1159
1486
  modal ? h(MaskPortal, {
1160
1487
  className: "myt-modalMask",
1161
1488
  onMouseDown: function (e) { if (e.target === e.currentTarget) setModalFile(null); },
1162
1489
  },
1163
- h("div", { className: "myt-modalCard", role: "dialog", "aria-modal": "true", "aria-label": "角色详情" },
1490
+ h("div", { className: "myt-modalCard", role: "dialog", "aria-modal": "true", "aria-label": tt("char.modalAria") },
1164
1491
  h("div", { className: "myt-modalHead" },
1165
1492
  h("h2", { className: "myt-modalTitle" }, orDash(modal.name)),
1166
1493
  h("div", { className: "myt-heroMeta" },
1167
1494
  roleBadge(modal.role),
1168
- h("button", { type: "button", className: "myt-closeBtn", "aria-label": "关闭角色卡", onClick: function () { setModalFile(null); } }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")))),
1495
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("char.closeAria"), onClick: function () { setModalFile(null); } }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")))),
1169
1496
  h("div", { className: "myt-modalBody" },
1170
1497
  CHAR_FULL_ROWS.map(function (row) {
1171
1498
  return h("div", { key: row[0], className: "myt-kvRow" },
@@ -1173,13 +1500,13 @@ window.__ModuleLoader__.load({
1173
1500
  h("span", { className: "myt-kvVal" }, orDash(modal[row[0]])));
1174
1501
  }),
1175
1502
  h("div", { className: "myt-kvRow" },
1176
- h("span", { className: "myt-kvKey" }, "标记"),
1503
+ h("span", { className: "myt-kvKey" }, tt("char.markers")),
1177
1504
  h("span", { className: "myt-kvVal myt-tagRow" },
1178
1505
  (modal.markers || []).length
1179
1506
  ? modal.markers.map(function (m, i) { return h("span", { key: i, className: "myt-markerTag" }, m); })
1180
1507
  : "—")),
1181
1508
  h("div", { className: "myt-kvRow" },
1182
- h("span", { className: "myt-kvKey" }, "文件"),
1509
+ h("span", { className: "myt-kvKey" }, tt("char.file")),
1183
1510
  h("span", { className: "myt-kvVal" }, modal.file || "—"))))) : null);
1184
1511
  }
1185
1512
 
@@ -1201,7 +1528,7 @@ window.__ModuleLoader__.load({
1201
1528
  var items = list.filter(function (f) { return (f.status || "planted") === col; });
1202
1529
  return h("div", { key: col, className: "myt-boardCol" },
1203
1530
  h("div", { className: "myt-boardHead" },
1204
- h("span", { className: "myt-boardTitle" }, FS_LABEL[col]),
1531
+ h("span", { className: "myt-boardTitle" }, tt("foreshadow." + col)),
1205
1532
  h("span", { className: "myt-boardCount" }, items.length)),
1206
1533
  items.map(function (fs) {
1207
1534
  var next = FS_FLOW[col] || null;
@@ -1211,26 +1538,26 @@ window.__ModuleLoader__.load({
1211
1538
  var noStatus = !fs.status;
1212
1539
  var noWrite = !fs.id || noStatus;
1213
1540
  var noWriteTip = !fs.id
1214
- ? "该条目缺 id,无法写状态"
1215
- : "该条目缺 status 行,服务端会拒绝流转(409);请先在 foreshadows.yml 补上 status";
1216
- var chRange = [fs.planted_chapter || "埋设章未标", fs.expected_resolve].filter(Boolean).join(" → ");
1541
+ ? tt("foreshadow.noIdTip")
1542
+ : tt("foreshadow.noStatusTip");
1543
+ var chRange = [fs.planted_chapter || tt("foreshadow.plantedUnmarked"), fs.expected_resolve].filter(Boolean).join(" → ");
1217
1544
  return h("div", { key: fs.id || fs.title, className: "myt-fsCard" },
1218
1545
  h("div", { className: "myt-fsTitle" },
1219
1546
  priDot(fs.priority), h("span", null, orDash(fs.title) || orDash(fs.id)),
1220
1547
  // 回填徽章(五期账本回填):区别于写前规划条目,悬浮见来源。
1221
- fs.source === "backfill" ? h("span", { className: "myt-backfillBadge", title: "从作者正文回填" }, "回填") : null,
1222
- noStatus ? h("span", { className: "myt-fsNoStatus", title: noWriteTip }, "缺 status") : null),
1548
+ fs.source === "backfill" ? h("span", { className: "myt-backfillBadge", title: tt("foreshadow.backfillTip") }, tt("foreshadow.backfill")) : null,
1549
+ noStatus ? h("span", { className: "myt-fsNoStatus", title: noWriteTip }, tt("foreshadow.missingStatus")) : null),
1223
1550
  h("div", { className: "myt-fsMeta" },
1224
- h("span", null, col === "resolved" && fs.resolved_chapter ? "回收于 " + fs.resolved_chapter : chRange),
1225
- fs.priority && fs.priority !== "normal" ? h("span", null, "优先级 " + (PRI_LABEL[fs.priority] || fs.priority)) : null),
1551
+ h("span", null, col === "resolved" && fs.resolved_chapter ? tf("foreshadow.resolvedAt", { ch: fs.resolved_chapter }) : chRange),
1552
+ fs.priority && fs.priority !== "normal" ? h("span", null, tf("foreshadow.priorityLabel", { pri: ["high", "low", "normal"].indexOf(fs.priority) !== -1 ? tt("pri." + fs.priority) : fs.priority })) : null),
1226
1553
  fs.notes ? h("div", { className: "myt-fsNotes", title: fs.notes }, fs.notes) : null,
1227
1554
  h("div", { className: "myt-fsActions" },
1228
- next ? h("button", { type: "button", className: "myt-btn", disabled: busy || noWrite, title: noWriteTip, onClick: function () { setStatus(fs, next); } }, busy ? "推进中…" : (col === "planted" ? "推进" : "回收")) : null,
1555
+ next ? h("button", { type: "button", className: "myt-btn", disabled: busy || noWrite, title: noWriteTip, onClick: function () { setStatus(fs, next); } }, busy ? tt("foreshadow.advancing") : (col === "planted" ? tt("foreshadow.advance") : tt("foreshadow.resolve"))) : null,
1229
1556
  col !== "resolved" && col !== "abandoned"
1230
- ? h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy || noWrite, title: noWriteTip, onClick: function () { setStatus(fs, "abandoned"); } }, "弃用") : null));
1557
+ ? h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy || noWrite, title: noWriteTip, onClick: function () { setStatus(fs, "abandoned"); } }, tt("foreshadow.abandon")) : null));
1231
1558
  }),
1232
- items.length ? null : h("div", { className: "myt-muted" }, "空"));
1233
- })) : h(Empty, { text: "foreshadows.yml 还没有伏笔条目" }));
1559
+ items.length ? null : h("div", { className: "myt-muted" }, tt("foreshadow.colEmpty")));
1560
+ })) : h(Empty, { text: tt("foreshadow.empty") }));
1234
1561
  }
1235
1562
 
1236
1563
  // ── 时间线 tab:垂直轴 + importance 点位(1-5 → 大小/深浅)──────────────
@@ -1239,17 +1566,17 @@ window.__ModuleLoader__.load({
1239
1566
  if (items.length) {
1240
1567
  items.sort(function (a, b) { return String(a.year || "").localeCompare(String(b.year || ""), "zh-CN", { numeric: true }); });
1241
1568
  }
1242
- if (!items.length) return h(Empty, { text: "timeline.yml 还没有时间线条目" });
1569
+ if (!items.length) return h(Empty, { text: tt("timeline.empty") });
1243
1570
  return h("div", { className: "myt-card" },
1244
1571
  h("div", { className: "myt-timeline" }, items.map(function (t, i) {
1245
1572
  var imp = Math.min(5, Math.max(1, typeof t.importance === "number" ? t.importance : 3));
1246
1573
  return h("div", { key: i, className: "myt-tlRow" },
1247
1574
  h("div", { className: "myt-tlYear" }, orDash(t.year)),
1248
- h("div", { className: "myt-tlAxis" }, h("span", { className: "myt-tlDot", "data-imp": String(imp), title: "重要度 " + imp })),
1575
+ h("div", { className: "myt-tlAxis" }, h("span", { className: "myt-tlDot", "data-imp": String(imp), title: tf("timeline.importance", { n: imp }) })),
1249
1576
  h("div", { className: "myt-tlCard" },
1250
1577
  h("div", { className: "myt-tlTitle" }, orDash(t.title)),
1251
1578
  t.description ? h("div", { className: "myt-tlDesc" }, t.description) : null,
1252
- t.chapter ? h("div", { className: "myt-tlChapter" }, "关联章节:" + t.chapter) : null));
1579
+ t.chapter ? h("div", { className: "myt-tlChapter" }, tf("timeline.linkedChapter", { ch: t.chapter })) : null));
1253
1580
  })));
1254
1581
  }
1255
1582
 
@@ -1264,14 +1591,14 @@ window.__ModuleLoader__.load({
1264
1591
  // 真值 → TypeError → 整面板白屏;Object.create(null) 杜绝。
1265
1592
  var groups = Object.create(null);
1266
1593
  world.forEach(function (w) {
1267
- var key = w.category || "未分类";
1594
+ var key = w.category || tt("common.uncategorized");
1268
1595
  if (!groups[key]) groups[key] = [];
1269
1596
  groups[key].push(w);
1270
1597
  });
1271
1598
  var groupKeys = Object.keys(groups).sort(function (a, b) { return a.localeCompare(b, "zh-CN"); });
1272
1599
  return h("div", { className: "myt-stack" },
1273
1600
  h("div", { className: "myt-card" },
1274
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "世界观条目")),
1601
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.title"))),
1275
1602
  groupKeys.length ? h("div", { className: "myt-stack" }, groupKeys.map(function (key) {
1276
1603
  return h("div", { key: key, className: "myt-worldGroup" },
1277
1604
  h("div", { className: "myt-listHead" }, key, h("span", { className: "myt-boardCount" }, groups[key].length)),
@@ -1282,23 +1609,23 @@ window.__ModuleLoader__.load({
1282
1609
  (w.tags || []).length ? h("span", { className: "myt-tagRow" }, w.tags.map(function (t, j) { return h("span", { key: j, className: "myt-tag" }, t); })) : null),
1283
1610
  w.description ? h(MdView, { text: w.description, className: "myt-worldDesc" }) : null);
1284
1611
  }));
1285
- })) : h(Empty, { text: "world/ 目录还没有设定条目" })),
1612
+ })) : h(Empty, { text: tt("world.empty") })),
1286
1613
  h("div", { className: "myt-card" },
1287
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "科学设定"),
1614
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.science")),
1288
1615
  h("span", { className: "myt-boardCount" }, science.length)),
1289
1616
  science.length ? h("div", { className: "myt-stack" }, science.map(function (s, i) {
1290
- var label = SCI_LABEL[s.label] ? s.label : null;
1617
+ var label = ["known", "extrapolation", "hypothesis"].indexOf(s.label) !== -1 ? s.label : null;
1291
1618
  return h("div", { key: s.file || i, className: "myt-worldItem" },
1292
1619
  h("div", { className: "myt-worldHead" },
1293
1620
  h("span", { className: "myt-worldName" }, orDash(s.name)),
1294
1621
  h("span", { className: "myt-badge myt-sciTag", "data-label": label || "hypothesis", title: s.label && !label ? s.label : undefined },
1295
- label ? SCI_LABEL[label] : (s.label || "未标级"))),
1622
+ label ? tt("sci." + label) : (s.label || tt("world.unlabeled")))),
1296
1623
  s.description ? h(MdView, { text: s.description, className: "myt-worldDesc" }) : null,
1297
1624
  (s.references || []).length ? h("div", { className: "myt-refList" },
1298
- s.references.map(function (r, j) { return h("span", { key: j, className: "myt-refItem" }, "参考 " + (j + 1) + ":" + r); })) : null);
1299
- })) : h(Empty, { text: "science/ 目录还没有科学设定" })),
1625
+ s.references.map(function (r, j) { return h("span", { key: j, className: "myt-refItem" }, tf("world.ref", { n: j + 1, r: r })); })) : null);
1626
+ })) : h(Empty, { text: tt("world.scienceEmpty") })),
1300
1627
  research.length ? h("div", { className: "myt-card" },
1301
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "资料检索"),
1628
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.research")),
1302
1629
  h("span", { className: "myt-boardCount" }, research.length)),
1303
1630
  h("div", { className: "myt-tagRow" }, research.map(function (r, i) {
1304
1631
  return h("span", { key: r.file || i, className: "myt-tag", title: r.file || "" }, orDash(r.topic) || r.file);
@@ -1322,27 +1649,28 @@ window.__ModuleLoader__.load({
1322
1649
  // 无原型容器:kind/category 是数据文件自由文本,见 WorldTab 处说明。
1323
1650
  var clueGroups = Object.create(null);
1324
1651
  clues.forEach(function (c) {
1325
- var key = c.kind || "未分类";
1652
+ var key = c.kind || tt("common.uncategorized");
1326
1653
  if (!clueGroups[key]) clueGroups[key] = [];
1327
1654
  clueGroups[key].push(c);
1328
1655
  });
1329
1656
  var memGroups = Object.create(null);
1330
1657
  memories.forEach(function (m) {
1331
- var key = m.category || "未分类";
1658
+ var key = m.category || tt("common.uncategorized");
1332
1659
  if (!memGroups[key]) memGroups[key] = [];
1333
1660
  memGroups[key].push(m);
1334
1661
  });
1335
1662
  function groupKey(a, b) { return a.localeCompare(b, "zh-CN"); }
1336
1663
  return h("div", { className: "myt-stack" },
1337
1664
  h("div", { className: "myt-cardHead" },
1338
- h("h3", { className: "myt-cardTitle" }, "索引板(clue-board / memories)"),
1339
- h("button", { type: "button", className: "myt-btn", disabled: rebuildBusy, onClick: rebuild, title: "按 CLI index 同一逻辑重建 index.yml" },
1340
- rebuildBusy ? "重建中…" : "重建索引")),
1665
+ h("h3", { className: "myt-cardTitle" }, tt("index.title")),
1666
+ h("button", { type: "button", className: "myt-btn", disabled: rebuildBusy, onClick: rebuild, title: tt("index.rebuildTip") },
1667
+ rebuildBusy ? tt("index.rebuilding") : tt("index.rebuild"))),
1341
1668
  h("div", { className: "myt-indexCols" },
1342
1669
  h("div", { className: "myt-card" },
1343
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "线索板"), h("span", { className: "myt-boardCount" }, clues.length)),
1670
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.clueBoard")), h("span", { className: "myt-boardCount" }, clues.length)),
1344
1671
  clues.length ? Object.keys(clueGroups).sort(groupKey).map(function (kind) {
1345
- var label = CLUE_KIND_LABEL[kind] || kind;
1672
+ var ck = CLUE_KIND_KEYS[kind];
1673
+ var label = ck ? tt("clue." + ck) : kind;
1346
1674
  return h("div", { key: kind, className: "myt-worldGroup" },
1347
1675
  h("div", { className: "myt-listHead" }, label, h("span", { className: "myt-boardCount" }, clueGroups[kind].length)),
1348
1676
  clueGroups[kind].map(function (c, i) {
@@ -1350,21 +1678,21 @@ window.__ModuleLoader__.load({
1350
1678
  h("span", { className: "myt-clueText" }, orDash(c.content)),
1351
1679
  c.related_chapter ? h("span", { className: "myt-clueChapter" }, c.related_chapter) : null);
1352
1680
  }));
1353
- }) : h(Empty, { text: "clue-board.yml 还没有条目" })),
1681
+ }) : h(Empty, { text: tt("index.clueEmpty") })),
1354
1682
  h("div", { className: "myt-card" },
1355
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "记忆板"), h("span", { className: "myt-boardCount" }, memories.length)),
1683
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.memBoard")), h("span", { className: "myt-boardCount" }, memories.length)),
1356
1684
  memories.length ? Object.keys(memGroups).sort(groupKey).map(function (cat) {
1357
1685
  var isPromise = /promise|承诺/i.test(cat);
1358
1686
  return h("div", { key: cat, className: "myt-worldGroup" },
1359
1687
  h("div", { className: "myt-listHead" },
1360
- cat, isPromise ? h("span", { className: "myt-markerTag" }, "承诺线") : null,
1688
+ cat, isPromise ? h("span", { className: "myt-markerTag" }, tt("index.promise")) : null,
1361
1689
  h("span", { className: "myt-boardCount" }, memGroups[cat].length)),
1362
1690
  memGroups[cat].map(function (m, i) {
1363
1691
  return h("div", { key: i, className: "myt-memItem", "data-promise": isPromise ? "true" : undefined },
1364
1692
  h("span", { className: "myt-clueText" }, orDash(m.content)),
1365
1693
  m.source_chapter ? h("span", { className: "myt-memChapter" }, m.source_chapter) : null);
1366
1694
  }));
1367
- }) : h(Empty, { text: "memories.yml 还没有条目" }))));
1695
+ }) : h(Empty, { text: tt("index.memEmpty") }))));
1368
1696
  }
1369
1697
 
1370
1698
  // ── 关系图 tab:canvas 力导向(斥力 + 弹簧 + 中心引力,rAF 循环)─────────
@@ -1573,7 +1901,7 @@ window.__ModuleLoader__.load({
1573
1901
  tipRafRef.current = 0;
1574
1902
  var e2 = hitEdge(p.x, p.y);
1575
1903
  if (e2) {
1576
- setTip({ x: p.x, y: p.y, text: (e2.type ? e2.type + ":" : "") + (e2.desc || "(无描述)") });
1904
+ setTip({ x: p.x, y: p.y, text: (e2.type ? e2.type + ":" : "") + (e2.desc || tt("relations.noDesc")) });
1577
1905
  } else {
1578
1906
  setTip(function (prev) { return prev ? null : prev; });
1579
1907
  }
@@ -1606,18 +1934,18 @@ window.__ModuleLoader__.load({
1606
1934
  canvas.removeEventListener("dblclick", onDbl);
1607
1935
  };
1608
1936
  }, [snap]);
1609
- if (!graph.nodes.length) return h(Empty, { text: "没有角色或关系数据,无法绘图" });
1937
+ if (!graph.nodes.length) return h(Empty, { text: tt("relations.noData") });
1610
1938
  return h("div", { className: "myt-stack" },
1611
1939
  h("div", { className: "myt-card" },
1612
1940
  h("div", { className: "myt-cardHead" },
1613
- h("h3", { className: "myt-cardTitle" }, "角色关系图"),
1614
- h("span", { className: "myt-muted" }, graph.nodes.length + " 节点 · " + graph.edges.length + " 关系")),
1941
+ h("h3", { className: "myt-cardTitle" }, tt("relations.title")),
1942
+ h("span", { className: "myt-muted" }, tf("relations.counts", { n: graph.nodes.length, m: graph.edges.length }))),
1615
1943
  h("div", { className: "myt-graphWrap", ref: wrapRef },
1616
1944
  h("canvas", { className: "myt-graphCanvas", ref: canvasRef, "data-dragging": "false" }),
1617
1945
  tip ? h("div", { className: "myt-graphTip", style: { left: Math.min(tip.x + 12, 320) + "px", top: Math.max(tip.y - 34, 4) + "px" } }, tip.text) : null),
1618
- h("div", { className: "myt-graphFoot" }, "拖节点固定位置 · 滚轮缩放 · 双击复位 · 悬停边看关系描述")),
1946
+ h("div", { className: "myt-graphFoot" }, tt("relations.help"))),
1619
1947
  graph.unlinked.length ? h("div", { className: "myt-card" },
1620
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "未入图角色(无关系边)")),
1948
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("relations.unlinkedTitle"))),
1621
1949
  h("div", { className: "myt-unlinked" }, graph.unlinked.map(function (n, i) {
1622
1950
  return h("span", { key: n || i, className: "myt-unlinkedTag" }, n);
1623
1951
  }))) : null);