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