dsh-maoying 0.1.20 → 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
  }
@@ -285,6 +296,10 @@ window.__ModuleLoader__.load({
285
296
  };
286
297
  }
287
298
  var toast = createToastCtrl();
299
+ // 脏退出守卫的跨组件脏标记:编辑面(正文/章纲/受管文件/风格/元信息)各自
300
+ // 在渲染后写入「是否有未保存草稿」,切 tab 等自身不知情的退出路径读它拦截
301
+ // 确认。面板同一时刻至多一个编辑面打开,单布尔够用;卸载时清零防残留误拦。
302
+ var DIRTY_EXIT = { dirty: false };
288
303
 
289
304
  function ToastHost() {
290
305
  var _t = useState(function () { return toast.snapshot(); }), items = _t[0], setItems = _t[1];
@@ -758,6 +773,39 @@ window.__ModuleLoader__.load({
758
773
  toast.err(tf("toast.prefix", { msg: err.message }));
759
774
  });
760
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
+ }
761
809
  useEffect(function () { loadWorkspaces(); }, [loadWorkspaces]);
762
810
  // 每次打开面板都重拉工作区列表与整份 state 快照:懒挂载下关闭只是摘掉
763
811
  // active 属性,组件不卸载,上面的 mount effect 整个页面生命周期只跑一
