dsh-maoying 0.1.19 → 0.1.21

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
@@ -193,7 +193,7 @@ window.__ModuleLoader__.load({
193
193
  opts.signal = ctrl.signal;
194
194
  timer = setTimeout(function () { ctrl.abort(); }, limit);
195
195
  }
196
- return fetch(API_BASE + path, opts).then(function (r) {
196
+ var p = fetch(API_BASE + path, opts).then(function (r) {
197
197
  // 先取全文再 JSON.parse:代理错误页 / 空体不再抛 SyntaxError。
198
198
  return r.text().then(function (raw) {
199
199
  var d = null;
@@ -211,11 +211,20 @@ window.__ModuleLoader__.load({
211
211
  }).catch(function (err) {
212
212
  if (ctrl && err && err.name === "AbortError") throw new Error(tf("api.timeout", { s: Math.round(limit / 1000) }));
213
213
  throw err;
214
- }).finally(function () {
214
+ });
215
+ if (!ctrl) {
216
+ // 老宿主无 AbortController:fetch 中断不了,race 一个超时 reject 兜底,
217
+ // 照样防按钮永久转圈(真连接留在后台,本次调用不再等它)。
218
+ p = Promise.race([p, new Promise(function (_resolve, reject) {
219
+ timer = setTimeout(function () { reject(new Error(tf("api.timeout", { s: Math.round(limit / 1000) }))); }, limit);
220
+ })]);
221
+ }
222
+ return p.finally(function () {
215
223
  if (timer !== null) clearTimeout(timer);
216
224
  });
217
225
  }
218
- function apiGet(path, params) {
226
+ function apiGet(path, params, opts) {
227
+ var quiet = opts && opts.silent;
219
228
  var qs = "";
220
229
  if (params) {
221
230
  var usp = new URLSearchParams();
@@ -223,16 +232,18 @@ window.__ModuleLoader__.load({
223
232
  qs = usp.toString();
224
233
  }
225
234
  return maoyingApi(path + (qs ? (path.indexOf("?") === -1 ? "?" : "&") + qs : "")).catch(function (err) {
226
- toast.err(tf("toast.prefix", { msg: err.message }));
235
+ // silent:调用方自渲染卡内错误位时抑制 toast,同一错误只展示一次。
236
+ if (!quiet) toast.err(tf("toast.prefix", { msg: err.message }));
227
237
  throw err;
228
238
  });
229
239
  }
230
- function apiPost(path, body) {
240
+ function apiPost(path, body, opts) {
241
+ var quiet = opts && opts.silent;
231
242
  return maoyingApi(path, body || {}).then(function (d) {
232
243
  toast.ok(apiPostOkText(path, d));
233
244
  return d;
234
245
  }, function (err) {
235
- toast.err(tf("toast.prefix", { msg: err.message }));
246
+ if (!quiet) toast.err(tf("toast.prefix", { msg: err.message })); // silent 同 apiGet
236
247
  throw err;
237
248
  });
238
249
  }
@@ -246,6 +257,9 @@ window.__ModuleLoader__.load({
246
257
  if (path === "style-save") return tt("toast.styleSaved");
247
258
  if (path === "chapter-save") return tt("toast.chapterSaved");
248
259
  if (path === "outline-save") return tt("toast.outlineSaved");
260
+ if (path === "file-save") return tf("toast.fileSaved", { rel: (d && d.rel) || "" });
261
+ if (path === "file-create") return tf("toast.fileCreated", { rel: (d && d.rel) || "" });
262
+ if (path === "file-delete") return tf("toast.fileDeleted", { rel: (d && d.rel) || "" });
249
263
  return tt("toast.success");
250
264
  }
251
265
 
@@ -282,6 +296,10 @@ window.__ModuleLoader__.load({
282
296
  };
283
297
  }
284
298
  var toast = createToastCtrl();
299
+ // 脏退出守卫的跨组件脏标记:编辑面(正文/章纲/受管文件/风格/元信息)各自
300
+ // 在渲染后写入「是否有未保存草稿」,切 tab 等自身不知情的退出路径读它拦截
301
+ // 确认。面板同一时刻至多一个编辑面打开,单布尔够用;卸载时清零防残留误拦。
302
+ var DIRTY_EXIT = { dirty: false };
285
303
 
286
304
  function ToastHost() {
287
305
  var _t = useState(function () { return toast.snapshot(); }), items = _t[0], setItems = _t[1];
@@ -545,8 +563,8 @@ window.__ModuleLoader__.load({
545
563
  }
546
564
 
547
565
  // ── 页面骨架:顶栏(工作区下拉 + 刷新 + 关闭)+ tab 条 + 内容区 ─────────
548
- // 数据流:workspaces 每次打开面板都重拉(懒挂载下关闭不卸载,见下方
549
- // activate 监听);state 跟随选中工作区;写操作 POST 成功后
566
+ // 数据流:workspaces 与 state 每次打开面板都重拉(懒挂载下关闭不卸载,
567
+ // 见下方 activate 监听);state 另跟随选中工作区;写操作 POST 成功后
550
568
  // 整份重拉 state(不乐观更新)。选择记 localStorage('maoying.workspace')。
551
569
  function PageShell(props) {
552
570
  var onClose = props.onClose;
@@ -559,6 +577,8 @@ window.__ModuleLoader__.load({
559
577
  var _tb = useState("overview"), tab = _tb[0], setTab = _tb[1];
560
578
  // 章节阅读页(整页视图,替代原右侧抽屉):null = 列表态。
561
579
  var _rd = useState(null), readerFile = _rd[0], setReaderFile = _rd[1];
580
+ // 面板重开序号:每次 activate 广播自增,透传 ChapterReader 驱动本章重拉。
581
+ var _rs = useState(0), reopenSeq = _rs[0], setReopenSeq = _rs[1];
562
582
  // 系统设置:字号档位持久化 + 面板整体缩放(portal 节点读同一 CSS 变量)。
563
583
  var _sm = useState(false), showSettings = _sm[0], setShowSettings = _sm[1];
564
584
  var _fs = useState(function () {
@@ -753,21 +773,61 @@ window.__ModuleLoader__.load({
753
773
  toast.err(tf("toast.prefix", { msg: err.message }));
754
774
  });
755
775
  }
776
+ // 手动检测更新:update-check 只在面板打开时拉一次,页面开久了拿不到
777
+ // 新判定(服务端还有 1h 缓存)——这里带 fresh=1 让服务端绕过缓存现测。
778
+ // 不走 apiGet:成功要带版本号、失败要报「检测失败」,apiGet 会先统一
779
+ // toast 一遍错误再 rethrow,这里直连 maoyingApi 与 runSelfUpdate 同一
780
+ // 理由(避免双份错误糊屏);超时沿用统一 60s(服务端 10s 就收敛)。
781
+ // 错误瘦身:服务端 error 可能是三路全败的长串排障文案,只取首句,
782
+ // 首句仍超 ~80 字符再截断加省略号,别糊屏。
783
+ function shortErrText(msg) {
784
+ var s = String(msg || "").trim();
785
+ var end = s.indexOf("。");
786
+ if (end !== -1) s = s.slice(0, end);
787
+ if (s.length > 80) s = s.slice(0, 79) + "…";
788
+ return s || tt("common.unknownErr");
789
+ }
790
+ var _verChecking = useState(false), verChecking = _verChecking[0], setVerChecking = _verChecking[1];
791
+ function runVerCheck() {
792
+ if (verChecking) return;
793
+ setVerChecking(true);
794
+ maoyingApi("update-check?fresh=1").then(function (d) {
795
+ if (d && d.current) setVer(d); // 徽章与一键升级按钮随 setVer 自然出现/消失
796
+ if (d && d.updateAvailable) {
797
+ toast.ok(tf("toast.selfUpdate.found", { ver: (d && d.latest) || "?" }));
798
+ } else if (d && !d.error) {
799
+ toast.ok(tf("toast.selfUpdate.upToDate", { ver: (d && d.current) || "?" }));
800
+ } else {
801
+ toast.err(tf("toast.selfUpdate.checkFail", { msg: shortErrText(d && d.error) }));
802
+ }
803
+ }).catch(function (err) {
804
+ toast.err(tf("toast.selfUpdate.checkFail", { msg: shortErrText(err && err.message) }));
805
+ }).finally(function () {
806
+ setVerChecking(false); // 无论成败按钮都恢复可点
807
+ });
808
+ }
756
809
  useEffect(function () { loadWorkspaces(); }, [loadWorkspaces]);
757
- // 每次打开面板都重拉工作区列表:懒挂载下关闭只是摘掉 active 属性,
758
- // 组件不卸载,上面的 mount effect 整个页面生命周期只跑一次,期间外部
759
- // (CLI / 其他面板)新建或删除的工作区在重开时看不见。mountPanel 每次
760
- // 打开都会广播 dsh-panel-activate(source: "maoying"),借它触发重拉;
761
- // 首次打开的广播早于本组件挂载(render 异步调度),收不到也无妨——
762
- // mount effect 已覆盖首拉。其余面板的同名广播按 source 过滤掉。
810
+ // 每次打开面板都重拉工作区列表与整份 state 快照:懒挂载下关闭只是摘掉
811
+ // active 属性,组件不卸载,上面的 mount effect 整个页面生命周期只跑一
812
+ // 次,期间外部(CLI / agent / 其他面板)新建/删除的工作区、改动的小说
813
+ // 内容在重开时看不见——state 原本只跟 effectiveWs 走,同工作区重开不
814
+ // 会重拉,用户得手点右上角刷新。mountPanel 每次打开都会广播
815
+ // dsh-panel-activate(source: "maoying"),借它触发三重重拉:workspaces、
816
+ // state 快照、阅读页本章(reopenSeq 自增);首次打开的广播早于本组件挂
817
+ // 载(render 异步调度),收不到也无妨——mount effect 已覆盖首拉。其余
818
+ // 面板的同名广播按 source 过滤掉。重拉不动编辑中的草稿:loadState 本就
819
+ // 不碰草稿,阅读页走 silent 刷新(见 ChapterReader)。
763
820
  useEffect(function () {
764
821
  var onActivate = function (event) {
765
822
  var source = event && event.detail && event.detail.source;
766
- if (source === "maoying") loadWorkspaces();
823
+ if (source !== "maoying") return;
824
+ loadWorkspaces();
825
+ reload(); // 重开即最新小说内容,不必手点右上角刷新
826
+ setReopenSeq(function (n) { return n + 1; });
767
827
  };
768
828
  document.addEventListener(ACTIVATE_EVENT, onActivate);
769
829
  return function () { document.removeEventListener(ACTIVATE_EVENT, onActivate); };
770
- }, [loadWorkspaces]);
830
+ }, [loadWorkspaces, reload]);
771
831
  useEffect(function () { loadState(effectiveWs); }, [effectiveWs, loadState]);
772
832
  // 校正后的选择回写记忆(首次打开 / 记忆失效时自动落位)。
773
833
  useEffect(function () {
@@ -780,6 +840,9 @@ window.__ModuleLoader__.load({
780
840
  setSelWs(path);
781
841
  }
782
842
  function gotoTab(next, chapterFile) {
843
+ // 脏退出守卫:任一编辑面(DIRTY_EXIT.dirty)有未保存草稿先确认,取消
844
+ // 则留在原地——切 tab 会卸载组件,草稿本来会被静默丢弃。
845
+ if (DIRTY_EXIT.dirty && !window.confirm(tt("common.dirtyExit"))) return;
783
846
  setTab(next);
784
847
  setReaderFile(next === "chapters" && chapterFile ? chapterFile : null);
785
848
  }
@@ -807,7 +870,7 @@ window.__ModuleLoader__.load({
807
870
  });
808
871
  } else if (snap) {
809
872
  var tabProps = { snap: snap, reload: reload, wsPath: effectiveWs };
810
- if (readerFile) body = h(ChapterReader, { file: readerFile, snap: snap, reload: reload, wsPath: effectiveWs, onBack: function () { setReaderFile(null); } });
873
+ if (readerFile) body = h(ChapterReader, { file: readerFile, snap: snap, reload: reload, wsPath: effectiveWs, refreshSeq: reopenSeq, onBack: function () { setReaderFile(null); } });
811
874
  else if (tab === "chapters") body = h(ChaptersTab, Object.assign({ onOpenChapter: function (f) { setReaderFile(f); } }, tabProps));
812
875
  else if (tab === "characters") body = h(CharactersTab, tabProps);
813
876
  else if (tab === "foreshadows") body = h(ForeshadowsTab, tabProps);
@@ -824,6 +887,13 @@ window.__ModuleLoader__.load({
824
887
  h("div", null,
825
888
  h("h1", { className: "myt-title" }, "毛颖创作台",
826
889
  ver && ver.current ? h("span", { className: "myt-verTag", title: tt("header.curVerTitle") }, ver.current) : null,
890
+ // 「检测更新」:手动触发 fresh=1 现测(常态只在开面板时拉一次,
891
+ // 页面开久判定陈旧);与一键升级同形描边胶囊,检测中禁用防连点。
892
+ h("button", {
893
+ type: "button", className: "myt-verCheck", disabled: verChecking,
894
+ title: verChecking ? tt("header.checkingUpdate") : tt("header.checkUpdate"),
895
+ onClick: runVerCheck,
896
+ }, verChecking ? tt("header.checkingUpdate") : tt("header.checkUpdate")),
827
897
  ver && ver.updateAvailable
828
898
  ? [
829
899
  // 徽章(跳发布页)+「一键升级」按钮双元素;装完(installed)
@@ -906,6 +976,8 @@ window.__ModuleLoader__.load({
906
976
  var project = snap.project || {};
907
977
  // 编辑态:true 时 hero 卡切换为书稿元信息表单(ProjectMetaForm)。
908
978
  var _ed = useState(false), editing = _ed[0], setEditing = _ed[1];
979
+ // 分卷规划账源文件直达(0.1.20 写面):volumes.yml 整文件可编辑。
980
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
909
981
  var totals = snap.totals || {};
910
982
  var byStatus = totals.byStatus || {};
911
983
  var _b = useState(false), phaseBusy = _b[0], setPhaseBusy = _b[1];
@@ -981,7 +1053,8 @@ window.__ModuleLoader__.load({
981
1053
  }))),
982
1054
  volumes.length ? h("div", { className: "myt-card" },
983
1055
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("overview.volumes")),
984
- h("span", { className: "myt-boardCount" }, volumes.length)),
1056
+ h("span", { className: "myt-boardCount" }, volumes.length),
1057
+ srcEditBtn("volumes.yml", function () { setSrcRel("volumes.yml"); })),
985
1058
  h("div", { className: "myt-stack" }, volumes.map(function (v, i) {
986
1059
  return h("div", { key: v.id || i, className: "myt-worldGroup" },
987
1060
  h("div", { className: "myt-listHead" },
@@ -999,14 +1072,17 @@ window.__ModuleLoader__.load({
999
1072
  h("span", { className: "myt-chPath" }, chWhere(ch)),
1000
1073
  h("span", { className: "myt-wordsCell" }, tf("common.charCount", { n: fmtNum(ch.words) })));
1001
1074
  }))
1002
- : h(Empty, { text: tt("chapters.empty") })));
1075
+ : h(Empty, { text: tt("chapters.empty") })),
1076
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
1003
1077
  }
1004
1078
 
1005
1079
  // ── 轻量 Markdown 渲染(零依赖):先整体 HTML 转义再施加 md 变换,杜绝
1006
1080
  // 注入;支持标题/加粗/斜体/删除线/行内码/围栏码/引用/有序无序列表/分隔线/
1007
- // 链接/段落换行。供章纲、世界观条目等 md 阅读位使用,正文手稿仍走 pre。 ──
1081
+ // 链接/GFM 管道表格/段落换行。供章纲、世界观条目等 md 阅读位使用,正文
1082
+ // 手稿仍走 pre。 ──
1008
1083
  function escapeHtml(s) {
1009
- return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1084
+ // 单引号也转义:属性当前虽全双引号,零成本防御纵深。
1085
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1010
1086
  }
1011
1087
  // 链接 scheme 白名单:仅放行 http/https 绝对地址与无 scheme 的相对路径 /
1012
1088
  // 锚点 / //协议相对;javascript:/data:/vbscript: 等一律拒绝(调用方落纯文本)。
@@ -1033,6 +1109,56 @@ window.__ModuleLoader__.load({
1033
1109
  });
1034
1110
  return out.replace(/\u0000MYTCODE(\d+)\u0000/g, function (_m, i) { return codes[Number(i)] || ""; });
1035
1111
  }
1112
+ // ── GFM 管道表格辅助:输入行均已过 escapeHtml(反斜杠原样保留),只做
1113
+ // 结构识别与切格,单元格文本最终仍走 inlineMd,不自建未转义通道。 ──
1114
+ // 分隔行解析:仅由 |、:、-、空白组成,去掉首尾 | 后按 | 切出 ≥1 段、每段
1115
+ // :?-{1,}:?(--- / :--- / ---: / :---:);是分隔行则返回各列对齐标记
1116
+ // ('l'|'c'|'r'|'',与表头单元格一一对应),否则 null。
1117
+ function tableSepAligns(line) {
1118
+ if (!/^[\s:|-]+$/.test(line) || line.indexOf("-") === -1) return null;
1119
+ var trimmed = String(line).trim();
1120
+ if (trimmed.charAt(0) === "|") trimmed = trimmed.slice(1);
1121
+ if (trimmed.charAt(trimmed.length - 1) === "|") trimmed = trimmed.slice(0, -1);
1122
+ var segs = trimmed.split("|");
1123
+ var aligns = [];
1124
+ for (var k = 0; k < segs.length; k++) {
1125
+ var seg = segs[k].trim();
1126
+ if (!/^:?-{1,}:?$/.test(seg)) return null;
1127
+ aligns.push(seg.charAt(0) === ":" && seg.charAt(seg.length - 1) === ":" ? "c"
1128
+ : seg.charAt(seg.length - 1) === ":" ? "r"
1129
+ : seg.charAt(0) === ":" ? "l" : "");
1130
+ }
1131
+ return aligns;
1132
+ }
1133
+ // 表格行切格:转义管道 \| 先换哨兵(NUL 前缀序列,正文不可能出现)再按 |
1134
+ // 切,逐格 trim 后回填为字面 |;首尾空单元(外圈竖线)丢弃。
1135
+ function splitTableCells(line) {
1136
+ var raw = String(line).replace(/\\\|/g, "\u0000MYTPIPE").split("|");
1137
+ var cells = [];
1138
+ for (var k = 0; k < raw.length; k++) cells.push(raw[k].trim().replace(/\u0000MYTPIPE/g, "|"));
1139
+ while (cells.length && cells[0] === "") cells.shift();
1140
+ while (cells.length && cells[cells.length - 1] === "") cells.pop();
1141
+ return cells;
1142
+ }
1143
+ // 表格起始:当前行含 |、下一行是分隔行,且表头至少一个非空单元格。
1144
+ function isTableStart(lines, i) {
1145
+ if (i + 1 >= lines.length || lines[i].indexOf("|") === -1) return false;
1146
+ var aligns = tableSepAligns(lines[i + 1]);
1147
+ if (!aligns) return false;
1148
+ var head = splitTableCells(lines[i]);
1149
+ // GFM 规范:表头列数须与分隔行列数相等才认表——否则「含 | 的段落行 +
1150
+ // --- 分隔线」会被误吞成表头(数据行列数宽容是另一回事,见 renderMd)。
1151
+ if (head.length !== aligns.length || head.length === 0) return false;
1152
+ for (var k = 0; k < head.length; k++) { if (head[k] !== "") return true; }
1153
+ return false;
1154
+ }
1155
+ // 对齐落 class(th/td 共用,不用 inline style,深浅色主题只吃令牌)。
1156
+ function tableAlignCls(a) {
1157
+ if (a === "l") return ' class="myt-ta-l"';
1158
+ if (a === "c") return ' class="myt-ta-c"';
1159
+ if (a === "r") return ' class="myt-ta-r"';
1160
+ return "";
1161
+ }
1036
1162
  function renderMd(src) {
1037
1163
  var lines = escapeHtml(src).split(/\r?\n/);
1038
1164
  var html = [];
@@ -1085,9 +1211,37 @@ window.__ModuleLoader__.load({
1085
1211
  html.push('<ol class="myt-mdList">' + oitems.join("") + "</ol>");
1086
1212
  continue;
1087
1213
  }
1214
+ // GFM 管道表格:表头行 + |---|---| 分隔行 + 数据行。数据行逐行吃到
1215
+ // 空行 / 不含 | 的行 / 其它块起始(围栏/标题/引用/列表/hr)为止;列数
1216
+ // 不齐宽容渲染(缺列补空 td、多列照渲染,对齐按列号取、越界无 class)。
1217
+ if (isTableStart(lines, i)) {
1218
+ var aligns = tableSepAligns(lines[i + 1]);
1219
+ var headCells = splitTableCells(lines[i]);
1220
+ i += 2; // 吃掉表头行与分隔行
1221
+ var bodyCells = [];
1222
+ while (i < lines.length && lines[i].indexOf("|") !== -1 && !/^\s*$/.test(lines[i]) && !/^```|^#{1,6}\s|^\s*&gt;|^\s*[-*+]\s|^\s*\d+[.)]\s|^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/.test(lines[i])) {
1223
+ bodyCells.push(splitTableCells(lines[i]));
1224
+ i++;
1225
+ }
1226
+ var ths = [];
1227
+ for (var t = 0; t < headCells.length; t++) ths.push("<th" + tableAlignCls(aligns[t]) + ">" + inlineMd(headCells[t]) + "</th>");
1228
+ var trs = [];
1229
+ for (t = 0; t < bodyCells.length; t++) {
1230
+ var tds = [];
1231
+ for (var d = 0; d < Math.max(headCells.length, bodyCells[t].length); d++) {
1232
+ tds.push("<td" + tableAlignCls(aligns[d]) + ">" + inlineMd(bodyCells[t][d] || "") + "</td>");
1233
+ }
1234
+ trs.push("<tr>" + tds.join("") + "</tr>");
1235
+ }
1236
+ // 宽表由 .myt-mdTableWrap 横向滚动;th/td 对齐全走 myt-ta-* class。
1237
+ html.push('<div class="myt-mdTableWrap"><table class="myt-mdTable"><thead><tr>' + ths.join("") + "</tr></thead><tbody>" + trs.join("") + "</tbody></table></div>");
1238
+ continue;
1239
+ }
1088
1240
  var para = [line];
1089
1241
  i++;
1090
- while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^```|^\s*&gt;|^\s*[-*+]\s|^\s*\d+[.)]\s|^#{1,6}\s/.test(lines[i])) {
1242
+ // 段落兜底遇到表格起始(|a|b| + |---|---|)也要截断:段落后紧跟表格时
1243
+ // 表格独立成块,不把管道行并进段落(GitHub 行为)。
1244
+ while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^```|^\s*&gt;|^\s*[-*+]\s|^\s*\d+[.)]\s|^#{1,6}\s/.test(lines[i]) && !isTableStart(lines, i)) {
1091
1245
  para.push(lines[i]); i++;
1092
1246
  }
1093
1247
  html.push('<p class="myt-mdP">' + inlineMd(para.join("<br>")) + "</p>");
@@ -1121,10 +1275,12 @@ window.__ModuleLoader__.load({
1121
1275
  "header.upgradeBtn": "一键升级", "header.upgrading": "安装中…", "header.upgraded": "已安装,待重启",
1122
1276
  "header.upgradeTitle": "用 pnpm 安装 npm 最新版(最长约 3 分钟),装完需重启 DeepSeek 生效",
1123
1277
  "header.upgradedTitle": "已安装新版,重启 DeepSeek 后生效",
1278
+ "header.checkUpdate": "检测更新", "header.checkingUpdate": "检测中…",
1124
1279
  "toast.selfUpdate.ok": "已安装 {ver},重启 DeepSeek 后生效",
1125
1280
  "toast.selfUpdate.upToDate": "已是最新版({ver})",
1126
1281
  "toast.selfUpdate.globalOk": "全局 maoying CLI {from} → {to}", "toast.selfUpdate.globalFail": "全局 CLI 更新失败:{error}",
1127
1282
  "toast.selfUpdate.devLink": "开发目录安装,请 git pull 更新", "toast.selfUpdate.pnpmMissing": "未找到 pnpm,请手动安装",
1283
+ "toast.selfUpdate.found": "发现新版 {ver}", "toast.selfUpdate.checkFail": "检测失败:{msg}",
1128
1284
  "header.workspace": "工作区", "header.wsAria": "选择工作区", "header.noNovel": "(无 novel)", "header.untitled": "未命名",
1129
1285
  "header.settings": "系统设置", "header.refresh": "重新拉取", "header.closeAria": "关闭创作台", "header.closeTitle": "关闭",
1130
1286
  "tabs.aria": "创作台分区",
@@ -1142,10 +1298,26 @@ window.__ModuleLoader__.load({
1142
1298
  "guide.copied": "已复制 maoying init", "guide.copyFailed": "复制失败,请手动选择命令文本", "guide.copying": "复制中…", "guide.copyCmd": "复制命令",
1143
1299
  "common.retry": "重试", "common.empty": "暂无数据", "common.uncategorized": "未分类", "common.charCount": "{n} 字",
1144
1300
  "common.closeTip": "关闭提示", "common.unknownErr": "未知错误", "common.priorityTitle": "优先级:{pri}", "common.priorityAria": "优先级{pri}",
1301
+ "common.dirtyExit": "有未保存的修改,离开将丢失。确定要放弃吗?",
1145
1302
  "common.chapterNum": "第 {n} 章", "pri.high": "高", "pri.normal": "中", "pri.low": "低",
1146
1303
  "toast.prefix": "毛颖创作台:{msg}", "toast.success": "操作成功", "toast.indexRebuilt": "索引已重建({n} 章)",
1147
1304
  "toast.chapterStatus": "章节状态已更新", "toast.chapterCreated": "已从章纲建章「{title}」(待写)", "toast.foreshadowStatus": "伏笔状态已更新", "toast.phaseAdvanced": "阶段已推进到「{phase}」",
1148
1305
  "toast.styleSaved": "写作风格已保存", "toast.chapterSaved": "章节正文已保存", "toast.outlineSaved": "章纲已保存",
1306
+ "src.modalAria": "源文件编辑", "src.closeAria": "关闭源文件编辑",
1307
+ "src.editSource": "编辑源文件", "src.editSourceTip": "编辑源文件:{rel}",
1308
+ "src.deleteFile": "删除文件", "src.deleteTip": "删除文件:{rel}",
1309
+ "src.loading": "源文件加载中…", "src.loadFailed": "源文件读取失败:{msg}",
1310
+ "src.editHint": "直接编辑文件全文;保存时做结构校验(账本 yml 须解析为条目数组、md 实体卡须带 frontmatter 围栏),语义问题交给保存后的静态体检。",
1311
+ "edit.conflict409": "保存冲突:文件在编辑期间被外部修改({msg})。草稿已保留,请选择处理方式。",
1312
+ "edit.conflictDrop": "载入磁盘版(放弃草稿)", "edit.conflictKeep": "基于最新版重改",
1313
+ "src.slugLabel": "文件名 slug", "src.slugPh": "如 lin-waner(小写字母/数字与 -)", "src.slugInvalid": "slug 只接受小写字母/数字与 -,且以字母或数字开头",
1314
+ "src.createHint": "内容已按模板预填,可修改后再创建。", "src.create": "创建", "src.creating": "创建中…",
1315
+ "src.confirmTitle": "确认删除", "src.confirmText": "即将删除 {rel}。此操作不可逆——后悔靠 git 回滚;删除后静态体检会把指向它的悬空引用(伏笔/时间线/关系等)标为问题。",
1316
+ "src.confirmDelete": "确认删除", "src.deleting": "删除中…",
1317
+ "toast.fileSaved": "已保存 {rel}", "toast.fileCreated": "已创建 {rel}", "toast.fileDeleted": "已删除 {rel}",
1318
+ "reader.createOutline": "新建章纲", "reader.deleteChapter": "删除章节",
1319
+ "outline.deleteBtn": "删除章纲",
1320
+ "char.createBtn": "新建角色", "world.createBtn": "新建设定", "world.createScienceBtn": "新建科学设定",
1149
1321
  "api.notJson": "响应不是 JSON(HTTP {code})", "api.timeout": "请求超时({s} 秒)",
1150
1322
  "status.pending": "待写", "status.writing": "写作中", "status.review": "审校中", "status.accepted": "已定稿", "status.rawSuffix": "({raw})",
1151
1323
  "phase.idea": "立意", "phase.setting": "设定", "phase.outline": "大纲", "phase.writing": "写作", "phase.review": "审校",
@@ -1168,7 +1340,7 @@ window.__ModuleLoader__.load({
1168
1340
  "timeline.empty": "timeline.yml 还没有时间线条目", "timeline.importance": "重要度 {n}", "timeline.linkedChapter": "关联章节:{ch}",
1169
1341
  "world.title": "世界观条目", "world.empty": "world/ 目录还没有设定条目", "world.science": "科学设定",
1170
1342
  "world.scienceEmpty": "science/ 目录还没有科学设定", "world.research": "资料检索", "world.ref": "参考 {n}:{r}",
1171
- "world.researchEmpty": "research/ 目录还没有考据资料", "world.subTabsAria": "设定分区",
1343
+ "world.researchEmpty": "research/ 目录还没有考据资料", "world.subTabsAria": "设定分区", "world.catTabsAria": "世界观分类",
1172
1344
  "relations.title": "角色关系图", "relations.counts": "{n} 节点 · {m} 关系",
1173
1345
  "relations.help": "拖节点固定位置 · 滚轮或 +/- 按钮缩放 · 双击复位 · 悬停边看关系描述 · 右上角按钮全屏",
1174
1346
  "relations.unlinkedTitle": "未入图角色(无关系边)", "relations.noData": "没有角色或关系数据,无法绘图",
@@ -1221,10 +1393,12 @@ window.__ModuleLoader__.load({
1221
1393
  "header.upgradeBtn": "Upgrade now", "header.upgrading": "Installing…", "header.upgraded": "Installed — restart pending",
1222
1394
  "header.upgradeTitle": "Install the latest npm release with pnpm (up to ~3 min); takes effect after restarting DeepSeek",
1223
1395
  "header.upgradedTitle": "New version installed — takes effect after restarting DeepSeek",
1396
+ "header.checkUpdate": "Check updates", "header.checkingUpdate": "Checking…",
1224
1397
  "toast.selfUpdate.ok": "Installed {ver} — takes effect after restarting DeepSeek",
1225
1398
  "toast.selfUpdate.upToDate": "Already up to date ({ver})",
1226
1399
  "toast.selfUpdate.globalOk": "Global maoying CLI {from} → {to}", "toast.selfUpdate.globalFail": "Global CLI update failed: {error}",
1227
1400
  "toast.selfUpdate.devLink": "Installed from a dev directory — update with git pull", "toast.selfUpdate.pnpmMissing": "pnpm not found — install it manually",
1401
+ "toast.selfUpdate.found": "New version {ver} found", "toast.selfUpdate.checkFail": "Check failed: {msg}",
1228
1402
  "header.workspace": "Workspace", "header.wsAria": "Select workspace", "header.noNovel": "(no novel)", "header.untitled": "Untitled",
1229
1403
  "header.settings": "Settings", "header.refresh": "Refresh", "header.closeAria": "Close Maoying Studio", "header.closeTitle": "Close",
1230
1404
  "tabs.aria": "Studio sections",
@@ -1242,10 +1416,26 @@ window.__ModuleLoader__.load({
1242
1416
  "guide.copied": "Copied maoying init", "guide.copyFailed": "Copy failed — select the command text manually", "guide.copying": "Copying…", "guide.copyCmd": "Copy command",
1243
1417
  "common.retry": "Retry", "common.empty": "No data", "common.uncategorized": "Uncategorized", "common.charCount": "{n} chars",
1244
1418
  "common.closeTip": "Dismiss notification", "common.unknownErr": "Unknown error", "common.priorityTitle": "Priority: {pri}", "common.priorityAria": "Priority {pri}",
1419
+ "common.dirtyExit": "You have unsaved changes that will be lost. Discard them?",
1245
1420
  "common.chapterNum": "Ch. {n}", "pri.high": "High", "pri.normal": "Normal", "pri.low": "Low",
1246
1421
  "toast.prefix": "Maoying Studio: {msg}", "toast.success": "Success", "toast.indexRebuilt": "Index rebuilt ({n} chapters)",
1247
1422
  "toast.chapterStatus": "Chapter status updated", "toast.chapterCreated": "Created chapter \"{title}\" from the outline (pending)", "toast.foreshadowStatus": "Foreshadowing status updated", "toast.phaseAdvanced": "Phase advanced to {phase}",
1248
1423
  "toast.styleSaved": "Style baseline saved", "toast.chapterSaved": "Chapter text saved", "toast.outlineSaved": "Outline saved",
1424
+ "src.modalAria": "Source file editor", "src.closeAria": "Close source editor",
1425
+ "src.editSource": "Edit source file", "src.editSourceTip": "Edit source file: {rel}",
1426
+ "src.deleteFile": "Delete file", "src.deleteTip": "Delete file: {rel}",
1427
+ "src.loading": "Loading source file…", "src.loadFailed": "Failed to load source file: {msg}",
1428
+ "src.editHint": "Edit the whole file; saving runs structural validation (ledger yml must parse as an entry array, md entity cards must carry frontmatter fences) — semantics are left to the static check after saving.",
1429
+ "edit.conflict409": "Save conflict: the file was changed externally while editing ({msg}). Your draft is kept — choose how to proceed.",
1430
+ "edit.conflictDrop": "Load disk version (discard draft)", "edit.conflictKeep": "Rebase onto latest",
1431
+ "src.slugLabel": "File slug", "src.slugPh": "e.g. lin-waner (lowercase letters/digits and -)", "src.slugInvalid": "Slug accepts lowercase letters/digits and - only, starting with a letter or digit",
1432
+ "src.createHint": "Pre-filled from the template — adjust it, then create.", "src.create": "Create", "src.creating": "Creating…",
1433
+ "src.confirmTitle": "Confirm delete", "src.confirmText": "About to delete {rel}. This is irreversible — recovery is via git rollback; afterwards the static check will flag dangling references to it (foreshadows / timeline / relations etc.).",
1434
+ "src.confirmDelete": "Delete", "src.deleting": "Deleting…",
1435
+ "toast.fileSaved": "Saved {rel}", "toast.fileCreated": "Created {rel}", "toast.fileDeleted": "Deleted {rel}",
1436
+ "reader.createOutline": "Create outline", "reader.deleteChapter": "Delete chapter",
1437
+ "outline.deleteBtn": "Delete outline",
1438
+ "char.createBtn": "New character", "world.createBtn": "New world entry", "world.createScienceBtn": "New science entry",
1249
1439
  "api.notJson": "Response is not JSON (HTTP {code})", "api.timeout": "Request timed out ({s} s)",
1250
1440
  "status.pending": "Pending", "status.writing": "Writing", "status.review": "Review", "status.accepted": "Accepted", "status.rawSuffix": "({raw})",
1251
1441
  "phase.idea": "Idea", "phase.setting": "Setting", "phase.outline": "Outline", "phase.writing": "Writing", "phase.review": "Review",
@@ -1268,7 +1458,7 @@ window.__ModuleLoader__.load({
1268
1458
  "timeline.empty": "No entries in timeline.yml yet", "timeline.importance": "Importance {n}", "timeline.linkedChapter": "Related chapter: {ch}",
1269
1459
  "world.title": "Worldbuilding entries", "world.empty": "No worldbuilding entries in world/ yet", "world.science": "Science settings",
1270
1460
  "world.scienceEmpty": "No science entries in science/ yet", "world.research": "Research", "world.ref": "Ref {n}: {r}",
1271
- "world.researchEmpty": "No research notes in research/ yet", "world.subTabsAria": "Worldbuilding sections",
1461
+ "world.researchEmpty": "No research notes in research/ yet", "world.subTabsAria": "Worldbuilding sections", "world.catTabsAria": "Worldbuilding categories",
1272
1462
  "relations.title": "Character relations", "relations.counts": "{n} nodes · {m} relations",
1273
1463
  "relations.help": "Drag a node to pin it · scroll or the +/- buttons to zoom · double-click to reset · hover an edge for its description · top-right button for full screen",
1274
1464
  "relations.unlinkedTitle": "Unlinked characters (no relation edges)", "relations.noData": "No character or relation data to draw",
@@ -1370,6 +1560,7 @@ window.__ModuleLoader__.load({
1370
1560
  var wsPath = props.wsPath;
1371
1561
  var snap = props.snap;
1372
1562
  var reload = props.reload;
1563
+ var reopenSeq = props.refreshSeq || 0; // 面板重开序号(PageShell 广播自增)
1373
1564
  var onBack = props.onBack;
1374
1565
  var _d = useState(null), detail = _d[0], setDetail = _d[1]; // {loading, data, err}
1375
1566
  var _b = useState(false), flowBusy = _b[0], setFlowBusy = _b[1];
@@ -1383,11 +1574,41 @@ window.__ModuleLoader__.load({
1383
1574
  var _ob = useState(false), olBusy = _ob[0], setOlBusy = _ob[1];
1384
1575
  var _be = useState(""), bodyErr = _be[0], setBodyErr = _be[1];
1385
1576
  var _oerr = useState(""), olErr = _oerr[0], setOlErr = _oerr[1];
1577
+ // 409 冲突态:true 时编辑区亮出「载入磁盘版 / 基于最新版重改」两个出口,
1578
+ // 草稿绝不丢(见 saveBody/saveOutline 的 catch)。
1579
+ var _bc = useState(false), bodyConflict = _bc[0], setBodyConflict = _bc[1];
1580
+ var _oc = useState(false), olConflict = _oc[0], setOlConflict = _oc[1];
1386
1581
  var detailSeqRef = useRef(0); // chapter 请求序号:快速切章时丢弃过期响应
1387
1582
  // 409 后的一次性重置标记:loadDetail 带回磁盘真值时把草稿放回最新内容
1388
- // (只在保存冲突路径置位;顶栏刷新、状态流转等其他重拉不动正在编辑的草稿)。
1583
+ // (只在保存冲突「放弃草稿」出口置位;顶栏刷新、状态流转等其他重拉不动
1584
+ // 正在编辑的草稿)。
1389
1585
  var bodyResetRef = useRef(false);
1390
1586
  var olResetRef = useRef(false);
1587
+ // 进入编辑态时的原文基线:草稿 ≠ 基线即脏,供取消/返回的退出确认。
1588
+ var bodyBaseRef = useRef("");
1589
+ var olBaseRef = useRef("");
1590
+ // 当前选中章的实时值:在途保存/流转/建章回调用它比对闭包里的旧 file,
1591
+ // 不一致则跳过 UI 状态回填,防旧章数据覆写新章视图(B 标题+A正文)。
1592
+ var fileRef = useRef(file);
1593
+ fileRef.current = file;
1594
+ // 0.1.20 写面:新建章纲(无章纲态)/ 删除章节 / 删除章纲(各带确认弹层)。
1595
+ var stem = file.replace(/\.md$/i, "");
1596
+ var stemOk = /^v\d+-c\d+$/.test(stem); // 章纲 rel 挂钩 vXX-cXXX stem,不合规范不亮按钮
1597
+ var _co = useState(false), coBusy = _co[0], setCoBusy = _co[1];
1598
+ var _dc = useState(false), delChapterOpen = _dc[0], setDelChapterOpen = _dc[1];
1599
+ var _do = useState(false), delOutlineOpen = _do[0], setDelOutlineOpen = _do[1];
1600
+ function createOutline() {
1601
+ if (coBusy || !stemOk) return;
1602
+ setCoBusy(true);
1603
+ // 骨架照 spec 六件章纲的其中五件预填(章尾钩子独立成节,留给写的人
1604
+ // 展开成 `## 钩子`);服务端只验形状,成败提示走 toast 通道。
1605
+ apiPost("file-create", { path: wsPath, rel: "outlines/" + stem + ".md", text: outlineTemplateOf(ch, stem) })
1606
+ .then(function () { return reload().then(function () {
1607
+ // 时序守卫:建章期间切了章——快照照刷,旧章详情不回填新章视图。
1608
+ if (fileRef.current === file) return loadDetail(file);
1609
+ }); })
1610
+ .catch(function () {}).finally(function () { setCoBusy(false); });
1611
+ }
1391
1612
  var loadDetail = useCallback(function (f, opts) {
1392
1613
  var seq = ++detailSeqRef.current;
1393
1614
  if (!f) { setDetail(null); return; }
@@ -1396,20 +1617,43 @@ window.__ModuleLoader__.load({
1396
1617
  apiGet("chapter", { path: wsPath, file: f }).then(function (d) {
1397
1618
  if (seq !== detailSeqRef.current) return; // 过期响应:已在看另一章,丢弃
1398
1619
  setDetail({ loading: false, data: d, err: "" });
1399
- if (bodyResetRef.current) { bodyResetRef.current = false; setDraft(d && typeof d.body === "string" ? d.body : ""); }
1400
- if (olResetRef.current) { olResetRef.current = false; setOlDraft(d && d.outline && typeof d.outline.body === "string" ? d.outline.body : ""); }
1620
+ if (bodyResetRef.current) {
1621
+ bodyResetRef.current = false;
1622
+ var body0 = d && typeof d.body === "string" ? d.body : "";
1623
+ bodyBaseRef.current = body0; // 放回的磁盘真值即新基线(脏检查归零)
1624
+ setDraft(body0);
1625
+ }
1626
+ if (olResetRef.current) {
1627
+ olResetRef.current = false;
1628
+ var ol0 = d && d.outline && typeof d.outline.body === "string" ? d.outline.body : "";
1629
+ olBaseRef.current = ol0;
1630
+ setOlDraft(ol0);
1631
+ }
1401
1632
  }).catch(function (err) {
1402
1633
  if (seq !== detailSeqRef.current) return;
1403
1634
  setDetail({ loading: false, data: null, err: err.message });
1404
1635
  });
1405
1636
  }, [wsPath]);
1406
1637
  useEffect(function () { loadDetail(file); }, [file, loadDetail]);
1638
+ // 面板重开(reopenSeq 递增):重拉本章让磁盘最新内容即时可见。编辑中
1639
+ // 走 silent——不闪骨架、不打断 textarea,draft 只在 409 冲突重置路径才
1640
+ // 动(上方注释的纪律不变);阅读态非 silent,换血后照常渲染新正文。
1641
+ useEffect(function () {
1642
+ if (reopenSeq > 0) loadDetail(file, { silent: editing });
1643
+ }, [reopenSeq]);
1407
1644
  // 切章必须退出编辑态并清草稿:草稿属于「那一章的那一次编辑」,跨章带过
1408
1645
  // 去会把 A 章的文字存进 B 章文件。
1409
1646
  useEffect(function () {
1410
- setEditing(false); setDraft(""); setBodyErr(""); setBodyBusy(false); bodyResetRef.current = false;
1411
- setOlEditing(false); setOlDraft(""); setOlErr(""); setOlBusy(false); olResetRef.current = false;
1647
+ setEditing(false); setDraft(""); setBodyErr(""); setBodyBusy(false); bodyResetRef.current = false; setBodyConflict(false);
1648
+ setOlEditing(false); setOlDraft(""); setOlErr(""); setOlBusy(false); olResetRef.current = false; setOlConflict(false);
1649
+ bodyBaseRef.current = ""; olBaseRef.current = "";
1412
1650
  }, [file]);
1651
+ // 脏标记上报:Shell 的切 tab 守卫(gotoTab)读 DIRTY_EXIT 拦截;本组件
1652
+ // 不随切 tab 卸载(整页视图),标记随每轮渲染同步,卸载时清零。
1653
+ useEffect(function () {
1654
+ DIRTY_EXIT.dirty = (editing && draft !== bodyBaseRef.current) || (olEditing && olDraft !== olBaseRef.current);
1655
+ return function () { DIRTY_EXIT.dirty = false; };
1656
+ });
1413
1657
  var ch = null;
1414
1658
  var chapters = (snap && snap.chapters) || [];
1415
1659
  for (var i = 0; i < chapters.length; i++) { if (chapters[i].file === file) { ch = chapters[i]; break; } }
@@ -1421,7 +1665,10 @@ window.__ModuleLoader__.load({
1421
1665
  setFlowBusy(true);
1422
1666
  apiPost("chapter-status", { path: wsPath, file: ch.file, status: next })
1423
1667
  .then(function () { return reload(); })
1424
- .then(function () { return loadDetail(ch.file); })
1668
+ .then(function () {
1669
+ // 时序守卫:流转期间切了章——快照照刷(无害),旧章详情不回填。
1670
+ if (fileRef.current === ch.file) return loadDetail(ch.file);
1671
+ })
1425
1672
  .catch(function () {}).finally(function () { setFlowBusy(false); });
1426
1673
  }
1427
1674
  function copyChapterPath() {
@@ -1433,34 +1680,73 @@ window.__ModuleLoader__.load({
1433
1680
  function startBodyEdit() {
1434
1681
  if (!detail.data || typeof detail.data.body !== "string") return;
1435
1682
  setBodyErr("");
1683
+ bodyBaseRef.current = detail.data.body; // 进入编辑态即记基线,供脏检查
1436
1684
  setDraft(detail.data.body);
1437
1685
  setEditing(true);
1438
1686
  }
1439
1687
  function cancelBodyEdit() {
1440
1688
  if (bodyBusy) return; // 保存在途不许退出:避免卸载后 setState
1441
- setEditing(false); setDraft(""); setBodyErr("");
1689
+ if (draft !== bodyBaseRef.current && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认
1690
+ setEditing(false); setDraft(""); setBodyErr(""); setBodyConflict(false);
1691
+ }
1692
+ // 返回章节列表:正文或章纲任一有未保存草稿,先确认再退。
1693
+ function leaveReader() {
1694
+ if ((editing && draft !== bodyBaseRef.current) || (olEditing && olDraft !== olBaseRef.current)) {
1695
+ if (!window.confirm(tt("common.dirtyExit"))) return;
1696
+ }
1697
+ onBack();
1442
1698
  }
1443
1699
  function saveBody() {
1444
1700
  if (bodyBusy || !detail.data) return;
1445
1701
  setBodyBusy(true); setBodyErr("");
1446
- // 成败提示统一走 apiPost 的 toast 通道(这里不再另弹,避免双 toast)。
1447
- apiPost("chapter-save", { path: wsPath, file: file, body: draft, base_hash: detail.data.hash }).then(function (res) {
1448
- // 写后指纹即下一轮基线(连续保存不再被自家 409 拦住);本地 data 同步
1449
- // 为新正文,退出编辑态立即看到刚落盘的内容(不等重拉,防闪回旧文)。
1450
- setDetail({ loading: false, data: Object.assign({}, detail.data, {
1451
- body: draft,
1452
- hash: res && typeof res.hash === "string" ? res.hash : detail.data.hash,
1453
- }), err: "" });
1454
- setEditing(false); setDraft("");
1702
+ // 失败经卡内 bodyErr 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走 toast。
1703
+ apiPost("chapter-save", { path: wsPath, file: file, body: draft, base_hash: detail.data.hash }, { silent: true }).then(function (res) {
1704
+ // 时序守卫:保存期间切了章——旧章结果不得覆写当前 detail(快照 reload 无害照做)。
1705
+ if (fileRef.current === file) {
1706
+ // 写后指纹即下一轮基线(连续保存不再被自家 409 拦住);本地 data 同步
1707
+ // 为新正文,退出编辑态立即看到刚落盘的内容(不等重拉,防闪回旧文)。
1708
+ setDetail({ loading: false, data: Object.assign({}, detail.data, {
1709
+ body: draft,
1710
+ hash: res && typeof res.hash === "string" ? res.hash : detail.data.hash,
1711
+ }), err: "" });
1712
+ setBodyConflict(false);
1713
+ setEditing(false); setDraft("");
1714
+ }
1455
1715
  return reload(); // 字数/统计随正文变,快照由 reload 刷新
1456
1716
  }).catch(function (err) {
1457
- setBodyErr(err.message);
1458
- // 409 = 章节文件在进入编辑态后被外部改过:草稿丢弃、重拉磁盘真值放回
1459
- // 编辑器(与 ProjectMetaForm 同纪律),别让用户在过期基线上盲改。
1460
- if (err && err.status === 409) { bodyResetRef.current = true; loadDetail(file); }
1461
- // 其他失败(网络抖动等)不动草稿,只报错。
1717
+ if (fileRef.current !== file) return; // 切章后的过期失败:不打扰当前章
1718
+ if (err && err.status === 409) {
1719
+ // 409 = 章节文件在进入编辑态后被外部改过:草稿绝不丢——保留编辑态
1720
+ // 与草稿原文,报冲突并亮两个出口(放弃草稿载入磁盘版 / 基于磁盘
1721
+ // 最新版重改),由用户选择,不再静默重拉覆盖草稿。
1722
+ setBodyConflict(true);
1723
+ setBodyErr(tf("edit.conflict409", { msg: err.message }));
1724
+ } else {
1725
+ setBodyErr(err.message); // 其他失败(网络抖动等)不动草稿,只报错。
1726
+ }
1462
1727
  }).finally(function () { setBodyBusy(false); });
1463
1728
  }
1729
+ // 409 出口①:放弃草稿——磁盘真值放回编辑器(bodyResetRef 一次性重置链路)。
1730
+ function dropBodyDraft() {
1731
+ if (bodyBusy) return;
1732
+ setBodyConflict(false); setBodyErr("");
1733
+ bodyResetRef.current = true;
1734
+ loadDetail(file);
1735
+ }
1736
+ // 409 出口②:基于磁盘最新版重改——只把 detail 换成磁盘最新(拿到新
1737
+ // base_hash),草稿原样留在编辑器,用户改完再存即用新基线。
1738
+ function rebaseBodyDraft() {
1739
+ if (bodyBusy) return;
1740
+ var seq = ++detailSeqRef.current;
1741
+ apiGet("chapter", { path: wsPath, file: file }).then(function (d) {
1742
+ if (seq !== detailSeqRef.current) return; // 过期响应:已切章,丢弃
1743
+ setDetail({ loading: false, data: d, err: "" });
1744
+ setBodyConflict(false); setBodyErr("");
1745
+ }).catch(function (err2) {
1746
+ if (seq !== detailSeqRef.current) return;
1747
+ setBodyErr(err2.message); // 重拉失败:冲突态保留,出口仍在可重试
1748
+ });
1749
+ }
1464
1750
  function startOutlineEdit() {
1465
1751
  var ol = detail.data && detail.data.outline;
1466
1752
  if (!ol || typeof ol.body !== "string") return;
@@ -1470,33 +1756,67 @@ window.__ModuleLoader__.load({
1470
1756
  }
1471
1757
  function cancelOutlineEdit() {
1472
1758
  if (olBusy) return;
1473
- setOlEditing(false); setOlDraft(""); setOlErr("");
1759
+ if (olDraft !== olBaseRef.current && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认
1760
+ setOlEditing(false); setOlDraft(""); setOlErr(""); setOlConflict(false);
1474
1761
  }
1475
1762
  function saveOutline() {
1476
1763
  var ol = detail.data && detail.data.outline;
1477
1764
  if (olBusy || !ol) return;
1478
1765
  setOlBusy(true); setOlErr("");
1479
- apiPost("outline-save", { path: wsPath, file: file, body: olDraft, base_hash: ol.hash }).then(function (res) {
1480
- setDetail({ loading: false, data: Object.assign({}, detail.data, {
1481
- outline: Object.assign({}, ol, { body: olDraft, hash: res && typeof res.hash === "string" ? res.hash : ol.hash }),
1482
- }), err: "" });
1483
- setOlEditing(false); setOlDraft("");
1766
+ // 失败经卡内 olErr 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走 toast。
1767
+ apiPost("outline-save", { path: wsPath, file: file, body: olDraft, base_hash: ol.hash }, { silent: true }).then(function (res) {
1768
+ if (fileRef.current === file) { // 时序守卫:同 saveBody
1769
+ setDetail({ loading: false, data: Object.assign({}, detail.data, {
1770
+ outline: Object.assign({}, ol, { body: olDraft, hash: res && typeof res.hash === "string" ? res.hash : ol.hash }),
1771
+ }), err: "" });
1772
+ setOlConflict(false);
1773
+ setOlEditing(false); setOlDraft("");
1774
+ }
1484
1775
  // 章纲字数 chip 客户端不算 CJK——silent 重拉静默对齐,不闪骨架。
1485
- return reload().then(function () { return loadDetail(file, { silent: true }); });
1776
+ return reload().then(function () {
1777
+ if (fileRef.current === file) return loadDetail(file, { silent: true });
1778
+ });
1486
1779
  }).catch(function (err) {
1487
- setOlErr(err.message);
1488
- if (err && err.status === 409) { olResetRef.current = true; loadDetail(file); }
1780
+ if (fileRef.current !== file) return; // 切章后的过期失败:不打扰当前章
1781
+ if (err && err.status === 409) {
1782
+ // 409 = 章纲文件被外部改过:草稿绝不丢,两个出口同 saveBody。
1783
+ setOlConflict(true);
1784
+ setOlErr(tf("edit.conflict409", { msg: err.message }));
1785
+ } else {
1786
+ setOlErr(err.message);
1787
+ }
1489
1788
  }).finally(function () { setOlBusy(false); });
1490
1789
  }
1790
+ // 409 出口①:放弃章纲草稿——磁盘真值放回编辑器(olResetRef 一次性重置链路)。
1791
+ function dropOutlineDraft() {
1792
+ if (olBusy) return;
1793
+ setOlConflict(false); setOlErr("");
1794
+ olResetRef.current = true;
1795
+ loadDetail(file);
1796
+ }
1797
+ // 409 出口②:基于磁盘最新版重改——只换 detail 拿新 base_hash,章纲草稿
1798
+ // 原样留在编辑器,改完再存即用新基线。
1799
+ function rebaseOutlineDraft() {
1800
+ if (olBusy) return;
1801
+ var seq = ++detailSeqRef.current;
1802
+ apiGet("chapter", { path: wsPath, file: file }).then(function (d) {
1803
+ if (seq !== detailSeqRef.current) return;
1804
+ setDetail({ loading: false, data: d, err: "" });
1805
+ setOlConflict(false); setOlErr("");
1806
+ }).catch(function (err2) {
1807
+ if (seq !== detailSeqRef.current) return;
1808
+ setOlErr(err2.message); // 重拉失败:冲突态保留,出口仍在可重试
1809
+ });
1810
+ }
1491
1811
  // 章纲编辑钩子(传给 OutlinePanel;无章纲返回 null = 只读,不给编辑入口——
1492
- // v1 不做面板新建章纲文件)。
1812
+ // v1 不做面板新建章纲文件)。conflict/drop/rebase:409 两出口透传。
1493
1813
  function outlineEditor() {
1494
1814
  if (!detail.data || !detail.data.outline) return null;
1495
- return { active: olEditing, draft: olDraft, busy: olBusy, err: olErr, start: startOutlineEdit, change: setOlDraft, cancel: cancelOutlineEdit, save: saveOutline };
1815
+ return { active: olEditing, draft: olDraft, busy: olBusy, err: olErr, conflict: olConflict, start: startOutlineEdit, change: setOlDraft, cancel: cancelOutlineEdit, save: saveOutline, drop: dropOutlineDraft, rebase: rebaseOutlineDraft };
1496
1816
  }
1497
1817
  return h("div", { className: "myt-reader" },
1498
1818
  h("div", { className: "myt-readerBar" },
1499
- h("button", { type: "button", className: "myt-btn", onClick: onBack, title: tt("reader.backTip") },
1819
+ h("button", { type: "button", className: "myt-btn", onClick: leaveReader, title: tt("reader.backTip") },
1500
1820
  ic("IconChevronRightOutlineRegular", 13), tt("reader.back")),
1501
1821
  h("h2", { className: "myt-readerTitle" }, ch ? chFullTitle(ch) : file),
1502
1822
  ch ? statusBadge(ch.status) : null,
@@ -1510,15 +1830,31 @@ window.__ModuleLoader__.load({
1510
1830
  type: "button", className: "myt-btn myt-copyBtn",
1511
1831
  title: tt("reader.copyPathTip"), "aria-label": tt("reader.copyPathTip"),
1512
1832
  onClick: copyChapterPath,
1513
- }, ic("IconCopyOutlineRegular", 13), tt("reader.copyPath")))),
1833
+ }, ic("IconCopyOutlineRegular", 13), tt("reader.copyPath")),
1834
+ // 删除章节(0.1.20 写面):服务端不做二次确认,弹层在下方强制;
1835
+ // 删除成功 → 重拉快照 + 退出阅读页回列表(章节已不存在)。
1836
+ h("button", {
1837
+ type: "button", className: "myt-btn myt-btnDanger",
1838
+ title: tt("reader.deleteChapter"), "aria-label": tt("reader.deleteChapter"),
1839
+ onClick: function () { setDelChapterOpen(true); },
1840
+ }, tt("reader.deleteChapter")))),
1514
1841
  h("div", { className: "myt-readerBody" },
1515
1842
  !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%" } }))
1516
1843
  : detail.err ? h(ErrorStrip, { text: tf("reader.loadFailed", { msg: detail.err }), onRetry: function () { loadDetail(file); } })
1517
1844
  : [
1518
1845
  (function () {
1519
1846
  var ol = detail.data && detail.data.outline;
1520
- return ol ? h(OutlinePanel, { key: "outline", outline: ol, editor: outlineEditor() })
1521
- : h("div", { key: "outline-none", className: "myt-outlineEmpty" }, tt("reader.noOutline"));
1847
+ return ol ? h(OutlinePanel, { key: "outline", outline: ol, editor: outlineEditor(), onDelete: stemOk ? function () { setDelOutlineOpen(true); } : null })
1848
+ : [
1849
+ h("div", { key: "outline-none", className: "myt-outlineEmpty" }, tt("reader.noOutline")),
1850
+ // 新建章纲(0.1.20 写面):outlines/<stem>.md 原不存在才走到
1851
+ // 这里;预填五件内容骨架,建好即重拉详情点亮章纲对照栏。
1852
+ stemOk ? h("button", {
1853
+ key: "outline-create", type: "button", className: "myt-btn",
1854
+ disabled: coBusy, title: tt("reader.createOutline"), "aria-label": tt("reader.createOutline"),
1855
+ onClick: createOutline,
1856
+ }, coBusy ? tt("src.creating") : tt("reader.createOutline")) : null,
1857
+ ];
1522
1858
  })(),
1523
1859
  editing
1524
1860
  ? h("div", { key: "body", className: "myt-bodyEdit" },
@@ -1528,6 +1864,9 @@ window.__ModuleLoader__.load({
1528
1864
  }),
1529
1865
  h("div", { className: "myt-editHint" }, tt("reader.bodyEditHint")),
1530
1866
  bodyErr ? h(ErrorStrip, { text: bodyErr }) : null,
1867
+ bodyConflict ? h("div", { className: "myt-setActions" },
1868
+ h("button", { type: "button", className: "myt-btn", disabled: bodyBusy, onClick: dropBodyDraft }, tt("edit.conflictDrop")),
1869
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: bodyBusy, onClick: rebaseBodyDraft }, tt("edit.conflictKeep"))) : null,
1531
1870
  h("div", { className: "myt-setActions" },
1532
1871
  h("button", { type: "button", className: "myt-btn", disabled: bodyBusy, onClick: cancelBodyEdit }, tt("novel.cancel")),
1533
1872
  h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: bodyBusy, onClick: saveBody }, bodyBusy ? tt("novel.saving") : tt("reader.save"))))
@@ -1546,7 +1885,29 @@ window.__ModuleLoader__.load({
1546
1885
  return [h("span", { className: "myt-muted", key: "t" }, tt("reader.statusFlow")),
1547
1886
  h("button", { key: "b", type: "button", className: "myt-btn myt-btnPrimary", disabled: flowBusy, onClick: advance },
1548
1887
  flowBusy ? tt("reader.flowing") : tf("reader.advanceTo", { next: tt("status." + next) }))];
1549
- })()) : null);
1888
+ })()) : null,
1889
+ delChapterOpen ? h(ConfirmModal, {
1890
+ title: tt("reader.deleteChapter"),
1891
+ text: tf("src.confirmText", { rel: "chapters/" + file }),
1892
+ onCancel: function () { setDelChapterOpen(false); },
1893
+ onConfirm: function () {
1894
+ return apiPost("file-delete", { path: wsPath, rel: "chapters/" + file }).then(function () {
1895
+ setDelChapterOpen(false);
1896
+ return reload().then(onBack); // 章已删:重拉快照后退出阅读页回列表
1897
+ });
1898
+ },
1899
+ }) : null,
1900
+ delOutlineOpen ? h(ConfirmModal, {
1901
+ title: tt("outline.deleteBtn"),
1902
+ text: tf("src.confirmText", { rel: "outlines/" + stem + ".md" }),
1903
+ onCancel: function () { setDelOutlineOpen(false); },
1904
+ onConfirm: function () {
1905
+ return apiPost("file-delete", { path: wsPath, rel: "outlines/" + stem + ".md" }).then(function () {
1906
+ setDelOutlineOpen(false);
1907
+ return reload().then(function () { return loadDetail(file); }); // 章纲栏熄灭、空态亮出
1908
+ });
1909
+ },
1910
+ }) : null);
1550
1911
  }
1551
1912
 
1552
1913
  // ── 书稿元信息编辑表单(概览「编辑」按钮展开):可视化 project.yml 平
@@ -1600,14 +1961,24 @@ window.__ModuleLoader__.load({
1600
1961
  }, [snap, wsPath]);
1601
1962
  var _b = useState(false), busy = _b[0], setBusy = _b[1];
1602
1963
  var _e = useState(""), saveErr = _e[0], setSaveErr = _e[1];
1964
+ // 脏检查:表单与磁盘初值(进入编辑态时的 project 快照)逐键比对,任一
1965
+ // 不同即脏——取消退出确认与 Shell 切 tab 拦截共用。上报卸载即清零。
1966
+ var diskForm = settingsFormOf(project);
1967
+ var dirty = (function () {
1968
+ for (var k in diskForm) { if (String(form[k]) !== String(diskForm[k])) return true; }
1969
+ return false;
1970
+ })();
1971
+ useEffect(function () {
1972
+ DIRTY_EXIT.dirty = dirty;
1973
+ return function () { DIRTY_EXIT.dirty = false; };
1974
+ });
1603
1975
  function set(k, v) { setForm(Object.assign({}, form, (function () { var o = {}; o[k] = v; return o; })())); }
1604
1976
  function save() {
1605
1977
  if (busy) return;
1606
1978
  if (!String(form.title || "").trim()) { setSaveErr(tt("novel.titleRequired")); return; }
1607
1979
  setBusy(true); setSaveErr("");
1608
- // 成败提示统一走 apiPost 的 toast 通道(这里不再另弹,避免双 toast);
1609
- // 卡内 saveErr 只承担表单旁的错误态。
1610
- apiPost("project-save", { path: wsPath, values: form, base_hash: baseHashRef.current }).then(function (res) {
1980
+ // 失败经卡内 saveErr 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走 toast。
1981
+ apiPost("project-save", { path: wsPath, values: form, base_hash: baseHashRef.current }, { silent: true }).then(function (res) {
1611
1982
  // 写后指纹即下一轮基线:不等 reload 快照就先接住——连续两次保存时
1612
1983
  // 第二次若还回传载入时的旧指纹,会被自家 409 拦住(假冲突)。
1613
1984
  if (res && typeof res.project_hash === "string") baseHashRef.current = res.project_hash;
@@ -1642,6 +2013,7 @@ window.__ModuleLoader__.load({
1642
2013
  }
1643
2014
  function cancel() {
1644
2015
  if (busy) return; // 保存在途不许退出:避免卸载后 setState
2016
+ if (dirty && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认
1645
2017
  onDone();
1646
2018
  }
1647
2019
  return h("div", { className: "myt-stack" },
@@ -1698,6 +2070,13 @@ window.__ModuleLoader__.load({
1698
2070
  onClick: function (e) { e.stopPropagation(); ed.start(); },
1699
2071
  onKeyDown: function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); ed.start(); } },
1700
2072
  }, ic("IconEditOutlineRegular", 12), tt("reader.edit")) : null,
2073
+ // 删除章纲(0.1.20 写面):同 head-span 形态;确认弹层由 ChapterReader 渲染。
2074
+ props.onDelete && ed !== null && !ed.active ? h("span", {
2075
+ className: "myt-outlineEditBtn myt-outlineDelBtn", role: "button", tabIndex: 0,
2076
+ title: tt("outline.deleteBtn"), "aria-label": tt("outline.deleteBtn"),
2077
+ onClick: function (e) { e.stopPropagation(); props.onDelete(); },
2078
+ onKeyDown: function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); props.onDelete(); } },
2079
+ }, tt("outline.deleteBtn")) : null,
1701
2080
  h("span", { className: "myt-outlineChevron", "aria-hidden": "true" },
1702
2081
  ic(open ? "IconChevronDownOutlineRegular" : "IconChevronRightOutlineRegular", 14))),
1703
2082
  open ? (ed !== null && ed.active
@@ -1708,6 +2087,9 @@ window.__ModuleLoader__.load({
1708
2087
  }),
1709
2088
  h("div", { className: "myt-editHint" }, tt("outline.editHint")),
1710
2089
  ed.err ? h(ErrorStrip, { text: ed.err }) : null,
2090
+ ed.conflict ? h("div", { className: "myt-setActions" },
2091
+ h("button", { type: "button", className: "myt-btn", disabled: ed.busy, onClick: ed.drop }, tt("edit.conflictDrop")),
2092
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: ed.busy, onClick: ed.rebase }, tt("edit.conflictKeep"))) : null,
1711
2093
  h("div", { className: "myt-setActions" },
1712
2094
  h("button", { type: "button", className: "myt-btn", disabled: ed.busy, onClick: ed.cancel }, tt("novel.cancel")),
1713
2095
  h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: ed.busy, onClick: ed.save }, ed.busy ? tt("novel.saving") : tt("reader.save"))))
@@ -1764,6 +2146,207 @@ window.__ModuleLoader__.load({
1764
2146
  : h(MdView, { text: (detail.data && detail.data.body) || tt("outline.empty"), className: "myt-outlineBody" }))));
1765
2147
  }
1766
2148
 
2149
+ // ── 受管文件通用弹层三件套(0.1.20「全量可编辑写面」)────────────────────
2150
+ // 新建实体卡的预填模板(服务端只验形状,内容由客户端模板兜底——spec 各
2151
+ // 文件的最小骨架;数据文件按约定中文书写,不随界面语言切换)。
2152
+ var MANAGED_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; // 与服务端 managedRelCheck 同一条正则
2153
+ var TPL_CHARACTER = "---\nname: \nrole: supporting\n---\n\n";
2154
+ var TPL_WORLD = "---\ncategory: 地点\n---\n\n";
2155
+ var TPL_SCIENCE = "---\nlabel: known\n---\n\n";
2156
+ function outlineTemplateOf(ch, stem) {
2157
+ var num = ch && ch.num ? ch.num : ((stem.match(/c(\d+)/i) || [])[1] || "?");
2158
+ var title = ch && ch.title ? " " + ch.title : "";
2159
+ return "# 第" + num + "章" + title + " 章纲\n\n- 本章目标:\n- 节拍:\n- 出场角色:\n- 需埋/需收伏笔:\n- 与前章接口:\n";
2160
+ }
2161
+ // 各 tab 复用的「编辑源文件 / 删除文件」小按钮:title 点名文件路径,
2162
+ // 删除走红色按钮(确认弹层由调用方持 state 渲染)。
2163
+ function srcEditBtn(rel, onOpen) {
2164
+ return h("button", {
2165
+ type: "button", className: "myt-btn",
2166
+ title: tf("src.editSourceTip", { rel: rel }), "aria-label": tf("src.editSourceTip", { rel: rel }),
2167
+ onClick: onOpen,
2168
+ }, ic("IconEditOutlineRegular", 13), tt("src.editSource"));
2169
+ }
2170
+ function srcDeleteBtn(rel, onAsk) {
2171
+ return h("button", {
2172
+ type: "button", className: "myt-btn myt-btnDanger",
2173
+ title: tf("src.deleteTip", { rel: rel }), "aria-label": tf("src.deleteTip", { rel: rel }),
2174
+ onClick: onAsk,
2175
+ }, tt("src.deleteFile"));
2176
+ }
2177
+ // 编辑既有受管文件全文。打开即 GET file-source(请求序号防快速开关时的
2178
+ // 过期响应,对照 ChapterReader);保存 POST file-save 带 base_hash 基线,
2179
+ // 409 = 编辑期间文件被外部改过——草稿绝不丢:保留草稿报冲突,亮两个出口
2180
+ // (放弃草稿载入磁盘版 / 基于磁盘最新版重改),别让用户在过期基线上盲改
2181
+ // 也不许静默覆盖草稿。关闭统一走脏检查,未保存草稿先确认。成功后关弹层
2182
+ // 并触发调用方 reload(快照重算),成功提示走 apiPost 的 toast 通道。
2183
+ function SourceEditorModal(props) {
2184
+ var title = props.title;
2185
+ var rel = props.rel;
2186
+ var wsPath = props.wsPath;
2187
+ var onClose = props.onClose;
2188
+ var onSaved = props.onSaved;
2189
+ var _d = useState(null), detail = _d[0], setDetail = _d[1]; // { data, err }
2190
+ var _dr = useState(""), draft = _dr[0], setDraft = _dr[1];
2191
+ var _b = useState(false), busy = _b[0], setBusy = _b[1];
2192
+ var _e = useState(""), err = _e[0], setErr = _e[1];
2193
+ var _c = useState(false), conflict = _c[0], setConflict = _c[1];
2194
+ var seqRef = useRef(0);
2195
+ // 脏检查基线 = detail.data.text:拉取/重基线都随 data 一致更新,草稿是否
2196
+ // 偏离磁盘原文直接可比。上报 DIRTY_EXIT 供 Shell 切 tab 拦截,卸载清零。
2197
+ var dirty = !!detail && !!detail.data && draft !== ((detail.data && detail.data.text) || "");
2198
+ useEffect(function () {
2199
+ DIRTY_EXIT.dirty = dirty;
2200
+ return function () { DIRTY_EXIT.dirty = false; };
2201
+ });
2202
+ function close() {
2203
+ if (dirty && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认
2204
+ onClose();
2205
+ }
2206
+ var load = useCallback(function () {
2207
+ var seq = ++seqRef.current;
2208
+ setDetail(null); setErr(""); setConflict(false);
2209
+ apiGet("file-source", { path: wsPath, rel: rel }).then(function (d) {
2210
+ if (seq !== seqRef.current) return;
2211
+ setDetail({ data: d, err: "" });
2212
+ setDraft(d && typeof d.text === "string" ? d.text : "");
2213
+ }).catch(function (e2) {
2214
+ if (seq !== seqRef.current) return;
2215
+ setDetail({ data: null, err: e2 && e2.message ? e2.message : tt("common.unknownErr") });
2216
+ });
2217
+ }, [wsPath, rel]);
2218
+ useEffect(function () { load(); }, [load]);
2219
+ function save() {
2220
+ if (busy || !detail || !detail.data) return;
2221
+ setBusy(true); setErr("");
2222
+ // 失败经卡内 err 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走 toast。
2223
+ apiPost("file-save", { path: wsPath, rel: rel, text: draft, base_hash: detail.data.hash }, { silent: true }).then(function () {
2224
+ onClose();
2225
+ if (onSaved) onSaved();
2226
+ }).catch(function (e2) {
2227
+ if (e2 && e2.status === 409) {
2228
+ // 草稿绝不丢:报冲突并亮两个出口(见顶部注释),不再静默 load 覆盖。
2229
+ setConflict(true);
2230
+ setErr(tf("edit.conflict409", { msg: e2.message }));
2231
+ } else setErr(e2 && e2.message ? e2.message : tt("common.unknownErr"));
2232
+ }).finally(function () { setBusy(false); });
2233
+ }
2234
+ // 409 出口①:放弃草稿——重拉磁盘真值回填(load 原行为)。
2235
+ function dropDraft() { setConflict(false); setErr(""); load(); }
2236
+ // 409 出口②:基于磁盘最新版重改——只换 detail 拿新 base_hash,草稿原样
2237
+ // 留在编辑器,改完再存即用新基线。
2238
+ function rebaseDraft() {
2239
+ var seq = ++seqRef.current;
2240
+ apiGet("file-source", { path: wsPath, rel: rel }).then(function (d) {
2241
+ if (seq !== seqRef.current) return;
2242
+ setDetail({ data: d, err: "" });
2243
+ setConflict(false); setErr("");
2244
+ }).catch(function (e3) {
2245
+ if (seq !== seqRef.current) return;
2246
+ setErr(e3 && e3.message ? e3.message : tt("common.unknownErr")); // 冲突态保留可重试
2247
+ });
2248
+ }
2249
+ return h(MaskPortal, {
2250
+ className: "myt-modalMask",
2251
+ onMouseDown: function (e) { if (e.target === e.currentTarget && !busy) close(); },
2252
+ },
2253
+ h("div", { className: "myt-modalCard myt-srcCard", role: "dialog", "aria-modal": "true", "aria-label": tt("src.modalAria") },
2254
+ h("div", { className: "myt-modalHead" },
2255
+ h("h2", { className: "myt-modalTitle" }, title),
2256
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("src.closeAria"), onClick: close }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
2257
+ h("div", { className: "myt-modalBody" },
2258
+ h("div", { className: "myt-srcRel" }, rel),
2259
+ h("div", { className: "myt-muted myt-srcHint" }, tt("src.editHint")),
2260
+ !detail ? h("div", { className: "myt-muted" }, tt("src.loading"))
2261
+ : detail.err ? h(ErrorStrip, { text: tf("src.loadFailed", { msg: detail.err }), onRetry: load })
2262
+ : [
2263
+ h("textarea", { key: "a", className: "myt-setInput myt-setArea myt-srcArea", rows: 16, spellCheck: false,
2264
+ value: draft, onChange: function (e) { setDraft(e.target.value); } }),
2265
+ err ? h(ErrorStrip, { key: "e", text: err }) : null,
2266
+ conflict ? h("div", { key: "c", className: "myt-setActions" },
2267
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: dropDraft }, tt("edit.conflictDrop")),
2268
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: rebaseDraft }, tt("edit.conflictKeep"))) : null,
2269
+ h("div", { key: "s", className: "myt-setActions" },
2270
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: close }, tt("novel.cancel")),
2271
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save }, busy ? tt("novel.saving") : tt("novel.save"))),
2272
+ ])));
2273
+ }
2274
+ // 新建受管 md 实体卡(角色/设定/科学设定共用):slug 客户端预校(与服务
2275
+ // 端同一条正则,错在敲键时就报),内容按模板预填可改;失败(409 已存在等)
2276
+ // 留在弹层内报错,草稿不丢——建卡是低频操作,丢一遍模板重填太伤。
2277
+ function CreateFileModal(props) {
2278
+ var title = props.title;
2279
+ var dir = props.dir;
2280
+ var template = props.template;
2281
+ var wsPath = props.wsPath;
2282
+ var onClose = props.onClose;
2283
+ var onSaved = props.onSaved;
2284
+ var _s = useState(""), slug = _s[0], setSlug = _s[1];
2285
+ var _t = useState(template), text = _t[0], setText = _t[1];
2286
+ var _b = useState(false), busy = _b[0], setBusy = _b[1];
2287
+ var _e = useState(""), err = _e[0], setErr = _e[1];
2288
+ var slugOk = MANAGED_SLUG_RE.test(slug);
2289
+ function create() {
2290
+ if (busy) return;
2291
+ if (!slugOk) { setErr(tt("src.slugInvalid")); return; }
2292
+ setBusy(true); setErr("");
2293
+ // 失败经卡内 err 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走 toast。
2294
+ apiPost("file-create", { path: wsPath, rel: dir + "/" + slug + ".md", text: text }, { silent: true }).then(function () {
2295
+ onClose();
2296
+ if (onSaved) onSaved();
2297
+ }).catch(function (e2) { setErr(e2 && e2.message ? e2.message : tt("common.unknownErr")); })
2298
+ .finally(function () { setBusy(false); });
2299
+ }
2300
+ return h(MaskPortal, {
2301
+ className: "myt-modalMask",
2302
+ onMouseDown: function (e) { if (e.target === e.currentTarget && !busy) onClose(); },
2303
+ },
2304
+ h("div", { className: "myt-modalCard", role: "dialog", "aria-modal": "true", "aria-label": title },
2305
+ h("div", { className: "myt-modalHead" },
2306
+ h("h2", { className: "myt-modalTitle" }, title),
2307
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("src.closeAria"), onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
2308
+ h("div", { className: "myt-modalBody" },
2309
+ h("label", { className: "myt-setField" },
2310
+ h("span", { className: "myt-setFieldLabel" }, tt("src.slugLabel")),
2311
+ h("input", { type: "text", className: "myt-setInput", placeholder: tt("src.slugPh"), value: slug, spellCheck: false,
2312
+ onChange: function (e) { setSlug(e.target.value); setErr(""); } })),
2313
+ h("div", { className: "myt-muted myt-srcHint" }, tt("src.createHint")),
2314
+ h("textarea", { className: "myt-setInput myt-setArea myt-srcArea", rows: 10, spellCheck: false,
2315
+ value: text, onChange: function (e) { setText(e.target.value); } }),
2316
+ err ? h(ErrorStrip, { text: err }) : null,
2317
+ h("div", { className: "myt-setActions" },
2318
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: onClose }, tt("novel.cancel")),
2319
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy || slug === "", onClick: create }, busy ? tt("src.creating") : tt("src.create"))))));
2320
+ }
2321
+ // 删除二次确认(服务端不做二次确认,UI 层强制——删除不可逆,文案点名后
2322
+ // 悔靠 git、check 会标孤儿引用)。onConfirm 返回 promise,在途禁双击;
2323
+ // 成败提示走 apiPost 的 toast 通道。
2324
+ function ConfirmModal(props) {
2325
+ var title = props.title;
2326
+ var text = props.text;
2327
+ var onCancel = props.onCancel;
2328
+ var onConfirm = props.onConfirm;
2329
+ var _b = useState(false), busy = _b[0], setBusy = _b[1];
2330
+ function confirm() {
2331
+ if (busy) return;
2332
+ setBusy(true);
2333
+ Promise.resolve(onConfirm()).catch(function () {}).finally(function () { setBusy(false); });
2334
+ }
2335
+ return h(MaskPortal, {
2336
+ className: "myt-modalMask",
2337
+ onMouseDown: function (e) { if (e.target === e.currentTarget && !busy) onCancel(); },
2338
+ },
2339
+ h("div", { className: "myt-modalCard myt-confirmCard", role: "alertdialog", "aria-modal": "true", "aria-label": title },
2340
+ h("div", { className: "myt-modalHead" },
2341
+ h("h2", { className: "myt-modalTitle" }, title),
2342
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("src.closeAria"), disabled: busy, onClick: onCancel }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
2343
+ h("div", { className: "myt-modalBody" },
2344
+ h("div", { className: "myt-confirmText" }, text),
2345
+ h("div", { className: "myt-setActions" },
2346
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: onCancel }, tt("novel.cancel")),
2347
+ h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy, onClick: confirm }, busy ? tt("src.deleting") : tt("src.confirmDelete"))))));
2348
+ }
2349
+
1767
2350
  function ChaptersTab(props) {
1768
2351
  var snap = props.snap;
1769
2352
  var wsPath = props.wsPath;
@@ -1885,10 +2468,20 @@ window.__ModuleLoader__.load({
1885
2468
  ];
1886
2469
  function CharactersTab(props) {
1887
2470
  var characters = props.snap.characters || [];
2471
+ var wsPath = props.wsPath;
2472
+ var reload = props.reload;
1888
2473
  var _m = useState(null), modalFile = _m[0], setModalFile = _m[1];
2474
+ // 受管文件三态:编辑 rel / 新建弹层开关 / 删除确认 rel(0.1.20 写面)。
2475
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
2476
+ var _new = useState(false), createOpen = _new[0], setCreateOpen = _new[1];
2477
+ var _del = useState(null), delRel = _del[0], setDelRel = _del[1];
1889
2478
  var modal = null;
1890
2479
  for (var i = 0; i < characters.length; i++) { if (characters[i].file === modalFile) { modal = characters[i]; break; } }
1891
2480
  return h("div", { className: "myt-stack" },
2481
+ h("div", { className: "myt-cardHead" },
2482
+ h("h3", { className: "myt-cardTitle" }, tt("tabs.characters")),
2483
+ h("span", { className: "myt-boardCount" }, characters.length),
2484
+ h("button", { type: "button", className: "myt-btn", onClick: function () { setCreateOpen(true); } }, tt("char.createBtn"))),
1892
2485
  characters.length ? h("div", { className: "myt-charWall" }, characters.map(function (c) {
1893
2486
  return h("button", { key: c.file, type: "button", className: "myt-card myt-charCard", onClick: function () { setModalFile(c.file); } },
1894
2487
  h("div", { className: "myt-charHead" },
@@ -1923,7 +2516,27 @@ window.__ModuleLoader__.load({
1923
2516
  : "—")),
1924
2517
  h("div", { className: "myt-kvRow" },
1925
2518
  h("span", { className: "myt-kvKey" }, tt("char.file")),
1926
- h("span", { className: "myt-kvVal" }, modal.file || "—"))))) : null);
2519
+ h("span", { className: "myt-kvVal" }, modal.file || "—")),
2520
+ // 源文件直达(0.1.20 写面):rel 由快照 basename 拼目录前缀。
2521
+ h("div", { className: "myt-setActions" },
2522
+ h("button", { type: "button", className: "myt-btn", onClick: function () { setSrcRel("characters/" + modal.file); } },
2523
+ ic("IconEditOutlineRegular", 13), tt("src.editSource")),
2524
+ h("button", { type: "button", className: "myt-btn myt-btnDanger", onClick: function () { setDelRel("characters/" + modal.file); } },
2525
+ tt("src.deleteFile")))))) : null,
2526
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null,
2527
+ createOpen ? h(CreateFileModal, { title: tt("char.createBtn"), dir: "characters", template: TPL_CHARACTER, wsPath: wsPath, onClose: function () { setCreateOpen(false); }, onSaved: reload }) : null,
2528
+ delRel ? h(ConfirmModal, {
2529
+ title: tf("src.deleteTip", { rel: delRel }),
2530
+ text: tf("src.confirmText", { rel: delRel }),
2531
+ onCancel: function () { setDelRel(null); },
2532
+ onConfirm: function () {
2533
+ return apiPost("file-delete", { path: wsPath, rel: delRel }).then(function () {
2534
+ setDelRel(null);
2535
+ if (modalFile && "characters/" + modalFile === delRel) setModalFile(null); // 删的是打开中的卡:详情弹层一并收
2536
+ return reload();
2537
+ });
2538
+ },
2539
+ }) : null);
1927
2540
  }
1928
2541
 
1929
2542
  // ── 伏笔 tab:四列看板(列头计数 + 推进按钮 + priority 色点)────────────
@@ -1939,14 +2552,19 @@ window.__ModuleLoader__.load({
1939
2552
  .then(reload).catch(function () {}).finally(function () { setBusyId(null); });
1940
2553
  }
1941
2554
  var list = snap.foreshadows || [];
2555
+ // 伏笔台账源文件直达(0.1.20 写面):foreshadows.yml 整文件可编辑。
2556
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
1942
2557
  return h("div", { className: "myt-stack" },
2558
+ h("div", { className: "myt-cardHead" },
2559
+ h("h3", { className: "myt-cardTitle" }, tt("tabs.foreshadows")),
2560
+ srcEditBtn("foreshadows.yml", function () { setSrcRel("foreshadows.yml"); })),
1943
2561
  list.length ? h("div", { className: "myt-board" }, FS_ORDER.map(function (col) {
1944
2562
  var items = list.filter(function (f) { return (f.status || "planted") === col; });
1945
2563
  return h("div", { key: col, className: "myt-boardCol" },
1946
2564
  h("div", { className: "myt-boardHead" },
1947
2565
  h("span", { className: "myt-boardTitle" }, tt("foreshadow." + col)),
1948
2566
  h("span", { className: "myt-boardCount" }, items.length)),
1949
- items.map(function (fs) {
2567
+ items.map(function (fs, i) {
1950
2568
  var next = FS_FLOW[col] || null;
1951
2569
  var busy = busyId === fs.id;
1952
2570
  // status 行缺失(服务端要求每条恰一处 status,写必 409):禁用流
@@ -1957,7 +2575,8 @@ window.__ModuleLoader__.load({
1957
2575
  ? tt("foreshadow.noIdTip")
1958
2576
  : tt("foreshadow.noStatusTip");
1959
2577
  var chRange = [fs.planted_chapter || tt("foreshadow.plantedUnmarked"), fs.expected_resolve].filter(Boolean).join(" → ");
1960
- return h("div", { key: fs.id || fs.title, className: "myt-fsCard" },
2578
+ // key 索引兜底:无 id 且标题重复时 id||title 相撞会丢行
2579
+ return h("div", { key: fs.id || (fs.title + ":" + i), className: "myt-fsCard" },
1961
2580
  h("div", { className: "myt-fsTitle" },
1962
2581
  priDot(fs.priority), h("span", null, orDash(fs.title) || orDash(fs.id)),
1963
2582
  // 回填徽章(五期账本回填):区别于写前规划条目,悬浮见来源。
@@ -1973,17 +2592,33 @@ window.__ModuleLoader__.load({
1973
2592
  ? h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy || noWrite, title: noWriteTip, onClick: function () { setStatus(fs, "abandoned"); } }, tt("foreshadow.abandon")) : null));
1974
2593
  }),
1975
2594
  items.length ? null : h("div", { className: "myt-muted" }, tt("foreshadow.colEmpty")));
1976
- })) : h(Empty, { text: tt("foreshadow.empty") }));
2595
+ })) : h(Empty, { text: tt("foreshadow.empty") }),
2596
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
1977
2597
  }
1978
2598
 
1979
2599
  // ── 时间线 tab:垂直轴 + importance 点位(1-5 → 大小/深浅)──────────────
1980
2600
  function TimelineTab(props) {
1981
2601
  var items = (props.snap.timeline || []).slice();
2602
+ var wsPath = props.wsPath;
2603
+ var reload = props.reload;
2604
+ // 时间线账本源文件直达(0.1.20 写面):空态也保留入口——账本空不等于
2605
+ // 不可编辑,恰恰是第一条要从这里写进去。
2606
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
2607
+ var head = h("div", { className: "myt-cardHead" },
2608
+ h("h3", { className: "myt-cardTitle" }, tt("tabs.timeline")),
2609
+ srcEditBtn("timeline.yml", function () { setSrcRel("timeline.yml"); }));
1982
2610
  if (items.length) {
1983
2611
  items.sort(function (a, b) { return String(a.year || "").localeCompare(String(b.year || ""), "zh-CN", { numeric: true }); });
1984
2612
  }
1985
- if (!items.length) return h(Empty, { text: tt("timeline.empty") });
1986
- return h("div", { className: "myt-card" },
2613
+ if (!items.length) {
2614
+ return h("div", { className: "myt-stack" },
2615
+ head,
2616
+ h(Empty, { text: tt("timeline.empty") }),
2617
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
2618
+ }
2619
+ return h("div", { className: "myt-stack" },
2620
+ head,
2621
+ h("div", { className: "myt-card" },
1987
2622
  h("div", { className: "myt-timeline" }, items.map(function (t, i) {
1988
2623
  var imp = Math.min(5, Math.max(1, typeof t.importance === "number" ? t.importance : 3));
1989
2624
  return h("div", { key: i, className: "myt-tlRow" },
@@ -1993,7 +2628,8 @@ window.__ModuleLoader__.load({
1993
2628
  h("div", { className: "myt-tlTitle" }, orDash(t.title)),
1994
2629
  t.description ? h("div", { className: "myt-tlDesc" }, t.description) : null,
1995
2630
  t.chapter ? h("div", { className: "myt-tlChapter" }, tf("timeline.linkedChapter", { ch: t.chapter })) : null));
1996
- })));
2631
+ }))),
2632
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
1997
2633
  }
1998
2634
 
1999
2635
  // ── 设定 tab:world 按 category 分组 + science 三色标签 ─────────────────
@@ -2001,8 +2637,8 @@ window.__ModuleLoader__.load({
2001
2637
  // 遵守)。挂载/换工作区时 GET style 按需拉取(请求序号防过期响应,对照
2002
2638
  // OverviewTab 的 check effect);视图态 MdView 渲染全文,缺失时空态卡用
2003
2639
  // 插件模板预填创建;编辑态整文件保存(style-save 携 base_hash,409 人工
2004
- // 改动保护链路照 ProjectMetaForm:成功接写后新指纹防连续保存假冲突,
2005
- // 409 重拉磁盘真值后编辑器原地重置、停留编辑态让用户自行取舍)。──
2640
+ // 改动保护链路:成功接写后新指纹防连续保存假冲突;409 草稿绝不丢——保留
2641
+ // 草稿报冲突,亮「载入磁盘版 / 基于最新版重改」两个出口,同 ChapterReader)。
2006
2642
  function StyleTab(props) {
2007
2643
  var wsPath = props.wsPath;
2008
2644
  var _st = useState(null), st = _st[0], setSt = _st[1]; // {loading, data, err}
@@ -2013,6 +2649,8 @@ window.__ModuleLoader__.load({
2013
2649
  var _dh = useState(null), draftHash = _dh[0], setDraftHash = _dh[1];
2014
2650
  var _er = useState(""), editErr = _er[0], setEditErr = _er[1];
2015
2651
  var _b = useState(false), busy = _b[0], setBusy = _b[1];
2652
+ // 409 冲突态:true 时编辑区亮出「载入磁盘版 / 基于最新版重改」两个出口。
2653
+ var _cf = useState(false), conflict = _cf[0], setConflict = _cf[1];
2016
2654
  // 409 后的一次性重置标记:reload 带回磁盘真值时把编辑器与基线一并重置
2017
2655
  // (只置位于保存冲突路径;顶栏刷新等其他拉取不碰正在编辑的内容)。
2018
2656
  var pendingResetRef = useRef(false);
@@ -2025,8 +2663,8 @@ window.__ModuleLoader__.load({
2025
2663
  if (seq !== seqRef.current) return; // 过期响应:已有更新的请求在途,丢弃
2026
2664
  setSt({ loading: false, data: d, err: "" });
2027
2665
  if (pendingResetRef.current) {
2028
- // 409 冲突后的重拉:编辑器放回磁盘真值、基线对齐最新指纹,停留
2029
- // 编辑态让用户基于最新内容自行取舍。
2666
+ // 409 冲突出口①「放弃草稿」的重拉:编辑器放回磁盘真值、基线对齐
2667
+ // 最新指纹,停留编辑态。
2030
2668
  pendingResetRef.current = false;
2031
2669
  setDraft(d && typeof d.text === "string" ? d.text : "");
2032
2670
  setDraftHash(d && typeof d.hash === "string" ? d.hash : null);
@@ -2039,7 +2677,7 @@ window.__ModuleLoader__.load({
2039
2677
  useEffect(function () {
2040
2678
  if (wsRef.current !== wsPath) {
2041
2679
  wsRef.current = wsPath;
2042
- setDraft(null); setDraftHash(null); setEditErr(""); pendingResetRef.current = false;
2680
+ setDraft(null); setDraftHash(null); setEditErr(""); setConflict(false); pendingResetRef.current = false;
2043
2681
  }
2044
2682
  load();
2045
2683
  }, [wsPath, load]);
@@ -2057,30 +2695,62 @@ window.__ModuleLoader__.load({
2057
2695
  }
2058
2696
  function cancelEdit() {
2059
2697
  if (busy) return; // 保存在途不许退出:避免卸载后 setState
2060
- setDraft(null); setDraftHash(null); setEditErr("");
2698
+ if (dirty && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认
2699
+ setDraft(null); setDraftHash(null); setEditErr(""); setConflict(false);
2061
2700
  }
2062
2701
  function save() {
2063
2702
  if (busy) return;
2064
2703
  setBusy(true); setEditErr("");
2065
- // 成败提示统一走 apiPost 的 toast 通道;空文本允许保存(空文件 = 无
2066
- // 基准,agent 侧优雅降级),不做非空校验。
2067
- apiPost("style-save", { path: wsPath, text: draft, base_hash: draftHash }).then(function (res) {
2704
+ // 失败经卡内 editErr 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走
2705
+ // toast 通道。空文本允许保存(空文件 = 无基准,agent 侧优雅降级)。
2706
+ apiPost("style-save", { path: wsPath, text: draft, base_hash: draftHash }, { silent: true }).then(function (res) {
2068
2707
  // 写后指纹即下一轮基线(连续保存不再被自家 409 拦住);本地数据
2069
2708
  // 同步为新文本,取消编辑退回视图态时看到的已是刚落盘的内容。
2070
2709
  var hash = res && typeof res.hash === "string" ? res.hash : null;
2071
2710
  setDraftHash(hash);
2711
+ setConflict(false);
2072
2712
  setSt({ loading: false, data: { found: true, text: draft, hash: hash, template: st.data ? st.data.template : null }, err: "" });
2073
2713
  }).catch(function (err) {
2074
- setEditErr(err.message);
2075
- // 409 = style.md 在进入编辑态后被外部改过:重拉磁盘真值并一次性
2076
- // 把编辑器放回最新内容(停留编辑态),别让用户在过期基线上盲改。
2077
2714
  if (err && err.status === 409) {
2078
- pendingResetRef.current = true;
2079
- load();
2715
+ // 409 = style.md 在进入编辑态后被外部改过:草稿绝不丢——报冲突并
2716
+ // 亮两个出口(放弃草稿载入磁盘版 / 基于最新版重改),不再静默重置。
2717
+ setConflict(true);
2718
+ setEditErr(tf("edit.conflict409", { msg: err.message }));
2719
+ } else {
2720
+ setEditErr(err.message);
2080
2721
  }
2081
2722
  }).finally(function () { setBusy(false); });
2082
2723
  }
2724
+ // 409 出口①:放弃草稿——重拉磁盘真值放回编辑器(pendingResetRef 老链路)。
2725
+ function dropStyleDraft() {
2726
+ if (busy) return;
2727
+ setConflict(false); setEditErr("");
2728
+ pendingResetRef.current = true;
2729
+ load();
2730
+ }
2731
+ // 409 出口②:基于磁盘最新版重改——只换 data 拿新指纹(draftHash),草稿
2732
+ // 原样留在编辑器,改完再存即用新基线。
2733
+ function rebaseStyleDraft() {
2734
+ if (busy) return;
2735
+ var seq = ++seqRef.current;
2736
+ apiGet("style", { path: wsPath }).then(function (d) {
2737
+ if (seq !== seqRef.current) return;
2738
+ setSt({ loading: false, data: d, err: "" });
2739
+ setDraftHash(d && typeof d.hash === "string" ? d.hash : null);
2740
+ setConflict(false); setEditErr("");
2741
+ }).catch(function (err) {
2742
+ if (seq !== seqRef.current) return;
2743
+ setEditErr(err.message); // 重拉失败:冲突态保留可重试
2744
+ });
2745
+ }
2083
2746
  var data = st ? st.data : null;
2747
+ // 脏检查:草稿偏离磁盘原文即脏(模板创建时磁盘原文按空算)。上报
2748
+ // DIRTY_EXIT 供切 tab 拦截——本 tab 切走即卸载,草稿会丢。
2749
+ var dirty = draft !== null && draft !== (data && typeof data.text === "string" ? data.text : "");
2750
+ useEffect(function () {
2751
+ DIRTY_EXIT.dirty = dirty;
2752
+ return function () { DIRTY_EXIT.dirty = false; };
2753
+ });
2084
2754
  var head = h("div", { className: "myt-cardHead" },
2085
2755
  h("h3", { className: "myt-cardTitle" }, tt("tabs.style")),
2086
2756
  h("span", { className: "myt-muted" }, tt("style.note")),
@@ -2115,6 +2785,9 @@ window.__ModuleLoader__.load({
2115
2785
  value: draft, onChange: function (e) { setDraft(e.target.value); },
2116
2786
  })),
2117
2787
  editErr ? h(ErrorStrip, { text: editErr }) : null,
2788
+ conflict ? h("div", { className: "myt-setActions" },
2789
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: dropStyleDraft }, tt("edit.conflictDrop")),
2790
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: rebaseStyleDraft }, tt("edit.conflictKeep"))) : null,
2118
2791
  h("div", { className: "myt-setActions" },
2119
2792
  h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: cancelEdit }, tt("novel.cancel")),
2120
2793
  h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save },
@@ -2141,9 +2814,16 @@ window.__ModuleLoader__.load({
2141
2814
 
2142
2815
  function WorldTab(props) {
2143
2816
  var snap = props.snap;
2817
+ var wsPath = props.wsPath;
2818
+ var reload = props.reload;
2144
2819
  var world = snap.world || [];
2145
2820
  var science = snap.science || [];
2146
2821
  var research = snap.research || [];
2822
+ // 受管文件三态:编辑 rel / 新建分区(world|science)/ 删除确认 rel。
2823
+ // research 是调研笔记目录,v1 无面板入口(快照有卡,写面暂不开放)。
2824
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
2825
+ var _new = useState(null), createDir = _new[0], setCreateDir = _new[1];
2826
+ var _del = useState(null), delRel = _del[0], setDelRel = _del[1];
2147
2827
  // 无原型容器:key 来自数据文件的自由文本(category),值为
2148
2828
  // constructor/__proto__/toString 等时若用普通 {} 会命中 Object.prototype
2149
2829
  // 真值 → TypeError → 整面板白屏;Object.create(null) 杜绝。
@@ -2158,11 +2838,16 @@ window.__ModuleLoader__.load({
2158
2838
  // state 存在本组件(切走再切回不丢;卡头 h3 随卡一起只画激活分区)。
2159
2839
  // research 空时原实现整卡隐藏,现 tab 恒在、空态走 world.researchEmpty。
2160
2840
  var _st = useState("world"), subTab = _st[0], setSubTab = _st[1];
2841
+ // world 分区内的二级分类 tab:state 只存用户上次的偏好,快照重拉后该
2842
+ // 分类可能已不存在(条目被删/改名),此时回落第一个分类;null = 首选
2843
+ // 第一个。单分类时不出 tab 条、直接平铺。
2844
+ var _cat = useState(null), catPref = _cat[0], setCatPref = _cat[1];
2161
2845
  var subDefs = [
2162
2846
  ["world", tt("world.title"), world.length],
2163
2847
  ["science", tt("world.science"), science.length],
2164
2848
  ["research", tt("world.research"), research.length],
2165
2849
  ];
2850
+ var activeCat = catPref !== null && groupKeys.indexOf(catPref) !== -1 ? catPref : groupKeys[0];
2166
2851
  return h("div", { className: "myt-stack" },
2167
2852
  h("nav", { className: "myt-subTabs", "aria-label": tt("world.subTabsAria") },
2168
2853
  subDefs.map(function (s) {
@@ -2172,21 +2857,32 @@ window.__ModuleLoader__.load({
2172
2857
  }, s[1], h("span", { className: "myt-boardCount" }, String(s[2])));
2173
2858
  })),
2174
2859
  subTab === "world" ? h("div", { className: "myt-card" },
2175
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.title"))),
2176
- groupKeys.length ? h("div", { className: "myt-stack" }, groupKeys.map(function (key) {
2177
- return h("div", { key: key, className: "myt-worldGroup" },
2178
- h("div", { className: "myt-listHead" }, key, h("span", { className: "myt-boardCount" }, groups[key].length)),
2179
- groups[key].map(function (w, i) {
2180
- return h("div", { key: w.file || i, className: "myt-worldItem" },
2181
- h("div", { className: "myt-worldHead" },
2182
- h("span", { className: "myt-worldName" }, orDash(w.name)),
2183
- (w.tags || []).length ? h("span", { className: "myt-tagRow" }, w.tags.map(function (t, j) { return h("span", { key: j, className: "myt-tag" }, t); })) : null),
2184
- w.description ? h(MdView, { text: w.description, className: "myt-worldDesc" }) : null);
2185
- }));
2860
+ h("div", { className: "myt-cardHead" },
2861
+ h("h3", { className: "myt-cardTitle" }, tt("world.title")),
2862
+ h("button", { type: "button", className: "myt-btn", onClick: function () { setCreateDir("world"); } }, tt("world.createBtn"))),
2863
+ // 多分类:二级子 tab 条(分类名 + 条目计数),分组头不再需要——tab
2864
+ // 已承载分类名与计数;单分类/无分类不出 tab 条直接平铺。
2865
+ groupKeys.length > 1 ? h("nav", { className: "myt-subTabs myt-subTabsInner", "aria-label": tt("world.catTabsAria") },
2866
+ groupKeys.map(function (key) {
2867
+ return h("button", {
2868
+ key: key, type: "button", className: "myt-subTab", "data-active": activeCat === key ? "true" : undefined,
2869
+ onClick: function () { setCatPref(key); },
2870
+ }, key, h("span", { className: "myt-boardCount" }, String(groups[key].length)));
2871
+ })) : null,
2872
+ groupKeys.length ? h("div", { className: "myt-stack" }, groups[activeCat].map(function (w, i) {
2873
+ return h("div", { key: w.file || i, className: "myt-worldItem" },
2874
+ h("div", { className: "myt-worldHead" },
2875
+ h("span", { className: "myt-worldName" }, orDash(w.name)),
2876
+ (w.tags || []).length ? h("span", { className: "myt-tagRow" }, w.tags.map(function (t, j) { return h("span", { key: j, className: "myt-tag" }, t); })) : null),
2877
+ w.description ? h(MdView, { text: w.description, className: "myt-worldDesc" }) : null,
2878
+ h("div", { className: "myt-itemActions" },
2879
+ srcEditBtn("world/" + w.file, function () { setSrcRel("world/" + w.file); }),
2880
+ srcDeleteBtn("world/" + w.file, function () { setDelRel("world/" + w.file); })));
2186
2881
  })) : h(Empty, { text: tt("world.empty") })) : null,
2187
2882
  subTab === "science" ? h("div", { className: "myt-card" },
2188
2883
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.science")),
2189
- h("span", { className: "myt-boardCount" }, science.length)),
2884
+ h("span", { className: "myt-boardCount" }, science.length),
2885
+ h("button", { type: "button", className: "myt-btn", onClick: function () { setCreateDir("science"); } }, tt("world.createScienceBtn"))),
2190
2886
  science.length ? h("div", { className: "myt-stack" }, science.map(function (s, i) {
2191
2887
  var label = ["known", "extrapolation", "hypothesis"].indexOf(s.label) !== -1 ? s.label : null;
2192
2888
  return h("div", { key: s.file || i, className: "myt-worldItem" },
@@ -2196,7 +2892,10 @@ window.__ModuleLoader__.load({
2196
2892
  label ? tt("sci." + label) : (s.label || tt("world.unlabeled")))),
2197
2893
  s.description ? h(MdView, { text: s.description, className: "myt-worldDesc" }) : null,
2198
2894
  (s.references || []).length ? h("div", { className: "myt-refList" },
2199
- s.references.map(function (r, j) { return h("span", { key: j, className: "myt-refItem" }, tf("world.ref", { n: j + 1, r: r })); })) : null);
2895
+ s.references.map(function (r, j) { return h("span", { key: j, className: "myt-refItem" }, tf("world.ref", { n: j + 1, r: r })); })) : null,
2896
+ h("div", { className: "myt-itemActions" },
2897
+ srcEditBtn("science/" + s.file, function () { setSrcRel("science/" + s.file); }),
2898
+ srcDeleteBtn("science/" + s.file, function () { setDelRel("science/" + s.file); })));
2200
2899
  })) : h(Empty, { text: tt("world.scienceEmpty") })) : null,
2201
2900
  subTab === "research" ? h("div", { className: "myt-card" },
2202
2901
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.research")),
@@ -2208,7 +2907,25 @@ window.__ModuleLoader__.load({
2208
2907
  r.description ? h(MdView, { text: r.description, className: "myt-worldDesc" }) : null,
2209
2908
  (r.sources || []).length ? h("div", { className: "myt-refList" },
2210
2909
  r.sources.map(function (s, j) { return h("span", { key: j, className: "myt-refItem", title: s }, tf("world.ref", { n: j + 1, r: s })); })) : null);
2211
- })) : h(Empty, { text: tt("world.researchEmpty") })) : null);
2910
+ })) : h(Empty, { text: tt("world.researchEmpty") })) : null,
2911
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null,
2912
+ createDir ? h(CreateFileModal, {
2913
+ title: createDir === "science" ? tt("world.createScienceBtn") : tt("world.createBtn"),
2914
+ dir: createDir,
2915
+ template: createDir === "science" ? TPL_SCIENCE : TPL_WORLD,
2916
+ wsPath: wsPath, onClose: function () { setCreateDir(null); }, onSaved: reload,
2917
+ }) : null,
2918
+ delRel ? h(ConfirmModal, {
2919
+ title: tf("src.deleteTip", { rel: delRel }),
2920
+ text: tf("src.confirmText", { rel: delRel }),
2921
+ onCancel: function () { setDelRel(null); },
2922
+ onConfirm: function () {
2923
+ return apiPost("file-delete", { path: wsPath, rel: delRel }).then(function () {
2924
+ setDelRel(null);
2925
+ return reload();
2926
+ });
2927
+ },
2928
+ }) : null);
2212
2929
  }
2213
2930
 
2214
2931
  // ── 索引板 tab:clues 按 kind 分组(resolved 划线)+ memories 按 category
@@ -2239,6 +2956,8 @@ window.__ModuleLoader__.load({
2239
2956
  memGroups[key].push(m);
2240
2957
  });
2241
2958
  function groupKey(a, b) { return a.localeCompare(b, "zh-CN"); }
2959
+ // 两本索引账源文件直达(0.1.20 写面):clue-board.yml / memories.yml。
2960
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
2242
2961
  return h("div", { className: "myt-stack" },
2243
2962
  h("div", { className: "myt-cardHead" },
2244
2963
  h("h3", { className: "myt-cardTitle" }, tt("index.title")),
@@ -2246,7 +2965,8 @@ window.__ModuleLoader__.load({
2246
2965
  rebuildBusy ? tt("index.rebuilding") : tt("index.rebuild"))),
2247
2966
  h("div", { className: "myt-indexCols" },
2248
2967
  h("div", { className: "myt-card" },
2249
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.clueBoard")), h("span", { className: "myt-boardCount" }, clues.length)),
2968
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.clueBoard")), h("span", { className: "myt-boardCount" }, clues.length),
2969
+ srcEditBtn("clue-board.yml", function () { setSrcRel("clue-board.yml"); })),
2250
2970
  clues.length ? Object.keys(clueGroups).sort(groupKey).map(function (kind) {
2251
2971
  var ck = CLUE_KIND_KEYS[kind];
2252
2972
  var label = ck ? tt("clue." + ck) : kind;
@@ -2259,7 +2979,8 @@ window.__ModuleLoader__.load({
2259
2979
  }));
2260
2980
  }) : h(Empty, { text: tt("index.clueEmpty") })),
2261
2981
  h("div", { className: "myt-card" },
2262
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.memBoard")), h("span", { className: "myt-boardCount" }, memories.length)),
2982
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.memBoard")), h("span", { className: "myt-boardCount" }, memories.length),
2983
+ srcEditBtn("memories.yml", function () { setSrcRel("memories.yml"); })),
2263
2984
  memories.length ? Object.keys(memGroups).sort(groupKey).map(function (cat) {
2264
2985
  var isPromise = /promise|承诺/i.test(cat);
2265
2986
  return h("div", { key: cat, className: "myt-worldGroup" },
@@ -2271,13 +2992,18 @@ window.__ModuleLoader__.load({
2271
2992
  h("span", { className: "myt-clueText" }, orDash(m.content)),
2272
2993
  m.source_chapter ? h("span", { className: "myt-memChapter" }, m.source_chapter) : null);
2273
2994
  }));
2274
- }) : h(Empty, { text: tt("index.memEmpty") }))));
2995
+ }) : h(Empty, { text: tt("index.memEmpty") }))),
2996
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
2275
2997
  }
2276
2998
 
2277
- // ── 关系图 tab:canvas 力导向(斥力 + 弹簧 + 中心引力,rAF 循环)─────────
2278
- // 节点固定 18px、名字 label 下方;边宽 = intensity 1-5 → 1-4px(缺省 3),
2279
- // type 标在边中点、hover 显示 description;拖拽固定、滚轮缩放、双击复位;
2280
- // 角色多于 30 个时只画 relations 相关节点,其余进「未入图」。
2999
+ // ── 关系图 tab:canvas 力导向(斥力 + 弹簧 + 中心引力 + 硬碰撞分离,rAF 循环)─
3000
+ // 节点固定 18px、名字 label 下方,最小间距 62 位置级互推保证静止帧不压叠;
3001
+ // 边为二次曲线(绕开途中节点、重复边互相分开),type 标在曲线 t=0.5 点、
3002
+ // hover 显示 description(命中检测按曲线几何采样);拖拽固定、滚轮缩放、
3003
+ // 双击复位;角色多于 30 个时只画 relations 相关节点,其余进「未入图」。
3004
+ // 关系图节点角色配色:主角红 / 配角蓝 / 反派黑 / 龙套黄(用户指定四色);
3005
+ // 越枚举/缺省 role 一律并入龙套黄(查表 miss 落 minor),全节点恒有色。
3006
+ var NODE_ROLE_COLORS = { protagonist: "#f53f3f", supporting: "#165dff", antagonist: "#1d2129", minor: "#fadc19" };
2281
3007
  function edgeWidth(intensity) {
2282
3008
  var n = typeof intensity === "number" && isFinite(intensity) ? Math.round(intensity) : 3;
2283
3009
  n = Math.min(5, Math.max(1, n));
@@ -2285,6 +3011,10 @@ window.__ModuleLoader__.load({
2285
3011
  }
2286
3012
  function RelationsTab(props) {
2287
3013
  var snap = props.snap;
3014
+ var wsPath = props.wsPath;
3015
+ var reload = props.reload;
3016
+ // 关系账本源文件直达(0.1.20 写面):relations.yml 整文件可编辑。
3017
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
2288
3018
  var chars = snap.characters || [];
2289
3019
  var rels = (snap.relations || []).filter(function (r) { return r && r.a && r.b && r.a !== r.b; });
2290
3020
  var _tip = useState(null), tip = _tip[0], setTip = _tip[1]; // {x, y, text}
@@ -2311,6 +3041,14 @@ window.__ModuleLoader__.load({
2311
3041
  nodes.forEach(function (nd, i) { idx[nd.id] = i; });
2312
3042
  var edges = rels.filter(function (r) { return idx[r.a] !== undefined && idx[r.b] !== undefined; })
2313
3043
  .map(function (r) { return { a: idx[r.a], b: idx[r.b], type: r.type || "", desc: r.description || "", w: edgeWidth(r.intensity) }; });
3044
+ // 同一对节点的重复边编号:dup = 按 (a,b) 无序对分组后的组内序号(0 起)。
3045
+ // draw 的 edgeCtrl 按 dup 放大曲线偏移并交替朝向,重复边互相分开不重合。
3046
+ var dupCount = Object.create(null);
3047
+ edges.forEach(function (e) {
3048
+ var key = Math.min(e.a, e.b) + "-" + Math.max(e.a, e.b);
3049
+ e.dup = dupCount[key] || 0;
3050
+ dupCount[key] = e.dup + 1;
3051
+ });
2314
3052
  return { nodes: nodes, edges: edges, unlinked: unlinked };
2315
3053
  }, [snap]); // chars/rels 均由 snap 派生,snap 不变则图不变
2316
3054
  useEffect(function () {
@@ -2320,10 +3058,19 @@ window.__ModuleLoader__.load({
2320
3058
  var ctx = canvas.getContext("2d");
2321
3059
  if (!ctx) return undefined;
2322
3060
  var dpr = (window.devicePixelRatio || 1) > 0 ? window.devicePixelRatio || 1 : 1;
2323
- var sim = { zoom: 1, drag: null, raf: 0, frame: 0, w: 0, h: 0, tokens: null };
3061
+ // 收敛停机参数:能量(速度平方和)阈值 + 碰撞位移静阈 + 连续静止帧数
3062
+ // (~0.5s@60fps);STILL_CAP 是单次唤醒的帧数上限(~15s)兜底——拖拽
3063
+ // 节点压着邻居时,弹簧+碰撞+限速会形成持续亚像素抖动的极限环,永远
3064
+ // 到不了静止判据,必须有硬上限。面板关闭仅 CSS 隐藏、组件不卸载,rAF
3065
+ // 不停就后台永久燃 CPU。
3066
+ var ENERGY_TAU = 0.5;
3067
+ var MOVE_EPS = 0.5;
3068
+ var STILL_FRAMES = 30;
3069
+ var STILL_CAP = 900;
3070
+ var sim = { zoom: 1, drag: null, raf: 0, frame: 0, w: 0, h: 0, tokens: null, still: 0, frames: 0 };
2324
3071
  // 缩放按钮桥:渲染层按钮 → effect 内 sim.zoom(钳制与滚轮同口径 0.4-2.5)
2325
3072
  zoomApiRef.current = {
2326
- zoomBy: function (k) { sim.zoom = Math.min(2.5, Math.max(0.4, sim.zoom * k)); },
3073
+ zoomBy: function (k) { sim.zoom = Math.min(2.5, Math.max(0.4, sim.zoom * k)); wake(); },
2327
3074
  };
2328
3075
  // 令牌缓存:每 30 帧重读 computed style,浅/深色切换约半秒内跟上。
2329
3076
  function readTokens() {
@@ -2342,6 +3089,7 @@ window.__ModuleLoader__.load({
2342
3089
  sim.h = wrap.clientHeight || 480;
2343
3090
  canvas.width = Math.max(1, Math.round(sim.w * dpr));
2344
3091
  canvas.height = Math.max(1, Math.round(sim.h * dpr));
3092
+ wake(); // 改 width 即清空位图:停机后也要立刻重画(含全屏进出触发的 RO)
2345
3093
  }
2346
3094
  // 初始摆在同心圆上,交给力导向收敛。
2347
3095
  var R = Math.max(90, Math.min(sim.w, sim.h) / 3);
@@ -2352,7 +3100,50 @@ window.__ModuleLoader__.load({
2352
3100
  nd.vx = nd.vy = 0;
2353
3101
  });
2354
3102
  function toWorld(sx, sy) { return { x: (sx - sim.w / 2) / sim.zoom, y: (sy - sim.h / 2) / sim.zoom }; }
2355
- function toScreen(x, y) { return { x: sim.w / 2 + x * sim.zoom, y: sim.h / 2 + y * sim.zoom }; }
3103
+ // 每条边的二次贝塞尔控制点:bow 大小随边长缩放、钳 [12,28] 世界像素;
3104
+ // side ∈ {-1,+1} 曲线朝向(挂边对象上帧间保留,做迟滞防抖);同一对
3105
+ // 节点的重复边按 dup 序号放大偏移并交替朝向,互相分开不重合。
3106
+ function edgeCtrl(e) {
3107
+ var a = graph.nodes[e.a], b = graph.nodes[e.b];
3108
+ var dx = b.x - a.x, dy = b.y - a.y;
3109
+ var len = Math.sqrt(dx * dx + dy * dy) || 1;
3110
+ var ux = -dy / len, uy = dx / len; // 单位法向
3111
+ var mag = Math.min(28, Math.max(12, len * 0.1));
3112
+ var k = e.dup || 0;
3113
+ var m = mag * (1 + 0.6 * k);
3114
+ var side = (e.side || 1) * (k % 2 ? -1 : 1);
3115
+ return { cx: (a.x + b.x) / 2 + ux * m * side, cy: (a.y + b.y) / 2 + uy * m * side };
3116
+ }
3117
+ // 曲线(A、C、B 三点二次贝塞尔)采样 9 个点到各无关节点(跳过两端)
3118
+ // 的最小距离:值越大说明这一侧越空旷。
3119
+ function clearance(e, cx, cy) {
3120
+ var a = graph.nodes[e.a], b = graph.nodes[e.b];
3121
+ var best = 999;
3122
+ for (var t = 1; t <= 9; t++) {
3123
+ var tt = t / 10, it = 1 - tt;
3124
+ var px = it * it * a.x + 2 * it * tt * cx + tt * tt * b.x;
3125
+ var py = it * it * a.y + 2 * it * tt * cy + tt * tt * b.y;
3126
+ for (var ni = 0; ni < graph.nodes.length; ni++) {
3127
+ if (ni === e.a || ni === e.b) continue;
3128
+ var nd = graph.nodes[ni];
3129
+ var ddx = px - nd.x, ddy = py - nd.y;
3130
+ var d = Math.sqrt(ddx * ddx + ddy * ddy);
3131
+ if (d < best) best = d;
3132
+ }
3133
+ }
3134
+ return best;
3135
+ }
3136
+ // 朝向迟滞更新:算当前侧与取反侧的 clearance,只有另一侧明显更空
3137
+ // (+12 世界像素)才翻转——力导向抖动时曲线不左右闪。
3138
+ function updateSide(e) {
3139
+ var side = e.side || 1;
3140
+ var c1 = edgeCtrl(e);
3141
+ var cur = clearance(e, c1.cx, c1.cy);
3142
+ e.side = -side;
3143
+ var c2 = edgeCtrl(e);
3144
+ var alt = clearance(e, c2.cx, c2.cy);
3145
+ if (alt <= cur + 12) e.side = side; // 另一侧不明显更空:保持原朝向
3146
+ }
2356
3147
  function hitNode(sx, sy) {
2357
3148
  var p = toWorld(sx, sy);
2358
3149
  for (var i = graph.nodes.length - 1; i >= 0; i--) {
@@ -2366,18 +3157,30 @@ window.__ModuleLoader__.load({
2366
3157
  var p = toWorld(sx, sy);
2367
3158
  var best = null, bestD = 10;
2368
3159
  graph.edges.forEach(function (e) {
3160
+ // 命中跟曲线走:二次贝塞尔采样 9 段(10 点含两端),点到各线段
3161
+ // 距离取最小;edgeCtrl 与 draw 同源(side 存边对象上,天然一致)。
2369
3162
  var a = graph.nodes[e.a], b = graph.nodes[e.b];
2370
- var ax = a.x, ay = a.y, bx = b.x - a.x, by = b.y - a.y;
2371
- var len2 = bx * bx + by * by || 1;
2372
- var t = Math.min(1, Math.max(0, ((p.x - ax) * bx + (p.y - ay) * by) / len2));
2373
- var dx = p.x - (ax + bx * t), dy = p.y - (ay + by * t);
2374
- var d = Math.sqrt(dx * dx + dy * dy);
2375
- if (d < bestD) { bestD = d; best = e; }
3163
+ var c = edgeCtrl(e);
3164
+ var qx = a.x, qy = a.y; // 上一采样点,首点 = 端点 A
3165
+ for (var k = 1; k <= 9; k++) {
3166
+ var tt = k / 9, it = 1 - tt;
3167
+ var nx = it * it * a.x + 2 * it * tt * c.cx + tt * tt * b.x;
3168
+ var ny = it * it * a.y + 2 * it * tt * c.cy + tt * tt * b.y;
3169
+ var segx = nx - qx, segy = ny - qy;
3170
+ var len2 = segx * segx + segy * segy || 1;
3171
+ var u = Math.min(1, Math.max(0, ((p.x - qx) * segx + (p.y - qy) * segy) / len2));
3172
+ var dx = p.x - (qx + segx * u), dy = p.y - (qy + segy * u);
3173
+ var d = Math.sqrt(dx * dx + dy * dy);
3174
+ if (d < bestD) { bestD = d; best = e; }
3175
+ qx = nx; qy = ny;
3176
+ }
2376
3177
  });
2377
3178
  return best;
2378
3179
  }
2379
3180
  function step() {
2380
3181
  var nodes = graph.nodes, edges = graph.edges, n = nodes.length;
3182
+ var energy = 0; // 节点速度平方和:本帧系统总动能
3183
+ var movedPx = 0; // 硬碰撞分离的本帧位移量:纯位置修正能量看不见,按位移计静
2381
3184
  for (var i = 0; i < n; i++) {
2382
3185
  for (var j = i + 1; j < n; j++) {
2383
3186
  var a = nodes[i], b = nodes[j];
@@ -2394,7 +3197,7 @@ window.__ModuleLoader__.load({
2394
3197
  var a = nodes[e.a], b = nodes[e.b];
2395
3198
  var dx = b.x - a.x, dy = b.y - a.y;
2396
3199
  var d = Math.sqrt(dx * dx + dy * dy) || 1;
2397
- var f = (d - 120) * 0.02; // 弹簧,rest 120
3200
+ var f = (d - 130) * 0.02; // 弹簧,rest 130(原 120,多留缝隙配合硬碰撞分离)
2398
3201
  var fx = (dx / d) * f, fy = (dy / d) * f;
2399
3202
  a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
2400
3203
  });
@@ -2405,7 +3208,37 @@ window.__ModuleLoader__.load({
2405
3208
  nd.vx *= 0.85; nd.vy *= 0.85;
2406
3209
  nd.x += Math.max(-8, Math.min(8, nd.vx));
2407
3210
  nd.y += Math.max(-8, Math.min(8, nd.vy));
3211
+ energy += nd.vx * nd.vx + nd.vy * nd.vy; // 固定/拖拽节点速度已清零不计
2408
3212
  });
3213
+ // 硬碰撞分离:力式斥力有上限(f 封顶 4),拥挤时节点仍会压叠;这里在
3214
+ // 速度积分之后按最小间距 62(两节点半径 18×2 + 名字 label 纵向净空)
3215
+ // 做位置级互推,保证静止帧节点(含下方 label)不重叠。固定节点
3216
+ // (fixed 或正在拖拽)不动、推对方,双固定跳过;位移不写回速度——
3217
+ // 纯位置修正,靠阻尼自然收敛,不与力导向抢劲。两轮 pass 处理推挤
3218
+ // 连锁(A 推开 B 后 B 又压到 C)。
3219
+ var MIN_DIST = 62;
3220
+ for (var pass = 0; pass < 2; pass++) {
3221
+ for (var ci = 0; ci < n; ci++) {
3222
+ for (var cj = ci + 1; cj < n; cj++) {
3223
+ var na = nodes[ci], nb = nodes[cj];
3224
+ var cdx = nb.x - na.x, cdy = nb.y - na.y;
3225
+ var cd2 = cdx * cdx + cdy * cdy;
3226
+ if (cd2 >= MIN_DIST * MIN_DIST) continue;
3227
+ var cd = Math.sqrt(cd2);
3228
+ if (cd < 0.01) { cdx = 1; cdy = 0; cd = 1; } // 完全重合:任意方向分开
3229
+ var push = (MIN_DIST - cd) / cd; // 归一化差向量:差多少补多少
3230
+ var aFixed = na.fixed || na === (sim.drag && sim.drag.node);
3231
+ var bFixed = nb.fixed || nb === (sim.drag && sim.drag.node);
3232
+ if (aFixed && bFixed) continue;
3233
+ var mA = aFixed ? 0 : (bFixed ? 1 : 0.5); // 固定方不动,另一方全担
3234
+ movedPx += (MIN_DIST - cd) * (mA + mB); // 有实际位移才计(双固定跳过不计)
3235
+ var mB = bFixed ? 0 : (aFixed ? 1 : 0.5);
3236
+ na.x -= cdx * push * mA; na.y -= cdy * push * mA;
3237
+ nb.x += cdx * push * mB; nb.y += cdy * push * mB;
3238
+ }
3239
+ }
3240
+ }
3241
+ return { energy: energy, movedPx: movedPx };
2409
3242
  }
2410
3243
  function draw() {
2411
3244
  if (sim.frame % 30 === 0 || !sim.tokens) sim.tokens = readTokens();
@@ -2417,29 +3250,30 @@ window.__ModuleLoader__.load({
2417
3250
  ctx.scale(sim.zoom, sim.zoom);
2418
3251
  graph.edges.forEach(function (e) {
2419
3252
  var a = graph.nodes[e.a], b = graph.nodes[e.b];
3253
+ updateSide(e); // 朝向迟滞:另一侧明显更空才翻,防抖(详见 updateSide)
3254
+ var c = edgeCtrl(e);
2420
3255
  ctx.strokeStyle = tk.line;
2421
3256
  ctx.globalAlpha = 0.8;
2422
3257
  ctx.lineWidth = e.w;
2423
- ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
3258
+ ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.quadraticCurveTo(c.cx, c.cy, b.x, b.y); ctx.stroke();
2424
3259
  if (e.type && sim.zoom >= 0.7) {
2425
3260
  ctx.globalAlpha = 1;
2426
3261
  ctx.fillStyle = tk.faint;
2427
3262
  ctx.font = "10px sans-serif";
2428
3263
  ctx.textAlign = "center";
2429
- ctx.fillText(e.type, (a.x + b.x) / 2, (a.y + b.y) / 2 - 3);
3264
+ // type 标在曲线 t=0.5 点(二次贝塞尔中点),与曲线本体一致
3265
+ ctx.fillText(e.type, 0.25 * a.x + 0.5 * c.cx + 0.25 * b.x, 0.25 * a.y + 0.5 * c.cy + 0.25 * b.y - 3);
2430
3266
  }
2431
3267
  });
2432
3268
  ctx.globalAlpha = 1;
2433
3269
  graph.nodes.forEach(function (nd) {
2434
- var s = toScreen(nd.x, nd.y);
2435
3270
  var r = 18; // 世界坐标画:半径随 zoom 缩放
3271
+ var rc = NODE_ROLE_COLORS[nd.role] || NODE_ROLE_COLORS.minor;
2436
3272
  ctx.beginPath();
2437
3273
  ctx.arc(nd.x, nd.y, r, 0, Math.PI * 2);
2438
- ctx.fillStyle = nd.fixed ? tk.accent : tk.card;
2439
- ctx.globalAlpha = nd.fixed ? 0.25 : 1;
3274
+ ctx.fillStyle = rc;
2440
3275
  ctx.fill();
2441
- ctx.globalAlpha = 1;
2442
- ctx.lineWidth = 1.5;
3276
+ ctx.lineWidth = nd.fixed ? 3 : 1.5; // 固定态=加粗 accent 描边(全节点恒有色后的唯一固定指示)
2443
3277
  ctx.strokeStyle = tk.accent;
2444
3278
  ctx.stroke();
2445
3279
  ctx.fillStyle = tk.label;
@@ -2450,9 +3284,26 @@ window.__ModuleLoader__.load({
2450
3284
  ctx.restore();
2451
3285
  }
2452
3286
  function loop() {
2453
- step();
3287
+ var m = step();
2454
3288
  draw();
2455
3289
  sim.frame++;
3290
+ // 收敛停机:能量与碰撞位移都低于静阈连续 STILL_FRAMES 帧即真停 rAF
3291
+ // (raf 归零),不再每帧 O(n²) + 全量重绘空转;交互经 wake() 重启。
3292
+ // STILL_CAP 兜底:拖拽压邻等病态抖动循环到不了静止判据,也必须停。
3293
+ if (m.energy < ENERGY_TAU && m.movedPx < MOVE_EPS) {
3294
+ if (++sim.still >= STILL_FRAMES) { sim.raf = 0; return; }
3295
+ } else {
3296
+ sim.still = 0;
3297
+ }
3298
+ if (++sim.frames >= STILL_CAP) { sim.raf = 0; return; }
3299
+ sim.raf = requestAnimationFrame(loop);
3300
+ }
3301
+ // 唤醒:静止停机后任何会改变画面/物理的交互都要重启循环(幂等,已跑不
3302
+ // 叠加),并重新给满静止计数与帧数预算。
3303
+ function wake() {
3304
+ if (sim.raf) return;
3305
+ sim.still = 0;
3306
+ sim.frames = 0;
2456
3307
  sim.raf = requestAnimationFrame(loop);
2457
3308
  }
2458
3309
  resize();
@@ -2470,6 +3321,7 @@ window.__ModuleLoader__.load({
2470
3321
  sim.drag = { node: nd };
2471
3322
  nd.fixed = true; // 拖拽固定
2472
3323
  canvas.dataset.dragging = "true";
3324
+ wake(); // 拖拽移动物理系统,停机态先唤醒
2473
3325
  e.preventDefault();
2474
3326
  }
2475
3327
  }
@@ -2498,11 +3350,13 @@ window.__ModuleLoader__.load({
2498
3350
  e.preventDefault();
2499
3351
  var next = sim.zoom * Math.exp(-e.deltaY * 0.0012);
2500
3352
  sim.zoom = Math.min(2.5, Math.max(0.4, next));
3353
+ wake(); // 缩放变了画面:停机态也要重绘
2501
3354
  }
2502
3355
  function onDbl() {
2503
3356
  sim.zoom = 1; // 复位:缩放回 1、释放全部拖拽固定
2504
3357
  graph.nodes.forEach(function (nd) { nd.fixed = false; });
2505
3358
  setTip(null);
3359
+ wake(); // 释放固定后力导向要重新收敛
2506
3360
  }
2507
3361
  canvas.addEventListener("mousedown", onDown);
2508
3362
  window.addEventListener("mousemove", onMove);
@@ -2559,12 +3413,20 @@ window.__ModuleLoader__.load({
2559
3413
  }
2560
3414
  } catch (e) { /* 全屏 API 个别浏览器仍有策略性拒绝,静默 */ }
2561
3415
  }
2562
- if (!graph.nodes.length) return h(Empty, { text: tt("relations.noData") });
3416
+ if (!graph.nodes.length) {
3417
+ return h("div", { className: "myt-stack" },
3418
+ h("div", { className: "myt-cardHead" },
3419
+ h("h3", { className: "myt-cardTitle" }, tt("relations.title")),
3420
+ srcEditBtn("relations.yml", function () { setSrcRel("relations.yml"); })),
3421
+ h(Empty, { text: tt("relations.noData") }),
3422
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
3423
+ }
2563
3424
  return h("div", { className: "myt-stack" },
2564
3425
  h("div", { className: "myt-card" },
2565
3426
  h("div", { className: "myt-cardHead" },
2566
3427
  h("h3", { className: "myt-cardTitle" }, tt("relations.title")),
2567
- h("span", { className: "myt-muted" }, tf("relations.counts", { n: graph.nodes.length, m: graph.edges.length }))),
3428
+ h("span", { className: "myt-muted" }, tf("relations.counts", { n: graph.nodes.length, m: graph.edges.length })),
3429
+ srcEditBtn("relations.yml", function () { setSrcRel("relations.yml"); })),
2568
3430
  h("div", { className: "myt-graphWrap", ref: wrapRef },
2569
3431
  h("canvas", { className: "myt-graphCanvas", ref: canvasRef, "data-dragging": "false" }),
2570
3432
  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,
@@ -2572,12 +3434,19 @@ window.__ModuleLoader__.load({
2572
3434
  h("button", { type: "button", className: "myt-graphTool", title: tt("relations.zoomOut"), "aria-label": tt("relations.zoomOut"), onClick: function () { if (zoomApiRef.current) zoomApiRef.current.zoomBy(1 / 1.25); } }, "-"),
2573
3435
  h("button", { type: "button", className: "myt-graphTool", title: tt("relations.zoomIn"), "aria-label": tt("relations.zoomIn"), onClick: function () { if (zoomApiRef.current) zoomApiRef.current.zoomBy(1.25); } }, "+"),
2574
3436
  canFs ? h("button", { type: "button", className: "myt-graphTool myt-graphToolText", title: isFs ? tt("relations.exitFs") : tt("relations.fs"), "aria-label": isFs ? tt("relations.exitFs") : tt("relations.fs"), onClick: toggleFs }, isFs ? tt("relations.exitFs") : tt("relations.fs")) : null)),
2575
- h("div", { className: "myt-graphFoot" }, tt("relations.help"))),
3437
+ h("div", { className: "myt-graphFoot" },
3438
+ tt("relations.help"),
3439
+ h("span", { className: "myt-graphLegend" },
3440
+ h("span", { className: "myt-legendItem" }, h("span", { className: "myt-legendDot", "data-role": "protagonist" }), tt("role.protagonist")),
3441
+ h("span", { className: "myt-legendItem" }, h("span", { className: "myt-legendDot", "data-role": "supporting" }), tt("role.supporting")),
3442
+ h("span", { className: "myt-legendItem" }, h("span", { className: "myt-legendDot", "data-role": "antagonist" }), tt("role.antagonist")),
3443
+ h("span", { className: "myt-legendItem" }, h("span", { className: "myt-legendDot", "data-role": "minor" }), tt("role.minor"))))),
2576
3444
  graph.unlinked.length ? h("div", { className: "myt-card" },
2577
3445
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("relations.unlinkedTitle"))),
2578
3446
  h("div", { className: "myt-unlinked" }, graph.unlinked.map(function (n, i) {
2579
3447
  return h("span", { key: n || i, className: "myt-unlinkedTag" }, n);
2580
- }))) : null);
3448
+ }))) : null,
3449
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
2581
3450
  }
2582
3451
 
2583
3452
  // ── cordis client 插件入口 ──────────────────────────────────────────────