dsh-maoying 0.1.11 → 0.1.13

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
@@ -32,7 +32,7 @@ window.__ModuleLoader__.load({
32
32
 
33
33
  var React = require("react");
34
34
  var h = React.createElement;
35
- var useState = React.useState, useEffect = React.useEffect, useCallback = React.useCallback, useRef = React.useRef;
35
+ var useState = React.useState, useEffect = React.useEffect, useCallback = React.useCallback, useRef = React.useRef, useMemo = React.useMemo;
36
36
  // 复制一份导出面再补属性:不往共享模块对象上写东西(避免污染宿主里
37
37
  // 其他 ui-primitives 消费者);后续片段看到的就是这份副本。
38
38
  var P = Object.assign({}, require("@deepseek-ai/dsh-client-ui-primitives"));
@@ -55,19 +55,15 @@ window.__ModuleLoader__.load({
55
55
  var API_TIMEOUT_MS = 60000;
56
56
 
57
57
  var STATUS_FLOW = ["pending", "writing", "review", "accepted"];
58
- var STATUS_LABEL = { pending: "待写", writing: "写作中", review: "审校中", accepted: "已定稿" };
59
58
  var FS_ORDER = ["planted", "progressing", "resolved", "abandoned"];
60
59
  var FS_FLOW = { planted: "progressing", progressing: "resolved" };
61
- var FS_LABEL = { planted: "埋设", progressing: "推进中", resolved: "已回收", abandoned: "已弃用" };
62
- var PRI_LABEL = { high: "高", normal: "中", low: "低" };
63
60
  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: "疑问" };
61
+ // snake→camel 键表:clue.* 词典键用小驼峰,数据里的 kind 是 snake_case。
62
+ var CLUE_KIND_KEYS = { clue: "clue", red_herring: "redHerring", inference: "inference", question: "question" };
67
63
  var TABS = [
68
- ["overview", "概览"], ["chapters", "章节"], ["characters", "角色"],
69
- ["foreshadows", "伏笔"], ["relations", "关系图"], ["timeline", "时间线"],
70
- ["world", "设定"], ["index", "索引板"],
64
+ ["overview", "tabs.overview"], ["chapters", "tabs.chapters"], ["characters", "tabs.characters"],
65
+ ["foreshadows", "tabs.foreshadows"], ["relations", "tabs.relations"], ["timeline", "tabs.timeline"],
66
+ ["world", "tabs.world"], ["index", "tabs.index"], ["settings", "tabs.settings"],
71
67
  ];
72
68
 
73
69
  // 角色徽章四色(role 是自由文本):按关键词归 tone,命中即返。
@@ -85,16 +81,16 @@ window.__ModuleLoader__.load({
85
81
  }
86
82
  function statusBadge(status) {
87
83
  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 + ")" : ""));
84
+ return h("span", { className: "myt-badge", "data-status": s }, tt("status." + s) + (status && s !== status ? tf("status.rawSuffix", { raw: status }) : ""));
89
85
  }
90
86
  function phasePill(phase) {
91
87
  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]);
88
+ if (!p) return h("span", { className: "myt-phase", "data-phase": "idea", title: phase || "" }, phase ? String(phase) : tt("phase.unset"));
89
+ return h("span", { className: "myt-phase", "data-phase": p }, tt("phase." + p));
94
90
  }
95
91
  function priDot(pri) {
96
92
  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] || "") });
93
+ 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
94
  }
99
95
 
100
96
  // ── 格式化小工具 ─────────────────────────────────────────────────────
@@ -114,7 +110,7 @@ window.__ModuleLoader__.load({
114
110
  // 章节「卷-章」定位:volume 缺省只显示第 N 章。
115
111
  function chWhere(ch) {
116
112
  if (!ch) return "";
117
- var num = typeof ch.num === "number" ? "第 " + ch.num + " 章" : "";
113
+ var num = typeof ch.num === "number" ? tf("common.chapterNum", { n: ch.num }) : "";
118
114
  return ch.volume ? String(ch.volume) + " · " + num : num;
119
115
  }
120
116
  function chFullTitle(ch) {
@@ -178,11 +174,11 @@ window.__ModuleLoader__.load({
178
174
  var d = null;
179
175
  try { d = raw ? JSON.parse(raw) : null; } catch (err) { d = null; }
180
176
  if (!r.ok) throw new Error((d && d.error) || ("HTTP " + r.status));
181
- if (!d) throw new Error("响应不是 JSON(HTTP " + r.status + ")");
177
+ if (!d) throw new Error(tf("api.notJson", { code: r.status }));
182
178
  return d;
183
179
  });
184
180
  }).catch(function (err) {
185
- if (ctrl && err && err.name === "AbortError") throw new Error("请求超时(" + API_TIMEOUT_MS / 1000 + " 秒)");
181
+ if (ctrl && err && err.name === "AbortError") throw new Error(tf("api.timeout", { s: API_TIMEOUT_MS / 1000 }));
186
182
  throw err;
187
183
  }).finally(function () {
188
184
  if (timer !== null) clearTimeout(timer);
@@ -196,7 +192,7 @@ window.__ModuleLoader__.load({
196
192
  qs = usp.toString();
197
193
  }
198
194
  return maoyingApi(path + (qs ? (path.indexOf("?") === -1 ? "?" : "&") + qs : "")).catch(function (err) {
199
- toast.err("毛颖创作台:" + err.message);
195
+ toast.err(tf("toast.prefix", { msg: err.message }));
200
196
  throw err;
201
197
  });
202
198
  }
@@ -205,17 +201,17 @@ window.__ModuleLoader__.load({
205
201
  toast.ok(apiPostOkText(path, d));
206
202
  return d;
207
203
  }, function (err) {
208
- toast.err("毛颖创作台:" + err.message);
204
+ toast.err(tf("toast.prefix", { msg: err.message }));
209
205
  throw err;
210
206
  });
211
207
  }
212
208
  // 写操作成功提示按路由分流(server 返回 chapters 计数时带上)。
213
209
  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 "操作成功";
210
+ if (path === "rebuild-index") return tf("toast.indexRebuilt", { n: fmtNum(d && d.chapters) });
211
+ if (path === "chapter-status") return tt("toast.chapterStatus");
212
+ if (path === "foreshadow-status") return tt("toast.foreshadowStatus");
213
+ if (path === "phase") return tf("toast.phaseAdvanced", { phase: d && d.phase && PHASES.indexOf(d.phase) !== -1 ? tt("phase." + d.phase) : (d && d.phase) || "—" });
214
+ return tt("toast.success");
219
215
  }
220
216
 
221
217
  // ── Toast 控制器(模块级单例;ok 4s / warn 6s 自动消失,err 手动关)────
@@ -267,7 +263,7 @@ window.__ModuleLoader__.load({
267
263
  },
268
264
  h("div", { className: "myt-toastText" }, it.text),
269
265
  h("button", {
270
- type: "button", className: "myt-toastClose", "aria-label": "关闭提示",
266
+ type: "button", className: "myt-toastClose", "aria-label": tt("common.closeTip"),
271
267
  onClick: function () { toast.dismiss(it.id); },
272
268
  }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")));
273
269
  }));
@@ -446,14 +442,14 @@ window.__ModuleLoader__.load({
446
442
 
447
443
  // ── 引导卡(无 novel 工作区 / 未初始化):maoying init 提示 + 复制 ──────
448
444
  function GuideCard(props) {
449
- var title = props.title || "该工作区还没有小说项目";
450
- var desc = props.desc || "在下面给出的命令里初始化目录骨架(或把已有 novel 目录放进工作区根),然后刷新本面板。";
445
+ var title = props.title || tt("guide.defaultTitle");
446
+ var desc = props.desc || tt("guide.defaultDesc");
451
447
  var _b = useState(false), busy = _b[0], setBusy = _b[1];
452
448
  function doCopy() {
453
449
  setBusy(true);
454
450
  copyText("maoying init").then(function (ok) {
455
- if (ok) toast.ok("已复制 maoying init");
456
- else toast.warn("复制失败,请手动选择命令文本");
451
+ if (ok) toast.ok(tt("guide.copied"));
452
+ else toast.warn(tt("guide.copyFailed"));
457
453
  }).finally(function () { setBusy(false); });
458
454
  }
459
455
  return h("div", { className: "myt-card myt-guide" },
@@ -461,7 +457,7 @@ window.__ModuleLoader__.load({
461
457
  h("p", { className: "myt-guideText" }, desc),
462
458
  h("div", { className: "myt-guideCode" },
463
459
  h("code", { className: "myt-guideCodeText" }, "maoying init"),
464
- h("button", { type: "button", className: "myt-btn myt-copyBtn", disabled: busy, onClick: doCopy }, busy ? "复制中…" : "复制命令")));
460
+ h("button", { type: "button", className: "myt-btn myt-copyBtn", disabled: busy, onClick: doCopy }, busy ? tt("guide.copying") : tt("guide.copyCmd"))));
465
461
  }
466
462
 
467
463
  // ── 骨架 / 错误 / 空态小件 ──────────────────────────────────────────────
@@ -474,9 +470,41 @@ window.__ModuleLoader__.load({
474
470
  return h("div", { className: "myt-strip myt-stripError" },
475
471
  h("span", { className: "myt-stripIcon" }, ic("IconWarningOutlineRegular", 14)),
476
472
  h("span", { className: "myt-stripText" }, props.text),
477
- props.onRetry ? h("button", { type: "button", className: "myt-btn", style: { marginLeft: "auto" }, onClick: props.onRetry }, "重试") : null);
473
+ props.onRetry ? h("button", { type: "button", className: "myt-btn", style: { marginLeft: "auto" }, onClick: props.onRetry }, tt("common.retry")) : null);
474
+ }
475
+ function Empty(props) { return h("div", { className: "myt-empty" }, props.text || tt("common.empty")); }
476
+
477
+ // tab 内容错误边界:任何 tab / 阅读页渲染抛错都降级为卡内错误态(错误摘要
478
+ // + 重试按钮),不再把整个面板炸成白屏。错误边界只有 class 组件能做
479
+ // (getDerivedStateFromError / componentDidCatch),此处破例用 class。
480
+ class TabErrorBoundary extends React.Component {
481
+ constructor(props) {
482
+ super(props);
483
+ this.state = { err: null };
484
+ this.retry = this.retry.bind(this);
485
+ }
486
+ static getDerivedStateFromError(err) {
487
+ return { err: err }; // 渲染抛错 → 下一次 render 走降级分支
488
+ }
489
+ componentDidCatch(err) {
490
+ // 只记控制台:卡内已有降级 UI,toast 通道留给请求错误,不重复打扰。
491
+ if (typeof console !== "undefined" && console.warn) console.warn("maoying: 分区渲染出错:", err);
492
+ }
493
+ retry() {
494
+ this.setState({ err: null });
495
+ if (typeof this.props.onRetry === "function") this.props.onRetry(); // 顺手重拉快照,坏数据一并刷掉
496
+ }
497
+ render() {
498
+ var err = this.state.err;
499
+ if (!err) return this.props.children;
500
+ var msg = String((err && err.message) || err || tt("common.unknownErr"));
501
+ return h("div", { className: "myt-card" },
502
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("errBoundary.title"))),
503
+ h("div", { className: "myt-muted", style: { overflowWrap: "anywhere" } }, tf("errBoundary.summary", { msg: truncate(msg, 300) })),
504
+ h("div", { className: "myt-heroMeta" },
505
+ h("button", { type: "button", className: "myt-btn", onClick: this.retry }, tt("common.retry"))));
506
+ }
478
507
  }
479
- function Empty(props) { return h("div", { className: "myt-empty" }, props.text || "暂无数据"); }
480
508
 
481
509
  // ── 页面骨架:顶栏(工作区下拉 + 刷新 + 关闭)+ tab 条 + 内容区 ─────────
482
510
  // 数据流:workspaces 一次拉取;state 跟随选中工作区;写操作 POST 成功后