@@ -792,6 +840,9 @@ window.__ModuleLoader__.load({
792
840
  setSelWs(path);
793
841
  }
794
842
  function gotoTab(next, chapterFile) {
843
+ // 脏退出守卫:任一编辑面(DIRTY_EXIT.dirty)有未保存草稿先确认,取消
844
+ // 则留在原地——切 tab 会卸载组件,草稿本来会被静默丢弃。
845
+ if (DIRTY_EXIT.dirty && !window.confirm(tt("common.dirtyExit"))) return;
795
846
  setTab(next);
796
847
  setReaderFile(next === "chapters" && chapterFile ? chapterFile : null);
797
848
  }
@@ -836,6 +887,13 @@ window.__ModuleLoader__.load({
836
887
  h("div", null,
837
888
  h("h1", { className: "myt-title" }, "毛颖创作台",
838
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")),
839
897
  ver && ver.updateAvailable
840
898
  ? [
841
899
  // 徽章(跳发布页)+「一键升级」按钮双元素;装完(installed)
@@ -1020,9 +1078,11 @@ window.__ModuleLoader__.load({
1020
1078
 
1021
1079
  // ── 轻量 Markdown 渲染(零依赖):先整体 HTML 转义再施加 md 变换,杜绝
1022
1080
  // 注入;支持标题/加粗/斜体/删除线/行内码/围栏码/引用/有序无序列表/分隔线/
1023
- // 链接/段落换行。供章纲、世界观条目等 md 阅读位使用,正文手稿仍走 pre。 ──
1081
+ // 链接/GFM 管道表格/段落换行。供章纲、世界观条目等 md 阅读位使用,正文
1082
+ // 手稿仍走 pre。 ──
1024
1083
  function escapeHtml(s) {
1025
- 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;");
1026
1086
  }
1027
1087
  // 链接 scheme 白名单:仅放行 http/https 绝对地址与无 scheme 的相对路径 /
1028
1088
  // 锚点 / //协议相对;javascript:/data:/vbscript: 等一律拒绝(调用方落纯文本)。
@@ -1049,6 +1109,56 @@ window.__ModuleLoader__.load({
1049
1109
  });
1050
1110
  return out.replace(/\u0000MYTCODE(\d+)\u0000/g, function (_m, i) { return codes[Number(i)] || ""; });
1051
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
+ }
1052
1162
  function renderMd(src) {
1053
1163
  var lines = escapeHtml(src).split(/\r?\n/);
1054
1164
  var html = [];
@@ -1101,9 +1211,37 @@ window.__ModuleLoader__.load({
1101
1211
  html.push('<ol class="myt-mdList">' + oitems.join("") + "</ol>");
1102
1212
  continue;
1103
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
+ }
1104
1240
  var para = [line];
1105
1241
  i++;
1106
- 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)) {
1107
1245
  para.push(lines[i]); i++;
1108
1246
  }
1109
1247
  html.push('<p class="myt-mdP">' + inlineMd(para.join("<br>")) + "</p>");
@@ -1137,10 +1275,12 @@ window.__ModuleLoader__.load({
1137
1275
  "header.upgradeBtn": "一键升级", "header.upgrading": "安装中…", "header.upgraded": "已安装,待重启",
1138
1276
  "header.upgradeTitle": "用 pnpm 安装 npm 最新版(最长约 3 分钟),装完需重启 DeepSeek 生效",
1139
1277
  "header.upgradedTitle": "已安装新版,重启 DeepSeek 后生效",
1278
+ "header.checkUpdate": "检测更新", "header.checkingUpdate": "检测中…",
1140
1279
  "toast.selfUpdate.ok": "已安装 {ver},重启 DeepSeek 后生效",
1141
1280
  "toast.selfUpdate.upToDate": "已是最新版({ver})",
1142
1281
  "toast.selfUpdate.globalOk": "全局 maoying CLI {from} → {to}", "toast.selfUpdate.globalFail": "全局 CLI 更新失败:{error}",
1143
1282
  "toast.selfUpdate.devLink": "开发目录安装,请 git pull 更新", "toast.selfUpdate.pnpmMissing": "未找到 pnpm,请手动安装",
1283
+ "toast.selfUpdate.found": "发现新版 {ver}", "toast.selfUpdate.checkFail": "检测失败:{msg}",
1144
1284
  "header.workspace": "工作区", "header.wsAria": "选择工作区", "header.noNovel": "(无 novel)", "header.untitled": "未命名",
1145
1285
  "header.settings": "系统设置", "header.refresh": "重新拉取", "header.closeAria": "关闭创作台", "header.closeTitle": "关闭",
1146
1286
  "tabs.aria": "创作台分区",
@@ -1158,6 +1298,7 @@ window.__ModuleLoader__.load({
1158
1298
  "guide.copied": "已复制 maoying init", "guide.copyFailed": "复制失败,请手动选择命令文本", "guide.copying": "复制中…", "guide.copyCmd": "复制命令",
1159
1299
  "common.retry": "重试", "common.empty": "暂无数据", "common.uncategorized": "未分类", "common.charCount": "{n} 字",
1160
1300
  "common.closeTip": "关闭提示", "common.unknownErr": "未知错误", "common.priorityTitle": "优先级:{pri}", "common.priorityAria": "优先级{pri}",
1301
+ "common.dirtyExit": "有未保存的修改,离开将丢失。确定要放弃吗?",
1161
1302
  "common.chapterNum": "第 {n} 章", "pri.high": "高", "pri.normal": "中", "pri.low": "低",
1162
1303
  "toast.prefix": "毛颖创作台:{msg}", "toast.success": "操作成功", "toast.indexRebuilt": "索引已重建({n} 章)",
1163
1304
  "toast.chapterStatus": "章节状态已更新", "toast.chapterCreated": "已从章纲建章「{title}」(待写)", "toast.foreshadowStatus": "伏笔状态已更新", "toast.phaseAdvanced": "阶段已推进到「{phase}」",
@@ -1167,7 +1308,8 @@ window.__ModuleLoader__.load({
1167
1308
  "src.deleteFile": "删除文件", "src.deleteTip": "删除文件:{rel}",
1168
1309
  "src.loading": "源文件加载中…", "src.loadFailed": "源文件读取失败:{msg}",
1169
1310
  "src.editHint": "直接编辑文件全文;保存时做结构校验(账本 yml 须解析为条目数组、md 实体卡须带 frontmatter 围栏),语义问题交给保存后的静态体检。",
1170
- "src.conflictReloaded": "文件已被外部修改,草稿已丢弃,已放回磁盘最新内容。",
1311
+ "edit.conflict409": "保存冲突:文件在编辑期间被外部修改({msg})。草稿已保留,请选择处理方式。",
1312
+ "edit.conflictDrop": "载入磁盘版(放弃草稿)", "edit.conflictKeep": "基于最新版重改",
1171
1313
  "src.slugLabel": "文件名 slug", "src.slugPh": "如 lin-waner(小写字母/数字与 -)", "src.slugInvalid": "slug 只接受小写字母/数字与 -,且以字母或数字开头",
1172
1314
  "src.createHint": "内容已按模板预填,可修改后再创建。", "src.create": "创建", "src.creating": "创建中…",
1173
1315
  "src.confirmTitle": "确认删除", "src.confirmText": "即将删除 {rel}。此操作不可逆——后悔靠 git 回滚;删除后静态体检会把指向它的悬空引用(伏笔/时间线/关系等)标为问题。",
@@ -1251,10 +1393,12 @@ window.__ModuleLoader__.load({
1251
1393
  "header.upgradeBtn": "Upgrade now", "header.upgrading": "Installing…", "header.upgraded": "Installed — restart pending",
1252
1394
  "header.upgradeTitle": "Install the latest npm release with pnpm (up to ~3 min); takes effect after restarting DeepSeek",
1253
1395
  "header.upgradedTitle": "New version installed — takes effect after restarting DeepSeek",
1396
+ "header.checkUpdate": "Check updates", "header.checkingUpdate": "Checking…",
1254
1397
  "toast.selfUpdate.ok": "Installed {ver} — takes effect after restarting DeepSeek",
1255
1398
  "toast.selfUpdate.upToDate": "Already up to date ({ver})",
1256
1399
  "toast.selfUpdate.globalOk": "Global maoying CLI {from} → {to}", "toast.selfUpdate.globalFail": "Global CLI update failed: {error}",
1257
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}",
1258
1402
  "header.workspace": "Workspace", "header.wsAria": "Select workspace", "header.noNovel": "(no novel)", "header.untitled": "Untitled",
1259
1403
  "header.settings": "Settings", "header.refresh": "Refresh", "header.closeAria": "Close Maoying Studio", "header.closeTitle": "Close",
1260
1404
  "tabs.aria": "Studio sections",
@@ -1272,6 +1416,7 @@ window.__ModuleLoader__.load({
1272
1416
  "guide.copied": "Copied maoying init", "guide.copyFailed": "Copy failed — select the command text manually", "guide.copying": "Copying…", "guide.copyCmd": "Copy command",
1273
1417
  "common.retry": "Retry", "common.empty": "No data", "common.uncategorized": "Uncategorized", "common.charCount": "{n} chars",
1274
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?",
1275
1420
  "common.chapterNum": "Ch. {n}", "pri.high": "High", "pri.normal": "Normal", "pri.low": "Low",
1276
1421
  "toast.prefix": "Maoying Studio: {msg}", "toast.success": "Success", "toast.indexRebuilt": "Index rebuilt ({n} chapters)",
1277
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}",
@@ -1281,7 +1426,8 @@ window.__ModuleLoader__.load({
1281
1426
  "src.deleteFile": "Delete file", "src.deleteTip": "Delete file: {rel}",
1282
1427
  "src.loading": "Loading source file…", "src.loadFailed": "Failed to load source file: {msg}",
1283
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.",
1284
- "src.conflictReloaded": "The file was changed externally; your draft was dropped and the latest on-disk content has been restored.",
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",
1285
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",
1286
1432
  "src.createHint": "Pre-filled from the template — adjust it, then create.", "src.create": "Create", "src.creating": "Creating…",
1287
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.).",
@@ -1428,11 +1574,23 @@ window.__ModuleLoader__.load({
1428
1574
  var _ob = useState(false), olBusy = _ob[0], setOlBusy = _ob[1];
1429
1575
  var _be = useState(""), bodyErr = _be[0], setBodyErr = _be[1];
1430
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];
1431
1581
  var detailSeqRef = useRef(0); // chapter 请求序号:快速切章时丢弃过期响应
1432
1582
  // 409 后的一次性重置标记:loadDetail 带回磁盘真值时把草稿放回最新内容
1433
- // (只在保存冲突路径置位;顶栏刷新、状态流转等其他重拉不动正在编辑的草稿)。
1583
+ // (只在保存冲突「放弃草稿」出口置位;顶栏刷新、状态流转等其他重拉不动
1584
+ // 正在编辑的草稿)。
1434
1585
  var bodyResetRef = useRef(false);
1435
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;
1436
1594
  // 0.1.20 写面:新建章纲(无章纲态)/ 删除章节 / 删除章纲(各带确认弹层)。
1437
1595
  var stem = file.replace(/\.md$/i, "");
1438
1596
  var stemOk = /^v\d+-c\d+$/.test(stem); // 章纲 rel 挂钩 vXX-cXXX stem,不合规范不亮按钮
@@ -1445,7 +1603,10 @@ window.__ModuleLoader__.load({
1445
1603
  // 骨架照 spec 六件章纲的其中五件预填(章尾钩子独立成节,留给写的人
1446
1604
  // 展开成 `## 钩子`);服务端只验形状,成败提示走 toast 通道。
1447
1605
  apiPost("file-create", { path: wsPath, rel: "outlines/" + stem + ".md", text: outlineTemplateOf(ch, stem) })
1448
- .then(function () { return reload().then(function () { return loadDetail(file); }); })
1606
+ .then(function () { return reload().then(function () {
1607
+ // 时序守卫:建章期间切了章——快照照刷,旧章详情不回填新章视图。
1608
+ if (fileRef.current === file) return loadDetail(file);
1609
+ }); })
1449
1610
  .catch(function () {}).finally(function () { setCoBusy(false); });
1450
1611
  }
1451
1612
  var loadDetail = useCallback(function (f, opts) {
@@ -1456,8 +1617,18 @@ window.__ModuleLoader__.load({
1456
1617
  apiGet("chapter", { path: wsPath, file: f }).then(function (d) {
1457
1618
  if (seq !== detailSeqRef.current) return; // 过期响应:已在看另一章,丢弃
1458
1619
  setDetail({ loading: false, data: d, err: "" });
1459
- if (bodyResetRef.current) { bodyResetRef.current = false; setDraft(d && typeof d.body === "string" ? d.body : ""); }
1460
- 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
+ }
1461
1632
  }).catch(function (err) {
1462
1633
  if (seq !== detailSeqRef.current) return;
1463
1634
  setDetail({ loading: false, data: null, err: err.message });
@@ -1473,9 +1644,16 @@ window.__ModuleLoader__.load({
1473
1644
  // 切章必须退出编辑态并清草稿:草稿属于「那一章的那一次编辑」,跨章带过
1474
1645
  // 去会把 A 章的文字存进 B 章文件。
1475
1646
  useEffect(function () {
1476
- setEditing(false); setDraft(""); setBodyErr(""); setBodyBusy(false); bodyResetRef.current = false;
1477
- 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 = "";
1478
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
+ });
1479
1657
  var ch = null;
1480
1658
  var chapters = (snap && snap.chapters) || [];
1481
1659
  for (var i = 0; i < chapters.length; i++) { if (chapters[i].file === file) { ch = chapters[i]; break; } }
@@ -1487,7 +1665,10 @@ window.__ModuleLoader__.load({
1487
1665
  setFlowBusy(true);
1488
1666
  apiPost("chapter-status", { path: wsPath, file: ch.file, status: next })
1489
1667
  .then(function () { return reload(); })
1490
- .then(function () { return loadDetail(ch.file); })
1668
+ .then(function () {
1669
+ // 时序守卫:流转期间切了章——快照照刷(无害),旧章详情不回填。
1670
+ if (fileRef.current === ch.file) return loadDetail(ch.file);
1671
+ })
1491
1672
  .catch(function () {}).finally(function () { setFlowBusy(false); });
1492
1673
  }
1493
1674
  function copyChapterPath() {
@@ -1499,34 +1680,73 @@ window.__ModuleLoader__.load({
1499
1680
  function startBodyEdit() {
1500
1681
  if (!detail.data || typeof detail.data.body !== "string") return;
1501
1682
  setBodyErr("");
1683
+ bodyBaseRef.current = detail.data.body; // 进入编辑态即记基线,供脏检查
1502
1684
  setDraft(detail.data.body);
1503
1685
  setEditing(true);
1504
1686
  }
1505
1687
  function cancelBodyEdit() {
1506
1688
  if (bodyBusy) return; // 保存在途不许退出:避免卸载后 setState
1507
- 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();
1508
1698
  }
1509
1699
  function saveBody() {
1510
1700
  if (bodyBusy || !detail.data) return;
1511
1701
  setBodyBusy(true); setBodyErr("");
1512
- // 成败提示统一走 apiPost 的 toast 通道(这里不再另弹,避免双 toast)。
1513
- apiPost("chapter-save", { path: wsPath, file: file, body: draft, base_hash: detail.data.hash }).then(function (res) {
1514
- // 写后指纹即下一轮基线(连续保存不再被自家 409 拦住);本地 data 同步
1515
- // 为新正文,退出编辑态立即看到刚落盘的内容(不等重拉,防闪回旧文)。
1516
- setDetail({ loading: false, data: Object.assign({}, detail.data, {
1517
- body: draft,
1518
- hash: res && typeof res.hash === "string" ? res.hash : detail.data.hash,
1519
- }), err: "" });
1520
- 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
+ }
1521
1715
  return reload(); // 字数/统计随正文变,快照由 reload 刷新
1522
1716
  }).catch(function (err) {
1523
- setBodyErr(err.message);
1524
- // 409 = 章节文件在进入编辑态后被外部改过:草稿丢弃、重拉磁盘真值放回
1525
- // 编辑器(与 ProjectMetaForm 同纪律),别让用户在过期基线上盲改。
1526
- if (err && err.status === 409) { bodyResetRef.current = true; loadDetail(file); }
1527
- // 其他失败(网络抖动等)不动草稿,只报错。
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
+ }
1528
1727
  }).finally(function () { setBodyBusy(false); });
1529
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
+ }
1530
1750
  function startOutlineEdit() {
1531
1751
  var ol = detail.data && detail.data.outline;
1532
1752
  if (!ol || typeof ol.body !== "string") return;
@@ -1536,33 +1756,67 @@ window.__ModuleLoader__.load({
1536
1756
  }
1537
1757
  function cancelOutlineEdit() {
1538
1758
  if (olBusy) return;
1539
- setOlEditing(false); setOlDraft(""); setOlErr("");
1759
+ if (olDraft !== olBaseRef.current && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认
1760
+ setOlEditing(false); setOlDraft(""); setOlErr(""); setOlConflict(false);
1540
1761
  }
1541
1762
  function saveOutline() {
1542
1763
  var ol = detail.data && detail.data.outline;
1543
1764
  if (olBusy || !ol) return;
1544
1765
  setOlBusy(true); setOlErr("");
1545
- apiPost("outline-save", { path: wsPath, file: file, body: olDraft, base_hash: ol.hash }).then(function (res) {
1546
- setDetail({ loading: false, data: Object.assign({}, detail.data, {
1547
- outline: Object.assign({}, ol, { body: olDraft, hash: res && typeof res.hash === "string" ? res.hash : ol.hash }),
1548
- }), err: "" });
1549
- 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
+ }
1550
1775
  // 章纲字数 chip 客户端不算 CJK——silent 重拉静默对齐,不闪骨架。
1551
- 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
+ });
1552
1779
  }).catch(function (err) {
1553
- setOlErr(err.message);
1554
- 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
+ }
1555
1788
  }).finally(function () { setOlBusy(false); });
1556
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
+ }
1557
1811
  // 章纲编辑钩子(传给 OutlinePanel;无章纲返回 null = 只读,不给编辑入口——
1558
- // v1 不做面板新建章纲文件)。
1812
+ // v1 不做面板新建章纲文件)。conflict/drop/rebase:409 两出口透传。
1559
1813
  function outlineEditor() {
1560
1814
  if (!detail.data || !detail.data.outline) return null;
1561
- 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 };
1562
1816
  }
1563
1817
  return h("div", { className: "myt-reader" },
1564
1818
  h("div", { className: "myt-readerBar" },
1565
- 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") },
1566
1820
  ic("IconChevronRightOutlineRegular", 13), tt("reader.back")),
1567
1821
  h("h2", { className: "myt-readerTitle" }, ch ? chFullTitle(ch) : file),
1568
1822
  ch ? statusBadge(ch.status) : null,
@@ -1610,6 +1864,9 @@ window.__ModuleLoader__.load({
1610
1864
  }),
1611
1865
  h("div", { className: "myt-editHint" }, tt("reader.bodyEditHint")),
1612
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,
1613
1870
  h("div", { className: "myt-setActions" },
1614
1871
  h("button", { type: "button", className: "myt-btn", disabled: bodyBusy, onClick: cancelBodyEdit }, tt("novel.cancel")),
1615
1872
  h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: bodyBusy, onClick: saveBody }, bodyBusy ? tt("novel.saving") : tt("reader.save"))))
@@ -1704,14 +1961,24 @@ window.__ModuleLoader__.load({
1704
1961
  }, [snap, wsPath]);
1705
1962
  var _b = useState(false), busy = _b[0], setBusy = _b[1];
1706
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
+ });
1707
1975
  function set(k, v) { setForm(Object.assign({}, form, (function () { var o = {}; o[k] = v; return o; })())); }
1708
1976
  function save() {
1709
1977
  if (busy) return;
1710
1978
  if (!String(form.title || "").trim()) { setSaveErr(tt("novel.titleRequired")); return; }
1711
1979
  setBusy(true); setSaveErr("");
1712
- // 成败提示统一走 apiPost 的 toast 通道(这里不再另弹,避免双 toast);
1713
- // 卡内 saveErr 只承担表单旁的错误态。
1714
- 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) {
1715
1982
  // 写后指纹即下一轮基线:不等 reload 快照就先接住——连续两次保存时
1716
1983
  // 第二次若还回传载入时的旧指纹,会被自家 409 拦住(假冲突)。
1717
1984
  if (res && typeof res.project_hash === "string") baseHashRef.current = res.project_hash;
@@ -1746,6 +2013,7 @@ window.__ModuleLoader__.load({
1746
2013
  }
1747
2014
  function cancel() {
1748
2015
  if (busy) return; // 保存在途不许退出:避免卸载后 setState
2016
+ if (dirty && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认
1749
2017
  onDone();
1750
2018
  }
1751
2019
  return h("div", { className: "myt-stack" },
@@ -1819,6 +2087,9 @@ window.__ModuleLoader__.load({
1819
2087
  }),
1820
2088
  h("div", { className: "myt-editHint" }, tt("outline.editHint")),
1821
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,
1822
2093
  h("div", { className: "myt-setActions" },
1823
2094
  h("button", { type: "button", className: "myt-btn", disabled: ed.busy, onClick: ed.cancel }, tt("novel.cancel")),
1824
2095
  h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: ed.busy, onClick: ed.save }, ed.busy ? tt("novel.saving") : tt("reader.save"))))
@@ -1905,9 +2176,10 @@ window.__ModuleLoader__.load({
1905
2176
  }
1906
2177
  // 编辑既有受管文件全文。打开即 GET file-source(请求序号防快速开关时的
1907
2178
  // 过期响应,对照 ChapterReader);保存 POST file-save 带 base_hash 基线,
1908
- // 409 = 编辑期间文件被外部改过——照 ChapterReader 纪律:草稿丢弃、重拉
1909
- // 磁盘真值放回编辑器,别让用户在过期基线上盲改。成功后关弹层并触发调用
1910
- // 方 reload(快照重算),成败提示走 apiPost 的 toast 通道。
2179
+ // 409 = 编辑期间文件被外部改过——草稿绝不丢:保留草稿报冲突,亮两个出口
2180
+ // (放弃草稿载入磁盘版 / 基于磁盘最新版重改),别让用户在过期基线上盲改
2181
+ // 也不许静默覆盖草稿。关闭统一走脏检查,未保存草稿先确认。成功后关弹层
2182
+ // 并触发调用方 reload(快照重算),成功提示走 apiPost 的 toast 通道。
1911
2183
  function SourceEditorModal(props) {
1912
2184
  var title = props.title;
1913
2185
  var rel = props.rel;
@@ -1918,10 +2190,22 @@ window.__ModuleLoader__.load({
1918
2190
  var _dr = useState(""), draft = _dr[0], setDraft = _dr[1];
1919
2191
  var _b = useState(false), busy = _b[0], setBusy = _b[1];
1920
2192
  var _e = useState(""), err = _e[0], setErr = _e[1];
2193
+ var _c = useState(false), conflict = _c[0], setConflict = _c[1];
1921
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
+ }
1922
2206
  var load = useCallback(function () {
1923
2207
  var seq = ++seqRef.current;
1924
- setDetail(null); setErr("");
2208
+ setDetail(null); setErr(""); setConflict(false);
1925
2209
  apiGet("file-source", { path: wsPath, rel: rel }).then(function (d) {
1926
2210
  if (seq !== seqRef.current) return;
1927
2211
  setDetail({ data: d, err: "" });
@@ -1935,22 +2219,41 @@ window.__ModuleLoader__.load({
1935
2219
  function save() {
1936
2220
  if (busy || !detail || !detail.data) return;
1937
2221
  setBusy(true); setErr("");
1938
- apiPost("file-save", { path: wsPath, rel: rel, text: draft, base_hash: detail.data.hash }).then(function () {
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 () {
1939
2224
  onClose();
1940
2225
  if (onSaved) onSaved();
1941
2226
  }).catch(function (e2) {
1942
- if (e2 && e2.status === 409) { setErr(tt("src.conflictReloaded")); load(); }
1943
- else setErr(e2 && e2.message ? e2.message : tt("common.unknownErr"));
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"));
1944
2232
  }).finally(function () { setBusy(false); });
1945
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
+ }
1946
2249
  return h(MaskPortal, {
1947
2250
  className: "myt-modalMask",
1948
- onMouseDown: function (e) { if (e.target === e.currentTarget && !busy) onClose(); },
2251
+ onMouseDown: function (e) { if (e.target === e.currentTarget && !busy) close(); },
1949
2252
  },
1950
2253
  h("div", { className: "myt-modalCard myt-srcCard", role: "dialog", "aria-modal": "true", "aria-label": tt("src.modalAria") },
1951
2254
  h("div", { className: "myt-modalHead" },
1952
2255
  h("h2", { className: "myt-modalTitle" }, title),
1953
- h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("src.closeAria"), onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
2256
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("src.closeAria"), onClick: close }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
1954
2257
  h("div", { className: "myt-modalBody" },
1955
2258
  h("div", { className: "myt-srcRel" }, rel),
1956
2259
  h("div", { className: "myt-muted myt-srcHint" }, tt("src.editHint")),
@@ -1960,8 +2263,11 @@ window.__ModuleLoader__.load({
1960
2263
  h("textarea", { key: "a", className: "myt-setInput myt-setArea myt-srcArea", rows: 16, spellCheck: false,
1961
2264
  value: draft, onChange: function (e) { setDraft(e.target.value); } }),
1962
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,
1963
2269
  h("div", { key: "s", className: "myt-setActions" },
1964
- h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: onClose }, tt("novel.cancel")),
2270
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: close }, tt("novel.cancel")),
1965
2271
  h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save }, busy ? tt("novel.saving") : tt("novel.save"))),
1966
2272
  ])));
1967
2273
  }
@@ -1984,7 +2290,8 @@ window.__ModuleLoader__.load({
1984
2290
  if (busy) return;
1985
2291
  if (!slugOk) { setErr(tt("src.slugInvalid")); return; }
1986
2292
  setBusy(true); setErr("");
1987
- apiPost("file-create", { path: wsPath, rel: dir + "/" + slug + ".md", text: text }).then(function () {
2293
+ // 失败经卡内 err 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走 toast。
2294
+ apiPost("file-create", { path: wsPath, rel: dir + "/" + slug + ".md", text: text }, { silent: true }).then(function () {
1988
2295
  onClose();
1989
2296
  if (onSaved) onSaved();
1990
2297
  }).catch(function (e2) { setErr(e2 && e2.message ? e2.message : tt("common.unknownErr")); })
@@ -2257,7 +2564,7 @@ window.__ModuleLoader__.load({
2257
2564
  h("div", { className: "myt-boardHead" },
2258
2565
  h("span", { className: "myt-boardTitle" }, tt("foreshadow." + col)),
2259
2566
  h("span", { className: "myt-boardCount" }, items.length)),
2260
- items.map(function (fs) {
2567
+ items.map(function (fs, i) {
2261
2568
  var next = FS_FLOW[col] || null;
2262
2569
  var busy = busyId === fs.id;
2263
2570
  // status 行缺失(服务端要求每条恰一处 status,写必 409):禁用流
@@ -2268,7 +2575,8 @@ window.__ModuleLoader__.load({
2268
2575
  ? tt("foreshadow.noIdTip")
2269
2576
  : tt("foreshadow.noStatusTip");
2270
2577
  var chRange = [fs.planted_chapter || tt("foreshadow.plantedUnmarked"), fs.expected_resolve].filter(Boolean).join(" → ");
2271
- 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" },
2272
2580
  h("div", { className: "myt-fsTitle" },
2273
2581
  priDot(fs.priority), h("span", null, orDash(fs.title) || orDash(fs.id)),
2274
2582
  // 回填徽章(五期账本回填):区别于写前规划条目,悬浮见来源。
@@ -2329,8 +2637,8 @@ window.__ModuleLoader__.load({
2329
2637
  // 遵守)。挂载/换工作区时 GET style 按需拉取(请求序号防过期响应,对照
2330
2638
  // OverviewTab 的 check effect);视图态 MdView 渲染全文,缺失时空态卡用
2331
2639
  // 插件模板预填创建;编辑态整文件保存(style-save 携 base_hash,409 人工
2332
- // 改动保护链路照 ProjectMetaForm:成功接写后新指纹防连续保存假冲突,
2333
- // 409 重拉磁盘真值后编辑器原地重置、停留编辑态让用户自行取舍)。──
2640
+ // 改动保护链路:成功接写后新指纹防连续保存假冲突;409 草稿绝不丢——保留
2641
+ // 草稿报冲突,亮「载入磁盘版 / 基于最新版重改」两个出口,同 ChapterReader)。
2334
2642
  function StyleTab(props) {
2335
2643
  var wsPath = props.wsPath;
2336
2644
  var _st = useState(null), st = _st[0], setSt = _st[1]; // {loading, data, err}
@@ -2341,6 +2649,8 @@ window.__ModuleLoader__.load({
2341
2649
  var _dh = useState(null), draftHash = _dh[0], setDraftHash = _dh[1];
2342
2650
  var _er = useState(""), editErr = _er[0], setEditErr = _er[1];
2343
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];
2344
2654
  // 409 后的一次性重置标记:reload 带回磁盘真值时把编辑器与基线一并重置
2345
2655
  // (只置位于保存冲突路径;顶栏刷新等其他拉取不碰正在编辑的内容)。
2346
2656
  var pendingResetRef = useRef(false);
@@ -2353,8 +2663,8 @@ window.__ModuleLoader__.load({
2353
2663
  if (seq !== seqRef.current) return; // 过期响应:已有更新的请求在途,丢弃
2354
2664
  setSt({ loading: false, data: d, err: "" });
2355
2665
  if (pendingResetRef.current) {
2356
- // 409 冲突后的重拉:编辑器放回磁盘真值、基线对齐最新指纹,停留
2357
- // 编辑态让用户基于最新内容自行取舍。
2666
+ // 409 冲突出口①「放弃草稿」的重拉:编辑器放回磁盘真值、基线对齐
2667
+ // 最新指纹,停留编辑态。
2358
2668
  pendingResetRef.current = false;
2359
2669
  setDraft(d && typeof d.text === "string" ? d.text : "");
2360
2670
  setDraftHash(d && typeof d.hash === "string" ? d.hash : null);
@@ -2367,7 +2677,7 @@ window.__ModuleLoader__.load({
2367
2677
  useEffect(function () {
2368
2678
  if (wsRef.current !== wsPath) {
2369
2679
  wsRef.current = wsPath;
2370
- setDraft(null); setDraftHash(null); setEditErr(""); pendingResetRef.current = false;
2680
+ setDraft(null); setDraftHash(null); setEditErr(""); setConflict(false); pendingResetRef.current = false;
2371
2681
  }
2372
2682
  load();
2373
2683
  }, [wsPath, load]);
@@ -2385,30 +2695,62 @@ window.__ModuleLoader__.load({
2385
2695
  }
2386
2696
  function cancelEdit() {
2387
2697
  if (busy) return; // 保存在途不许退出:避免卸载后 setState
2388
- setDraft(null); setDraftHash(null); setEditErr("");
2698
+ if (dirty && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认
2699
+ setDraft(null); setDraftHash(null); setEditErr(""); setConflict(false);
2389
2700
  }
2390
2701
  function save() {
2391
2702
  if (busy) return;
2392
2703
  setBusy(true); setEditErr("");
2393
- // 成败提示统一走 apiPost 的 toast 通道;空文本允许保存(空文件 = 无
2394
- // 基准,agent 侧优雅降级),不做非空校验。
2395
- 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) {
2396
2707
  // 写后指纹即下一轮基线(连续保存不再被自家 409 拦住);本地数据
2397
2708
  // 同步为新文本,取消编辑退回视图态时看到的已是刚落盘的内容。
2398
2709
  var hash = res && typeof res.hash === "string" ? res.hash : null;
2399
2710
  setDraftHash(hash);
2711
+ setConflict(false);
2400
2712
  setSt({ loading: false, data: { found: true, text: draft, hash: hash, template: st.data ? st.data.template : null }, err: "" });
2401
2713
  }).catch(function (err) {
2402
- setEditErr(err.message);
2403
- // 409 = style.md 在进入编辑态后被外部改过:重拉磁盘真值并一次性
2404
- // 把编辑器放回最新内容(停留编辑态),别让用户在过期基线上盲改。
2405
2714
  if (err && err.status === 409) {
2406
- pendingResetRef.current = true;
2407
- load();
2715
+ // 409 = style.md 在进入编辑态后被外部改过:草稿绝不丢——报冲突并
2716
+ // 亮两个出口(放弃草稿载入磁盘版 / 基于最新版重改),不再静默重置。
2717
+ setConflict(true);
2718
+ setEditErr(tf("edit.conflict409", { msg: err.message }));
2719
+ } else {
2720
+ setEditErr(err.message);
2408
2721
  }
2409
2722
  }).finally(function () { setBusy(false); });
2410
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
+ }
2411
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
+ });
2412
2754
  var head = h("div", { className: "myt-cardHead" },
2413
2755
  h("h3", { className: "myt-cardTitle" }, tt("tabs.style")),
2414
2756
  h("span", { className: "myt-muted" }, tt("style.note")),
@@ -2443,6 +2785,9 @@ window.__ModuleLoader__.load({
2443
2785
  value: draft, onChange: function (e) { setDraft(e.target.value); },
2444
2786
  })),
2445
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,
2446
2791
  h("div", { className: "myt-setActions" },
2447
2792
  h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: cancelEdit }, tt("novel.cancel")),
2448
2793
  h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save },
@@ -2713,10 +3058,19 @@ window.__ModuleLoader__.load({
2713
3058
  var ctx = canvas.getContext("2d");
2714
3059
  if (!ctx) return undefined;
2715
3060
  var dpr = (window.devicePixelRatio || 1) > 0 ? window.devicePixelRatio || 1 : 1;
2716
- 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 };
2717
3071
  // 缩放按钮桥:渲染层按钮 → effect 内 sim.zoom(钳制与滚轮同口径 0.4-2.5)
2718
3072
  zoomApiRef.current = {
2719
- 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(); },
2720
3074
  };
2721
3075
  // 令牌缓存:每 30 帧重读 computed style,浅/深色切换约半秒内跟上。
2722
3076
  function readTokens() {
@@ -2735,6 +3089,7 @@ window.__ModuleLoader__.load({
2735
3089
  sim.h = wrap.clientHeight || 480;
2736
3090
  canvas.width = Math.max(1, Math.round(sim.w * dpr));
2737
3091
  canvas.height = Math.max(1, Math.round(sim.h * dpr));
3092
+ wake(); // 改 width 即清空位图:停机后也要立刻重画(含全屏进出触发的 RO)
2738
3093
  }
2739
3094
  // 初始摆在同心圆上,交给力导向收敛。
2740
3095
  var R = Math.max(90, Math.min(sim.w, sim.h) / 3);
@@ -2824,6 +3179,8 @@ window.__ModuleLoader__.load({
2824
3179
  }
2825
3180
  function step() {
2826
3181
  var nodes = graph.nodes, edges = graph.edges, n = nodes.length;
3182
+ var energy = 0; // 节点速度平方和:本帧系统总动能
3183
+ var movedPx = 0; // 硬碰撞分离的本帧位移量:纯位置修正能量看不见,按位移计静
2827
3184
  for (var i = 0; i < n; i++) {
2828
3185
  for (var j = i + 1; j < n; j++) {
2829
3186
  var a = nodes[i], b = nodes[j];
@@ -2851,6 +3208,7 @@ window.__ModuleLoader__.load({
2851
3208
  nd.vx *= 0.85; nd.vy *= 0.85;
2852
3209
  nd.x += Math.max(-8, Math.min(8, nd.vx));
2853
3210
  nd.y += Math.max(-8, Math.min(8, nd.vy));
3211
+ energy += nd.vx * nd.vx + nd.vy * nd.vy; // 固定/拖拽节点速度已清零不计
2854
3212
  });
2855
3213
  // 硬碰撞分离:力式斥力有上限(f 封顶 4),拥挤时节点仍会压叠;这里在
2856
3214
  // 速度积分之后按最小间距 62(两节点半径 18×2 + 名字 label 纵向净空)
@@ -2873,12 +3231,14 @@ window.__ModuleLoader__.load({
2873
3231
  var bFixed = nb.fixed || nb === (sim.drag && sim.drag.node);
2874
3232
  if (aFixed && bFixed) continue;
2875
3233
  var mA = aFixed ? 0 : (bFixed ? 1 : 0.5); // 固定方不动,另一方全担
3234
+ movedPx += (MIN_DIST - cd) * (mA + mB); // 有实际位移才计(双固定跳过不计)
2876
3235
  var mB = bFixed ? 0 : (aFixed ? 1 : 0.5);
2877
3236
  na.x -= cdx * push * mA; na.y -= cdy * push * mA;
2878
3237
  nb.x += cdx * push * mB; nb.y += cdy * push * mB;
2879
3238
  }
2880
3239
  }
2881
3240
  }
3241
+ return { energy: energy, movedPx: movedPx };
2882
3242
  }
2883
3243
  function draw() {
2884
3244
  if (sim.frame % 30 === 0 || !sim.tokens) sim.tokens = readTokens();
@@ -2924,9 +3284,26 @@ window.__ModuleLoader__.load({
2924
3284
  ctx.restore();
2925
3285
  }
2926
3286
  function loop() {
2927
- step();
3287
+ var m = step();
2928
3288
  draw();
2929
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;
2930
3307
  sim.raf = requestAnimationFrame(loop);
2931
3308
  }
2932
3309
  resize();
@@ -2944,6 +3321,7 @@ window.__ModuleLoader__.load({
2944
3321
  sim.drag = { node: nd };
2945
3322
  nd.fixed = true; // 拖拽固定
2946
3323
  canvas.dataset.dragging = "true";
3324
+ wake(); // 拖拽移动物理系统,停机态先唤醒
2947
3325
  e.preventDefault();
2948
3326
  }
2949
3327
  }
@@ -2972,11 +3350,13 @@ window.__ModuleLoader__.load({
2972
3350
  e.preventDefault();
2973
3351
  var next = sim.zoom * Math.exp(-e.deltaY * 0.0012);
2974
3352
  sim.zoom = Math.min(2.5, Math.max(0.4, next));
3353
+ wake(); // 缩放变了画面:停机态也要重绘
2975
3354
  }
2976
3355
  function onDbl() {
2977
3356
  sim.zoom = 1; // 复位:缩放回 1、释放全部拖拽固定
2978
3357
  graph.nodes.forEach(function (nd) { nd.fixed = false; });
2979
3358
  setTip(null);
3359
+ wake(); // 释放固定后力导向要重新收敛
2980
3360
  }
2981
3361
  canvas.addEventListener("mousedown", onDown);
2982
3362
  window.addEventListener("mousemove", onMove);