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/README.en.md +4 -4
- package/README.md +2 -2
- package/docs/creator-panel.md +14 -8
- package/docs/design.md +8 -8
- package/docs/spec.md +2 -2
- package/index.mjs +126 -29
- package/lib/base.css +16 -4
- package/lib/client.js +458 -78
- package/lib/epub.mjs +6 -0
- package/lib/novel-parse.mjs +103 -19
- package/lib/panel-core.mjs +16 -7
- package/lib/self-update.mjs +118 -21
- package/lib/version.mjs +30 -2
- package/package.json +2 -1
- package/presets/maoying/agent.cordis.v2.yml +5 -0
- package/presets/maoying/agent.cordis.yml +9 -0
- package/presets/maoying/restrict.mjs +16 -3
- package/scripts/smoke-client.mjs +128 -0
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
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
|
-
//
|
|
1081
|
+
// 链接/GFM 管道表格/段落换行。供章纲、世界观条目等 md 阅读位使用,正文
|
|
1082
|
+
// 手稿仍走 pre。 ──
|
|
1024
1083
|
function escapeHtml(s) {
|
|
1025
|
-
|
|
1084
|
+
// 单引号也转义:属性当前虽全双引号,零成本防御纵深。
|
|
1085
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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*>|^\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
|
-
|
|
1242
|
+
// 段落兜底遇到表格起始(|a|b| + |---|---|)也要截断:段落后紧跟表格时
|
|
1243
|
+
// 表格独立成块,不把管道行并进段落(GitHub 行为)。
|
|
1244
|
+
while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^```|^\s*>|^\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
|
-
"
|
|
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
|
-
"
|
|
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 () {
|
|
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) {
|
|
1460
|
-
|
|
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 () {
|
|
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
|
-
|
|
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
|
-
//
|
|
1513
|
-
apiPost("chapter-save", { path: wsPath, file: file, body: draft, base_hash: detail.data.hash }).then(function (res) {
|
|
1514
|
-
//
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
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
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
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 () {
|
|
1776
|
+
return reload().then(function () {
|
|
1777
|
+
if (fileRef.current === file) return loadDetail(file, { silent: true });
|
|
1778
|
+
});
|
|
1552
1779
|
}).catch(function (err) {
|
|
1553
|
-
|
|
1554
|
-
if (err && err.status === 409) {
|
|
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:
|
|
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
|
-
//
|
|
1713
|
-
|
|
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 =
|
|
1909
|
-
//
|
|
1910
|
-
//
|
|
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
|
-
|
|
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) {
|
|
1943
|
-
|
|
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)
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2333
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
2394
|
-
//
|
|
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
|
-
|
|
2407
|
-
|
|
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
|
-
|
|
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);
|