@@ -490,7 +518,28 @@ window.__ModuleLoader__.load({
490
518
  var _ld = useState(true), loading = _ld[0], setLoading = _ld[1];
491
519
  var _se = useState(""), stateErr = _se[0], setStateErr = _se[1];
492
520
  var _tb = useState("overview"), tab = _tb[0], setTab = _tb[1];
493
- var _pc = useState(null), pendingChapter = _pc[0], setPendingChapter = _pc[1];
521
+ // 章节阅读页(整页视图,替代原右侧抽屉):null = 列表态。
522
+ var _rd = useState(null), readerFile = _rd[0], setReaderFile = _rd[1];
523
+ // 系统设置:字号档位持久化 + 面板整体缩放(portal 节点读同一 CSS 变量)。
524
+ var _sm = useState(false), showSettings = _sm[0], setShowSettings = _sm[1];
525
+ var _fs = useState(function () {
526
+ var stored = lsGet(FONT_SCALE_KEY) || "1";
527
+ return FONT_SCALES.some(function (o) { return o[0] === stored; }) ? stored : "1"; // 旧档位(1.8/2)回落 100%
528
+ }), fontScale = _fs[0], setFontScale = _fs[1];
529
+ var _lg = useState(function () {
530
+ var stored = lsGet(LANG_KEY) || "zh";
531
+ return UI_LANGS.some(function (o) { return o[0] === stored; }) ? stored : "zh";
532
+ }), lang = _lg[0], setLang = _lg[1];
533
+ useEffect(function () {
534
+ UI_LANG = lang;
535
+ lsSet(LANG_KEY, lang);
536
+ }, [lang]);
537
+ useEffect(function () {
538
+ if (typeof document !== "undefined" && document.documentElement) {
539
+ document.documentElement.style.setProperty("--myt-zoom", fontScale);
540
+ }
541
+ lsSet(FONT_SCALE_KEY, fontScale);
542
+ }, [fontScale]);
494
543
 
495
544
  // 选中校正:记忆的工作区必须仍在列表且 hasNovel,否则落回首个 hasNovel。
496
545
  var effectiveWs = (function () {
@@ -523,16 +572,22 @@ window.__ModuleLoader__.load({
523
572
  setWorkspaces([]);
524
573
  });
525
574
  }, []);
575
+ var stateSeqRef = useRef(0); // state 请求序号:快速切工作区时丢弃过期响应
526
576
  var loadState = useCallback(function (root) {
577
+ var seq = ++stateSeqRef.current; // 先自增再发请求;早退分支也作废在途旧响应
527
578
  if (!root) { setSnap(null); setStateErr(""); setLoading(false); return; }
528
579
  setLoading(true);
529
580
  setStateErr("");
530
581
  return apiGet("state", { path: root }).then(function (d) {
582
+ if (seq !== stateSeqRef.current) return; // 过期响应:已有更新的请求在途,丢弃
531
583
  setSnap(d && d.ok ? d : null);
532
584
  }).catch(function (err) {
585
+ if (seq !== stateSeqRef.current) return;
533
586
  setSnap(null);
534
587
  setStateErr(err.message);
535
- }).finally(function () { setLoading(false); });
588
+ }).finally(function () {
589
+ if (seq === stateSeqRef.current) setLoading(false); // 过期收尾不碰加载态
590
+ });
536
591
  }, []);
537
592
  var reload = useCallback(function () {
538
593
  return effectiveWs ? loadState(effectiveWs) : Promise.resolve();
@@ -543,7 +598,7 @@ window.__ModuleLoader__.load({
543
598
  var _ver = useState(null), ver = _ver[0], setVer = _ver[1];
544
599
  useEffect(function () {
545
600
  apiGet("update-check").then(function (d) {
546
- if (d && d.updateAvailable) setVer(d);
601
+ if (d && d.current) setVer(d); // 常态也显示当前版本号;有新版再亮徽章
547
602
  }).catch(function () {});
548
603
  }, []);
549
604
  useEffect(function () { loadWorkspaces(); }, [loadWorkspaces]);
@@ -560,7 +615,7 @@ window.__ModuleLoader__.load({
560
615
  }
561
616
  function gotoTab(next, chapterFile) {
562
617
  setTab(next);
563
- if (next === "chapters") setPendingChapter(chapterFile || null);
618
+ setReaderFile(next === "chapters" && chapterFile ? chapterFile : null);
564
619
  }
565
620
 
566
621
  var novelWorkspaces = (workspaces || []).filter(function (w) { return w.hasNovel; });
@@ -570,29 +625,31 @@ window.__ModuleLoader__.load({
570
625
  } else if (!effectiveWs) {
571
626
  // 全无 novel 工作区:引导卡(maoying init + 复制)。
572
627
  body = h("div", { className: "myt-stack" },
573
- wsErr ? ErrorStrip({ text: "工作区列表拉取失败:" + wsErr, onRetry: loadWorkspaces }) : null,
628
+ wsErr ? ErrorStrip({ text: tf("shell.wsListFailed", { msg: wsErr }), onRetry: loadWorkspaces }) : null,
574
629
  h(GuideCard, {
575
- title: "还没有可用的小说工作区",
576
- desc: "任一工作区根目录下出现 novel/project.yml 即自动入选;先用命令初始化目录骨架:",
630
+ title: tt("guide.noWsTitle"),
631
+ desc: tt("guide.noWsDesc"),
577
632
  }));
578
633
  } else if (loading && !snap) {
579
634
  body = h(SkelPage);
580
635
  } else if (stateErr) {
581
- body = h(ErrorStrip, { text: "快照拉取失败:" + stateErr, onRetry: reload });
636
+ body = h(ErrorStrip, { text: tf("shell.stateFailed", { msg: stateErr }), onRetry: reload });
582
637
  } else if (snap && snap.found === false) {
583
638
  body = h(GuideCard, {
584
- title: "该工作区尚未初始化小说项目",
585
- desc: "工作区根目录下没有 novel/project.yml。在该工作区根目录执行:",
639
+ title: tt("guide.notInitTitle"),
640
+ desc: tt("guide.notInitDesc"),
586
641
  });
587
642
  } else if (snap) {
588
643
  var tabProps = { snap: snap, reload: reload, wsPath: effectiveWs };
589
- if (tab === "chapters") body = h(ChaptersTab, Object.assign({ initialFile: pendingChapter, onConsumeInitial: function () { setPendingChapter(null); } }, tabProps));
644
+ if (readerFile) body = h(ChapterReader, { file: readerFile, snap: snap, reload: reload, wsPath: effectiveWs, onBack: function () { setReaderFile(null); } });
645
+ else if (tab === "chapters") body = h(ChaptersTab, Object.assign({ onOpenChapter: function (f) { setReaderFile(f); } }, tabProps));
590
646
  else if (tab === "characters") body = h(CharactersTab, tabProps);
591
647
  else if (tab === "foreshadows") body = h(ForeshadowsTab, tabProps);
592
648
  else if (tab === "relations") body = h(RelationsTab, tabProps);
593
649
  else if (tab === "timeline") body = h(TimelineTab, tabProps);
594
650
  else if (tab === "world") body = h(WorldTab, tabProps);
595
651
  else if (tab === "index") body = h(IndexTab, tabProps);
652
+ else if (tab === "settings") body = h(SettingsTab, tabProps);
596
653
  else body = h(OverviewTab, Object.assign({ gotoTab: gotoTab }, tabProps));
597
654
  }
598
655
 
@@ -600,33 +657,37 @@ window.__ModuleLoader__.load({
600
657
  h("header", { className: "myt-header" },
601
658
  h("div", null,
602
659
  h("h1", { className: "myt-title" }, "毛颖创作台",
660
+ ver && ver.current ? h("span", { className: "myt-verTag", title: tt("header.curVerTitle") }, ver.current) : null,
603
661
  ver && ver.updateAvailable
604
- ? h("a", { className: "myt-verBadge", href: ver.releaseUrl || "#", target: "_blank", rel: "noreferrer", title: "当前 " + ver.current + ",点击打开新版发布页" },
605
- "有新版 " + ver.latest + " ↗")
662
+ ? h("a", { className: "myt-verBadge", href: ver.releaseUrl || "#", target: "_blank", rel: "noreferrer", title: tf("header.upgradeTip", { cur: ver.current }) },
663
+ tf("header.newVersion", { ver: ver.latest }))
606
664
  : null),
607
- h("p", { className: "myt-subtitle" }, "网文项目可视化与状态流转 · 只读正文,写状态")),
665
+ h("p", { className: "myt-subtitle" }, tt("header.subtitle"))),
608
666
  h("div", { className: "myt-headActions" },
609
667
  workspaces && novelWorkspaces.length
610
- ? [h("span", { className: "myt-wsLabel", key: "l" }, "工作区"),
611
- h("select", { className: "myt-wsSelect", key: "s", value: effectiveWs, onChange: chooseWs, "aria-label": "选择工作区" },
668
+ ? [h("span", { className: "myt-wsLabel", key: "l" }, tt("header.workspace")),
669
+ h("select", { className: "myt-wsSelect", key: "s", value: effectiveWs, onChange: chooseWs, "aria-label": tt("header.wsAria") },
612
670
  workspaces.map(function (w, i) {
613
671
  return h("option", { key: w.path || i, value: w.path || "", disabled: !w.hasNovel },
614
- (w.hasNovel ? "" : "(无 novel)") + (w.name || w.path || "未命名"));
672
+ (w.hasNovel ? "" : tt("header.noNovel")) + (w.name || w.path || tt("header.untitled")));
615
673
  }))]
616
674
  : null,
617
- h("button", { type: "button", className: "myt-btn myt-btnIcon", title: "重新拉取", "aria-label": "重新拉取", disabled: loading, onClick: reload }, ic("IconRefreshOutlineRegular", 14)),
618
- h("button", { type: "button", className: "myt-closeBtn", "aria-label": "关闭创作台", title: "关闭", onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")))),
619
- h("nav", { className: "myt-tabs", "aria-label": "创作台分区" },
675
+ 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)),
676
+ 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)),
677
+ 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" }, "×")))),
678
+ readerFile ? null : h("nav", { className: "myt-tabs", "aria-label": tt("tabs.aria") },
620
679
  TABS.map(function (t) {
621
680
  return h("button", {
622
681
  key: t[0], type: "button", className: "myt-tab", "data-active": tab === t[0] ? "true" : undefined,
623
682
  onClick: function () { gotoTab(t[0]); },
624
- }, t[1]);
683
+ }, tt(t[1]));
625
684
  })),
626
685
  h("div", { className: "myt-body" },
627
686
  h("div", { className: "myt-page" },
628
687
  snap && Array.isArray(snap.warnings) && snap.warnings.length ? WarningsStrip(snap.warnings) : null,
629
- body)),
688
+ // 错误边界包住 tab 内容:key 随 tab / 阅读页变化重挂,切分区自动清错误态。
689
+ h(TabErrorBoundary, { key: readerFile ? "reader:" + readerFile : tab, onRetry: reload }, body))),
690
+ showSettings ? h(SettingsModal, { scale: fontScale, onPick: setFontScale, lang: lang, onPickLang: setLang, onClose: function () { setShowSettings(false); } }) : null,
630
691
  h(ToastHost));
631
692
  }
632
693
 
@@ -635,7 +696,7 @@ window.__ModuleLoader__.load({
635
696
  return h("div", { className: "myt-strip myt-stripWarn" },
636
697
  h("span", { className: "myt-stripIcon" }, ic("IconWarningOutlineRegular", 14)),
637
698
  h("span", { className: "myt-stripText" },
638
- h("div", null, h("strong", null, "部分文件解析异常(已跳过):")),
699
+ h("div", null, h("strong", null, tt("warnings.title"))),
639
700
  warnings.map(function (w, i) { return h("div", { key: i }, "· " + String(w)); })));
640
701
  }
641
702
 
@@ -659,50 +720,507 @@ window.__ModuleLoader__.load({
659
720
  return h("div", { className: "myt-stack" },
660
721
  h("div", { className: "myt-card myt-hero" },
661
722
  h("div", { className: "myt-heroMeta" },
662
- project.author ? h("span", { className: "myt-muted" }, "作者:" + project.author) : null,
723
+ project.author ? h("span", { className: "myt-muted" }, tf("overview.author", { name: project.author })) : null,
663
724
  project.genre ? h("span", { className: "myt-badge" }, project.genre) : null,
664
725
  project.length ? h("span", { className: "myt-badge" }, project.length) : null,
665
726
  project.language ? h("span", { className: "myt-badge" }, project.language) : null),
666
727
  h("h2", { className: "myt-heroTitle" }, orDash(project.title)),
667
728
  h("div", { className: "myt-heroMeta" },
668
729
  phasePill(project.phase),
669
- 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)),
730
+ 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)),
670
731
  project.description ? h("p", { className: "myt-heroDesc" }, project.description) : null),
671
732
  h("div", { className: "myt-statRow" },
672
- h("div", { className: "myt-card myt-stat" }, h("span", { className: "myt-statNum" }, fmtNum(totals.words)), h("span", { className: "myt-statLabel" }, "总字数(CJK)")),
673
- h("div", { className: "myt-card myt-stat" }, h("span", { className: "myt-statNum" }, fmtNum(totals.chapters)), h("span", { className: "myt-statLabel" }, "章节总数")),
733
+ h("div", { className: "myt-card myt-stat" }, h("span", { className: "myt-statNum" }, fmtNum(totals.words)), h("span", { className: "myt-statLabel" }, tt("overview.totalWords"))),
734
+ h("div", { className: "myt-card myt-stat" }, h("span", { className: "myt-statNum" }, fmtNum(totals.chapters)), h("span", { className: "myt-statLabel" }, tt("overview.totalChapters"))),
674
735
  h("div", { className: "myt-card myt-stat" },
675
736
  h("span", { className: "myt-statNum" }, fmtNum((snap.characters || []).length)),
676
- h("span", { className: "myt-statLabel" }, "角色数")),
737
+ h("span", { className: "myt-statLabel" }, tt("overview.characters"))),
677
738
  h("div", { className: "myt-card myt-stat" },
678
739
  h("span", { className: "myt-statNum" }, fmtNum((snap.foreshadows || []).filter(function (f) { return f.status === "planted" || f.status === "progressing"; }).length)),
679
- h("span", { className: "myt-statLabel" }, "未回收伏笔"))),
740
+ h("span", { className: "myt-statLabel" }, tt("overview.openForeshadows")))),
680
741
  h("div", { className: "myt-card" },
681
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "章节状态分布")),
742
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("overview.statusDist"))),
682
743
  h("div", { className: "myt-distBar", "aria-hidden": "true" }, STATUS_FLOW.map(function (s) {
683
744
  var n = byStatus[s] || 0;
684
745
  return n > 0 ? h("div", { key: s, className: "myt-distSeg", "data-status": s, style: { flexGrow: n } }) : null;
685
746
  })),
