dsh-maoying 0.1.20 → 0.1.22
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 +3 -3
- package/docs/creator-panel.md +17 -9
- package/docs/design.md +8 -8
- package/docs/spec.md +2 -2
- package/index.mjs +126 -29
- package/lib/base.css +25 -10
- package/lib/client.js +551 -85
- 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 回滚;删除后静态体检会把指向它的悬空引用(伏笔/时间线/关系等)标为问题。",
|
|
@@ -1223,7 +1365,7 @@ window.__ModuleLoader__.load({
|
|
|
1223
1365
|
"chapters.emptyWithOutlines": "chapters/ 目录还没有章节文件;下方「按单写作」已列出未建章章纲",
|
|
1224
1366
|
"reader.back": "返回", "reader.backTip": "返回章节列表", "reader.copyPath": "复制路径", "reader.copyPathTip": "复制章节文件路径",
|
|
1225
1367
|
"reader.edit": "编辑", "reader.editTip": "编辑章节正文(frontmatter 保留不动)",
|
|
1226
|
-
"reader.save": "保存", "reader.bodyEditHint": "
|
|
1368
|
+
"reader.save": "保存", "reader.bodyEditHint": "直接编辑正文:改动约 2 秒自动保存;回车换行自动加段首两个全角空格缩进。frontmatter(--- 围栏)保留不动;文件被外部改过时保存会被拒绝并给出处理出口。", "reader.autoSaved": "已自动保存({time})",
|
|
1227
1369
|
"outline.editTip": "编辑章纲", "outline.editHint": "直接编辑章纲全文;编辑期间文件被外部改过时保存会被拒绝,并放回磁盘最新内容。",
|
|
1228
1370
|
"reader.copied": "已复制章节文件路径", "reader.copyFailed": "复制失败,请手动选择路径文本", "reader.loadFailed": "正文读取失败:{msg}",
|
|
1229
1371
|
"reader.noOutline": "暂无章纲", "reader.emptyBody": "(空章节)", "reader.finalState": "已定稿(终态)", "reader.statusFlow": "状态流转:",
|
|
@@ -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.).",
|
|
@@ -1337,7 +1483,7 @@ window.__ModuleLoader__.load({
|
|
|
1337
1483
|
"chapters.emptyWithOutlines": "No chapter files in chapters/ yet — the Writing Queue below lists outlines without chapters",
|
|
1338
1484
|
"reader.back": "Back", "reader.backTip": "Back to chapter list", "reader.copyPath": "Copy path", "reader.copyPathTip": "Copy chapter file path",
|
|
1339
1485
|
"reader.edit": "Edit", "reader.editTip": "Edit the chapter text (frontmatter is preserved)",
|
|
1340
|
-
"reader.save": "Save", "reader.bodyEditHint": "Edit the chapter text directly;
|
|
1486
|
+
"reader.save": "Save", "reader.bodyEditHint": "Edit the chapter text directly: changes auto-save after ~2s; Enter starts a new paragraph indented with two full-width spaces. The frontmatter (--- fence) is preserved; if the file was changed externally, saving is rejected with recovery options.", "reader.autoSaved": "Auto-saved ({time})",
|
|
1341
1487
|
"outline.editTip": "Edit outline", "outline.editHint": "Edit the outline text directly; if the file was changed externally while editing, saving is rejected and the latest content is restored.",
|
|
1342
1488
|
"reader.copied": "Copied chapter file path", "reader.copyFailed": "Copy failed — select the path text manually", "reader.loadFailed": "Failed to load chapter text: {msg}",
|
|
1343
1489
|
"reader.noOutline": "No chapter outline", "reader.emptyBody": "(empty chapter)", "reader.finalState": "Accepted (final)", "reader.statusFlow": "Status:",
|
|
@@ -1407,8 +1553,8 @@ window.__ModuleLoader__.load({
|
|
|
1407
1553
|
// ── 章节阅读页(整页视图,替代右侧抽屉):返回按钮 + 章纲对照 + 正文 +
|
|
1408
1554
|
// 五维 + 状态流转。原抽屉是 position:fixed 侧弹层,在桌面端会被侧边栏
|
|
1409
1555
|
// 遮挡;整页视图天然在面板文档流内,无遮挡问题。0.1.19 起正文/章纲可在
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1556
|
+
// 本页编辑保存;0.1.22 起正文改自动保存(2s 防抖 + 回车自动段首缩进 +
|
|
1557
|
+
// 空章节直开编辑态),章纲仍手动保存;409 人工改动保护链路照 StyleTab。──
|
|
1412
1558
|
function ChapterReader(props) {
|
|
1413
1559
|
var file = props.file;
|
|
1414
1560
|
var wsPath = props.wsPath;
|
|
@@ -1428,11 +1574,37 @@ 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];
|
|
1581
|
+
// 正文自动保存(0.1.22):最近一次自动保存成功的时刻(HH:MM:SS),缀在编辑
|
|
1582
|
+
// hint 后;手动保存成功或切章时清零。
|
|
1583
|
+
var _as = useState(""), autoSavedAt = _as[0], setAutoSavedAt = _as[1];
|
|
1431
1584
|
var detailSeqRef = useRef(0); // chapter 请求序号:快速切章时丢弃过期响应
|
|
1432
1585
|
// 409 后的一次性重置标记:loadDetail 带回磁盘真值时把草稿放回最新内容
|
|
1433
|
-
//
|
|
1586
|
+
// (只在保存冲突「放弃草稿」出口置位;顶栏刷新、状态流转等其他重拉不动
|
|
1587
|
+
// 正在编辑的草稿)。
|
|
1434
1588
|
var bodyResetRef = useRef(false);
|
|
1435
1589
|
var olResetRef = useRef(false);
|
|
1590
|
+
// 进入编辑态时的原文基线:草稿 ≠ 基线即脏,供取消/返回的退出确认。
|
|
1591
|
+
var bodyBaseRef = useRef("");
|
|
1592
|
+
var olBaseRef = useRef("");
|
|
1593
|
+
// 当前选中章的实时值:在途保存/流转/建章回调用它比对闭包里的旧 file,
|
|
1594
|
+
// 不一致则跳过 UI 状态回填,防旧章数据覆写新章视图(B 标题+A正文)。
|
|
1595
|
+
var fileRef = useRef(file);
|
|
1596
|
+
fileRef.current = file;
|
|
1597
|
+
// editing 实时值:loadDetail 是 useCallback 闭包,直接读 editing state 是
|
|
1598
|
+
// 过期值——空章节直开编辑态的守卫必须走 ref。
|
|
1599
|
+
var editingRef = useRef(false);
|
|
1600
|
+
editingRef.current = editing;
|
|
1601
|
+
// 空章节直开去重:记本次访问已自动进入过编辑态的 file,同章不反复打扰
|
|
1602
|
+
// (手动取消退出后不再自动重进);切章清空,切走再切回可再次进入。
|
|
1603
|
+
var autoEditRef = useRef("");
|
|
1604
|
+
// 回车缩进的光标恢复:setDraft 提交换行+缩进后 DOM 未更新,下一轮渲染
|
|
1605
|
+
// (draft 已就位)再把光标挪到插入的缩进之后;areaRef 指向正文 textarea。
|
|
1606
|
+
var pendingCaretRef = useRef(null);
|
|
1607
|
+
var areaRef = useRef(null);
|
|
1436
1608
|
// 0.1.20 写面:新建章纲(无章纲态)/ 删除章节 / 删除章纲(各带确认弹层)。
|
|
1437
1609
|
var stem = file.replace(/\.md$/i, "");
|
|
1438
1610
|
var stemOk = /^v\d+-c\d+$/.test(stem); // 章纲 rel 挂钩 vXX-cXXX stem,不合规范不亮按钮
|
|
@@ -1445,7 +1617,10 @@ window.__ModuleLoader__.load({
|
|
|
1445
1617
|
// 骨架照 spec 六件章纲的其中五件预填(章尾钩子独立成节,留给写的人
|
|
1446
1618
|
// 展开成 `## 钩子`);服务端只验形状,成败提示走 toast 通道。
|
|
1447
1619
|
apiPost("file-create", { path: wsPath, rel: "outlines/" + stem + ".md", text: outlineTemplateOf(ch, stem) })
|
|
1448
|
-
.then(function () { return reload().then(function () {
|
|
1620
|
+
.then(function () { return reload().then(function () {
|
|
1621
|
+
// 时序守卫:建章期间切了章——快照照刷,旧章详情不回填新章视图。
|
|
1622
|
+
if (fileRef.current === file) return loadDetail(file);
|
|
1623
|
+
}); })
|
|
1449
1624
|
.catch(function () {}).finally(function () { setCoBusy(false); });
|
|
1450
1625
|
}
|
|
1451
1626
|
var loadDetail = useCallback(function (f, opts) {
|
|
@@ -1456,8 +1631,30 @@ window.__ModuleLoader__.load({
|
|
|
1456
1631
|
apiGet("chapter", { path: wsPath, file: f }).then(function (d) {
|
|
1457
1632
|
if (seq !== detailSeqRef.current) return; // 过期响应:已在看另一章,丢弃
|
|
1458
1633
|
setDetail({ loading: false, data: d, err: "" });
|
|
1459
|
-
if (bodyResetRef.current) {
|
|
1460
|
-
|
|
1634
|
+
if (bodyResetRef.current) {
|
|
1635
|
+
bodyResetRef.current = false;
|
|
1636
|
+
var body0 = d && typeof d.body === "string" ? d.body : "";
|
|
1637
|
+
bodyBaseRef.current = body0; // 放回的磁盘真值即新基线(脏检查归零)
|
|
1638
|
+
setDraft(body0);
|
|
1639
|
+
}
|
|
1640
|
+
if (olResetRef.current) {
|
|
1641
|
+
olResetRef.current = false;
|
|
1642
|
+
var ol0 = d && d.outline && typeof d.outline.body === "string" ? d.outline.body : "";
|
|
1643
|
+
olBaseRef.current = ol0;
|
|
1644
|
+
setOlDraft(ol0);
|
|
1645
|
+
}
|
|
1646
|
+
// 空章节直开编辑态(0.1.22):正文待写(body 全空白)的章打开即进编辑,
|
|
1647
|
+
// 不再经顶部「编辑」按钮中转。三重守卫缺一不可——正在编辑绝不打扰
|
|
1648
|
+
// (409 放弃草稿等编辑中重拉天然被排除)、同章本轮只自动进一次、
|
|
1649
|
+
// 非空章节照常走「编辑」按钮。预填两个全角空格让首段也有段首缩进,
|
|
1650
|
+
// 基线与预填同值(不判脏,纯缩进占位也不会触发自动保存落盘)。
|
|
1651
|
+
if (!editingRef.current && autoEditRef.current !== f && d && typeof d.body === "string" && d.body.trim() === "") {
|
|
1652
|
+
autoEditRef.current = f;
|
|
1653
|
+
bodyBaseRef.current = " ";
|
|
1654
|
+
setDraft(" ");
|
|
1655
|
+
setEditing(true);
|
|
1656
|
+
setBodyErr("");
|
|
1657
|
+
}
|
|
1461
1658
|
}).catch(function (err) {
|
|
1462
1659
|
if (seq !== detailSeqRef.current) return;
|
|
1463
1660
|
setDetail({ loading: false, data: null, err: err.message });
|
|
@@ -1468,14 +1665,23 @@ window.__ModuleLoader__.load({
|
|
|
1468
1665
|
// 走 silent——不闪骨架、不打断 textarea,draft 只在 409 冲突重置路径才
|
|
1469
1666
|
// 动(上方注释的纪律不变);阅读态非 silent,换血后照常渲染新正文。
|
|
1470
1667
|
useEffect(function () {
|
|
1471
|
-
|
|
1668
|
+
// 重开面板算新一轮访问:直开去重清空,空章节重开再次自动进编辑态。
|
|
1669
|
+
if (reopenSeq > 0) { autoEditRef.current = ""; loadDetail(file, { silent: editing }); }
|
|
1472
1670
|
}, [reopenSeq]);
|
|
1473
1671
|
// 切章必须退出编辑态并清草稿:草稿属于「那一章的那一次编辑」,跨章带过
|
|
1474
1672
|
// 去会把 A 章的文字存进 B 章文件。
|
|
1475
1673
|
useEffect(function () {
|
|
1476
|
-
setEditing(false); setDraft(""); setBodyErr(""); setBodyBusy(false); bodyResetRef.current = false;
|
|
1477
|
-
setOlEditing(false); setOlDraft(""); setOlErr(""); setOlBusy(false); olResetRef.current = false;
|
|
1674
|
+
setEditing(false); setDraft(""); setBodyErr(""); setBodyBusy(false); bodyResetRef.current = false; setBodyConflict(false);
|
|
1675
|
+
setOlEditing(false); setOlDraft(""); setOlErr(""); setOlBusy(false); olResetRef.current = false; setOlConflict(false);
|
|
1676
|
+
bodyBaseRef.current = ""; olBaseRef.current = "";
|
|
1677
|
+
autoEditRef.current = ""; setAutoSavedAt(""); // 直开去重与自动保存时刻随切章清零,新章新一轮
|
|
1478
1678
|
}, [file]);
|
|
1679
|
+
// 脏标记上报:Shell 的切 tab 守卫(gotoTab)读 DIRTY_EXIT 拦截;本组件
|
|
1680
|
+
// 不随切 tab 卸载(整页视图),标记随每轮渲染同步,卸载时清零。
|
|
1681
|
+
useEffect(function () {
|
|
1682
|
+
DIRTY_EXIT.dirty = (editing && draft !== bodyBaseRef.current) || (olEditing && olDraft !== olBaseRef.current);
|
|
1683
|
+
return function () { DIRTY_EXIT.dirty = false; };
|
|
1684
|
+
});
|
|
1479
1685
|
var ch = null;
|
|
1480
1686
|
var chapters = (snap && snap.chapters) || [];
|
|
1481
1687
|
for (var i = 0; i < chapters.length; i++) { if (chapters[i].file === file) { ch = chapters[i]; break; } }
|
|
@@ -1487,7 +1693,10 @@ window.__ModuleLoader__.load({
|
|
|
1487
1693
|
setFlowBusy(true);
|
|
1488
1694
|
apiPost("chapter-status", { path: wsPath, file: ch.file, status: next })
|
|
1489
1695
|
.then(function () { return reload(); })
|
|
1490
|
-
.then(function () {
|
|
1696
|
+
.then(function () {
|
|
1697
|
+
// 时序守卫:流转期间切了章——快照照刷(无害),旧章详情不回填。
|
|
1698
|
+
if (fileRef.current === ch.file) return loadDetail(ch.file);
|
|
1699
|
+
})
|
|
1491
1700
|
.catch(function () {}).finally(function () { setFlowBusy(false); });
|
|
1492
1701
|
}
|
|
1493
1702
|
function copyChapterPath() {
|
|
@@ -1499,34 +1708,114 @@ window.__ModuleLoader__.load({
|
|
|
1499
1708
|
function startBodyEdit() {
|
|
1500
1709
|
if (!detail.data || typeof detail.data.body !== "string") return;
|
|
1501
1710
|
setBodyErr("");
|
|
1711
|
+
setAutoSavedAt(""); // 上一次编辑会话的自动保存时刻不带进新一轮
|
|
1712
|
+
bodyBaseRef.current = detail.data.body; // 进入编辑态即记基线,供脏检查
|
|
1502
1713
|
setDraft(detail.data.body);
|
|
1503
1714
|
setEditing(true);
|
|
1504
1715
|
}
|
|
1505
1716
|
function cancelBodyEdit() {
|
|
1506
1717
|
if (bodyBusy) return; // 保存在途不许退出:避免卸载后 setState
|
|
1507
|
-
|
|
1718
|
+
if (draft !== bodyBaseRef.current && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认
|
|
1719
|
+
setEditing(false); setDraft(""); setBodyErr(""); setBodyConflict(false);
|
|
1508
1720
|
}
|
|
1509
|
-
|
|
1721
|
+
// 返回章节列表:正文或章纲任一有未保存草稿,先确认再退。
|
|
1722
|
+
function leaveReader() {
|
|
1723
|
+
if ((editing && draft !== bodyBaseRef.current) || (olEditing && olDraft !== olBaseRef.current)) {
|
|
1724
|
+
if (!window.confirm(tt("common.dirtyExit"))) return;
|
|
1725
|
+
}
|
|
1726
|
+
onBack();
|
|
1727
|
+
}
|
|
1728
|
+
function saveBody(opts) {
|
|
1729
|
+
var auto = !!(opts && opts.auto); // 自动保存(2s 防抖调度):成功不退出编辑态、不清草稿
|
|
1510
1730
|
if (bodyBusy || !detail.data) return;
|
|
1511
1731
|
setBodyBusy(true); setBodyErr("");
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
//
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1732
|
+
var bodyToSave = draft; // 调用点快照:在途期间继续打字的文本不该被误标已保存
|
|
1733
|
+
// 失败经卡内 bodyErr 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走 toast。
|
|
1734
|
+
apiPost("chapter-save", { path: wsPath, file: file, body: bodyToSave, base_hash: detail.data.hash }, { silent: true }).then(function (res) {
|
|
1735
|
+
// 时序守卫:保存期间切了章——旧章结果不得覆写当前 detail(快照 reload 无害照做)。
|
|
1736
|
+
if (fileRef.current === file) {
|
|
1737
|
+
// 写后指纹即下一轮基线(连续保存不再被自家 409 拦住);本地 data 同步
|
|
1738
|
+
// 为新正文,退出编辑态立即看到刚落盘的内容(不等重拉,防闪回旧文)。
|
|
1739
|
+
setDetail({ loading: false, data: Object.assign({}, detail.data, {
|
|
1740
|
+
body: bodyToSave,
|
|
1741
|
+
hash: res && typeof res.hash === "string" ? res.hash : detail.data.hash,
|
|
1742
|
+
}), err: "" });
|
|
1743
|
+
bodyBaseRef.current = bodyToSave; // 写盘内容即新基线:连续自动保存不假脏
|
|
1744
|
+
setBodyConflict(false);
|
|
1745
|
+
if (auto) {
|
|
1746
|
+
// 自动保存:停留编辑态续写,时刻缀在 hint;手动保存维持原行为退出。
|
|
1747
|
+
setAutoSavedAt(new Date().toTimeString().slice(0, 8));
|
|
1748
|
+
} else {
|
|
1749
|
+
setEditing(false); setDraft(""); setAutoSavedAt("");
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1521
1752
|
return reload(); // 字数/统计随正文变,快照由 reload 刷新
|
|
1522
1753
|
}).catch(function (err) {
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1754
|
+
if (fileRef.current !== file) return; // 切章后的过期失败:不打扰当前章
|
|
1755
|
+
if (err && err.status === 409) {
|
|
1756
|
+
// 409 = 章节文件在进入编辑态后被外部改过:草稿绝不丢——保留编辑态
|
|
1757
|
+
// 与草稿原文,报冲突并亮两个出口(放弃草稿载入磁盘版 / 基于磁盘
|
|
1758
|
+
// 最新版重改),由用户选择,不再静默重拉覆盖草稿。
|
|
1759
|
+
setBodyConflict(true);
|
|
1760
|
+
setBodyErr(tf("edit.conflict409", { msg: err.message }));
|
|
1761
|
+
} else {
|
|
1762
|
+
setBodyErr(err.message); // 其他失败(网络抖动等)不动草稿,只报错。
|
|
1763
|
+
}
|
|
1528
1764
|
}).finally(function () { setBodyBusy(false); });
|
|
1529
1765
|
}
|
|
1766
|
+
// 正文自动保存调度(0.1.22):无 dep 数组,每轮渲染重排——任何一次渲染都先
|
|
1767
|
+
// 清掉上一个 2s 定时器再按最新 draft 重排,连续打字永远差 2s 才落盘;切章、
|
|
1768
|
+
// 409 冲突、保存在途不排。draft 回到基线(刚保存/刚进编辑)不动作;纯缩进
|
|
1769
|
+
// 占位(trim 为空)不落盘,且旧「已保存」时刻一并撤下。setAutoSavedAt("")
|
|
1770
|
+
// 传同值时 React 不重渲,不会循环。
|
|
1771
|
+
useEffect(function () {
|
|
1772
|
+
if (!editing || bodyConflict || bodyBusy) return;
|
|
1773
|
+
if (!detail || !detail.data) return;
|
|
1774
|
+
if (draft === bodyBaseRef.current || draft.trim() === "") {
|
|
1775
|
+
if (draft !== bodyBaseRef.current) setAutoSavedAt(""); // 纯缩进占位:旧时刻不亮
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
setAutoSavedAt(""); // 新改动入列,清掉旧的「已保存」标
|
|
1779
|
+
var t = setTimeout(function () { if (fileRef.current === file) saveBody({ auto: true }); }, 2000);
|
|
1780
|
+
return function () { clearTimeout(t); };
|
|
1781
|
+
});
|
|
1782
|
+
// 回车缩进的光标恢复:DOM 换新后把光标放到插入的「\n 」缩进之后,
|
|
1783
|
+
// 打字位置即视觉位置;无待恢复请求(普通打字)直接跳过。
|
|
1784
|
+
useEffect(function () {
|
|
1785
|
+
if (pendingCaretRef.current == null) return;
|
|
1786
|
+
var pos = pendingCaretRef.current; pendingCaretRef.current = null;
|
|
1787
|
+
var ta = areaRef.current;
|
|
1788
|
+
if (ta) { ta.selectionStart = ta.selectionEnd = pos; }
|
|
1789
|
+
}, [draft]);
|
|
1790
|
+
// 编辑器自动长高(0.1.22):排版对齐只读正文(同 UI 字体 13px/22px 行高),
|
|
1791
|
+
// 高度随内容长上去,至少 320px;非编辑态(textarea 已卸载)跳过。
|
|
1792
|
+
useEffect(function () {
|
|
1793
|
+
var ta = areaRef.current;
|
|
1794
|
+
if (!editing || !ta) return;
|
|
1795
|
+
ta.style.height = "auto";
|
|
1796
|
+
ta.style.height = Math.max(320, ta.scrollHeight + 2) + "px";
|
|
1797
|
+
}, [draft, editing]);
|
|
1798
|
+
// 409 出口①:放弃草稿——磁盘真值放回编辑器(bodyResetRef 一次性重置链路)。
|
|
1799
|
+
function dropBodyDraft() {
|
|
1800
|
+
if (bodyBusy) return;
|
|
1801
|
+
setBodyConflict(false); setBodyErr("");
|
|
1802
|
+
bodyResetRef.current = true;
|
|
1803
|
+
loadDetail(file);
|
|
1804
|
+
}
|
|
1805
|
+
// 409 出口②:基于磁盘最新版重改——只把 detail 换成磁盘最新(拿到新
|
|
1806
|
+
// base_hash),草稿原样留在编辑器,用户改完再存即用新基线。
|
|
1807
|
+
function rebaseBodyDraft() {
|
|
1808
|
+
if (bodyBusy) return;
|
|
1809
|
+
var seq = ++detailSeqRef.current;
|
|
1810
|
+
apiGet("chapter", { path: wsPath, file: file }).then(function (d) {
|
|
1811
|
+
if (seq !== detailSeqRef.current) return; // 过期响应:已切章,丢弃
|
|
1812
|
+
setDetail({ loading: false, data: d, err: "" });
|
|
1813
|
+
setBodyConflict(false); setBodyErr("");
|
|
1814
|
+
}).catch(function (err2) {
|
|
1815
|
+
if (seq !== detailSeqRef.current) return;
|
|
1816
|
+
setBodyErr(err2.message); // 重拉失败:冲突态保留,出口仍在可重试
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1530
1819
|
function startOutlineEdit() {
|
|
1531
1820
|
var ol = detail.data && detail.data.outline;
|
|
1532
1821
|
if (!ol || typeof ol.body !== "string") return;
|
|
@@ -1536,33 +1825,67 @@ window.__ModuleLoader__.load({
|
|
|
1536
1825
|
}
|
|
1537
1826
|
function cancelOutlineEdit() {
|
|
1538
1827
|
if (olBusy) return;
|
|
1539
|
-
|
|
1828
|
+
if (olDraft !== olBaseRef.current && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认
|
|
1829
|
+
setOlEditing(false); setOlDraft(""); setOlErr(""); setOlConflict(false);
|
|
1540
1830
|
}
|
|
1541
1831
|
function saveOutline() {
|
|
1542
1832
|
var ol = detail.data && detail.data.outline;
|
|
1543
1833
|
if (olBusy || !ol) return;
|
|
1544
1834
|
setOlBusy(true); setOlErr("");
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1835
|
+
// 失败经卡内 olErr 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走 toast。
|
|
1836
|
+
apiPost("outline-save", { path: wsPath, file: file, body: olDraft, base_hash: ol.hash }, { silent: true }).then(function (res) {
|
|
1837
|
+
if (fileRef.current === file) { // 时序守卫:同 saveBody
|
|
1838
|
+
setDetail({ loading: false, data: Object.assign({}, detail.data, {
|
|
1839
|
+
outline: Object.assign({}, ol, { body: olDraft, hash: res && typeof res.hash === "string" ? res.hash : ol.hash }),
|
|
1840
|
+
}), err: "" });
|
|
1841
|
+
setOlConflict(false);
|
|
1842
|
+
setOlEditing(false); setOlDraft("");
|
|
1843
|
+
}
|
|
1550
1844
|
// 章纲字数 chip 客户端不算 CJK——silent 重拉静默对齐,不闪骨架。
|
|
1551
|
-
return reload().then(function () {
|
|
1845
|
+
return reload().then(function () {
|
|
1846
|
+
if (fileRef.current === file) return loadDetail(file, { silent: true });
|
|
1847
|
+
});
|
|
1552
1848
|
}).catch(function (err) {
|
|
1553
|
-
|
|
1554
|
-
if (err && err.status === 409) {
|
|
1849
|
+
if (fileRef.current !== file) return; // 切章后的过期失败:不打扰当前章
|
|
1850
|
+
if (err && err.status === 409) {
|
|
1851
|
+
// 409 = 章纲文件被外部改过:草稿绝不丢,两个出口同 saveBody。
|
|
1852
|
+
setOlConflict(true);
|
|
1853
|
+
setOlErr(tf("edit.conflict409", { msg: err.message }));
|
|
1854
|
+
} else {
|
|
1855
|
+
setOlErr(err.message);
|
|
1856
|
+
}
|
|
1555
1857
|
}).finally(function () { setOlBusy(false); });
|
|
1556
1858
|
}
|
|
1859
|
+
// 409 出口①:放弃章纲草稿——磁盘真值放回编辑器(olResetRef 一次性重置链路)。
|
|
1860
|
+
function dropOutlineDraft() {
|
|
1861
|
+
if (olBusy) return;
|
|
1862
|
+
setOlConflict(false); setOlErr("");
|
|
1863
|
+
olResetRef.current = true;
|
|
1864
|
+
loadDetail(file);
|
|
1865
|
+
}
|
|
1866
|
+
// 409 出口②:基于磁盘最新版重改——只换 detail 拿新 base_hash,章纲草稿
|
|
1867
|
+
// 原样留在编辑器,改完再存即用新基线。
|
|
1868
|
+
function rebaseOutlineDraft() {
|
|
1869
|
+
if (olBusy) return;
|
|
1870
|
+
var seq = ++detailSeqRef.current;
|
|
1871
|
+
apiGet("chapter", { path: wsPath, file: file }).then(function (d) {
|
|
1872
|
+
if (seq !== detailSeqRef.current) return;
|
|
1873
|
+
setDetail({ loading: false, data: d, err: "" });
|
|
1874
|
+
setOlConflict(false); setOlErr("");
|
|
1875
|
+
}).catch(function (err2) {
|
|
1876
|
+
if (seq !== detailSeqRef.current) return;
|
|
1877
|
+
setOlErr(err2.message); // 重拉失败:冲突态保留,出口仍在可重试
|
|
1878
|
+
});
|
|
1879
|
+
}
|
|
1557
1880
|
// 章纲编辑钩子(传给 OutlinePanel;无章纲返回 null = 只读,不给编辑入口——
|
|
1558
|
-
// v1 不做面板新建章纲文件)。
|
|
1881
|
+
// v1 不做面板新建章纲文件)。conflict/drop/rebase:409 两出口透传。
|
|
1559
1882
|
function outlineEditor() {
|
|
1560
1883
|
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 };
|
|
1884
|
+
return { active: olEditing, draft: olDraft, busy: olBusy, err: olErr, conflict: olConflict, start: startOutlineEdit, change: setOlDraft, cancel: cancelOutlineEdit, save: saveOutline, drop: dropOutlineDraft, rebase: rebaseOutlineDraft };
|
|
1562
1885
|
}
|
|
1563
1886
|
return h("div", { className: "myt-reader" },
|
|
1564
1887
|
h("div", { className: "myt-readerBar" },
|
|
1565
|
-
h("button", { type: "button", className: "myt-btn", onClick:
|
|
1888
|
+
h("button", { type: "button", className: "myt-btn", onClick: leaveReader, title: tt("reader.backTip") },
|
|
1566
1889
|
ic("IconChevronRightOutlineRegular", 13), tt("reader.back")),
|
|
1567
1890
|
h("h2", { className: "myt-readerTitle" }, ch ? chFullTitle(ch) : file),
|
|
1568
1891
|
ch ? statusBadge(ch.status) : null,
|
|
@@ -1605,11 +1928,31 @@ window.__ModuleLoader__.load({
|
|
|
1605
1928
|
editing
|
|
1606
1929
|
? h("div", { key: "body", className: "myt-bodyEdit" },
|
|
1607
1930
|
h("textarea", {
|
|
1931
|
+
ref: areaRef,
|
|
1608
1932
|
className: "myt-chapterEditArea", rows: 20, spellCheck: false,
|
|
1609
1933
|
value: draft, onChange: function (e) { setDraft(e.target.value); },
|
|
1934
|
+
onKeyDown: function (e) {
|
|
1935
|
+
// 回车 = 换行 + 段首两个全角空格(网文段落缩进惯例);IME
|
|
1936
|
+
// 组态中的回车是选词确认,不得劫持。
|
|
1937
|
+
if (e.key !== "Enter") return;
|
|
1938
|
+
if (e.keyCode === 229) return; // IME 组态中
|
|
1939
|
+
if (e.nativeEvent && e.nativeEvent.isComposing) return; // 中文输入法回车选词
|
|
1940
|
+
var ta = e.target;
|
|
1941
|
+
var s = ta.selectionStart, en = ta.selectionEnd;
|
|
1942
|
+
if (typeof s !== "number" || typeof en !== "number") return; // 退化环境交给默认
|
|
1943
|
+
e.preventDefault();
|
|
1944
|
+
var next = ta.value.slice(0, s) + "\n " + ta.value.slice(en);
|
|
1945
|
+
pendingCaretRef.current = s + 3; // "\n" + 两个全角空格
|
|
1946
|
+
setDraft(next);
|
|
1947
|
+
},
|
|
1610
1948
|
}),
|
|
1611
|
-
h("div", { className: "myt-editHint" },
|
|
1949
|
+
h("div", { className: "myt-editHint" },
|
|
1950
|
+
tt("reader.bodyEditHint"),
|
|
1951
|
+
autoSavedAt ? " · " + tf("reader.autoSaved", { time: autoSavedAt }) : null),
|
|
1612
1952
|
bodyErr ? h(ErrorStrip, { text: bodyErr }) : null,
|
|
1953
|
+
bodyConflict ? h("div", { className: "myt-setActions" },
|
|
1954
|
+
h("button", { type: "button", className: "myt-btn", disabled: bodyBusy, onClick: dropBodyDraft }, tt("edit.conflictDrop")),
|
|
1955
|
+
h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: bodyBusy, onClick: rebaseBodyDraft }, tt("edit.conflictKeep"))) : null,
|
|
1613
1956
|
h("div", { className: "myt-setActions" },
|
|
1614
1957
|
h("button", { type: "button", className: "myt-btn", disabled: bodyBusy, onClick: cancelBodyEdit }, tt("novel.cancel")),
|
|
1615
1958
|
h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: bodyBusy, onClick: saveBody }, bodyBusy ? tt("novel.saving") : tt("reader.save"))))
|
|
@@ -1704,14 +2047,24 @@ window.__ModuleLoader__.load({
|
|
|
1704
2047
|
}, [snap, wsPath]);
|
|
1705
2048
|
var _b = useState(false), busy = _b[0], setBusy = _b[1];
|
|
1706
2049
|
var _e = useState(""), saveErr = _e[0], setSaveErr = _e[1];
|
|
2050
|
+
// 脏检查:表单与磁盘初值(进入编辑态时的 project 快照)逐键比对,任一
|
|
2051
|
+
// 不同即脏——取消退出确认与 Shell 切 tab 拦截共用。上报卸载即清零。
|
|
2052
|
+
var diskForm = settingsFormOf(project);
|
|
2053
|
+
var dirty = (function () {
|
|
2054
|
+
for (var k in diskForm) { if (String(form[k]) !== String(diskForm[k])) return true; }
|
|
2055
|
+
return false;
|
|
2056
|
+
})();
|
|
2057
|
+
useEffect(function () {
|
|
2058
|
+
DIRTY_EXIT.dirty = dirty;
|
|
2059
|
+
return function () { DIRTY_EXIT.dirty = false; };
|
|
2060
|
+
});
|
|
1707
2061
|
function set(k, v) { setForm(Object.assign({}, form, (function () { var o = {}; o[k] = v; return o; })())); }
|
|
1708
2062
|
function save() {
|
|
1709
2063
|
if (busy) return;
|
|
1710
2064
|
if (!String(form.title || "").trim()) { setSaveErr(tt("novel.titleRequired")); return; }
|
|
1711
2065
|
setBusy(true); setSaveErr("");
|
|
1712
|
-
//
|
|
1713
|
-
|
|
1714
|
-
apiPost("project-save", { path: wsPath, values: form, base_hash: baseHashRef.current }).then(function (res) {
|
|
2066
|
+
// 失败经卡内 saveErr 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走 toast。
|
|
2067
|
+
apiPost("project-save", { path: wsPath, values: form, base_hash: baseHashRef.current }, { silent: true }).then(function (res) {
|
|
1715
2068
|
// 写后指纹即下一轮基线:不等 reload 快照就先接住——连续两次保存时
|
|
1716
2069
|
// 第二次若还回传载入时的旧指纹,会被自家 409 拦住(假冲突)。
|
|
1717
2070
|
if (res && typeof res.project_hash === "string") baseHashRef.current = res.project_hash;
|
|
@@ -1746,6 +2099,7 @@ window.__ModuleLoader__.load({
|
|
|
1746
2099
|
}
|
|
1747
2100
|
function cancel() {
|
|
1748
2101
|
if (busy) return; // 保存在途不许退出:避免卸载后 setState
|
|
2102
|
+
if (dirty && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认
|
|
1749
2103
|
onDone();
|
|
1750
2104
|
}
|
|
1751
2105
|
return h("div", { className: "myt-stack" },
|
|
@@ -1819,6 +2173,9 @@ window.__ModuleLoader__.load({
|
|
|
1819
2173
|
}),
|
|
1820
2174
|
h("div", { className: "myt-editHint" }, tt("outline.editHint")),
|
|
1821
2175
|
ed.err ? h(ErrorStrip, { text: ed.err }) : null,
|
|
2176
|
+
ed.conflict ? h("div", { className: "myt-setActions" },
|
|
2177
|
+
h("button", { type: "button", className: "myt-btn", disabled: ed.busy, onClick: ed.drop }, tt("edit.conflictDrop")),
|
|
2178
|
+
h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: ed.busy, onClick: ed.rebase }, tt("edit.conflictKeep"))) : null,
|
|
1822
2179
|
h("div", { className: "myt-setActions" },
|
|
1823
2180
|
h("button", { type: "button", className: "myt-btn", disabled: ed.busy, onClick: ed.cancel }, tt("novel.cancel")),
|
|
1824
2181
|
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 +2262,10 @@ window.__ModuleLoader__.load({
|
|
|
1905
2262
|
}
|
|
1906
2263
|
// 编辑既有受管文件全文。打开即 GET file-source(请求序号防快速开关时的
|
|
1907
2264
|
// 过期响应,对照 ChapterReader);保存 POST file-save 带 base_hash 基线,
|
|
1908
|
-
// 409 =
|
|
1909
|
-
//
|
|
1910
|
-
//
|
|
2265
|
+
// 409 = 编辑期间文件被外部改过——草稿绝不丢:保留草稿报冲突,亮两个出口
|
|
2266
|
+
// (放弃草稿载入磁盘版 / 基于磁盘最新版重改),别让用户在过期基线上盲改
|
|
2267
|
+
// 也不许静默覆盖草稿。关闭统一走脏检查,未保存草稿先确认。成功后关弹层
|
|
2268
|
+
// 并触发调用方 reload(快照重算),成功提示走 apiPost 的 toast 通道。
|
|
1911
2269
|
function SourceEditorModal(props) {
|
|
1912
2270
|
var title = props.title;
|
|
1913
2271
|
var rel = props.rel;
|
|
@@ -1918,10 +2276,22 @@ window.__ModuleLoader__.load({
|
|
|
1918
2276
|
var _dr = useState(""), draft = _dr[0], setDraft = _dr[1];
|
|
1919
2277
|
var _b = useState(false), busy = _b[0], setBusy = _b[1];
|
|
1920
2278
|
var _e = useState(""), err = _e[0], setErr = _e[1];
|
|
2279
|
+
var _c = useState(false), conflict = _c[0], setConflict = _c[1];
|
|
1921
2280
|
var seqRef = useRef(0);
|
|
2281
|
+
// 脏检查基线 = detail.data.text:拉取/重基线都随 data 一致更新,草稿是否
|
|
2282
|
+
// 偏离磁盘原文直接可比。上报 DIRTY_EXIT 供 Shell 切 tab 拦截,卸载清零。
|
|
2283
|
+
var dirty = !!detail && !!detail.data && draft !== ((detail.data && detail.data.text) || "");
|
|
2284
|
+
useEffect(function () {
|
|
2285
|
+
DIRTY_EXIT.dirty = dirty;
|
|
2286
|
+
return function () { DIRTY_EXIT.dirty = false; };
|
|
2287
|
+
});
|
|
2288
|
+
function close() {
|
|
2289
|
+
if (dirty && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认
|
|
2290
|
+
onClose();
|
|
2291
|
+
}
|
|
1922
2292
|
var load = useCallback(function () {
|
|
1923
2293
|
var seq = ++seqRef.current;
|
|
1924
|
-
setDetail(null); setErr("");
|
|
2294
|
+
setDetail(null); setErr(""); setConflict(false);
|
|
1925
2295
|
apiGet("file-source", { path: wsPath, rel: rel }).then(function (d) {
|
|
1926
2296
|
if (seq !== seqRef.current) return;
|
|
1927
2297
|
setDetail({ data: d, err: "" });
|
|
@@ -1935,22 +2305,41 @@ window.__ModuleLoader__.load({
|
|
|
1935
2305
|
function save() {
|
|
1936
2306
|
if (busy || !detail || !detail.data) return;
|
|
1937
2307
|
setBusy(true); setErr("");
|
|
1938
|
-
|
|
2308
|
+
// 失败经卡内 err 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走 toast。
|
|
2309
|
+
apiPost("file-save", { path: wsPath, rel: rel, text: draft, base_hash: detail.data.hash }, { silent: true }).then(function () {
|
|
1939
2310
|
onClose();
|
|
1940
2311
|
if (onSaved) onSaved();
|
|
1941
2312
|
}).catch(function (e2) {
|
|
1942
|
-
if (e2 && e2.status === 409) {
|
|
1943
|
-
|
|
2313
|
+
if (e2 && e2.status === 409) {
|
|
2314
|
+
// 草稿绝不丢:报冲突并亮两个出口(见顶部注释),不再静默 load 覆盖。
|
|
2315
|
+
setConflict(true);
|
|
2316
|
+
setErr(tf("edit.conflict409", { msg: e2.message }));
|
|
2317
|
+
} else setErr(e2 && e2.message ? e2.message : tt("common.unknownErr"));
|
|
1944
2318
|
}).finally(function () { setBusy(false); });
|
|
1945
2319
|
}
|
|
2320
|
+
// 409 出口①:放弃草稿——重拉磁盘真值回填(load 原行为)。
|
|
2321
|
+
function dropDraft() { setConflict(false); setErr(""); load(); }
|
|
2322
|
+
// 409 出口②:基于磁盘最新版重改——只换 detail 拿新 base_hash,草稿原样
|
|
2323
|
+
// 留在编辑器,改完再存即用新基线。
|
|
2324
|
+
function rebaseDraft() {
|
|
2325
|
+
var seq = ++seqRef.current;
|
|
2326
|
+
apiGet("file-source", { path: wsPath, rel: rel }).then(function (d) {
|
|
2327
|
+
if (seq !== seqRef.current) return;
|
|
2328
|
+
setDetail({ data: d, err: "" });
|
|
2329
|
+
setConflict(false); setErr("");
|
|
2330
|
+
}).catch(function (e3) {
|
|
2331
|
+
if (seq !== seqRef.current) return;
|
|
2332
|
+
setErr(e3 && e3.message ? e3.message : tt("common.unknownErr")); // 冲突态保留可重试
|
|
2333
|
+
});
|
|
2334
|
+
}
|
|
1946
2335
|
return h(MaskPortal, {
|
|
1947
2336
|
className: "myt-modalMask",
|
|
1948
|
-
onMouseDown: function (e) { if (e.target === e.currentTarget && !busy)
|
|
2337
|
+
onMouseDown: function (e) { if (e.target === e.currentTarget && !busy) close(); },
|
|
1949
2338
|
},
|
|
1950
2339
|
h("div", { className: "myt-modalCard myt-srcCard", role: "dialog", "aria-modal": "true", "aria-label": tt("src.modalAria") },
|
|
1951
2340
|
h("div", { className: "myt-modalHead" },
|
|
1952
2341
|
h("h2", { className: "myt-modalTitle" }, title),
|
|
1953
|
-
h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("src.closeAria"), onClick:
|
|
2342
|
+
h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("src.closeAria"), onClick: close }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
|
|
1954
2343
|
h("div", { className: "myt-modalBody" },
|
|
1955
2344
|
h("div", { className: "myt-srcRel" }, rel),
|
|
1956
2345
|
h("div", { className: "myt-muted myt-srcHint" }, tt("src.editHint")),
|
|
@@ -1960,8 +2349,11 @@ window.__ModuleLoader__.load({
|
|
|
1960
2349
|
h("textarea", { key: "a", className: "myt-setInput myt-setArea myt-srcArea", rows: 16, spellCheck: false,
|
|
1961
2350
|
value: draft, onChange: function (e) { setDraft(e.target.value); } }),
|
|
1962
2351
|
err ? h(ErrorStrip, { key: "e", text: err }) : null,
|
|
2352
|
+
conflict ? h("div", { key: "c", className: "myt-setActions" },
|
|
2353
|
+
h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: dropDraft }, tt("edit.conflictDrop")),
|
|
2354
|
+
h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: rebaseDraft }, tt("edit.conflictKeep"))) : null,
|
|
1963
2355
|
h("div", { key: "s", className: "myt-setActions" },
|
|
1964
|
-
h("button", { type: "button", className: "myt-btn", disabled: busy, onClick:
|
|
2356
|
+
h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: close }, tt("novel.cancel")),
|
|
1965
2357
|
h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save }, busy ? tt("novel.saving") : tt("novel.save"))),
|
|
1966
2358
|
])));
|
|
1967
2359
|
}
|
|
@@ -1984,7 +2376,8 @@ window.__ModuleLoader__.load({
|
|
|
1984
2376
|
if (busy) return;
|
|
1985
2377
|
if (!slugOk) { setErr(tt("src.slugInvalid")); return; }
|
|
1986
2378
|
setBusy(true); setErr("");
|
|
1987
|
-
|
|
2379
|
+
// 失败经卡内 err 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走 toast。
|
|
2380
|
+
apiPost("file-create", { path: wsPath, rel: dir + "/" + slug + ".md", text: text }, { silent: true }).then(function () {
|
|
1988
2381
|
onClose();
|
|
1989
2382
|
if (onSaved) onSaved();
|
|
1990
2383
|
}).catch(function (e2) { setErr(e2 && e2.message ? e2.message : tt("common.unknownErr")); })
|
|
@@ -2257,7 +2650,7 @@ window.__ModuleLoader__.load({
|
|
|
2257
2650
|
h("div", { className: "myt-boardHead" },
|
|
2258
2651
|
h("span", { className: "myt-boardTitle" }, tt("foreshadow." + col)),
|
|
2259
2652
|
h("span", { className: "myt-boardCount" }, items.length)),
|
|
2260
|
-
items.map(function (fs) {
|
|
2653
|
+
items.map(function (fs, i) {
|
|
2261
2654
|
var next = FS_FLOW[col] || null;
|
|
2262
2655
|
var busy = busyId === fs.id;
|
|
2263
2656
|
// status 行缺失(服务端要求每条恰一处 status,写必 409):禁用流
|
|
@@ -2268,7 +2661,8 @@ window.__ModuleLoader__.load({
|
|
|
2268
2661
|
? tt("foreshadow.noIdTip")
|
|
2269
2662
|
: tt("foreshadow.noStatusTip");
|
|
2270
2663
|
var chRange = [fs.planted_chapter || tt("foreshadow.plantedUnmarked"), fs.expected_resolve].filter(Boolean).join(" → ");
|
|
2271
|
-
|
|
2664
|
+
// key 索引兜底:无 id 且标题重复时 id||title 相撞会丢行
|
|
2665
|
+
return h("div", { key: fs.id || (fs.title + ":" + i), className: "myt-fsCard" },
|
|
2272
2666
|
h("div", { className: "myt-fsTitle" },
|
|
2273
2667
|
priDot(fs.priority), h("span", null, orDash(fs.title) || orDash(fs.id)),
|
|
2274
2668
|
// 回填徽章(五期账本回填):区别于写前规划条目,悬浮见来源。
|
|
@@ -2329,8 +2723,8 @@ window.__ModuleLoader__.load({
|
|
|
2329
2723
|
// 遵守)。挂载/换工作区时 GET style 按需拉取(请求序号防过期响应,对照
|
|
2330
2724
|
// OverviewTab 的 check effect);视图态 MdView 渲染全文,缺失时空态卡用
|
|
2331
2725
|
// 插件模板预填创建;编辑态整文件保存(style-save 携 base_hash,409 人工
|
|
2332
|
-
//
|
|
2333
|
-
//
|
|
2726
|
+
// 改动保护链路:成功接写后新指纹防连续保存假冲突;409 草稿绝不丢——保留
|
|
2727
|
+
// 草稿报冲突,亮「载入磁盘版 / 基于最新版重改」两个出口,同 ChapterReader)。
|
|
2334
2728
|
function StyleTab(props) {
|
|
2335
2729
|
var wsPath = props.wsPath;
|
|
2336
2730
|
var _st = useState(null), st = _st[0], setSt = _st[1]; // {loading, data, err}
|
|
@@ -2341,6 +2735,8 @@ window.__ModuleLoader__.load({
|
|
|
2341
2735
|
var _dh = useState(null), draftHash = _dh[0], setDraftHash = _dh[1];
|
|
2342
2736
|
var _er = useState(""), editErr = _er[0], setEditErr = _er[1];
|
|
2343
2737
|
var _b = useState(false), busy = _b[0], setBusy = _b[1];
|
|
2738
|
+
// 409 冲突态:true 时编辑区亮出「载入磁盘版 / 基于最新版重改」两个出口。
|
|
2739
|
+
var _cf = useState(false), conflict = _cf[0], setConflict = _cf[1];
|
|
2344
2740
|
// 409 后的一次性重置标记:reload 带回磁盘真值时把编辑器与基线一并重置
|
|
2345
2741
|
// (只置位于保存冲突路径;顶栏刷新等其他拉取不碰正在编辑的内容)。
|
|
2346
2742
|
var pendingResetRef = useRef(false);
|
|
@@ -2353,8 +2749,8 @@ window.__ModuleLoader__.load({
|
|
|
2353
2749
|
if (seq !== seqRef.current) return; // 过期响应:已有更新的请求在途,丢弃
|
|
2354
2750
|
setSt({ loading: false, data: d, err: "" });
|
|
2355
2751
|
if (pendingResetRef.current) {
|
|
2356
|
-
// 409
|
|
2357
|
-
//
|
|
2752
|
+
// 409 冲突出口①「放弃草稿」的重拉:编辑器放回磁盘真值、基线对齐
|
|
2753
|
+
// 最新指纹,停留编辑态。
|
|
2358
2754
|
pendingResetRef.current = false;
|
|
2359
2755
|
setDraft(d && typeof d.text === "string" ? d.text : "");
|
|
2360
2756
|
setDraftHash(d && typeof d.hash === "string" ? d.hash : null);
|
|
@@ -2367,7 +2763,7 @@ window.__ModuleLoader__.load({
|
|
|
2367
2763
|
useEffect(function () {
|
|
2368
2764
|
if (wsRef.current !== wsPath) {
|
|
2369
2765
|
wsRef.current = wsPath;
|
|
2370
|
-
setDraft(null); setDraftHash(null); setEditErr(""); pendingResetRef.current = false;
|
|
2766
|
+
setDraft(null); setDraftHash(null); setEditErr(""); setConflict(false); pendingResetRef.current = false;
|
|
2371
2767
|
}
|
|
2372
2768
|
load();
|
|
2373
2769
|
}, [wsPath, load]);
|
|
@@ -2385,30 +2781,62 @@ window.__ModuleLoader__.load({
|
|
|
2385
2781
|
}
|
|
2386
2782
|
function cancelEdit() {
|
|
2387
2783
|
if (busy) return; // 保存在途不许退出:避免卸载后 setState
|
|
2388
|
-
|
|
2784
|
+
if (dirty && !window.confirm(tt("common.dirtyExit"))) return; // 脏退出先确认
|
|
2785
|
+
setDraft(null); setDraftHash(null); setEditErr(""); setConflict(false);
|
|
2389
2786
|
}
|
|
2390
2787
|
function save() {
|
|
2391
2788
|
if (busy) return;
|
|
2392
2789
|
setBusy(true); setEditErr("");
|
|
2393
|
-
//
|
|
2394
|
-
//
|
|
2395
|
-
apiPost("style-save", { path: wsPath, text: draft, base_hash: draftHash }).then(function (res) {
|
|
2790
|
+
// 失败经卡内 editErr 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走
|
|
2791
|
+
// toast 通道。空文本允许保存(空文件 = 无基准,agent 侧优雅降级)。
|
|
2792
|
+
apiPost("style-save", { path: wsPath, text: draft, base_hash: draftHash }, { silent: true }).then(function (res) {
|
|
2396
2793
|
// 写后指纹即下一轮基线(连续保存不再被自家 409 拦住);本地数据
|
|
2397
2794
|
// 同步为新文本,取消编辑退回视图态时看到的已是刚落盘的内容。
|
|
2398
2795
|
var hash = res && typeof res.hash === "string" ? res.hash : null;
|
|
2399
2796
|
setDraftHash(hash);
|
|
2797
|
+
setConflict(false);
|
|
2400
2798
|
setSt({ loading: false, data: { found: true, text: draft, hash: hash, template: st.data ? st.data.template : null }, err: "" });
|
|
2401
2799
|
}).catch(function (err) {
|
|
2402
|
-
setEditErr(err.message);
|
|
2403
|
-
// 409 = style.md 在进入编辑态后被外部改过:重拉磁盘真值并一次性
|
|
2404
|
-
// 把编辑器放回最新内容(停留编辑态),别让用户在过期基线上盲改。
|
|
2405
2800
|
if (err && err.status === 409) {
|
|
2406
|
-
|
|
2407
|
-
|
|
2801
|
+
// 409 = style.md 在进入编辑态后被外部改过:草稿绝不丢——报冲突并
|
|
2802
|
+
// 亮两个出口(放弃草稿载入磁盘版 / 基于最新版重改),不再静默重置。
|
|
2803
|
+
setConflict(true);
|
|
2804
|
+
setEditErr(tf("edit.conflict409", { msg: err.message }));
|
|
2805
|
+
} else {
|
|
2806
|
+
setEditErr(err.message);
|
|
2408
2807
|
}
|
|
2409
2808
|
}).finally(function () { setBusy(false); });
|
|
2410
2809
|
}
|
|
2810
|
+
// 409 出口①:放弃草稿——重拉磁盘真值放回编辑器(pendingResetRef 老链路)。
|
|
2811
|
+
function dropStyleDraft() {
|
|
2812
|
+
if (busy) return;
|
|
2813
|
+
setConflict(false); setEditErr("");
|
|
2814
|
+
pendingResetRef.current = true;
|
|
2815
|
+
load();
|
|
2816
|
+
}
|
|
2817
|
+
// 409 出口②:基于磁盘最新版重改——只换 data 拿新指纹(draftHash),草稿
|
|
2818
|
+
// 原样留在编辑器,改完再存即用新基线。
|
|
2819
|
+
function rebaseStyleDraft() {
|
|
2820
|
+
if (busy) return;
|
|
2821
|
+
var seq = ++seqRef.current;
|
|
2822
|
+
apiGet("style", { path: wsPath }).then(function (d) {
|
|
2823
|
+
if (seq !== seqRef.current) return;
|
|
2824
|
+
setSt({ loading: false, data: d, err: "" });
|
|
2825
|
+
setDraftHash(d && typeof d.hash === "string" ? d.hash : null);
|
|
2826
|
+
setConflict(false); setEditErr("");
|
|
2827
|
+
}).catch(function (err) {
|
|
2828
|
+
if (seq !== seqRef.current) return;
|
|
2829
|
+
setEditErr(err.message); // 重拉失败:冲突态保留可重试
|
|
2830
|
+
});
|
|
2831
|
+
}
|
|
2411
2832
|
var data = st ? st.data : null;
|
|
2833
|
+
// 脏检查:草稿偏离磁盘原文即脏(模板创建时磁盘原文按空算)。上报
|
|
2834
|
+
// DIRTY_EXIT 供切 tab 拦截——本 tab 切走即卸载,草稿会丢。
|
|
2835
|
+
var dirty = draft !== null && draft !== (data && typeof data.text === "string" ? data.text : "");
|
|
2836
|
+
useEffect(function () {
|
|
2837
|
+
DIRTY_EXIT.dirty = dirty;
|
|
2838
|
+
return function () { DIRTY_EXIT.dirty = false; };
|
|
2839
|
+
});
|
|
2412
2840
|
var head = h("div", { className: "myt-cardHead" },
|
|
2413
2841
|
h("h3", { className: "myt-cardTitle" }, tt("tabs.style")),
|
|
2414
2842
|
h("span", { className: "myt-muted" }, tt("style.note")),
|
|
@@ -2443,6 +2871,9 @@ window.__ModuleLoader__.load({
|
|
|
2443
2871
|
value: draft, onChange: function (e) { setDraft(e.target.value); },
|
|
2444
2872
|
})),
|
|
2445
2873
|
editErr ? h(ErrorStrip, { text: editErr }) : null,
|
|
2874
|
+
conflict ? h("div", { className: "myt-setActions" },
|
|
2875
|
+
h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: dropStyleDraft }, tt("edit.conflictDrop")),
|
|
2876
|
+
h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: rebaseStyleDraft }, tt("edit.conflictKeep"))) : null,
|
|
2446
2877
|
h("div", { className: "myt-setActions" },
|
|
2447
2878
|
h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: cancelEdit }, tt("novel.cancel")),
|
|
2448
2879
|
h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save },
|
|
@@ -2713,10 +3144,19 @@ window.__ModuleLoader__.load({
|
|
|
2713
3144
|
var ctx = canvas.getContext("2d");
|
|
2714
3145
|
if (!ctx) return undefined;
|
|
2715
3146
|
var dpr = (window.devicePixelRatio || 1) > 0 ? window.devicePixelRatio || 1 : 1;
|
|
2716
|
-
|
|
3147
|
+
// 收敛停机参数:能量(速度平方和)阈值 + 碰撞位移静阈 + 连续静止帧数
|
|
3148
|
+
// (~0.5s@60fps);STILL_CAP 是单次唤醒的帧数上限(~15s)兜底——拖拽
|
|
3149
|
+
// 节点压着邻居时,弹簧+碰撞+限速会形成持续亚像素抖动的极限环,永远
|
|
3150
|
+
// 到不了静止判据,必须有硬上限。面板关闭仅 CSS 隐藏、组件不卸载,rAF
|
|
3151
|
+
// 不停就后台永久燃 CPU。
|
|
3152
|
+
var ENERGY_TAU = 0.5;
|
|
3153
|
+
var MOVE_EPS = 0.5;
|
|
3154
|
+
var STILL_FRAMES = 30;
|
|
3155
|
+
var STILL_CAP = 900;
|
|
3156
|
+
var sim = { zoom: 1, drag: null, raf: 0, frame: 0, w: 0, h: 0, tokens: null, still: 0, frames: 0 };
|
|
2717
3157
|
// 缩放按钮桥:渲染层按钮 → effect 内 sim.zoom(钳制与滚轮同口径 0.4-2.5)
|
|
2718
3158
|
zoomApiRef.current = {
|
|
2719
|
-
zoomBy: function (k) { sim.zoom = Math.min(2.5, Math.max(0.4, sim.zoom * k)); },
|
|
3159
|
+
zoomBy: function (k) { sim.zoom = Math.min(2.5, Math.max(0.4, sim.zoom * k)); wake(); },
|
|
2720
3160
|
};
|
|
2721
3161
|
// 令牌缓存:每 30 帧重读 computed style,浅/深色切换约半秒内跟上。
|
|
2722
3162
|
function readTokens() {
|
|
@@ -2735,6 +3175,7 @@ window.__ModuleLoader__.load({
|
|
|
2735
3175
|
sim.h = wrap.clientHeight || 480;
|
|
2736
3176
|
canvas.width = Math.max(1, Math.round(sim.w * dpr));
|
|
2737
3177
|
canvas.height = Math.max(1, Math.round(sim.h * dpr));
|
|
3178
|
+
wake(); // 改 width 即清空位图:停机后也要立刻重画(含全屏进出触发的 RO)
|
|
2738
3179
|
}
|
|
2739
3180
|
// 初始摆在同心圆上,交给力导向收敛。
|
|
2740
3181
|
var R = Math.max(90, Math.min(sim.w, sim.h) / 3);
|
|
@@ -2824,6 +3265,8 @@ window.__ModuleLoader__.load({
|
|
|
2824
3265
|
}
|
|
2825
3266
|
function step() {
|
|
2826
3267
|
var nodes = graph.nodes, edges = graph.edges, n = nodes.length;
|
|
3268
|
+
var energy = 0; // 节点速度平方和:本帧系统总动能
|
|
3269
|
+
var movedPx = 0; // 硬碰撞分离的本帧位移量:纯位置修正能量看不见,按位移计静
|
|
2827
3270
|
for (var i = 0; i < n; i++) {
|
|
2828
3271
|
for (var j = i + 1; j < n; j++) {
|
|
2829
3272
|
var a = nodes[i], b = nodes[j];
|
|
@@ -2851,6 +3294,7 @@ window.__ModuleLoader__.load({
|
|
|
2851
3294
|
nd.vx *= 0.85; nd.vy *= 0.85;
|
|
2852
3295
|
nd.x += Math.max(-8, Math.min(8, nd.vx));
|
|
2853
3296
|
nd.y += Math.max(-8, Math.min(8, nd.vy));
|
|
3297
|
+
energy += nd.vx * nd.vx + nd.vy * nd.vy; // 固定/拖拽节点速度已清零不计
|
|
2854
3298
|
});
|
|
2855
3299
|
// 硬碰撞分离:力式斥力有上限(f 封顶 4),拥挤时节点仍会压叠;这里在
|
|
2856
3300
|
// 速度积分之后按最小间距 62(两节点半径 18×2 + 名字 label 纵向净空)
|
|
@@ -2873,12 +3317,14 @@ window.__ModuleLoader__.load({
|
|
|
2873
3317
|
var bFixed = nb.fixed || nb === (sim.drag && sim.drag.node);
|
|
2874
3318
|
if (aFixed && bFixed) continue;
|
|
2875
3319
|
var mA = aFixed ? 0 : (bFixed ? 1 : 0.5); // 固定方不动,另一方全担
|
|
3320
|
+
movedPx += (MIN_DIST - cd) * (mA + mB); // 有实际位移才计(双固定跳过不计)
|
|
2876
3321
|
var mB = bFixed ? 0 : (aFixed ? 1 : 0.5);
|
|
2877
3322
|
na.x -= cdx * push * mA; na.y -= cdy * push * mA;
|
|
2878
3323
|
nb.x += cdx * push * mB; nb.y += cdy * push * mB;
|
|
2879
3324
|
}
|
|
2880
3325
|
}
|
|
2881
3326
|
}
|
|
3327
|
+
return { energy: energy, movedPx: movedPx };
|
|
2882
3328
|
}
|
|
2883
3329
|
function draw() {
|
|
2884
3330
|
if (sim.frame % 30 === 0 || !sim.tokens) sim.tokens = readTokens();
|
|
@@ -2924,9 +3370,26 @@ window.__ModuleLoader__.load({
|
|
|
2924
3370
|
ctx.restore();
|
|
2925
3371
|
}
|
|
2926
3372
|
function loop() {
|
|
2927
|
-
step();
|
|
3373
|
+
var m = step();
|
|
2928
3374
|
draw();
|
|
2929
3375
|
sim.frame++;
|
|
3376
|
+
// 收敛停机:能量与碰撞位移都低于静阈连续 STILL_FRAMES 帧即真停 rAF
|
|
3377
|
+
// (raf 归零),不再每帧 O(n²) + 全量重绘空转;交互经 wake() 重启。
|
|
3378
|
+
// STILL_CAP 兜底:拖拽压邻等病态抖动循环到不了静止判据,也必须停。
|
|
3379
|
+
if (m.energy < ENERGY_TAU && m.movedPx < MOVE_EPS) {
|
|
3380
|
+
if (++sim.still >= STILL_FRAMES) { sim.raf = 0; return; }
|
|
3381
|
+
} else {
|
|
3382
|
+
sim.still = 0;
|
|
3383
|
+
}
|
|
3384
|
+
if (++sim.frames >= STILL_CAP) { sim.raf = 0; return; }
|
|
3385
|
+
sim.raf = requestAnimationFrame(loop);
|
|
3386
|
+
}
|
|
3387
|
+
// 唤醒:静止停机后任何会改变画面/物理的交互都要重启循环(幂等,已跑不
|
|
3388
|
+
// 叠加),并重新给满静止计数与帧数预算。
|
|
3389
|
+
function wake() {
|
|
3390
|
+
if (sim.raf) return;
|
|
3391
|
+
sim.still = 0;
|
|
3392
|
+
sim.frames = 0;
|
|
2930
3393
|
sim.raf = requestAnimationFrame(loop);
|
|
2931
3394
|
}
|
|
2932
3395
|
resize();
|
|
@@ -2944,6 +3407,7 @@ window.__ModuleLoader__.load({
|
|
|
2944
3407
|
sim.drag = { node: nd };
|
|
2945
3408
|
nd.fixed = true; // 拖拽固定
|
|
2946
3409
|
canvas.dataset.dragging = "true";
|
|
3410
|
+
wake(); // 拖拽移动物理系统,停机态先唤醒
|
|
2947
3411
|
e.preventDefault();
|
|
2948
3412
|
}
|
|
2949
3413
|
}
|
|
@@ -2972,11 +3436,13 @@ window.__ModuleLoader__.load({
|
|
|
2972
3436
|
e.preventDefault();
|
|
2973
3437
|
var next = sim.zoom * Math.exp(-e.deltaY * 0.0012);
|
|
2974
3438
|
sim.zoom = Math.min(2.5, Math.max(0.4, next));
|
|
3439
|
+
wake(); // 缩放变了画面:停机态也要重绘
|
|
2975
3440
|
}
|
|
2976
3441
|
function onDbl() {
|
|
2977
3442
|
sim.zoom = 1; // 复位:缩放回 1、释放全部拖拽固定
|
|
2978
3443
|
graph.nodes.forEach(function (nd) { nd.fixed = false; });
|
|
2979
3444
|
setTip(null);
|
|
3445
|
+
wake(); // 释放固定后力导向要重新收敛
|
|
2980
3446
|
}
|
|
2981
3447
|
canvas.addEventListener("mousedown", onDown);
|
|
2982
3448
|
window.addEventListener("mousemove", onMove);
|