686
747
  h("div", { className: "myt-heroMeta" }, STATUS_FLOW.map(function (s) {
687
- return h("span", { key: s, className: "myt-muted" }, STATUS_LABEL[s] + " " + (byStatus[s] || 0));
748
+ return h("span", { key: s, className: "myt-muted" }, tt("status." + s) + " " + (byStatus[s] || 0));
688
749
  }))),
689
750
  h("div", { className: "myt-card" },
690
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "最近章节")),
751
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("overview.recent"))),
691
752
  recent.length
692
753
  ? h("div", { className: "myt-recentList" }, recent.map(function (ch) {
693
754
  return h("button", { key: ch.file, type: "button", className: "myt-recentRow", onClick: function () { gotoTab("chapters", ch.file); } },
694
755
  statusBadge(ch.status),
695
756
  h("span", { className: "myt-chTitle" }, orDash(ch.title)),
696
757
  h("span", { className: "myt-chPath" }, chWhere(ch)),
697
- h("span", { className: "myt-wordsCell" }, fmtNum(ch.words) + " 字"));
758
+ h("span", { className: "myt-wordsCell" }, tf("common.charCount", { n: fmtNum(ch.words) })));
698
759
  }))
699
- : h(Empty, { text: "chapters/ 目录还没有章节文件" })));
760
+ : h(Empty, { text: tt("chapters.empty") })));
761
+ }
762
+
763
+ // ── 轻量 Markdown 渲染(零依赖):先整体 HTML 转义再施加 md 变换,杜绝
764
+ // 注入;支持标题/加粗/斜体/删除线/行内码/围栏码/引用/有序无序列表/分隔线/
765
+ // 链接/段落换行。供章纲、世界观条目等 md 阅读位使用,正文手稿仍走 pre。 ──
766
+ function escapeHtml(s) {
767
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
768
+ }
769
+ // 链接 scheme 白名单:仅放行 http/https 绝对地址与无 scheme 的相对路径 /
770
+ // 锚点 / //协议相对;javascript:/data:/vbscript: 等一律拒绝(调用方落纯文本)。
771
+ function isSafeLinkUrl(url) {
772
+ var m = String(url || "").match(/^([a-zA-Z][a-zA-Z0-9+.\-]*):/);
773
+ return !m || /^https?$/i.test(m[1]);
774
+ }
775
+ function inlineMd(s) {
776
+ // 行内码占位保护:先抽出 `code` 区间换成哨兵占位符,加粗/斜体/删除线/
777
+ // 链接不再命中码内文本(否则 `*x*` 会被二次加格式),最后回填。
778
+ // 哨兵用 NUL 控制符 + 固定前缀:正文已过 escapeHtml,不可能出现该序列。
779
+ var codes = [];
780
+ var out = String(s).replace(/`([^`]+)`/g, function (_m, code) {
781
+ codes.push("<code>" + code + "</code>");
782
+ return "\u0000MYTCODE" + (codes.length - 1) + "\u0000";
783
+ })
784
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
785
+ .replace(/__([^_]+)__/g, "<strong>$1</strong>")
786
+ .replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
787
+ .replace(/~~([^~]+)~~/g, "<del>$1</del>")
788
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function (_m, txt, url) {
789
+ if (!isSafeLinkUrl(url)) return _m; // 白名单外:原样落纯文本,不生成 href
790
+ return '<a href="' + url + '" target="_blank" rel="noreferrer">' + txt + "</a>";
791
+ });
792
+ return out.replace(/\u0000MYTCODE(\d+)\u0000/g, function (_m, i) { return codes[Number(i)] || ""; });
793
+ }
794
+ function renderMd(src) {
795
+ var lines = escapeHtml(src).split(/\r?\n/);
796
+ var html = [];
797
+ var i = 0;
798
+ while (i < lines.length) {
799
+ var line = lines[i];
800
+ if (/^```/.test(line)) {
801
+ var code = [];
802
+ i++;
803
+ while (i < lines.length && !/^```/.test(lines[i])) { code.push(lines[i]); i++; }
804
+ i++; // 吃掉收尾 ```(缺失也吞到尾)
805
+ html.push('<pre class="myt-mdCode"><code>' + code.join("\n") + "</code></pre>");
806
+ continue;
807
+ }
808
+ if (/^\s*$/.test(line)) { i++; continue; }
809
+ var h2 = line.match(/^(#{1,6})\s+(.*)$/);
810
+ if (h2) {
811
+ var lv = h2[1].length;
812
+ html.push('<h' + lv + ' class="myt-mdH">' + inlineMd(h2[2]) + "</h" + lv + ">");
813
+ i++; continue;
814
+ }
815
+ if (/^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { html.push('<hr class="myt-mdHr">'); i++; continue; }
816
+ if (/^\s*&gt;\s?/.test(line)) {
817
+ var quote = [];
818
+ while (i < lines.length && /^\s*&gt;\s?/.test(lines[i])) { quote.push(inlineMd(lines[i].replace(/^\s*&gt;\s?/, ""))); i++; }
819
+ html.push('<blockquote class="myt-mdQuote">' + quote.join("<br>") + "</blockquote>");
820
+ continue;
821
+ }
822
+ var ul = line.match(/^\s*[-*+]\s+(.*)$/);
823
+ if (ul) {
824
+ var items = [];
825
+ while (i < lines.length) {
826
+ var m1 = lines[i].match(/^\s*[-*+]\s+(.*)$/);
827
+ if (!m1) break;
828
+ items.push("<li>" + inlineMd(m1[1]) + "</li>");
829
+ i++;
830
+ }
831
+ html.push('<ul class="myt-mdList">' + items.join("") + "</ul>");
832
+ continue;
833
+ }
834
+ var ol = line.match(/^\s*\d+[.)]\s+(.*)$/);
835
+ if (ol) {
836
+ var oitems = [];
837
+ while (i < lines.length) {
838
+ var m2 = lines[i].match(/^\s*\d+[.)]\s+(.*)$/);
839
+ if (!m2) break;
840
+ oitems.push("<li>" + inlineMd(m2[1]) + "</li>");
841
+ i++;
842
+ }
843
+ html.push('<ol class="myt-mdList">' + oitems.join("") + "</ol>");
844
+ continue;
845
+ }
846
+ var para = [line];
847
+ i++;
848
+ while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^```|^\s*&gt;|^\s*[-*+]\s|^\s*\d+[.)]\s|^#{1,6}\s/.test(lines[i])) {
849
+ para.push(lines[i]); i++;
850
+ }
851
+ html.push('<p class="myt-mdP">' + inlineMd(para.join("<br>")) + "</p>");
852
+ }
853
+ // 块间直接相连不补 "\n":落点容器(.myt-outlineBody / .myt-worldDesc)是
854
+ // white-space:pre-wrap,块间再塞换行会渲染成额外空行 → 双倍间距;
855
+ // 块间距已由 .myt-md* 各自 margin 承担。
856
+ return html.join("");
857
+ }
858
+ function MdView(props) {
859
+ return h("div", {
860
+ className: "myt-md" + (props.className ? " " + props.className : ""),
861
+ dangerouslySetInnerHTML: { __html: renderMd(props.text || "") },
862
+ });
863
+ }
864
+
865
+ // ── 系统设置:字号五档(面板整体 zoom,抽屉/弹层 portal 节点同缩放)──────
866
+ var FONT_SCALE_KEY = "maoying.fontScale";
867
+ var FONT_SCALES = [["1", "100%"], ["1.1", "110%"], ["1.2", "120%"], ["1.3", "130%"], ["1.4", "140%"], ["1.5", "150%"]];
868
+ // ── 界面语言(系统设置可选中文/English):UI_LANG 为模块级当前语言,
869
+ // PageShell 在 lang state 变化时改写它并整树重渲。tt/tf 是唯一取词口:
870
+ // zh 词典的值必须与历史硬编码文案逐字一致(中文界面零回归),en 词典
871
+ // 不留中文;词典缺键回落 key 本身(便于渐进迁移,一眼看出漏网串)。 ──
872
+ var LANG_KEY = "maoying.lang";
873
+ var UI_LANGS = [["zh", "中文"], ["en", "English"]];
874
+ var UI_LANG = "zh";
875
+ var I18N = {
876
+ zh: {
877
+ "shell.title": "毛颖创作台", "header.subtitle": "网文项目可视化与状态流转 · 只读正文,写状态",
878
+ "header.curVerTitle": "当前版本", "header.upgradeTip": "当前 {cur},点击打开新版发布页", "header.newVersion": "有新版 {ver} ↗",
879
+ "header.workspace": "工作区", "header.wsAria": "选择工作区", "header.noNovel": "(无 novel)", "header.untitled": "未命名",
880
+ "header.settings": "系统设置", "header.refresh": "重新拉取", "header.closeAria": "关闭创作台", "header.closeTitle": "关闭",
881
+ "tabs.aria": "创作台分区",
882
+ "tabs.overview": "概览", "tabs.chapters": "章节", "tabs.characters": "角色", "tabs.foreshadows": "伏笔", "tabs.relations": "关系图",
883
+ "tabs.timeline": "时间线", "tabs.world": "设定", "tabs.index": "索引板", "tabs.settings": "小说设置",
884
+ "shell.wsListFailed": "工作区列表拉取失败:{msg}", "shell.stateFailed": "快照拉取失败:{msg}",
885
+ "guide.noWsTitle": "还没有可用的小说工作区", "guide.noWsDesc": "任一工作区根目录下出现 novel/project.yml 即自动入选;先用命令初始化目录骨架:",
886
+ "guide.notInitTitle": "该工作区尚未初始化小说项目", "guide.notInitDesc": "工作区根目录下没有 novel/project.yml。在该工作区根目录执行:",
887
+ "guide.defaultTitle": "该工作区还没有小说项目", "guide.defaultDesc": "在下面给出的命令里初始化目录骨架(或把已有 novel 目录放进工作区根),然后刷新本面板。",
888
+ "guide.copied": "已复制 maoying init", "guide.copyFailed": "复制失败,请手动选择命令文本", "guide.copying": "复制中…", "guide.copyCmd": "复制命令",
889
+ "common.retry": "重试", "common.empty": "暂无数据", "common.uncategorized": "未分类", "common.charCount": "{n} 字",
890
+ "common.closeTip": "关闭提示", "common.unknownErr": "未知错误", "common.priorityTitle": "优先级:{pri}", "common.priorityAria": "优先级{pri}",
891
+ "common.chapterNum": "第 {n} 章", "pri.high": "高", "pri.normal": "中", "pri.low": "低",
892
+ "toast.prefix": "毛颖创作台:{msg}", "toast.success": "操作成功", "toast.indexRebuilt": "索引已重建({n} 章)",
893
+ "toast.chapterStatus": "章节状态已更新", "toast.foreshadowStatus": "伏笔状态已更新", "toast.phaseAdvanced": "阶段已推进到「{phase}」",
894
+ "api.notJson": "响应不是 JSON(HTTP {code})", "api.timeout": "请求超时({s} 秒)",
895
+ "status.pending": "待写", "status.writing": "写作中", "status.review": "审校中", "status.accepted": "已定稿", "status.rawSuffix": "({raw})",
896
+ "phase.idea": "立意", "phase.setting": "设定", "phase.outline": "大纲", "phase.writing": "写作", "phase.review": "审校",
897
+ "phase.consistency": "一致性", "phase.export": "导出", "phase.unset": "未设阶段",
898
+ "phase.advanceTitle": "推进到「{next}」", "phase.advance": "推进阶段", "phase.advancing": "推进中…", "phase.final": "已是最终阶段",
899
+ "foreshadow.planted": "埋设", "foreshadow.progressing": "推进中", "foreshadow.resolved": "已回收", "foreshadow.abandoned": "已弃用",
900
+ "foreshadow.advance": "推进", "foreshadow.resolve": "回收", "foreshadow.abandon": "弃用", "foreshadow.advancing": "推进中…",
901
+ "foreshadow.plantedUnmarked": "埋设章未标", "foreshadow.resolvedAt": "回收于 {ch}", "foreshadow.priorityLabel": "优先级 {pri}",
902
+ "foreshadow.backfill": "回填", "foreshadow.backfillTip": "从作者正文回填", "foreshadow.missingStatus": "缺 status",
903
+ "foreshadow.noIdTip": "该条目缺 id,无法写状态",
904
+ "foreshadow.noStatusTip": "该条目缺 status 行,服务端会拒绝流转(409);请先在 foreshadows.yml 补上 status",
905
+ "foreshadow.empty": "foreshadows.yml 还没有伏笔条目", "foreshadow.colEmpty": "空",
906
+ "clue.clue": "线索", "clue.redHerring": "红鲱鱼", "clue.inference": "推理", "clue.question": "疑问",
907
+ "sci.known": "已确证", "sci.extrapolation": "推演", "sci.hypothesis": "假说", "world.unlabeled": "未标级",
908
+ "warnings.title": "部分文件解析异常(已跳过):",
909
+ "index.title": "索引板(clue-board / memories)", "index.rebuild": "重建索引", "index.rebuilding": "重建中…",
910
+ "index.rebuildTip": "按 CLI index 同一逻辑重建 index.yml", "index.clueBoard": "线索板", "index.clueEmpty": "clue-board.yml 还没有条目",
911
+ "index.memBoard": "记忆板", "index.memEmpty": "memories.yml 还没有条目", "index.promise": "承诺线",
912
+ "timeline.empty": "timeline.yml 还没有时间线条目", "timeline.importance": "重要度 {n}", "timeline.linkedChapter": "关联章节:{ch}",
913
+ "world.title": "世界观条目", "world.empty": "world/ 目录还没有设定条目", "world.science": "科学设定",
914
+ "world.scienceEmpty": "science/ 目录还没有科学设定", "world.research": "资料检索", "world.ref": "参考 {n}:{r}",
915
+ "relations.title": "角色关系图", "relations.counts": "{n} 节点 · {m} 关系",
916
+ "relations.help": "拖节点固定位置 · 滚轮缩放 · 双击复位 · 悬停边看关系描述",
917
+ "relations.unlinkedTitle": "未入图角色(无关系边)", "relations.noData": "没有角色或关系数据,无法绘图",
918
+ "relations.edgeTipPrefix": "{type}:", "relations.noDesc": "(无描述)",
919
+ "char.empty": "characters/ 目录还没有角色卡",
920
+ "char.field.age": "年龄", "char.field.gender": "性别", "char.field.appearance": "外貌", "char.field.personality": "性格",
921
+ "char.field.background": "背景", "char.field.motivation": "动机", "char.field.arc": "成长弧",
922
+ "char.years": "{n} 岁", "char.linePersonality": "性格 ", "char.lineMotivation": "动机 ",
923
+ "char.modalAria": "角色详情", "char.closeAria": "关闭角色卡", "char.markers": "标记", "char.file": "文件",
924
+ "overview.author": "作者:{name}", "overview.totalWords": "总字数(CJK)", "overview.totalChapters": "章节总数",
925
+ "overview.characters": "角色数", "overview.openForeshadows": "未回收伏笔", "overview.statusDist": "章节状态分布", "overview.recent": "最近章节",
926
+ "chapters.empty": "chapters/ 目录还没有章节文件", "chapters.writeQueue": "按单写作", "chapters.rowTip": "点击打开该章",
927
+ "chapters.outlineWords": "章纲 {n} 字", "chapters.all": "全部", "chapters.thVolume": "卷 · 章", "chapters.thTitle": "标题",
928
+ "chapters.thStatus": "状态", "chapters.thWords": "字数", "chapters.thSummary": "梗概", "chapters.rowReadTip": "点击阅读正文",
929
+ "chapters.emptyFiltered": "该状态下没有章节",
930
+ "reader.back": "返回", "reader.backTip": "返回章节列表", "reader.copyPath": "复制路径", "reader.copyPathTip": "复制章节文件路径",
931
+ "reader.copied": "已复制章节文件路径", "reader.copyFailed": "复制失败,请手动选择路径文本", "reader.loadFailed": "正文读取失败:{msg}",
932
+ "reader.noOutline": "暂无章纲", "reader.emptyBody": "(空章节)", "reader.finalState": "已定稿(终态)", "reader.statusFlow": "状态流转:",
933
+ "reader.flowing": "流转中…", "reader.advanceTo": "推进到「{next}」",
934
+ "outline.title": "章纲对照", "outline.empty": "(章纲为空)", "outline.collapseTip": "收起章纲", "outline.expandTip": "展开章纲",
935
+ "novel.metaTitle": "书稿元信息", "novel.metaNote": "对应 novel/project.yml · 行尾注释会原样保留",
936
+ "novel.fTitle": "书名", "novel.titlePh": "如:长夜余火", "novel.fAuthor": "作者", "novel.authorPh": "笔名",
937
+ "novel.fGenre": "题材", "novel.genrePh": "如:东方玄幻 / 都市异能 / 历史架空", "novel.fLength": "预期篇幅",
938
+ "novel.lengthPh": "如:约100万字 / 300章", "novel.fLanguage": "正文语言", "novel.fPhase": "工作流阶段", "novel.fMode": "共创模式",
939
+ "novel.fDesc": "一句话简介", "novel.descPh": "写封面文案时可直接引用", "novel.titleRequired": "书名不能为空",
940
+ "mode.aiDraft": "AI 代笔(AI 写正文)", "mode.coWrite": "逐章共创(按章指派谁写)", "mode.authorDraft": "作者亲笔(AI 只备料审稿)",
941
+ "novel.saving": "保存中…", "novel.save": "保存设置",
942
+ "settings.title": "系统设置", "settings.closeAria": "关闭设置", "settings.language": "语言",
943
+ "settings.languageHint": "界面文字随语言选项切换,立即生效并记忆。", "settings.fontScale": "字号",
944
+ "settings.fontScaleHint": "按当前字号的百分比缩放整个创作台,立即生效并记忆。",
945
+ "errBoundary.title": "该分区渲染出错", "errBoundary.summary": "错误摘要:{msg}",
946
+ "dim.cognitiveFrame": "认知框架", "dim.emotionalAnchor": "情感锚点", "dim.worldTexture": "世界质感",
947
+ "dim.suspense": "悬念", "dim.interpersonalTension": "人际张力",
948
+ },
949
+ en: {
950
+ "shell.title": "Maoying Studio", "header.subtitle": "Web-novel project dashboard & status flow · read-only text, writable status",
951
+ "header.curVerTitle": "Current version", "header.upgradeTip": "Current {cur} — click to open the release page", "header.newVersion": "New version {ver} ↗",
952
+ "header.workspace": "Workspace", "header.wsAria": "Select workspace", "header.noNovel": "(no novel)", "header.untitled": "Untitled",
953
+ "header.settings": "Settings", "header.refresh": "Refresh", "header.closeAria": "Close Maoying Studio", "header.closeTitle": "Close",
954
+ "tabs.aria": "Studio sections",
955
+ "tabs.overview": "Overview", "tabs.chapters": "Chapters", "tabs.characters": "Characters", "tabs.foreshadows": "Foreshadowing", "tabs.relations": "Relations",
956
+ "tabs.timeline": "Timeline", "tabs.world": "Worldbuilding", "tabs.index": "Index Board", "tabs.settings": "Novel Settings",
957
+ "shell.wsListFailed": "Failed to load workspace list: {msg}", "shell.stateFailed": "Failed to load snapshot: {msg}",
958
+ "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:",
959
+ "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:",
960
+ "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.",
961
+ "guide.copied": "Copied maoying init", "guide.copyFailed": "Copy failed — select the command text manually", "guide.copying": "Copying…", "guide.copyCmd": "Copy command",
962
+ "common.retry": "Retry", "common.empty": "No data", "common.uncategorized": "Uncategorized", "common.charCount": "{n} chars",
963
+ "common.closeTip": "Dismiss notification", "common.unknownErr": "Unknown error", "common.priorityTitle": "Priority: {pri}", "common.priorityAria": "Priority {pri}",
964
+ "common.chapterNum": "Ch. {n}", "pri.high": "High", "pri.normal": "Normal", "pri.low": "Low",
965
+ "toast.prefix": "Maoying Studio: {msg}", "toast.success": "Success", "toast.indexRebuilt": "Index rebuilt ({n} chapters)",
966
+ "toast.chapterStatus": "Chapter status updated", "toast.foreshadowStatus": "Foreshadowing status updated", "toast.phaseAdvanced": "Phase advanced to {phase}",
967
+ "api.notJson": "Response is not JSON (HTTP {code})", "api.timeout": "Request timed out ({s} s)",
968
+ "status.pending": "Pending", "status.writing": "Writing", "status.review": "Review", "status.accepted": "Accepted", "status.rawSuffix": "({raw})",
969
+ "phase.idea": "Idea", "phase.setting": "Setting", "phase.outline": "Outline", "phase.writing": "Writing", "phase.review": "Review",
970
+ "phase.consistency": "Consistency", "phase.export": "Export", "phase.unset": "No phase",
971
+ "phase.advanceTitle": "Advance to {next}", "phase.advance": "Advance phase", "phase.advancing": "Advancing…", "phase.final": "Final phase",
972
+ "foreshadow.planted": "Planted", "foreshadow.progressing": "In Progress", "foreshadow.resolved": "Resolved", "foreshadow.abandoned": "Abandoned",
973
+ "foreshadow.advance": "Advance", "foreshadow.resolve": "Resolve", "foreshadow.abandon": "Abandon", "foreshadow.advancing": "Advancing…",
974
+ "foreshadow.plantedUnmarked": "Planting chapter unmarked", "foreshadow.resolvedAt": "Resolved in {ch}", "foreshadow.priorityLabel": "Priority {pri}",
975
+ "foreshadow.backfill": "Backfill", "foreshadow.backfillTip": "Backfilled from the author's manuscript", "foreshadow.missingStatus": "missing status",
976
+ "foreshadow.noIdTip": "This entry has no id — its status cannot be written",
977
+ "foreshadow.noStatusTip": "This entry has no status line, the server will reject the transition (409); add a status in foreshadows.yml first",
978
+ "foreshadow.empty": "No entries in foreshadows.yml yet", "foreshadow.colEmpty": "Empty",
979
+ "clue.clue": "Clue", "clue.redHerring": "Red herring", "clue.inference": "Inference", "clue.question": "Question",
980
+ "sci.known": "Confirmed", "sci.extrapolation": "Extrapolation", "sci.hypothesis": "Hypothesis", "world.unlabeled": "Unrated",
981
+ "warnings.title": "Some files failed to parse (skipped):",
982
+ "index.title": "Index Board (clue-board / memories)", "index.rebuild": "Rebuild index", "index.rebuilding": "Rebuilding…",
983
+ "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",
984
+ "index.memBoard": "Memory board", "index.memEmpty": "No entries in memories.yml yet", "index.promise": "Promise thread",
985
+ "timeline.empty": "No entries in timeline.yml yet", "timeline.importance": "Importance {n}", "timeline.linkedChapter": "Related chapter: {ch}",
986
+ "world.title": "Worldbuilding entries", "world.empty": "No worldbuilding entries in world/ yet", "world.science": "Science settings",
987
+ "world.scienceEmpty": "No science entries in science/ yet", "world.research": "Research", "world.ref": "Ref {n}: {r}",
988
+ "relations.title": "Character relations", "relations.counts": "{n} nodes · {m} relations",
989
+ "relations.help": "Drag a node to pin it · scroll to zoom · double-click to reset · hover an edge for its description",
990
+ "relations.unlinkedTitle": "Unlinked characters (no relation edges)", "relations.noData": "No character or relation data to draw",
991
+ "relations.edgeTipPrefix": "{type}: ", "relations.noDesc": "(no description)",
992
+ "char.empty": "No character cards in characters/ yet",
993
+ "char.field.age": "Age", "char.field.gender": "Gender", "char.field.appearance": "Appearance", "char.field.personality": "Personality",
994
+ "char.field.background": "Background", "char.field.motivation": "Motivation", "char.field.arc": "Character arc",
995
+ "char.years": "Age {n}", "char.linePersonality": "Personality ", "char.lineMotivation": "Motivation ",
996
+ "char.modalAria": "Character details", "char.closeAria": "Close character card", "char.markers": "Tags", "char.file": "File",
997
+ "overview.author": "Author: {name}", "overview.totalWords": "Total characters (CJK)", "overview.totalChapters": "Chapters",
998
+ "overview.characters": "Characters", "overview.openForeshadows": "Unresolved foreshadowing", "overview.statusDist": "Chapter status breakdown", "overview.recent": "Recent chapters",
999
+ "chapters.empty": "No chapter files in chapters/ yet", "chapters.writeQueue": "Writing Queue", "chapters.rowTip": "Click to open this chapter",
1000
+ "chapters.outlineWords": "Outline {n} chars", "chapters.all": "All", "chapters.thVolume": "Vol · Ch", "chapters.thTitle": "Title",
1001
+ "chapters.thStatus": "Status", "chapters.thWords": "Words", "chapters.thSummary": "Summary", "chapters.rowReadTip": "Click to read",
1002
+ "chapters.emptyFiltered": "No chapters with this status",
1003
+ "reader.back": "Back", "reader.backTip": "Back to chapter list", "reader.copyPath": "Copy path", "reader.copyPathTip": "Copy chapter file path",
1004
+ "reader.copied": "Copied chapter file path", "reader.copyFailed": "Copy failed — select the path text manually", "reader.loadFailed": "Failed to load chapter text: {msg}",
1005
+ "reader.noOutline": "No chapter outline", "reader.emptyBody": "(empty chapter)", "reader.finalState": "Accepted (final)", "reader.statusFlow": "Status:",
1006
+ "reader.flowing": "Transitioning…", "reader.advanceTo": "Advance to {next}",
1007
+ "outline.title": "Outline", "outline.empty": "(outline is empty)", "outline.collapseTip": "Collapse outline", "outline.expandTip": "Expand outline",
1008
+ "novel.metaTitle": "Manuscript metadata", "novel.metaNote": "Maps to novel/project.yml · trailing comments are preserved",
1009
+ "novel.fTitle": "Title", "novel.titlePh": "e.g. The Long Night", "novel.fAuthor": "Author", "novel.authorPh": "Pen name",
1010
+ "novel.fGenre": "Genre", "novel.genrePh": "e.g. Eastern fantasy / Urban powers / Alternate history", "novel.fLength": "Planned length",
1011
+ "novel.lengthPh": "e.g. ~1M characters / 300 chapters", "novel.fLanguage": "Text language", "novel.fPhase": "Workflow phase", "novel.fMode": "Co-writing mode",
1012
+ "novel.fDesc": "One-line synopsis", "novel.descPh": "Can be quoted directly in cover copy", "novel.titleRequired": "Title is required",
1013
+ "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)",
1014
+ "novel.saving": "Saving…", "novel.save": "Save",
1015
+ "settings.title": "Settings", "settings.closeAria": "Close settings", "settings.language": "Language",
1016
+ "settings.languageHint": "UI text follows the language option; it applies immediately and is remembered.", "settings.fontScale": "Font size",
1017
+ "settings.fontScaleHint": "Scales the whole studio as a percentage of the current font size; applies immediately and is remembered.",
1018
+ "errBoundary.title": "This section failed to render", "errBoundary.summary": "Error summary: {msg}",
1019
+ "dim.cognitiveFrame": "Cognitive Frame", "dim.emotionalAnchor": "Emotional Anchor", "dim.worldTexture": "World Texture",
1020
+ "dim.suspense": "Suspense", "dim.interpersonalTension": "Interpersonal Tension",
1021
+ },
1022
+ };
1023
+ function tt(key) {
1024
+ var d = I18N[UI_LANG] || I18N.zh;
1025
+ var v = d[key];
1026
+ if (v === undefined) v = I18N.zh[key];
1027
+ return v === undefined ? key : v;
1028
+ }
1029
+ function tf(key, vars) {
1030
+ var s = tt(key);
1031
+ if (vars) for (var k in vars) s = s.split("{" + k + "}").join(String(vars[k]));
1032
+ return s;
1033
+ }
1034
+ function SettingsModal(props) {
1035
+ var scale = props.scale;
1036
+ var onPick = props.onPick;
1037
+ var lang = props.lang;
1038
+ var onPickLang = props.onPickLang;
1039
+ var onClose = props.onClose;
1040
+ return h(MaskPortal, {
1041
+ className: "myt-settingsMask",
1042
+ onMouseDown: function (e) { if (e.target === e.currentTarget) onClose(); },
1043
+ },
1044
+ h("div", { className: "myt-settings", role: "dialog", "aria-modal": "true", "aria-label": tt("settings.title") },
1045
+ h("div", { className: "myt-settingsHead" },
1046
+ h("h3", { className: "myt-settingsTitle" }, tt("settings.title")),
1047
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("settings.closeAria"), onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
1048
+ h("div", { className: "myt-settingsBody" },
1049
+ h("div", { className: "myt-settingsLabel" }, tt("settings.language")),
1050
+ h("div", { className: "myt-setRow" }, UI_LANGS.map(function (o) {
1051
+ return h("button", {
1052
+ key: o[0], type: "button", className: "myt-setOpt", "data-active": lang === o[0] ? "true" : undefined,
1053
+ onClick: function () { onPickLang(o[0]); },
1054
+ }, o[1]);
1055
+ })),
1056
+ h("div", { className: "myt-settingsHint" }, tt("settings.languageHint")),
1057
+ h("div", { className: "myt-settingsLabel" }, tt("settings.fontScale")),
1058
+ h("div", { className: "myt-setRow" }, FONT_SCALES.map(function (o) {
1059
+ return h("button", {
1060
+ key: o[0], type: "button", className: "myt-setOpt", "data-active": scale === o[0] ? "true" : undefined,
1061
+ onClick: function () { onPick(o[0]); },
1062
+ }, o[1]);
1063
+ })),
1064
+ h("div", { className: "myt-settingsHint" }, tt("settings.fontScaleHint")))));
1065
+ }
1066
+
1067
+ // ── 章节阅读页(整页视图,替代右侧抽屉):返回按钮 + 章纲对照 + 正文 +
1068
+ // 五维 + 状态流转。原抽屉是 position:fixed 侧弹层,在桌面端会被侧边栏
1069
+ // 遮挡;整页视图天然在面板文档流内,无遮挡问题。 ──
1070
+ function ChapterReader(props) {
1071
+ var file = props.file;
1072
+ var wsPath = props.wsPath;
1073
+ var snap = props.snap;
1074
+ var reload = props.reload;
1075
+ var onBack = props.onBack;
1076
+ var _d = useState(null), detail = _d[0], setDetail = _d[1]; // {loading, data, err}
1077
+ var _b = useState(false), flowBusy = _b[0], setFlowBusy = _b[1];
1078
+ var detailSeqRef = useRef(0); // chapter 请求序号:快速切章时丢弃过期响应
1079
+ var loadDetail = useCallback(function (f) {
1080
+ var seq = ++detailSeqRef.current;
1081
+ if (!f) { setDetail(null); return; }
1082
+ setDetail({ loading: true, data: null, err: "" });
1083
+ apiGet("chapter", { path: wsPath, file: f }).then(function (d) {
1084
+ if (seq !== detailSeqRef.current) return; // 过期响应:已在看另一章,丢弃
1085
+ setDetail({ loading: false, data: d, err: "" });
1086
+ }).catch(function (err) {
1087
+ if (seq !== detailSeqRef.current) return;
1088
+ setDetail({ loading: false, data: null, err: err.message });
1089
+ });
1090
+ }, [wsPath]);
1091
+ useEffect(function () { loadDetail(file); }, [file, loadDetail]);
1092
+ var ch = null;
1093
+ var chapters = (snap && snap.chapters) || [];
1094
+ for (var i = 0; i < chapters.length; i++) { if (chapters[i].file === file) { ch = chapters[i]; break; } }
1095
+ function advance() {
1096
+ if (!ch) return;
1097
+ var idx = STATUS_FLOW.indexOf(ch.status);
1098
+ var next = idx !== -1 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null;
1099
+ if (!next || flowBusy) return;
1100
+ setFlowBusy(true);
1101
+ apiPost("chapter-status", { path: wsPath, file: ch.file, status: next })
1102
+ .then(function () { return reload(); })
1103
+ .then(function () { return loadDetail(ch.file); })
1104
+ .catch(function () {}).finally(function () { setFlowBusy(false); });
1105
+ }
1106
+ function copyChapterPath() {
1107
+ copyText(chapterAbsPath(snap && snap.novelDir, file)).then(function (ok) {
1108
+ if (ok) toast.ok(tt("reader.copied"));
1109
+ else toast.warn(tt("reader.copyFailed"));
1110
+ });
1111
+ }
1112
+ return h("div", { className: "myt-reader" },
1113
+ h("div", { className: "myt-readerBar" },
1114
+ h("button", { type: "button", className: "myt-btn", onClick: onBack, title: tt("reader.backTip") },
1115
+ ic("IconChevronRightOutlineRegular", 13), tt("reader.back")),
1116
+ h("h2", { className: "myt-readerTitle" }, ch ? chFullTitle(ch) : file),
1117
+ ch ? statusBadge(ch.status) : null,
1118
+ h("div", { className: "myt-readerBarActions" },
1119
+ h("button", {
1120
+ type: "button", className: "myt-btn myt-copyBtn",
1121
+ title: tt("reader.copyPathTip"), "aria-label": tt("reader.copyPathTip"),
1122
+ onClick: copyChapterPath,
1123
+ }, ic("IconCopyOutlineRegular", 13), tt("reader.copyPath")))),
1124
+ h("div", { className: "myt-readerBody" },
1125
+ !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%" } }))
1126
+ : detail.err ? h(ErrorStrip, { text: tf("reader.loadFailed", { msg: detail.err }), onRetry: function () { loadDetail(file); } })
1127
+ : [
1128
+ (function () {
1129
+ var ol = detail.data && detail.data.outline;
1130
+ return ol ? h(OutlinePanel, { key: "outline", outline: ol })
1131
+ : h("div", { key: "outline-none", className: "myt-outlineEmpty" }, tt("reader.noOutline"));
1132
+ })(),
1133
+ h("pre", { key: "body", className: "myt-chapterBody" }, (detail.data && detail.data.body) || tt("reader.emptyBody")),
1134
+ ],
1135
+ ch ? h("div", { className: "myt-dimGrid" }, FIVE_DIMS.map(function (dim) {
1136
+ return h("div", { key: dim[0], className: "myt-dim" },
1137
+ h("div", { className: "myt-dimLabel" }, dim[1]),
1138
+ h("div", { className: "myt-dimText" }, orDash(ch[dim[0]])));
1139
+ })) : null),
1140
+ ch ? h("div", { className: "myt-readerFoot" },
1141
+ (function () {
1142
+ var idx = STATUS_FLOW.indexOf(ch.status);
1143
+ var next = idx !== -1 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null;
1144
+ if (!next) return h("span", { className: "myt-muted" }, tt("reader.finalState"));
1145
+ return [h("span", { className: "myt-muted", key: "t" }, tt("reader.statusFlow")),
1146
+ h("button", { key: "b", type: "button", className: "myt-btn myt-btnPrimary", disabled: flowBusy, onClick: advance },
1147
+ flowBusy ? tt("reader.flowing") : tf("reader.advanceTo", { next: tt("status." + next) }))];
1148
+ })()) : null);
1149
+ }
1150
+
1151
+ // ── 小说设置 tab:可视化 project.yml 平键并保存。mode/phase 走枚举
1152
+ // 下拉,自由文本走输入框;保存走 project-save 路由(服务端校验 + 保
1153
+ // 注释改写),成功后重拉快照让概览同步。 ──
1154
+ function SettingsTab(props) {
1155
+ var snap = props.snap;
1156
+ var wsPath = props.wsPath;
1157
+ var reload = props.reload;
1158
+ var project = snap.project || {};
1159
+ var _f = useState(function () {
1160
+ return {
1161
+ title: project.title || "", author: project.author || "", genre: project.genre || "",
1162
+ length: project.length || "", language: project.language || "zh",
1163
+ phase: PHASES.indexOf(project.phase) !== -1 ? project.phase : "idea",
1164
+ mode: project.mode || "ai-draft",
1165
+ description: project.description || "",
1166
+ };
1167
+ }), form = _f[0], setForm = _f[1];
1168
+ var _b = useState(false), busy = _b[0], setBusy = _b[1];
1169
+ var _e = useState(""), saveErr = _e[0], setSaveErr = _e[1];
1170
+ function set(k, v) { setForm(Object.assign({}, form, (function () { var o = {}; o[k] = v; return o; })())); }
1171
+ function save() {
1172
+ if (busy) return;
1173
+ if (!String(form.title || "").trim()) { setSaveErr(tt("novel.titleRequired")); return; }
1174
+ setBusy(true); setSaveErr("");
1175
+ // 成败提示统一走 apiPost 的 toast 通道(这里不再另弹,避免双 toast);
1176
+ // 卡内 saveErr 只承担表单旁的错误态。
1177
+ apiPost("project-save", { path: wsPath, values: form }).then(function () {
1178
+ return reload();
1179
+ }).catch(function (err) {
1180
+ setSaveErr(err.message);
1181
+ }).finally(function () { setBusy(false); });
1182
+ }
1183
+ function field(label, key, placeholder, textarea) {
1184
+ var value = form[key] || "";
1185
+ return h("label", { className: "myt-setField" },
1186
+ h("span", { className: "myt-setFieldLabel" }, label),
1187
+ textarea
1188
+ ? h("textarea", { className: "myt-setInput myt-setArea", rows: 3, placeholder: placeholder, value: value,
1189
+ onChange: function (e) { set(key, e.target.value); } })
1190
+ : h("input", { type: "text", className: "myt-setInput", placeholder: placeholder, value: value,
1191
+ onChange: function (e) { set(key, e.target.value); } }));
1192
+ }
1193
+ function picker(label, key, opts) {
1194
+ var value = form[key] || "";
1195
+ return h("label", { className: "myt-setField" },
1196
+ h("span", { className: "myt-setFieldLabel" }, label),
1197
+ h("select", { className: "myt-setInput myt-setSelect", value: value, onChange: function (e) { set(key, e.target.value); } },
1198
+ opts.map(function (o) { return h("option", { key: o[0], value: o[0] }, o[1]); })));
1199
+ }
1200
+ return h("div", { className: "myt-stack" },
1201
+ h("div", { className: "myt-card" },
1202
+ h("div", { className: "myt-cardHead" },
1203
+ h("h3", { className: "myt-cardTitle" }, tt("novel.metaTitle")),
1204
+ h("span", { className: "myt-muted" }, tt("novel.metaNote"))),
1205
+ h("div", { className: "myt-setGrid" },
1206
+ field(tt("novel.fTitle"), "title", tt("novel.titlePh")),
1207
+ field(tt("novel.fAuthor"), "author", tt("novel.authorPh")),
1208
+ field(tt("novel.fGenre"), "genre", tt("novel.genrePh")),
1209
+ field(tt("novel.fLength"), "length", tt("novel.lengthPh")),
1210
+ picker(tt("novel.fLanguage"), "language", [["zh", "中文"], ["en", "English"], ["ja", "日本語"]]),
1211
+ picker(tt("novel.fPhase"), "phase", PHASES.map(function (p) { return [p, tt("phase." + p)]; })),
1212
+ picker(tt("novel.fMode"), "mode", [["ai-draft", tt("mode.aiDraft")], ["co-write", tt("mode.coWrite")], ["author-draft", tt("mode.authorDraft")]]),
1213
+ field(tt("novel.fDesc"), "description", tt("novel.descPh"), true))),
1214
+ saveErr ? h(ErrorStrip, { text: saveErr }) : null,
1215
+ h("div", { className: "myt-setActions" },
1216
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save },
1217
+ busy ? tt("novel.saving") : tt("novel.save"))));
700
1218
  }
701
1219
 
702
1220
  // ── 章节 tab:状态筛选 pills + 表格 + 行抽屉(只读正文 + 五维 + 流转)────
703
1221
  var FIVE_DIMS = [
704
- ["cognitive_frame", "认知框架"], ["emotional_anchor", "情感锚点"], ["world_texture", "世界质感"],
705
- ["suspense", "悬念"], ["interpersonal_tension", "人际张力"],
1222
+ ["cognitive_frame", tt("dim.cognitiveFrame")], ["emotional_anchor", tt("dim.emotionalAnchor")], ["world_texture", tt("dim.worldTexture")],
1223
+ ["suspense", tt("dim.suspense")], ["interpersonal_tension", tt("dim.interpersonalTension")],
706
1224
  ];
707
1225
  // 章纲对照栏(五期):outline 非空时渲染可折叠原文(默认展开,只读,样式
708
1226
  // 与正文区分);共享常量避免重渲染时折叠态意外重置——组件随抽屉挂载,
@@ -710,19 +1228,19 @@ window.__ModuleLoader__.load({
710
1228
  function OutlinePanel(props) {
711
1229
  var outline = props.outline;
712
1230
  var _op = useState(true), open = _op[0], setOpen = _op[1];
713
- var body = outline && typeof outline.body === "string" && outline.body.trim() !== "" ? outline.body : "(章纲为空)";
1231
+ var body = outline && typeof outline.body === "string" && outline.body.trim() !== "" ? outline.body : tt("outline.empty");
714
1232
  return h("div", { className: "myt-outlinePanel" },
715
1233
  h("button", {
716
1234
  type: "button", className: "myt-outlineHead",
717
1235
  "aria-expanded": open ? "true" : "false",
718
- title: open ? "收起章纲" : "展开章纲",
1236
+ title: open ? tt("outline.collapseTip") : tt("outline.expandTip"),
719
1237
  onClick: function () { setOpen(!open); },
720
1238
  },
721
- h("span", { className: "myt-outlineTitle" }, "章纲对照"),
722
- typeof (outline && outline.words) === "number" ? h("span", { className: "myt-outlineWords" }, fmtNum(outline.words) + " 字") : null,
1239
+ h("span", { className: "myt-outlineTitle" }, tt("outline.title")),
1240
+ typeof (outline && outline.words) === "number" ? h("span", { className: "myt-outlineWords" }, tf("common.charCount", { n: fmtNum(outline.words) })) : null,
723
1241
  h("span", { className: "myt-outlineChevron", "aria-hidden": "true" },
724
1242
  ic(open ? "IconChevronDownOutlineRegular" : "IconChevronRightOutlineRegular", 14))),
725
- open ? h("pre", { className: "myt-outlineBody" }, body) : null);
1243
+ open ? h(MdView, { text: body, className: "myt-outlineBody" }) : null);
726
1244
  }
727
1245
  // 抽屉/弹层专用 Portal:面板容器挂在宿主会话中栏里,中栏的 transform/
728
1246
  // overflow 会把 position:fixed 劫持成相对自身并连同裁剪——抽屉渲染了也
@@ -738,45 +1256,11 @@ window.__ModuleLoader__.load({
738
1256
 
739
1257
  function ChaptersTab(props) {
740
1258
  var snap = props.snap;
741
- var reload = props.reload;
742
- var wsPath = props.wsPath;
1259
+ var onOpen = props.onOpenChapter; // 行点击 → 整页阅读(PageShell.readerFile)
743
1260
  var _f = useState("all"), filter = _f[0], setFilter = _f[1];
744
- var _o = useState(null), openFile = _o[0], setOpenFile = _o[1];
745
- var _d = useState(null), detail = _d[0], setDetail = _d[1]; // {loading, data, err}
746
- var _b = useState(false), flowBusy = _b[0], setFlowBusy = _b[1];
747
- // 概览「最近章节」点进来:initialFile 只消费一次(吃掉后回置空,防止
748
- // 下次进 tab 时把旧抽屉又顶开)。
749
- useEffect(function () {
750
- if (props.initialFile) {
751
- setOpenFile(props.initialFile);
752
- if (typeof props.onConsumeInitial === "function") props.onConsumeInitial();
753
- }
754
- }, [props.initialFile]);
755
- var loadDetail = useCallback(function (file) {
756
- if (!file) { setDetail(null); return; }
757
- setDetail({ loading: true, data: null, err: "" });
758
- apiGet("chapter", { path: wsPath, file: file }).then(function (d) {
759
- setDetail({ loading: false, data: d, err: "" });
760
- }).catch(function (err) {
761
- setDetail({ loading: false, data: null, err: err.message });
762
- });
763
- }, [wsPath]);
764
- useEffect(function () { loadDetail(openFile); }, [openFile, loadDetail]);
765
- function advance(ch) {
766
- var idx = STATUS_FLOW.indexOf(ch.status);
767
- var next = idx !== -1 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null;
768
- if (!next || flowBusy) return;
769
- setFlowBusy(true);
770
- apiPost("chapter-status", { path: wsPath, file: ch.file, status: next })
771
- .then(function () { return reload(); })
772
- .then(function () { if (openFile === ch.file) return loadDetail(ch.file); })
773
- .catch(function () {}).finally(function () { setFlowBusy(false); });
774
- }
775
1261
  var chapters = snap.chapters || [];
776
1262
  var rows = filter === "all" ? chapters : chapters.filter(function (c) { return c.status === filter; });
777
1263
  var counts = snap.totals && snap.totals.byStatus ? snap.totals.byStatus : {};
778
- var openCh = null;
779
- for (var i = 0; i < chapters.length; i++) { if (chapters[i].file === openFile) { openCh = chapters[i]; break; } }
780
1264
  // 「按单写作」(五期):state.outlines 与 chapters 联交——有章纲且
781
1265
  // status=pending 的章,按章纲(卷-章号)序列出,共创模式的主工作清单;
782
1266
  // 空块(无待写章纲章)整块不渲染。孤儿章纲(chapter 无对应章节)联交
@@ -789,35 +1273,29 @@ window.__ModuleLoader__.load({
789
1273
  var ch = ol && ol.chapter ? chapterByFile[ol.chapter] : null;
790
1274
  if (ch && (ch.status || "pending") === "pending") writeOrder.push({ ch: ch, ol: ol });
791
1275
  });
792
- function copyChapterPath() {
793
- copyText(chapterAbsPath(snap && snap.novelDir, openFile)).then(function (ok) {
794
- if (ok) toast.ok("已复制章节文件路径");
795
- else toast.warn("复制失败,请手动选择路径文本");
796
- });
797
- }
798
1276
  return h("div", { className: "myt-stack" },
799
1277
  writeOrder.length ? h("div", { className: "myt-card myt-writeOrder" },
800
1278
  h("div", { className: "myt-cardHead" },
801
- h("h3", { className: "myt-cardTitle" }, "按单写作"),
1279
+ h("h3", { className: "myt-cardTitle" }, tt("chapters.writeQueue")),
802
1280
  h("span", { className: "myt-boardCount" }, writeOrder.length)),
803
1281
  h("div", { className: "myt-writeOrderList" }, writeOrder.map(function (item) {
804
1282
  var ch = item.ch;
805
1283
  var ol = item.ol;
806
1284
  return h("button", {
807
1285
  key: ch.file, type: "button", className: "myt-writeOrderRow",
808
- title: "点击打开该章抽屉",
809
- onClick: function () { setOpenFile(ch.file); },
1286
+ title: tt("chapters.rowTip"),
1287
+ onClick: function () { onOpen(ch.file); },
810
1288
  },
811
1289
  h("span", { className: "myt-chPath" }, chWhere(ch) || ch.file),
812
1290
  h("span", { className: "myt-chTitle" }, orDash(ch.title)),
813
1291
  statusBadge(ch.status),
814
1292
  ol && typeof ol.excerpt === "string" && ol.excerpt !== ""
815
1293
  ? h("span", { className: "myt-writeOrderExcerpt", title: ol.excerpt }, truncate(ol.excerpt, 60)) : null,
816
- ol && ol.words ? h("span", { className: "myt-wordsCell" }, "章纲 " + fmtNum(ol.words) + " 字") : null);
1294
+ ol && ol.words ? h("span", { className: "myt-wordsCell" }, tf("chapters.outlineWords", { n: fmtNum(ol.words) })) : null);
817
1295
  }))) : null,
818
1296
  h("div", { className: "myt-filterRow" },
819
- [{ k: "all", label: "全部", n: chapters.length }].concat(STATUS_FLOW.map(function (s) {
820
- return { k: s, label: STATUS_LABEL[s], n: counts[s] || 0 };
1297
+ [{ k: "all", label: tt("chapters.all"), n: chapters.length }].concat(STATUS_FLOW.map(function (s) {
1298
+ return { k: s, label: tt("status." + s), n: counts[s] || 0 };
821
1299
  })).map(function (f) {
822
1300
  return h("button", {
823
1301
  key: f.k, type: "button", className: "myt-filterPill",
@@ -828,63 +1306,22 @@ window.__ModuleLoader__.load({
828
1306
  rows.length ? h("div", { className: "myt-tableWrap" },
829
1307
  h("table", { className: "myt-table" },
830
1308
  h("thead", null, h("tr", null,
831
- h("th", null, "卷 · 章"), h("th", null, "标题"), h("th", null, "状态"), h("th", null, "字数"), h("th", null, "梗概"))),
1309
+ 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")))),
832
1310
  h("tbody", null, rows.map(function (ch) {
833
- return h("tr", { key: ch.file, onClick: function () { setOpenFile(ch.file); }, title: "点击查看正文" },
1311
+ return h("tr", { key: ch.file, onClick: function () { onOpen(ch.file); }, title: tt("chapters.rowReadTip") },
834
1312
  h("td", null, h("span", { className: "myt-chPath" }, chWhere(ch) || "—")),
835
1313
  h("td", null, h("span", { className: "myt-chTitle" }, orDash(ch.title))),
836
1314
  h("td", null, statusBadge(ch.status)),
837
1315
  h("td", null, h("span", { className: "myt-wordsCell" }, fmtNum(ch.words))),
838
1316
  h("td", null, h("div", { className: "myt-summaryCell myt-muted" }, orDash(ch.summary))));
839
1317
  }))))
840
- : (chapters.length ? h(Empty, { text: "该状态下没有章节" }) : h(Empty, { text: "chapters/ 目录还没有章节文件" })),
841
- openFile ? h(MaskPortal, {
842
- className: "myt-drawerMask",
843
- onMouseDown: function (e) { if (e.target === e.currentTarget) setOpenFile(null); },
844
- },
845
- h("aside", { className: "myt-drawer", role: "dialog", "aria-modal": "true", "aria-label": "章节详情" },
846
- h("div", { className: "myt-drawerHead" },
847
- h("h2", { className: "myt-drawerTitle" }, openCh ? chFullTitle(openCh) : openFile),
848
- h("div", { className: "myt-heroMeta" }, openCh ? statusBadge(openCh.status) : null,
849
- h("button", {
850
- type: "button", className: "myt-btn myt-copyBtn",
851
- title: "复制章节文件路径", "aria-label": "复制章节文件路径",
852
- onClick: copyChapterPath,
853
- }, ic("IconCopyOutlineRegular", 13), "复制路径"),
854
- h("button", { type: "button", className: "myt-closeBtn", "aria-label": "关闭抽屉", onClick: function () { setOpenFile(null); } }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")))),
855
- h("div", { className: "myt-drawerBody" },
856
- !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%" } }))
857
- : detail.err ? h(ErrorStrip, { text: "正文读取失败:" + detail.err, onRetry: function () { loadDetail(openFile); } })
858
- : [
859
- (function () {
860
- var ol = detail.data && detail.data.outline;
861
- // 有章纲:对照栏置顶(可折叠,默认展开);无章纲:一行
862
- // 淡字提示——共创模式下这是「先要章纲」的信号。
863
- return ol ? h(OutlinePanel, { key: "outline", outline: ol })
864
- : h("div", { key: "outline-none", className: "myt-outlineEmpty" }, "暂无章纲");
865
- })(),
866
- h("pre", { key: "body", className: "myt-chapterBody" }, (detail.data && detail.data.body) || "(空章节)"),
867
- ],
868
- openCh ? h("div", { className: "myt-dimGrid" }, FIVE_DIMS.map(function (dim) {
869
- return h("div", { key: dim[0], className: "myt-dim" },
870
- h("div", { className: "myt-dimLabel" }, dim[1]),
871
- h("div", { className: "myt-dimText" }, orDash(openCh[dim[0]])));
872
- })) : null),
873
- openCh ? h("div", { className: "myt-drawerFoot" },
874
- (function () {
875
- var idx = STATUS_FLOW.indexOf(openCh.status);
876
- var next = idx !== -1 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null;
877
- if (!next) return h("span", { className: "myt-muted" }, "已定稿(终态)");
878
- return [h("span", { className: "myt-muted", key: "t" }, "状态流转:"),
879
- h("button", { key: "b", type: "button", className: "myt-btn myt-btnPrimary", disabled: flowBusy, onClick: function () { advance(openCh); } },
880
- flowBusy ? "流转中…" : "推进到「" + STATUS_LABEL[next] + "」")];
881
- })()) : null)) : null);
1318
+ : (chapters.length ? h(Empty, { text: tt("chapters.emptyFiltered") }) : h(Empty, { text: tt("chapters.empty") })));
882
1319
  }
883
1320
 
884
1321
  // ── 角色 tab:卡墙 + 全卡弹层 ───────────────────────────────────────────
885
1322
  var CHAR_FULL_ROWS = [
886
- ["age", "年龄"], ["gender", "性别"], ["appearance", "外貌"], ["personality", "性格"],
887
- ["background", "背景"], ["motivation", "动机"], ["arc", "成长弧"],
1323
+ ["age", tt("char.field.age")], ["gender", tt("char.field.gender")], ["appearance", tt("char.field.appearance")], ["personality", tt("char.field.personality")],
1324
+ ["background", tt("char.field.background")], ["motivation", tt("char.field.motivation")], ["arc", tt("char.field.arc")],
888
1325
  ];
889
1326
  function CharactersTab(props) {
890
1327
  var characters = props.snap.characters || [];
@@ -898,20 +1335,20 @@ window.__ModuleLoader__.load({
898
1335
  h("span", { className: "myt-charName" }, orDash(c.name)),
899
1336
  roleBadge(c.role)),
900
1337
  h("div", { className: "myt-charMeta" },
901
- [c.age !== null && c.age !== undefined ? c.age + " 岁" : null, c.gender || null].filter(Boolean).join(" · ") || "—"),
902
- c.personality ? h("div", { className: "myt-charLine" }, h("span", { className: "myt-charLabel" }, "性格 "), truncate(c.personality, 60)) : null,
903
- c.motivation ? h("div", { className: "myt-charLine" }, h("span", { className: "myt-charLabel" }, "动机 "), truncate(c.motivation, 60)) : null);
904
- })) : h(Empty, { text: "characters/ 目录还没有角色卡" }),
1338
+ [c.age !== null && c.age !== undefined ? tf("char.years", { n: c.age }) : null, c.gender || null].filter(Boolean).join(" · ") || "—"),
1339
+ c.personality ? h("div", { className: "myt-charLine" }, h("span", { className: "myt-charLabel" }, tt("char.linePersonality")), truncate(c.personality, 60)) : null,
1340
+ c.motivation ? h("div", { className: "myt-charLine" }, h("span", { className: "myt-charLabel" }, tt("char.lineMotivation")), truncate(c.motivation, 60)) : null);
1341
+ })) : h(Empty, { text: tt("char.empty") }),
905
1342
  modal ? h(MaskPortal, {
906
1343
  className: "myt-modalMask",
907
1344
  onMouseDown: function (e) { if (e.target === e.currentTarget) setModalFile(null); },
908
1345
  },
909
- h("div", { className: "myt-modalCard", role: "dialog", "aria-modal": "true", "aria-label": "角色详情" },
1346
+ h("div", { className: "myt-modalCard", role: "dialog", "aria-modal": "true", "aria-label": tt("char.modalAria") },
910
1347
  h("div", { className: "myt-modalHead" },
911
1348
  h("h2", { className: "myt-modalTitle" }, orDash(modal.name)),
912
1349
  h("div", { className: "myt-heroMeta" },
913
1350
  roleBadge(modal.role),
914
- h("button", { type: "button", className: "myt-closeBtn", "aria-label": "关闭角色卡", onClick: function () { setModalFile(null); } }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")))),
1351
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("char.closeAria"), onClick: function () { setModalFile(null); } }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")))),
915
1352
  h("div", { className: "myt-modalBody" },
916
1353
  CHAR_FULL_ROWS.map(function (row) {
917
1354
  return h("div", { key: row[0], className: "myt-kvRow" },
@@ -919,13 +1356,13 @@ window.__ModuleLoader__.load({
919
1356
  h("span", { className: "myt-kvVal" }, orDash(modal[row[0]])));
920
1357
  }),
921
1358
  h("div", { className: "myt-kvRow" },
922
- h("span", { className: "myt-kvKey" }, "标记"),
1359
+ h("span", { className: "myt-kvKey" }, tt("char.markers")),
923
1360
  h("span", { className: "myt-kvVal myt-tagRow" },
924
1361
  (modal.markers || []).length
925
1362
  ? modal.markers.map(function (m, i) { return h("span", { key: i, className: "myt-markerTag" }, m); })
926
1363
  : "—")),
927
1364
  h("div", { className: "myt-kvRow" },
928
- h("span", { className: "myt-kvKey" }, "文件"),
1365
+ h("span", { className: "myt-kvKey" }, tt("char.file")),
929
1366
  h("span", { className: "myt-kvVal" }, modal.file || "—"))))) : null);
930
1367
  }
931
1368
 
@@ -947,28 +1384,36 @@ window.__ModuleLoader__.load({
947
1384
  var items = list.filter(function (f) { return (f.status || "planted") === col; });
948
1385
  return h("div", { key: col, className: "myt-boardCol" },
949
1386
  h("div", { className: "myt-boardHead" },
950
- h("span", { className: "myt-boardTitle" }, FS_LABEL[col]),
1387
+ h("span", { className: "myt-boardTitle" }, tt("foreshadow." + col)),
951
1388
  h("span", { className: "myt-boardCount" }, items.length)),
952
1389
  items.map(function (fs) {
953
1390
  var next = FS_FLOW[col] || null;
954
1391
  var busy = busyId === fs.id;
955
- var chRange = [fs.planted_chapter || "埋设章未标", fs.expected_resolve].filter(Boolean).join(" → ");
1392
+ // status 行缺失(服务端要求每条恰一处 status,写必 409):禁用流
1393
+ // 转/弃用按钮,降级为只读展示并给提示,避免「可点但必失败」。
1394
+ var noStatus = !fs.status;
1395
+ var noWrite = !fs.id || noStatus;
1396
+ var noWriteTip = !fs.id
1397
+ ? tt("foreshadow.noIdTip")
1398
+ : tt("foreshadow.noStatusTip");
1399
+ var chRange = [fs.planted_chapter || tt("foreshadow.plantedUnmarked"), fs.expected_resolve].filter(Boolean).join(" → ");
956
1400
  return h("div", { key: fs.id || fs.title, className: "myt-fsCard" },
957
1401
  h("div", { className: "myt-fsTitle" },
958
1402
  priDot(fs.priority), h("span", null, orDash(fs.title) || orDash(fs.id)),
959
1403
  // 回填徽章(五期账本回填):区别于写前规划条目,悬浮见来源。
960
- fs.source === "backfill" ? h("span", { className: "myt-backfillBadge", title: "从作者正文回填" }, "回填") : null),
1404
+ fs.source === "backfill" ? h("span", { className: "myt-backfillBadge", title: tt("foreshadow.backfillTip") }, tt("foreshadow.backfill")) : null,
1405
+ noStatus ? h("span", { className: "myt-fsNoStatus", title: noWriteTip }, tt("foreshadow.missingStatus")) : null),
961
1406
  h("div", { className: "myt-fsMeta" },
962
- h("span", null, col === "resolved" && fs.resolved_chapter ? "回收于 " + fs.resolved_chapter : chRange),
963
- fs.priority && fs.priority !== "normal" ? h("span", null, "优先级 " + (PRI_LABEL[fs.priority] || fs.priority)) : null),
1407
+ h("span", null, col === "resolved" && fs.resolved_chapter ? tf("foreshadow.resolvedAt", { ch: fs.resolved_chapter }) : chRange),
1408
+ 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),
964
1409
  fs.notes ? h("div", { className: "myt-fsNotes", title: fs.notes }, fs.notes) : null,
965
1410
  h("div", { className: "myt-fsActions" },
966
- next ? h("button", { type: "button", className: "myt-btn", disabled: busy || !fs.id, title: fs.id ? undefined : "该条目缺 id,无法写状态", onClick: function () { setStatus(fs, next); } }, busy ? "推进中…" : (col === "planted" ? "推进" : "回收")) : null,
1411
+ 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,
967
1412
  col !== "resolved" && col !== "abandoned"
968
- ? h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy || !fs.id, onClick: function () { setStatus(fs, "abandoned"); } }, "弃用") : null));
1413
+ ? h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy || noWrite, title: noWriteTip, onClick: function () { setStatus(fs, "abandoned"); } }, tt("foreshadow.abandon")) : null));
969
1414
  }),
970
- items.length ? null : h("div", { className: "myt-muted" }, "空"));
971
- })) : h(Empty, { text: "foreshadows.yml 还没有伏笔条目" }));
1415
+ items.length ? null : h("div", { className: "myt-muted" }, tt("foreshadow.colEmpty")));
1416
+ })) : h(Empty, { text: tt("foreshadow.empty") }));
972
1417
  }
973
1418
 
974
1419
  // ── 时间线 tab:垂直轴 + importance 点位(1-5 → 大小/深浅)──────────────
@@ -977,17 +1422,17 @@ window.__ModuleLoader__.load({
977
1422
  if (items.length) {
978
1423
  items.sort(function (a, b) { return String(a.year || "").localeCompare(String(b.year || ""), "zh-CN", { numeric: true }); });
979
1424
  }
980
- if (!items.length) return h(Empty, { text: "timeline.yml 还没有时间线条目" });
1425
+ if (!items.length) return h(Empty, { text: tt("timeline.empty") });
981
1426
  return h("div", { className: "myt-card" },
982
1427
  h("div", { className: "myt-timeline" }, items.map(function (t, i) {
983
1428
  var imp = Math.min(5, Math.max(1, typeof t.importance === "number" ? t.importance : 3));
984
1429
  return h("div", { key: i, className: "myt-tlRow" },
985
1430
  h("div", { className: "myt-tlYear" }, orDash(t.year)),
986
- h("div", { className: "myt-tlAxis" }, h("span", { className: "myt-tlDot", "data-imp": String(imp), title: "重要度 " + imp })),
1431
+ h("div", { className: "myt-tlAxis" }, h("span", { className: "myt-tlDot", "data-imp": String(imp), title: tf("timeline.importance", { n: imp }) })),
987
1432
  h("div", { className: "myt-tlCard" },
988
1433
  h("div", { className: "myt-tlTitle" }, orDash(t.title)),
989
1434
  t.description ? h("div", { className: "myt-tlDesc" }, t.description) : null,
990
- t.chapter ? h("div", { className: "myt-tlChapter" }, "关联章节:" + t.chapter) : null));
1435
+ t.chapter ? h("div", { className: "myt-tlChapter" }, tf("timeline.linkedChapter", { ch: t.chapter })) : null));
991
1436
  })));
992
1437
  }
993
1438
 
@@ -997,16 +1442,19 @@ window.__ModuleLoader__.load({
997
1442
  var world = snap.world || [];
998
1443
  var science = snap.science || [];
999
1444
  var research = snap.research || [];
1000
- var groups = {};
1445
+ // 无原型容器:key 来自数据文件的自由文本(category),值为
1446
+ // constructor/__proto__/toString 等时若用普通 {} 会命中 Object.prototype
1447
+ // 真值 → TypeError → 整面板白屏;Object.create(null) 杜绝。
1448
+ var groups = Object.create(null);
1001
1449
  world.forEach(function (w) {
1002
- var key = w.category || "未分类";
1450
+ var key = w.category || tt("common.uncategorized");
1003
1451
  if (!groups[key]) groups[key] = [];
1004
1452
  groups[key].push(w);
1005
1453
  });
1006
1454
  var groupKeys = Object.keys(groups).sort(function (a, b) { return a.localeCompare(b, "zh-CN"); });
1007
1455
  return h("div", { className: "myt-stack" },
1008
1456
  h("div", { className: "myt-card" },
1009
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "世界观条目")),
1457
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.title"))),
1010
1458
  groupKeys.length ? h("div", { className: "myt-stack" }, groupKeys.map(function (key) {
1011
1459
  return h("div", { key: key, className: "myt-worldGroup" },
1012
1460
  h("div", { className: "myt-listHead" }, key, h("span", { className: "myt-boardCount" }, groups[key].length)),
@@ -1015,25 +1463,25 @@ window.__ModuleLoader__.load({
1015
1463
  h("div", { className: "myt-worldHead" },
1016
1464
  h("span", { className: "myt-worldName" }, orDash(w.name)),
1017
1465
  (w.tags || []).length ? h("span", { className: "myt-tagRow" }, w.tags.map(function (t, j) { return h("span", { key: j, className: "myt-tag" }, t); })) : null),
1018
- w.description ? h("div", { className: "myt-worldDesc" }, w.description) : null);
1466
+ w.description ? h(MdView, { text: w.description, className: "myt-worldDesc" }) : null);
1019
1467
  }));
1020
- })) : h(Empty, { text: "world/ 目录还没有设定条目" })),
1468
+ })) : h(Empty, { text: tt("world.empty") })),
1021
1469
  h("div", { className: "myt-card" },
1022
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "科学设定"),
1470
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.science")),
1023
1471
  h("span", { className: "myt-boardCount" }, science.length)),
1024
1472
  science.length ? h("div", { className: "myt-stack" }, science.map(function (s, i) {
1025
- var label = SCI_LABEL[s.label] ? s.label : null;
1473
+ var label = ["known", "extrapolation", "hypothesis"].indexOf(s.label) !== -1 ? s.label : null;
1026
1474
  return h("div", { key: s.file || i, className: "myt-worldItem" },
1027
1475
  h("div", { className: "myt-worldHead" },
1028
1476
  h("span", { className: "myt-worldName" }, orDash(s.name)),
1029
1477
  h("span", { className: "myt-badge myt-sciTag", "data-label": label || "hypothesis", title: s.label && !label ? s.label : undefined },
1030
- label ? SCI_LABEL[label] : (s.label || "未标级"))),
1031
- s.description ? h("div", { className: "myt-worldDesc" }, s.description) : null,
1478
+ label ? tt("sci." + label) : (s.label || tt("world.unlabeled")))),
1479
+ s.description ? h(MdView, { text: s.description, className: "myt-worldDesc" }) : null,
1032
1480
  (s.references || []).length ? h("div", { className: "myt-refList" },
1033
- s.references.map(function (r, j) { return h("span", { key: j, className: "myt-refItem" }, "参考 " + (j + 1) + ":" + r); })) : null);
1034
- })) : h(Empty, { text: "science/ 目录还没有科学设定" })),
1481
+ s.references.map(function (r, j) { return h("span", { key: j, className: "myt-refItem" }, tf("world.ref", { n: j + 1, r: r })); })) : null);
1482
+ })) : h(Empty, { text: tt("world.scienceEmpty") })),
1035
1483
  research.length ? h("div", { className: "myt-card" },
1036
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "资料检索"),
1484
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.research")),
1037
1485
  h("span", { className: "myt-boardCount" }, research.length)),
1038
1486
  h("div", { className: "myt-tagRow" }, research.map(function (r, i) {
1039
1487
  return h("span", { key: r.file || i, className: "myt-tag", title: r.file || "" }, orDash(r.topic) || r.file);
@@ -1054,29 +1502,31 @@ window.__ModuleLoader__.load({
1054
1502
  }
1055
1503
  var clues = snap.clues || [];
1056
1504
  var memories = snap.memories || [];
1057
- var clueGroups = {};
1505
+ // 无原型容器:kind/category 是数据文件自由文本,见 WorldTab 处说明。
1506
+ var clueGroups = Object.create(null);
1058
1507
  clues.forEach(function (c) {
1059
- var key = c.kind || "未分类";
1508
+ var key = c.kind || tt("common.uncategorized");
1060
1509
  if (!clueGroups[key]) clueGroups[key] = [];
1061
1510
  clueGroups[key].push(c);
1062
1511
  });
1063
- var memGroups = {};
1512
+ var memGroups = Object.create(null);
1064
1513
  memories.forEach(function (m) {
1065
- var key = m.category || "未分类";
1514
+ var key = m.category || tt("common.uncategorized");
1066
1515
  if (!memGroups[key]) memGroups[key] = [];
1067
1516
  memGroups[key].push(m);
1068
1517
  });
1069
1518
  function groupKey(a, b) { return a.localeCompare(b, "zh-CN"); }
1070
1519
  return h("div", { className: "myt-stack" },
1071
1520
  h("div", { className: "myt-cardHead" },
1072
- h("h3", { className: "myt-cardTitle" }, "索引板(clue-board / memories)"),
1073
- h("button", { type: "button", className: "myt-btn", disabled: rebuildBusy, onClick: rebuild, title: "按 CLI index 同一逻辑重建 index.yml" },
1074
- rebuildBusy ? "重建中…" : "重建索引")),
1521
+ h("h3", { className: "myt-cardTitle" }, tt("index.title")),
1522
+ h("button", { type: "button", className: "myt-btn", disabled: rebuildBusy, onClick: rebuild, title: tt("index.rebuildTip") },
1523
+ rebuildBusy ? tt("index.rebuilding") : tt("index.rebuild"))),
1075
1524
  h("div", { className: "myt-indexCols" },
1076
1525
  h("div", { className: "myt-card" },
1077
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "线索板"), h("span", { className: "myt-boardCount" }, clues.length)),
1526
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.clueBoard")), h("span", { className: "myt-boardCount" }, clues.length)),
1078
1527
  clues.length ? Object.keys(clueGroups).sort(groupKey).map(function (kind) {
1079
- var label = CLUE_KIND_LABEL[kind] || kind;
1528
+ var ck = CLUE_KIND_KEYS[kind];
1529
+ var label = ck ? tt("clue." + ck) : kind;
1080
1530
  return h("div", { key: kind, className: "myt-worldGroup" },
1081
1531
  h("div", { className: "myt-listHead" }, label, h("span", { className: "myt-boardCount" }, clueGroups[kind].length)),
1082
1532
  clueGroups[kind].map(function (c, i) {
@@ -1084,21 +1534,21 @@ window.__ModuleLoader__.load({
1084
1534
  h("span", { className: "myt-clueText" }, orDash(c.content)),
1085
1535
  c.related_chapter ? h("span", { className: "myt-clueChapter" }, c.related_chapter) : null);
1086
1536
  }));
1087
- }) : h(Empty, { text: "clue-board.yml 还没有条目" })),
1537
+ }) : h(Empty, { text: tt("index.clueEmpty") })),
1088
1538
  h("div", { className: "myt-card" },
1089
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "记忆板"), h("span", { className: "myt-boardCount" }, memories.length)),
1539
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.memBoard")), h("span", { className: "myt-boardCount" }, memories.length)),
1090
1540
  memories.length ? Object.keys(memGroups).sort(groupKey).map(function (cat) {
1091
1541
  var isPromise = /promise|承诺/i.test(cat);
1092
1542
  return h("div", { key: cat, className: "myt-worldGroup" },
1093
1543
  h("div", { className: "myt-listHead" },
1094
- cat, isPromise ? h("span", { className: "myt-markerTag" }, "承诺线") : null,
1544
+ cat, isPromise ? h("span", { className: "myt-markerTag" }, tt("index.promise")) : null,
1095
1545
  h("span", { className: "myt-boardCount" }, memGroups[cat].length)),
1096
1546
  memGroups[cat].map(function (m, i) {
1097
1547
  return h("div", { key: i, className: "myt-memItem", "data-promise": isPromise ? "true" : undefined },
1098
1548
  h("span", { className: "myt-clueText" }, orDash(m.content)),
1099
1549
  m.source_chapter ? h("span", { className: "myt-memChapter" }, m.source_chapter) : null);
1100
1550
  }));
1101
- }) : h(Empty, { text: "memories.yml 还没有条目" }))));
1551
+ }) : h(Empty, { text: tt("index.memEmpty") }))));
1102
1552
  }
1103
1553
 
1104
1554
  // ── 关系图 tab:canvas 力导向(斥力 + 弹簧 + 中心引力,rAF 循环)─────────
@@ -1117,24 +1567,26 @@ window.__ModuleLoader__.load({
1117
1567
  var _tip = useState(null), tip = _tip[0], setTip = _tip[1]; // {x, y, text}
1118
1568
  var wrapRef = useRef(null);
1119
1569
  var canvasRef = useRef(null);
1120
- var simRef = useRef(null);
1570
+ var tipRafRef = useRef(0); // hover 提示 rAF 句柄:卸载/换图时取消在途合帧
1121
1571
  // 数据准备:>30 角色只留 relations 相关节点;角色空时退化为关系端点名。
1122
- var graph = (function () {
1123
- var byName = {};
1572
+ // useMemo 缓存:hover 提示等重渲染不再重复建图(原渲染体内 IIFE 每帧重建)。
1573
+ // 容器用无原型对象:角色名/关系端点是数据文件自由文本,见 WorldTab 处说明。
1574
+ var graph = useMemo(function () {
1575
+ var byName = Object.create(null);
1124
1576
  chars.forEach(function (c) { if (c && c.name && !byName[c.name]) byName[c.name] = c; });
1125
- var deg = {};
1577
+ var deg = Object.create(null);
1126
1578
  rels.forEach(function (r) { deg[r.a] = (deg[r.a] || 0) + 1; deg[r.b] = (deg[r.b] || 0) + 1; });
1127
1579
  var names = chars.length ? chars.map(function (c) { return c.name; }) : Object.keys(deg);
1128
1580
  var linked = names.filter(function (n) { return deg[n]; });
1129
1581
  var drawNames = names.length > 30 ? linked : names;
1130
1582
  var unlinked = names.filter(function (n) { return drawNames.indexOf(n) === -1; });
1131
1583
  var nodes = drawNames.map(function (n) { return { id: n, role: byName[n] ? byName[n].role : null, x: 0, y: 0, vx: 0, vy: 0, fixed: false }; });
1132
- var idx = {};
1584
+ var idx = Object.create(null);
1133
1585
  nodes.forEach(function (nd, i) { idx[nd.id] = i; });
1134
1586
  var edges = rels.filter(function (r) { return idx[r.a] !== undefined && idx[r.b] !== undefined; })
1135
1587
  .map(function (r) { return { a: idx[r.a], b: idx[r.b], type: r.type || "", desc: r.description || "", w: edgeWidth(r.intensity) }; });
1136
1588
  return { nodes: nodes, edges: edges, unlinked: unlinked };
1137
- })();
1589
+ }, [snap]); // chars/rels 均由 snap 派生,snap 不变则图不变
1138
1590
  useEffect(function () {
1139
1591
  var wrap = wrapRef.current;
1140
1592
  var canvas = canvasRef.current;
@@ -1142,8 +1594,7 @@ window.__ModuleLoader__.load({
1142
1594
  var ctx = canvas.getContext("2d");
1143
1595
  if (!ctx) return undefined;
1144
1596
  var dpr = (window.devicePixelRatio || 1) > 0 ? window.devicePixelRatio || 1 : 1;
1145
- var sim = { zoom: 1, drag: null, raf: 0, frame: 0, w: 0, h: 0, tokens: null, mx: -1, my: -1 };
1146
- simRef.current = sim;
1597
+ var sim = { zoom: 1, drag: null, raf: 0, frame: 0, w: 0, h: 0, tokens: null };
1147
1598
  // 令牌缓存:每 30 帧重读 computed style,浅/深色切换约半秒内跟上。
1148
1599
  function readTokens() {
1149
1600
  var cs = getComputedStyle(document.body);
@@ -1294,18 +1745,23 @@ window.__ModuleLoader__.load({
1294
1745
  }
1295
1746
  function onMove(e) {
1296
1747
  var p = localXY(e);
1297
- sim.mx = p.x; sim.my = p.y;
1298
1748
  if (sim.drag) {
1299
1749
  var w = toWorld(p.x, p.y);
1300
1750
  sim.drag.node.x = w.x; sim.drag.node.y = w.y;
1301
1751
  return;
1302
1752
  }
1303
- var e2 = hitEdge(p.x, p.y);
1304
- if (e2) {
1305
- setTip({ x: p.x, y: p.y, text: (e2.type ? e2.type + ":" : "") + (e2.desc || "(无描述)") });
1306
- } else {
1307
- setTip(function (prev) { return prev ? null : prev; });
1308
- }
1753
+ // hover 提示 rAF 节流:mousemove 高频触发,合帧后再命中检测 + setTip,
1754
+ // 避免逐事件整 tab 重渲染(建图已 useMemo,这里再省掉无效重绘)。
1755
+ if (tipRafRef.current) cancelAnimationFrame(tipRafRef.current);
1756
+ tipRafRef.current = requestAnimationFrame(function () {
1757
+ tipRafRef.current = 0;
1758
+ var e2 = hitEdge(p.x, p.y);
1759
+ if (e2) {
1760
+ setTip({ x: p.x, y: p.y, text: (e2.type ? e2.type + ":" : "") + (e2.desc || tt("relations.noDesc")) });
1761
+ } else {
1762
+ setTip(function (prev) { return prev ? null : prev; });
1763
+ }
1764
+ });
1309
1765
  }
1310
1766
  function onUp() { sim.drag = null; canvas.dataset.dragging = "false"; }
1311
1767
  function onWheel(e) {
@@ -1325,27 +1781,27 @@ window.__ModuleLoader__.load({
1325
1781
  canvas.addEventListener("dblclick", onDbl);
1326
1782
  return function () {
1327
1783
  cancelAnimationFrame(sim.raf);
1784
+ if (tipRafRef.current) { cancelAnimationFrame(tipRafRef.current); tipRafRef.current = 0; }
1328
1785
  if (ro) ro.disconnect();
1329
1786
  canvas.removeEventListener("mousedown", onDown);
1330
1787
  window.removeEventListener("mousemove", onMove);
1331
1788
  window.removeEventListener("mouseup", onUp);
1332
1789
  canvas.removeEventListener("wheel", onWheel);
1333
1790
  canvas.removeEventListener("dblclick", onDbl);
1334
- simRef.current = null;
1335
1791
  };
1336
1792
  }, [snap]);
1337
- if (!graph.nodes.length) return h(Empty, { text: "没有角色或关系数据,无法绘图" });
1793
+ if (!graph.nodes.length) return h(Empty, { text: tt("relations.noData") });
1338
1794
  return h("div", { className: "myt-stack" },
1339
1795
  h("div", { className: "myt-card" },
1340
1796
  h("div", { className: "myt-cardHead" },
1341
- h("h3", { className: "myt-cardTitle" }, "角色关系图"),
1342
- h("span", { className: "myt-muted" }, graph.nodes.length + " 节点 · " + graph.edges.length + " 关系")),
1797
+ h("h3", { className: "myt-cardTitle" }, tt("relations.title")),
1798
+ h("span", { className: "myt-muted" }, tf("relations.counts", { n: graph.nodes.length, m: graph.edges.length }))),
1343
1799
  h("div", { className: "myt-graphWrap", ref: wrapRef },
1344
1800
  h("canvas", { className: "myt-graphCanvas", ref: canvasRef, "data-dragging": "false" }),
1345
1801
  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),
1346
- h("div", { className: "myt-graphFoot" }, "拖节点固定位置 · 滚轮缩放 · 双击复位 · 悬停边看关系描述")),
1802
+ h("div", { className: "myt-graphFoot" }, tt("relations.help"))),
1347
1803
  graph.unlinked.length ? h("div", { className: "myt-card" },
1348
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "未入图角色(无关系边)")),
1804
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("relations.unlinkedTitle"))),
1349
1805
  h("div", { className: "myt-unlinked" }, graph.unlinked.map(function (n, i) {
1350
1806
  return h("span", { key: n || i, className: "myt-unlinkedTag" }, n);
1351
1807
  }))) : null);