dsh-maoying 0.1.10 → 0.1.12
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.md +15 -2
- package/bin/maoying.mjs +95 -50
- package/docs/creator-panel.md +31 -7
- package/docs/design.md +10 -7
- package/docs/spec.md +7 -4
- package/index.mjs +250 -88
- package/lib/base.css +174 -25
- package/lib/client.js +404 -120
- package/lib/epub.mjs +37 -6
- package/lib/novel-parse.mjs +171 -46
- package/lib/panel-core.mjs +116 -10
- package/lib/version.mjs +340 -0
- package/lib/yaml-loader.mjs +10 -2
- package/package.json +6 -2
- package/presets/maoying/agent.cordis.v2.yml +6 -1
- package/presets/maoying/agent.cordis.yml +5 -2
- package/presets/maoying/restrict.mjs +5 -6
- package/skills/maoying-review-checklist/SKILL.md +1 -1
- package/templates/novel/README.md +3 -3
- package/templates/novel/foreshadows.yml +1 -0
- package/templates/novel/timeline.yml +1 -1
package/lib/client.js
CHANGED
|
@@ -32,7 +32,7 @@ window.__ModuleLoader__.load({
|
|
|
32
32
|
|
|
33
33
|
var React = require("react");
|
|
34
34
|
var h = React.createElement;
|
|
35
|
-
var useState = React.useState, useEffect = React.useEffect, useCallback = React.useCallback, useRef = React.useRef;
|
|
35
|
+
var useState = React.useState, useEffect = React.useEffect, useCallback = React.useCallback, useRef = React.useRef, useMemo = React.useMemo;
|
|
36
36
|
// 复制一份导出面再补属性:不往共享模块对象上写东西(避免污染宿主里
|
|
37
37
|
// 其他 ui-primitives 消费者);后续片段看到的就是这份副本。
|
|
38
38
|
var P = Object.assign({}, require("@deepseek-ai/dsh-client-ui-primitives"));
|
|
@@ -67,7 +67,7 @@ window.__ModuleLoader__.load({
|
|
|
67
67
|
var TABS = [
|
|
68
68
|
["overview", "概览"], ["chapters", "章节"], ["characters", "角色"],
|
|
69
69
|
["foreshadows", "伏笔"], ["relations", "关系图"], ["timeline", "时间线"],
|
|
70
|
-
["world", "设定"], ["index", "索引板"],
|
|
70
|
+
["world", "设定"], ["index", "索引板"], ["settings", "小说设置"],
|
|
71
71
|
];
|
|
72
72
|
|
|
73
73
|
// 角色徽章四色(role 是自由文本):按关键词归 tone,命中即返。
|
|
@@ -478,6 +478,38 @@ window.__ModuleLoader__.load({
|
|
|
478
478
|
}
|
|
479
479
|
function Empty(props) { return h("div", { className: "myt-empty" }, props.text || "暂无数据"); }
|
|
480
480
|
|
|
481
|
+
// tab 内容错误边界:任何 tab / 阅读页渲染抛错都降级为卡内错误态(错误摘要
|
|
482
|
+
// + 重试按钮),不再把整个面板炸成白屏。错误边界只有 class 组件能做
|
|
483
|
+
// (getDerivedStateFromError / componentDidCatch),此处破例用 class。
|
|
484
|
+
class TabErrorBoundary extends React.Component {
|
|
485
|
+
constructor(props) {
|
|
486
|
+
super(props);
|
|
487
|
+
this.state = { err: null };
|
|
488
|
+
this.retry = this.retry.bind(this);
|
|
489
|
+
}
|
|
490
|
+
static getDerivedStateFromError(err) {
|
|
491
|
+
return { err: err }; // 渲染抛错 → 下一次 render 走降级分支
|
|
492
|
+
}
|
|
493
|
+
componentDidCatch(err) {
|
|
494
|
+
// 只记控制台:卡内已有降级 UI,toast 通道留给请求错误,不重复打扰。
|
|
495
|
+
if (typeof console !== "undefined" && console.warn) console.warn("maoying: 分区渲染出错:", err);
|
|
496
|
+
}
|
|
497
|
+
retry() {
|
|
498
|
+
this.setState({ err: null });
|
|
499
|
+
if (typeof this.props.onRetry === "function") this.props.onRetry(); // 顺手重拉快照,坏数据一并刷掉
|
|
500
|
+
}
|
|
501
|
+
render() {
|
|
502
|
+
var err = this.state.err;
|
|
503
|
+
if (!err) return this.props.children;
|
|
504
|
+
var msg = String((err && err.message) || err || "未知错误");
|
|
505
|
+
return h("div", { className: "myt-card" },
|
|
506
|
+
h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, "该分区渲染出错")),
|
|
507
|
+
h("div", { className: "myt-muted", style: { overflowWrap: "anywhere" } }, "错误摘要:" + truncate(msg, 300)),
|
|
508
|
+
h("div", { className: "myt-heroMeta" },
|
|
509
|
+
h("button", { type: "button", className: "myt-btn", onClick: this.retry }, "重试")));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
481
513
|
// ── 页面骨架:顶栏(工作区下拉 + 刷新 + 关闭)+ tab 条 + 内容区 ─────────
|
|
482
514
|
// 数据流:workspaces 一次拉取;state 跟随选中工作区;写操作 POST 成功后
|
|
483
515
|
// 整份重拉 state(不乐观更新)。选择记 localStorage('maoying.workspace')。
|
|
@@ -490,7 +522,17 @@ window.__ModuleLoader__.load({
|
|
|
490
522
|
var _ld = useState(true), loading = _ld[0], setLoading = _ld[1];
|
|
491
523
|
var _se = useState(""), stateErr = _se[0], setStateErr = _se[1];
|
|
492
524
|
var _tb = useState("overview"), tab = _tb[0], setTab = _tb[1];
|
|
493
|
-
|
|
525
|
+
// 章节阅读页(整页视图,替代原右侧抽屉):null = 列表态。
|
|
526
|
+
var _rd = useState(null), readerFile = _rd[0], setReaderFile = _rd[1];
|
|
527
|
+
// 系统设置:字号档位持久化 + 面板整体缩放(portal 节点读同一 CSS 变量)。
|
|
528
|
+
var _sm = useState(false), showSettings = _sm[0], setShowSettings = _sm[1];
|
|
529
|
+
var _fs = useState(function () { return lsGet(FONT_SCALE_KEY) || "1"; }), fontScale = _fs[0], setFontScale = _fs[1];
|
|
530
|
+
useEffect(function () {
|
|
531
|
+
if (typeof document !== "undefined" && document.documentElement) {
|
|
532
|
+
document.documentElement.style.setProperty("--myt-zoom", fontScale);
|
|
533
|
+
}
|
|
534
|
+
lsSet(FONT_SCALE_KEY, fontScale);
|
|
535
|
+
}, [fontScale]);
|
|
494
536
|
|
|
495
537
|
// 选中校正:记忆的工作区必须仍在列表且 hasNovel,否则落回首个 hasNovel。
|
|
496
538
|
var effectiveWs = (function () {
|
|
@@ -523,21 +565,35 @@ window.__ModuleLoader__.load({
|
|
|
523
565
|
setWorkspaces([]);
|
|
524
566
|
});
|
|
525
567
|
}, []);
|
|
568
|
+
var stateSeqRef = useRef(0); // state 请求序号:快速切工作区时丢弃过期响应
|
|
526
569
|
var loadState = useCallback(function (root) {
|
|
570
|
+
var seq = ++stateSeqRef.current; // 先自增再发请求;早退分支也作废在途旧响应
|
|
527
571
|
if (!root) { setSnap(null); setStateErr(""); setLoading(false); return; }
|
|
528
572
|
setLoading(true);
|
|
529
573
|
setStateErr("");
|
|
530
574
|
return apiGet("state", { path: root }).then(function (d) {
|
|
575
|
+
if (seq !== stateSeqRef.current) return; // 过期响应:已有更新的请求在途,丢弃
|
|
531
576
|
setSnap(d && d.ok ? d : null);
|
|
532
577
|
}).catch(function (err) {
|
|
578
|
+
if (seq !== stateSeqRef.current) return;
|
|
533
579
|
setSnap(null);
|
|
534
580
|
setStateErr(err.message);
|
|
535
|
-
}).finally(function () {
|
|
581
|
+
}).finally(function () {
|
|
582
|
+
if (seq === stateSeqRef.current) setLoading(false); // 过期收尾不碰加载态
|
|
583
|
+
});
|
|
536
584
|
}, []);
|
|
537
585
|
var reload = useCallback(function () {
|
|
538
586
|
return effectiveWs ? loadState(effectiveWs) : Promise.resolve();
|
|
539
587
|
}, [effectiveWs, loadState]);
|
|
540
588
|
|
|
589
|
+
// 新版检测(模仿 dsh-paoding):面板打开时拉一次(服务端有 1h 缓存),
|
|
590
|
+
// updateAvailable 才渲染徽章;失败静默,不打扰任何主流程。
|
|
591
|
+
var _ver = useState(null), ver = _ver[0], setVer = _ver[1];
|
|
592
|
+
useEffect(function () {
|
|
593
|
+
apiGet("update-check").then(function (d) {
|
|
594
|
+
if (d && d.current) setVer(d); // 常态也显示当前版本号;有新版再亮徽章
|
|
595
|
+
}).catch(function () {});
|
|
596
|
+
}, []);
|
|
541
597
|
useEffect(function () { loadWorkspaces(); }, [loadWorkspaces]);
|
|
542
598
|
useEffect(function () { loadState(effectiveWs); }, [effectiveWs, loadState]);
|
|
543
599
|
// 校正后的选择回写记忆(首次打开 / 记忆失效时自动落位)。
|
|
@@ -552,7 +608,7 @@ window.__ModuleLoader__.load({
|
|
|
552
608
|
}
|
|
553
609
|
function gotoTab(next, chapterFile) {
|
|
554
610
|
setTab(next);
|
|
555
|
-
|
|
611
|
+
setReaderFile(next === "chapters" && chapterFile ? chapterFile : null);
|
|
556
612
|
}
|
|
557
613
|
|
|
558
614
|
var novelWorkspaces = (workspaces || []).filter(function (w) { return w.hasNovel; });
|
|
@@ -578,20 +634,27 @@ window.__ModuleLoader__.load({
|
|
|
578
634
|
});
|
|
579
635
|
} else if (snap) {
|
|
580
636
|
var tabProps = { snap: snap, reload: reload, wsPath: effectiveWs };
|
|
581
|
-
if (
|
|
637
|
+
if (readerFile) body = h(ChapterReader, { file: readerFile, snap: snap, reload: reload, wsPath: effectiveWs, onBack: function () { setReaderFile(null); } });
|
|
638
|
+
else if (tab === "chapters") body = h(ChaptersTab, Object.assign({ onOpenChapter: function (f) { setReaderFile(f); } }, tabProps));
|
|
582
639
|
else if (tab === "characters") body = h(CharactersTab, tabProps);
|
|
583
640
|
else if (tab === "foreshadows") body = h(ForeshadowsTab, tabProps);
|
|
584
641
|
else if (tab === "relations") body = h(RelationsTab, tabProps);
|
|
585
642
|
else if (tab === "timeline") body = h(TimelineTab, tabProps);
|
|
586
643
|
else if (tab === "world") body = h(WorldTab, tabProps);
|
|
587
644
|
else if (tab === "index") body = h(IndexTab, tabProps);
|
|
645
|
+
else if (tab === "settings") body = h(SettingsTab, tabProps);
|
|
588
646
|
else body = h(OverviewTab, Object.assign({ gotoTab: gotoTab }, tabProps));
|
|
589
647
|
}
|
|
590
648
|
|
|
591
649
|
return h("div", { className: "myt-shell" },
|
|
592
650
|
h("header", { className: "myt-header" },
|
|
593
651
|
h("div", null,
|
|
594
|
-
h("h1", { className: "myt-title" }, "毛颖创作台"
|
|
652
|
+
h("h1", { className: "myt-title" }, "毛颖创作台",
|
|
653
|
+
ver && ver.current ? h("span", { className: "myt-verTag", title: "当前版本" }, ver.current) : null,
|
|
654
|
+
ver && ver.updateAvailable
|
|
655
|
+
? h("a", { className: "myt-verBadge", href: ver.releaseUrl || "#", target: "_blank", rel: "noreferrer", title: "当前 " + ver.current + ",点击打开新版发布页" },
|
|
656
|
+
"有新版 " + ver.latest + " ↗")
|
|
657
|
+
: null),
|
|
595
658
|
h("p", { className: "myt-subtitle" }, "网文项目可视化与状态流转 · 只读正文,写状态")),
|
|
596
659
|
h("div", { className: "myt-headActions" },
|
|
597
660
|
workspaces && novelWorkspaces.length
|
|
@@ -602,9 +665,10 @@ window.__ModuleLoader__.load({
|
|
|
602
665
|
(w.hasNovel ? "" : "(无 novel)") + (w.name || w.path || "未命名"));
|
|
603
666
|
}))]
|
|
604
667
|
: null,
|
|
668
|
+
h("button", { type: "button", className: "myt-btn myt-btnIcon", title: "系统设置", "aria-label": "系统设置", onClick: function () { setShowSettings(true); } }, ic("IconSettingsOutlineRegular", 14)),
|
|
605
669
|
h("button", { type: "button", className: "myt-btn myt-btnIcon", title: "重新拉取", "aria-label": "重新拉取", disabled: loading, onClick: reload }, ic("IconRefreshOutlineRegular", 14)),
|
|
606
670
|
h("button", { type: "button", className: "myt-closeBtn", "aria-label": "关闭创作台", title: "关闭", onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")))),
|
|
607
|
-
h("nav", { className: "myt-tabs", "aria-label": "创作台分区" },
|
|
671
|
+
readerFile ? null : h("nav", { className: "myt-tabs", "aria-label": "创作台分区" },
|
|
608
672
|
TABS.map(function (t) {
|
|
609
673
|
return h("button", {
|
|
610
674
|
key: t[0], type: "button", className: "myt-tab", "data-active": tab === t[0] ? "true" : undefined,
|
|
@@ -614,7 +678,9 @@ window.__ModuleLoader__.load({
|
|
|
614
678
|
h("div", { className: "myt-body" },
|
|
615
679
|
h("div", { className: "myt-page" },
|
|
616
680
|
snap && Array.isArray(snap.warnings) && snap.warnings.length ? WarningsStrip(snap.warnings) : null,
|
|
617
|
-
|
|
681
|
+
// 错误边界包住 tab 内容:key 随 tab / 阅读页变化重挂,切分区自动清错误态。
|
|
682
|
+
h(TabErrorBoundary, { key: readerFile ? "reader:" + readerFile : tab, onRetry: reload }, body))),
|
|
683
|
+
showSettings ? h(SettingsModal, { scale: fontScale, onPick: setFontScale, onClose: function () { setShowSettings(false); } }) : null,
|
|
618
684
|
h(ToastHost));
|
|
619
685
|
}
|
|
620
686
|
|
|
@@ -687,6 +753,287 @@ window.__ModuleLoader__.load({
|
|
|
687
753
|
: h(Empty, { text: "chapters/ 目录还没有章节文件" })));
|
|
688
754
|
}
|
|
689
755
|
|
|
756
|
+
// ── 轻量 Markdown 渲染(零依赖):先整体 HTML 转义再施加 md 变换,杜绝
|
|
757
|
+
// 注入;支持标题/加粗/斜体/删除线/行内码/围栏码/引用/有序无序列表/分隔线/
|
|
758
|
+
// 链接/段落换行。供章纲、世界观条目等 md 阅读位使用,正文手稿仍走 pre。 ──
|
|
759
|
+
function escapeHtml(s) {
|
|
760
|
+
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
761
|
+
}
|
|
762
|
+
// 链接 scheme 白名单:仅放行 http/https 绝对地址与无 scheme 的相对路径 /
|
|
763
|
+
// 锚点 / //协议相对;javascript:/data:/vbscript: 等一律拒绝(调用方落纯文本)。
|
|
764
|
+
function isSafeLinkUrl(url) {
|
|
765
|
+
var m = String(url || "").match(/^([a-zA-Z][a-zA-Z0-9+.\-]*):/);
|
|
766
|
+
return !m || /^https?$/i.test(m[1]);
|
|
767
|
+
}
|
|
768
|
+
function inlineMd(s) {
|
|
769
|
+
// 行内码占位保护:先抽出 `code` 区间换成哨兵占位符,加粗/斜体/删除线/
|
|
770
|
+
// 链接不再命中码内文本(否则 `*x*` 会被二次加格式),最后回填。
|
|
771
|
+
// 哨兵用 NUL 控制符 + 固定前缀:正文已过 escapeHtml,不可能出现该序列。
|
|
772
|
+
var codes = [];
|
|
773
|
+
var out = String(s).replace(/`([^`]+)`/g, function (_m, code) {
|
|
774
|
+
codes.push("<code>" + code + "</code>");
|
|
775
|
+
return "\u0000MYTCODE" + (codes.length - 1) + "\u0000";
|
|
776
|
+
})
|
|
777
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
778
|
+
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
|
|
779
|
+
.replace(/\*([^*\n]+)\*/g, "<em>$1</em>")
|
|
780
|
+
.replace(/~~([^~]+)~~/g, "<del>$1</del>")
|
|
781
|
+
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, function (_m, txt, url) {
|
|
782
|
+
if (!isSafeLinkUrl(url)) return _m; // 白名单外:原样落纯文本,不生成 href
|
|
783
|
+
return '<a href="' + url + '" target="_blank" rel="noreferrer">' + txt + "</a>";
|
|
784
|
+
});
|
|
785
|
+
return out.replace(/\u0000MYTCODE(\d+)\u0000/g, function (_m, i) { return codes[Number(i)] || ""; });
|
|
786
|
+
}
|
|
787
|
+
function renderMd(src) {
|
|
788
|
+
var lines = escapeHtml(src).split(/\r?\n/);
|
|
789
|
+
var html = [];
|
|
790
|
+
var i = 0;
|
|
791
|
+
while (i < lines.length) {
|
|
792
|
+
var line = lines[i];
|
|
793
|
+
if (/^```/.test(line)) {
|
|
794
|
+
var code = [];
|
|
795
|
+
i++;
|
|
796
|
+
while (i < lines.length && !/^```/.test(lines[i])) { code.push(lines[i]); i++; }
|
|
797
|
+
i++; // 吃掉收尾 ```(缺失也吞到尾)
|
|
798
|
+
html.push('<pre class="myt-mdCode"><code>' + code.join("\n") + "</code></pre>");
|
|
799
|
+
continue;
|
|
800
|
+
}
|
|
801
|
+
if (/^\s*$/.test(line)) { i++; continue; }
|
|
802
|
+
var h2 = line.match(/^(#{1,6})\s+(.*)$/);
|
|
803
|
+
if (h2) {
|
|
804
|
+
var lv = h2[1].length;
|
|
805
|
+
html.push('<h' + lv + ' class="myt-mdH">' + inlineMd(h2[2]) + "</h" + lv + ">");
|
|
806
|
+
i++; continue;
|
|
807
|
+
}
|
|
808
|
+
if (/^ {0,3}(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { html.push('<hr class="myt-mdHr">'); i++; continue; }
|
|
809
|
+
if (/^\s*>\s?/.test(line)) {
|
|
810
|
+
var quote = [];
|
|
811
|
+
while (i < lines.length && /^\s*>\s?/.test(lines[i])) { quote.push(inlineMd(lines[i].replace(/^\s*>\s?/, ""))); i++; }
|
|
812
|
+
html.push('<blockquote class="myt-mdQuote">' + quote.join("<br>") + "</blockquote>");
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
var ul = line.match(/^\s*[-*+]\s+(.*)$/);
|
|
816
|
+
if (ul) {
|
|
817
|
+
var items = [];
|
|
818
|
+
while (i < lines.length) {
|
|
819
|
+
var m1 = lines[i].match(/^\s*[-*+]\s+(.*)$/);
|
|
820
|
+
if (!m1) break;
|
|
821
|
+
items.push("<li>" + inlineMd(m1[1]) + "</li>");
|
|
822
|
+
i++;
|
|
823
|
+
}
|
|
824
|
+
html.push('<ul class="myt-mdList">' + items.join("") + "</ul>");
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
var ol = line.match(/^\s*\d+[.)]\s+(.*)$/);
|
|
828
|
+
if (ol) {
|
|
829
|
+
var oitems = [];
|
|
830
|
+
while (i < lines.length) {
|
|
831
|
+
var m2 = lines[i].match(/^\s*\d+[.)]\s+(.*)$/);
|
|
832
|
+
if (!m2) break;
|
|
833
|
+
oitems.push("<li>" + inlineMd(m2[1]) + "</li>");
|
|
834
|
+
i++;
|
|
835
|
+
}
|
|
836
|
+
html.push('<ol class="myt-mdList">' + oitems.join("") + "</ol>");
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
var para = [line];
|
|
840
|
+
i++;
|
|
841
|
+
while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^```|^\s*>|^\s*[-*+]\s|^\s*\d+[.)]\s|^#{1,6}\s/.test(lines[i])) {
|
|
842
|
+
para.push(lines[i]); i++;
|
|
843
|
+
}
|
|
844
|
+
html.push('<p class="myt-mdP">' + inlineMd(para.join("<br>")) + "</p>");
|
|
845
|
+
}
|
|
846
|
+
// 块间直接相连不补 "\n":落点容器(.myt-outlineBody / .myt-worldDesc)是
|
|
847
|
+
// white-space:pre-wrap,块间再塞换行会渲染成额外空行 → 双倍间距;
|
|
848
|
+
// 块间距已由 .myt-md* 各自 margin 承担。
|
|
849
|
+
return html.join("");
|
|
850
|
+
}
|
|
851
|
+
function MdView(props) {
|
|
852
|
+
return h("div", {
|
|
853
|
+
className: "myt-md" + (props.className ? " " + props.className : ""),
|
|
854
|
+
dangerouslySetInnerHTML: { __html: renderMd(props.text || "") },
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ── 系统设置:字号五档(面板整体 zoom,抽屉/弹层 portal 节点同缩放)──────
|
|
859
|
+
var FONT_SCALE_KEY = "maoying.fontScale";
|
|
860
|
+
var FONT_SCALES = [["1", "默认(100%)"], ["1.2", "大(120%)"], ["1.5", "更大(150%)"], ["1.8", "超大(180%)"], ["2", "巨大(200%)"]];
|
|
861
|
+
function SettingsModal(props) {
|
|
862
|
+
var scale = props.scale;
|
|
863
|
+
var onPick = props.onPick;
|
|
864
|
+
var onClose = props.onClose;
|
|
865
|
+
return h(MaskPortal, {
|
|
866
|
+
className: "myt-settingsMask",
|
|
867
|
+
onMouseDown: function (e) { if (e.target === e.currentTarget) onClose(); },
|
|
868
|
+
},
|
|
869
|
+
h("div", { className: "myt-settings", role: "dialog", "aria-modal": "true", "aria-label": "系统设置" },
|
|
870
|
+
h("div", { className: "myt-settingsHead" },
|
|
871
|
+
h("h3", { className: "myt-settingsTitle" }, "系统设置"),
|
|
872
|
+
h("button", { type: "button", className: "myt-closeBtn", "aria-label": "关闭设置", onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
|
|
873
|
+
h("div", { className: "myt-settingsBody" },
|
|
874
|
+
h("div", { className: "myt-settingsLabel" }, "字号"),
|
|
875
|
+
h("div", { className: "myt-setRow" }, FONT_SCALES.map(function (o) {
|
|
876
|
+
return h("button", {
|
|
877
|
+
key: o[0], type: "button", className: "myt-setOpt", "data-active": scale === o[0] ? "true" : undefined,
|
|
878
|
+
onClick: function () { onPick(o[0]); },
|
|
879
|
+
}, o[1]);
|
|
880
|
+
})),
|
|
881
|
+
h("div", { className: "myt-settingsHint" }, "按当前字号的百分比缩放整个创作台,立即生效并记忆。"))));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ── 章节阅读页(整页视图,替代右侧抽屉):返回按钮 + 章纲对照 + 正文 +
|
|
885
|
+
// 五维 + 状态流转。原抽屉是 position:fixed 侧弹层,在桌面端会被侧边栏
|
|
886
|
+
// 遮挡;整页视图天然在面板文档流内,无遮挡问题。 ──
|
|
887
|
+
function ChapterReader(props) {
|
|
888
|
+
var file = props.file;
|
|
889
|
+
var wsPath = props.wsPath;
|
|
890
|
+
var snap = props.snap;
|
|
891
|
+
var reload = props.reload;
|
|
892
|
+
var onBack = props.onBack;
|
|
893
|
+
var _d = useState(null), detail = _d[0], setDetail = _d[1]; // {loading, data, err}
|
|
894
|
+
var _b = useState(false), flowBusy = _b[0], setFlowBusy = _b[1];
|
|
895
|
+
var detailSeqRef = useRef(0); // chapter 请求序号:快速切章时丢弃过期响应
|
|
896
|
+
var loadDetail = useCallback(function (f) {
|
|
897
|
+
var seq = ++detailSeqRef.current;
|
|
898
|
+
if (!f) { setDetail(null); return; }
|
|
899
|
+
setDetail({ loading: true, data: null, err: "" });
|
|
900
|
+
apiGet("chapter", { path: wsPath, file: f }).then(function (d) {
|
|
901
|
+
if (seq !== detailSeqRef.current) return; // 过期响应:已在看另一章,丢弃
|
|
902
|
+
setDetail({ loading: false, data: d, err: "" });
|
|
903
|
+
}).catch(function (err) {
|
|
904
|
+
if (seq !== detailSeqRef.current) return;
|
|
905
|
+
setDetail({ loading: false, data: null, err: err.message });
|
|
906
|
+
});
|
|
907
|
+
}, [wsPath]);
|
|
908
|
+
useEffect(function () { loadDetail(file); }, [file, loadDetail]);
|
|
909
|
+
var ch = null;
|
|
910
|
+
var chapters = (snap && snap.chapters) || [];
|
|
911
|
+
for (var i = 0; i < chapters.length; i++) { if (chapters[i].file === file) { ch = chapters[i]; break; } }
|
|
912
|
+
function advance() {
|
|
913
|
+
if (!ch) return;
|
|
914
|
+
var idx = STATUS_FLOW.indexOf(ch.status);
|
|
915
|
+
var next = idx !== -1 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null;
|
|
916
|
+
if (!next || flowBusy) return;
|
|
917
|
+
setFlowBusy(true);
|
|
918
|
+
apiPost("chapter-status", { path: wsPath, file: ch.file, status: next })
|
|
919
|
+
.then(function () { return reload(); })
|
|
920
|
+
.then(function () { return loadDetail(ch.file); })
|
|
921
|
+
.catch(function () {}).finally(function () { setFlowBusy(false); });
|
|
922
|
+
}
|
|
923
|
+
function copyChapterPath() {
|
|
924
|
+
copyText(chapterAbsPath(snap && snap.novelDir, file)).then(function (ok) {
|
|
925
|
+
if (ok) toast.ok("已复制章节文件路径");
|
|
926
|
+
else toast.warn("复制失败,请手动选择路径文本");
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
return h("div", { className: "myt-reader" },
|
|
930
|
+
h("div", { className: "myt-readerBar" },
|
|
931
|
+
h("button", { type: "button", className: "myt-btn", onClick: onBack, title: "返回章节列表" },
|
|
932
|
+
ic("IconChevronRightOutlineRegular", 13), "返回"),
|
|
933
|
+
h("h2", { className: "myt-readerTitle" }, ch ? chFullTitle(ch) : file),
|
|
934
|
+
ch ? statusBadge(ch.status) : null,
|
|
935
|
+
h("div", { className: "myt-readerBarActions" },
|
|
936
|
+
h("button", {
|
|
937
|
+
type: "button", className: "myt-btn myt-copyBtn",
|
|
938
|
+
title: "复制章节文件路径", "aria-label": "复制章节文件路径",
|
|
939
|
+
onClick: copyChapterPath,
|
|
940
|
+
}, ic("IconCopyOutlineRegular", 13), "复制路径"))),
|
|
941
|
+
h("div", { className: "myt-readerBody" },
|
|
942
|
+
!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%" } }))
|
|
943
|
+
: detail.err ? h(ErrorStrip, { text: "正文读取失败:" + detail.err, onRetry: function () { loadDetail(file); } })
|
|
944
|
+
: [
|
|
945
|
+
(function () {
|
|
946
|
+
var ol = detail.data && detail.data.outline;
|
|
947
|
+
return ol ? h(OutlinePanel, { key: "outline", outline: ol })
|
|
948
|
+
: h("div", { key: "outline-none", className: "myt-outlineEmpty" }, "暂无章纲");
|
|
949
|
+
})(),
|
|
950
|
+
h("pre", { key: "body", className: "myt-chapterBody" }, (detail.data && detail.data.body) || "(空章节)"),
|
|
951
|
+
],
|
|
952
|
+
ch ? h("div", { className: "myt-dimGrid" }, FIVE_DIMS.map(function (dim) {
|
|
953
|
+
return h("div", { key: dim[0], className: "myt-dim" },
|
|
954
|
+
h("div", { className: "myt-dimLabel" }, dim[1]),
|
|
955
|
+
h("div", { className: "myt-dimText" }, orDash(ch[dim[0]])));
|
|
956
|
+
})) : null),
|
|
957
|
+
ch ? h("div", { className: "myt-readerFoot" },
|
|
958
|
+
(function () {
|
|
959
|
+
var idx = STATUS_FLOW.indexOf(ch.status);
|
|
960
|
+
var next = idx !== -1 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null;
|
|
961
|
+
if (!next) return h("span", { className: "myt-muted" }, "已定稿(终态)");
|
|
962
|
+
return [h("span", { className: "myt-muted", key: "t" }, "状态流转:"),
|
|
963
|
+
h("button", { key: "b", type: "button", className: "myt-btn myt-btnPrimary", disabled: flowBusy, onClick: advance },
|
|
964
|
+
flowBusy ? "流转中…" : "推进到「" + STATUS_LABEL[next] + "」")];
|
|
965
|
+
})()) : null);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// ── 小说设置 tab:可视化 project.yml 平键并保存。mode/phase 走枚举
|
|
969
|
+
// 下拉,自由文本走输入框;保存走 project-save 路由(服务端校验 + 保
|
|
970
|
+
// 注释改写),成功后重拉快照让概览同步。 ──
|
|
971
|
+
function SettingsTab(props) {
|
|
972
|
+
var snap = props.snap;
|
|
973
|
+
var wsPath = props.wsPath;
|
|
974
|
+
var reload = props.reload;
|
|
975
|
+
var project = snap.project || {};
|
|
976
|
+
var _f = useState(function () {
|
|
977
|
+
return {
|
|
978
|
+
title: project.title || "", author: project.author || "", genre: project.genre || "",
|
|
979
|
+
length: project.length || "", language: project.language || "zh",
|
|
980
|
+
phase: PHASES.indexOf(project.phase) !== -1 ? project.phase : "idea",
|
|
981
|
+
mode: project.mode || "ai-draft",
|
|
982
|
+
description: project.description || "",
|
|
983
|
+
};
|
|
984
|
+
}), form = _f[0], setForm = _f[1];
|
|
985
|
+
var _b = useState(false), busy = _b[0], setBusy = _b[1];
|
|
986
|
+
var _e = useState(""), saveErr = _e[0], setSaveErr = _e[1];
|
|
987
|
+
function set(k, v) { setForm(Object.assign({}, form, (function () { var o = {}; o[k] = v; return o; })())); }
|
|
988
|
+
function save() {
|
|
989
|
+
if (busy) return;
|
|
990
|
+
if (!String(form.title || "").trim()) { setSaveErr("书名不能为空"); return; }
|
|
991
|
+
setBusy(true); setSaveErr("");
|
|
992
|
+
// 成败提示统一走 apiPost 的 toast 通道(这里不再另弹,避免双 toast);
|
|
993
|
+
// 卡内 saveErr 只承担表单旁的错误态。
|
|
994
|
+
apiPost("project-save", { path: wsPath, values: form }).then(function () {
|
|
995
|
+
return reload();
|
|
996
|
+
}).catch(function (err) {
|
|
997
|
+
setSaveErr(err.message);
|
|
998
|
+
}).finally(function () { setBusy(false); });
|
|
999
|
+
}
|
|
1000
|
+
function field(label, key, placeholder, textarea) {
|
|
1001
|
+
var value = form[key] || "";
|
|
1002
|
+
return h("label", { className: "myt-setField" },
|
|
1003
|
+
h("span", { className: "myt-setFieldLabel" }, label),
|
|
1004
|
+
textarea
|
|
1005
|
+
? h("textarea", { className: "myt-setInput myt-setArea", rows: 3, placeholder: placeholder, value: value,
|
|
1006
|
+
onChange: function (e) { set(key, e.target.value); } })
|
|
1007
|
+
: h("input", { type: "text", className: "myt-setInput", placeholder: placeholder, value: value,
|
|
1008
|
+
onChange: function (e) { set(key, e.target.value); } }));
|
|
1009
|
+
}
|
|
1010
|
+
function picker(label, key, opts) {
|
|
1011
|
+
var value = form[key] || "";
|
|
1012
|
+
return h("label", { className: "myt-setField" },
|
|
1013
|
+
h("span", { className: "myt-setFieldLabel" }, label),
|
|
1014
|
+
h("select", { className: "myt-setInput myt-setSelect", value: value, onChange: function (e) { set(key, e.target.value); } },
|
|
1015
|
+
opts.map(function (o) { return h("option", { key: o[0], value: o[0] }, o[1]); })));
|
|
1016
|
+
}
|
|
1017
|
+
return h("div", { className: "myt-stack" },
|
|
1018
|
+
h("div", { className: "myt-card" },
|
|
1019
|
+
h("div", { className: "myt-cardHead" },
|
|
1020
|
+
h("h3", { className: "myt-cardTitle" }, "书稿元信息"),
|
|
1021
|
+
h("span", { className: "myt-muted" }, "对应 novel/project.yml · 行尾注释会原样保留")),
|
|
1022
|
+
h("div", { className: "myt-setGrid" },
|
|
1023
|
+
field("书名", "title", "如:长夜余火"),
|
|
1024
|
+
field("作者", "author", "笔名"),
|
|
1025
|
+
field("题材", "genre", "如:东方玄幻 / 都市异能 / 历史架空"),
|
|
1026
|
+
field("预期篇幅", "length", "如:约100万字 / 300章"),
|
|
1027
|
+
picker("正文语言", "language", [["zh", "中文"], ["en", "English"], ["ja", "日本語"]]),
|
|
1028
|
+
picker("工作流阶段", "phase", PHASES.map(function (p) { return [p, PHASE_LABEL[p] || p]; })),
|
|
1029
|
+
picker("共创模式", "mode", [["ai-draft", "AI 代笔(AI 写正文)"], ["co-write", "逐章共创(按章指派谁写)"], ["author-draft", "作者亲笔(AI 只备料审稿)"]]),
|
|
1030
|
+
field("一句话简介", "description", "写封面文案时可直接引用", true))),
|
|
1031
|
+
saveErr ? h(ErrorStrip, { text: saveErr }) : null,
|
|
1032
|
+
h("div", { className: "myt-setActions" },
|
|
1033
|
+
h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save },
|
|
1034
|
+
busy ? "保存中…" : "保存设置")));
|
|
1035
|
+
}
|
|
1036
|
+
|
|
690
1037
|
// ── 章节 tab:状态筛选 pills + 表格 + 行抽屉(只读正文 + 五维 + 流转)────
|
|
691
1038
|
var FIVE_DIMS = [
|
|
692
1039
|
["cognitive_frame", "认知框架"], ["emotional_anchor", "情感锚点"], ["world_texture", "世界质感"],
|
|
@@ -710,7 +1057,7 @@ window.__ModuleLoader__.load({
|
|
|
710
1057
|
typeof (outline && outline.words) === "number" ? h("span", { className: "myt-outlineWords" }, fmtNum(outline.words) + " 字") : null,
|
|
711
1058
|
h("span", { className: "myt-outlineChevron", "aria-hidden": "true" },
|
|
712
1059
|
ic(open ? "IconChevronDownOutlineRegular" : "IconChevronRightOutlineRegular", 14))),
|
|
713
|
-
open ? h(
|
|
1060
|
+
open ? h(MdView, { text: body, className: "myt-outlineBody" }) : null);
|
|
714
1061
|
}
|
|
715
1062
|
// 抽屉/弹层专用 Portal:面板容器挂在宿主会话中栏里,中栏的 transform/
|
|
716
1063
|
// overflow 会把 position:fixed 劫持成相对自身并连同裁剪——抽屉渲染了也
|
|
@@ -726,45 +1073,11 @@ window.__ModuleLoader__.load({
|
|
|
726
1073
|
|
|
727
1074
|
function ChaptersTab(props) {
|
|
728
1075
|
var snap = props.snap;
|
|
729
|
-
var
|
|
730
|
-
var wsPath = props.wsPath;
|
|
1076
|
+
var onOpen = props.onOpenChapter; // 行点击 → 整页阅读(PageShell.readerFile)
|
|
731
1077
|
var _f = useState("all"), filter = _f[0], setFilter = _f[1];
|
|
732
|
-
var _o = useState(null), openFile = _o[0], setOpenFile = _o[1];
|
|
733
|
-
var _d = useState(null), detail = _d[0], setDetail = _d[1]; // {loading, data, err}
|
|
734
|
-
var _b = useState(false), flowBusy = _b[0], setFlowBusy = _b[1];
|
|
735
|
-
// 概览「最近章节」点进来:initialFile 只消费一次(吃掉后回置空,防止
|
|
736
|
-
// 下次进 tab 时把旧抽屉又顶开)。
|
|
737
|
-
useEffect(function () {
|
|
738
|
-
if (props.initialFile) {
|
|
739
|
-
setOpenFile(props.initialFile);
|
|
740
|
-
if (typeof props.onConsumeInitial === "function") props.onConsumeInitial();
|
|
741
|
-
}
|
|
742
|
-
}, [props.initialFile]);
|
|
743
|
-
var loadDetail = useCallback(function (file) {
|
|
744
|
-
if (!file) { setDetail(null); return; }
|
|
745
|
-
setDetail({ loading: true, data: null, err: "" });
|
|
746
|
-
apiGet("chapter", { path: wsPath, file: file }).then(function (d) {
|
|
747
|
-
setDetail({ loading: false, data: d, err: "" });
|
|
748
|
-
}).catch(function (err) {
|
|
749
|
-
setDetail({ loading: false, data: null, err: err.message });
|
|
750
|
-
});
|
|
751
|
-
}, [wsPath]);
|
|
752
|
-
useEffect(function () { loadDetail(openFile); }, [openFile, loadDetail]);
|
|
753
|
-
function advance(ch) {
|
|
754
|
-
var idx = STATUS_FLOW.indexOf(ch.status);
|
|
755
|
-
var next = idx !== -1 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null;
|
|
756
|
-
if (!next || flowBusy) return;
|
|
757
|
-
setFlowBusy(true);
|
|
758
|
-
apiPost("chapter-status", { path: wsPath, file: ch.file, status: next })
|
|
759
|
-
.then(function () { return reload(); })
|
|
760
|
-
.then(function () { if (openFile === ch.file) return loadDetail(ch.file); })
|
|
761
|
-
.catch(function () {}).finally(function () { setFlowBusy(false); });
|
|
762
|
-
}
|
|
763
1078
|
var chapters = snap.chapters || [];
|
|
764
1079
|
var rows = filter === "all" ? chapters : chapters.filter(function (c) { return c.status === filter; });
|
|
765
1080
|
var counts = snap.totals && snap.totals.byStatus ? snap.totals.byStatus : {};
|
|
766
|
-
var openCh = null;
|
|
767
|
-
for (var i = 0; i < chapters.length; i++) { if (chapters[i].file === openFile) { openCh = chapters[i]; break; } }
|
|
768
1081
|
// 「按单写作」(五期):state.outlines 与 chapters 联交——有章纲且
|
|
769
1082
|
// status=pending 的章,按章纲(卷-章号)序列出,共创模式的主工作清单;
|
|
770
1083
|
// 空块(无待写章纲章)整块不渲染。孤儿章纲(chapter 无对应章节)联交
|
|
@@ -777,12 +1090,6 @@ window.__ModuleLoader__.load({
|
|
|
777
1090
|
var ch = ol && ol.chapter ? chapterByFile[ol.chapter] : null;
|
|
778
1091
|
if (ch && (ch.status || "pending") === "pending") writeOrder.push({ ch: ch, ol: ol });
|
|
779
1092
|
});
|
|
780
|
-
function copyChapterPath() {
|
|
781
|
-
copyText(chapterAbsPath(snap && snap.novelDir, openFile)).then(function (ok) {
|
|
782
|
-
if (ok) toast.ok("已复制章节文件路径");
|
|
783
|
-
else toast.warn("复制失败,请手动选择路径文本");
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
1093
|
return h("div", { className: "myt-stack" },
|
|
787
1094
|
writeOrder.length ? h("div", { className: "myt-card myt-writeOrder" },
|
|
788
1095
|
h("div", { className: "myt-cardHead" },
|
|
@@ -793,8 +1100,8 @@ window.__ModuleLoader__.load({
|
|
|
793
1100
|
var ol = item.ol;
|
|
794
1101
|
return h("button", {
|
|
795
1102
|
key: ch.file, type: "button", className: "myt-writeOrderRow",
|
|
796
|
-
title: "
|
|
797
|
-
onClick: function () {
|
|
1103
|
+
title: "点击打开该章",
|
|
1104
|
+
onClick: function () { onOpen(ch.file); },
|
|
798
1105
|
},
|
|
799
1106
|
h("span", { className: "myt-chPath" }, chWhere(ch) || ch.file),
|
|
800
1107
|
h("span", { className: "myt-chTitle" }, orDash(ch.title)),
|
|
@@ -818,55 +1125,14 @@ window.__ModuleLoader__.load({
|
|
|
818
1125
|
h("thead", null, h("tr", null,
|
|
819
1126
|
h("th", null, "卷 · 章"), h("th", null, "标题"), h("th", null, "状态"), h("th", null, "字数"), h("th", null, "梗概"))),
|
|
820
1127
|
h("tbody", null, rows.map(function (ch) {
|
|
821
|
-
return h("tr", { key: ch.file, onClick: function () {
|
|
1128
|
+
return h("tr", { key: ch.file, onClick: function () { onOpen(ch.file); }, title: "点击阅读正文" },
|
|
822
1129
|
h("td", null, h("span", { className: "myt-chPath" }, chWhere(ch) || "—")),
|
|
823
1130
|
h("td", null, h("span", { className: "myt-chTitle" }, orDash(ch.title))),
|
|
824
1131
|
h("td", null, statusBadge(ch.status)),
|
|
825
1132
|
h("td", null, h("span", { className: "myt-wordsCell" }, fmtNum(ch.words))),
|
|
826
1133
|
h("td", null, h("div", { className: "myt-summaryCell myt-muted" }, orDash(ch.summary))));
|
|
827
1134
|
}))))
|
|
828
|
-
: (chapters.length ? h(Empty, { text: "该状态下没有章节" }) : h(Empty, { text: "chapters/ 目录还没有章节文件" }))
|
|
829
|
-
openFile ? h(MaskPortal, {
|
|
830
|
-
className: "myt-drawerMask",
|
|
831
|
-
onMouseDown: function (e) { if (e.target === e.currentTarget) setOpenFile(null); },
|
|
832
|
-
},
|
|
833
|
-
h("aside", { className: "myt-drawer", role: "dialog", "aria-modal": "true", "aria-label": "章节详情" },
|
|
834
|
-
h("div", { className: "myt-drawerHead" },
|
|
835
|
-
h("h2", { className: "myt-drawerTitle" }, openCh ? chFullTitle(openCh) : openFile),
|
|
836
|
-
h("div", { className: "myt-heroMeta" }, openCh ? statusBadge(openCh.status) : null,
|
|
837
|
-
h("button", {
|
|
838
|
-
type: "button", className: "myt-btn myt-copyBtn",
|
|
839
|
-
title: "复制章节文件路径", "aria-label": "复制章节文件路径",
|
|
840
|
-
onClick: copyChapterPath,
|
|
841
|
-
}, ic("IconCopyOutlineRegular", 13), "复制路径"),
|
|
842
|
-
h("button", { type: "button", className: "myt-closeBtn", "aria-label": "关闭抽屉", onClick: function () { setOpenFile(null); } }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")))),
|
|
843
|
-
h("div", { className: "myt-drawerBody" },
|
|
844
|
-
!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%" } }))
|
|
845
|
-
: detail.err ? h(ErrorStrip, { text: "正文读取失败:" + detail.err, onRetry: function () { loadDetail(openFile); } })
|
|
846
|
-
: [
|
|
847
|
-
(function () {
|
|
848
|
-
var ol = detail.data && detail.data.outline;
|
|
849
|
-
// 有章纲:对照栏置顶(可折叠,默认展开);无章纲:一行
|
|
850
|
-
// 淡字提示——共创模式下这是「先要章纲」的信号。
|
|
851
|
-
return ol ? h(OutlinePanel, { key: "outline", outline: ol })
|
|
852
|
-
: h("div", { key: "outline-none", className: "myt-outlineEmpty" }, "暂无章纲");
|
|
853
|
-
})(),
|
|
854
|
-
h("pre", { key: "body", className: "myt-chapterBody" }, (detail.data && detail.data.body) || "(空章节)"),
|
|
855
|
-
],
|
|
856
|
-
openCh ? h("div", { className: "myt-dimGrid" }, FIVE_DIMS.map(function (dim) {
|
|
857
|
-
return h("div", { key: dim[0], className: "myt-dim" },
|
|
858
|
-
h("div", { className: "myt-dimLabel" }, dim[1]),
|
|
859
|
-
h("div", { className: "myt-dimText" }, orDash(openCh[dim[0]])));
|
|
860
|
-
})) : null),
|
|
861
|
-
openCh ? h("div", { className: "myt-drawerFoot" },
|
|
862
|
-
(function () {
|
|
863
|
-
var idx = STATUS_FLOW.indexOf(openCh.status);
|
|
864
|
-
var next = idx !== -1 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null;
|
|
865
|
-
if (!next) return h("span", { className: "myt-muted" }, "已定稿(终态)");
|
|
866
|
-
return [h("span", { className: "myt-muted", key: "t" }, "状态流转:"),
|
|
867
|
-
h("button", { key: "b", type: "button", className: "myt-btn myt-btnPrimary", disabled: flowBusy, onClick: function () { advance(openCh); } },
|
|
868
|
-
flowBusy ? "流转中…" : "推进到「" + STATUS_LABEL[next] + "」")];
|
|
869
|
-
})()) : null)) : null);
|
|
1135
|
+
: (chapters.length ? h(Empty, { text: "该状态下没有章节" }) : h(Empty, { text: "chapters/ 目录还没有章节文件" })));
|
|
870
1136
|
}
|
|
871
1137
|
|
|
872
1138
|
// ── 角色 tab:卡墙 + 全卡弹层 ───────────────────────────────────────────
|
|
@@ -940,20 +1206,28 @@ window.__ModuleLoader__.load({
|
|
|
940
1206
|
items.map(function (fs) {
|
|
941
1207
|
var next = FS_FLOW[col] || null;
|
|
942
1208
|
var busy = busyId === fs.id;
|
|
1209
|
+
// status 行缺失(服务端要求每条恰一处 status,写必 409):禁用流
|
|
1210
|
+
// 转/弃用按钮,降级为只读展示并给提示,避免「可点但必失败」。
|
|
1211
|
+
var noStatus = !fs.status;
|
|
1212
|
+
var noWrite = !fs.id || noStatus;
|
|
1213
|
+
var noWriteTip = !fs.id
|
|
1214
|
+
? "该条目缺 id,无法写状态"
|
|
1215
|
+
: "该条目缺 status 行,服务端会拒绝流转(409);请先在 foreshadows.yml 补上 status";
|
|
943
1216
|
var chRange = [fs.planted_chapter || "埋设章未标", fs.expected_resolve].filter(Boolean).join(" → ");
|
|
944
1217
|
return h("div", { key: fs.id || fs.title, className: "myt-fsCard" },
|
|
945
1218
|
h("div", { className: "myt-fsTitle" },
|
|
946
1219
|
priDot(fs.priority), h("span", null, orDash(fs.title) || orDash(fs.id)),
|
|
947
1220
|
// 回填徽章(五期账本回填):区别于写前规划条目,悬浮见来源。
|
|
948
|
-
fs.source === "backfill" ? h("span", { className: "myt-backfillBadge", title: "从作者正文回填" }, "回填") : null
|
|
1221
|
+
fs.source === "backfill" ? h("span", { className: "myt-backfillBadge", title: "从作者正文回填" }, "回填") : null,
|
|
1222
|
+
noStatus ? h("span", { className: "myt-fsNoStatus", title: noWriteTip }, "缺 status") : null),
|
|
949
1223
|
h("div", { className: "myt-fsMeta" },
|
|
950
1224
|
h("span", null, col === "resolved" && fs.resolved_chapter ? "回收于 " + fs.resolved_chapter : chRange),
|
|
951
1225
|
fs.priority && fs.priority !== "normal" ? h("span", null, "优先级 " + (PRI_LABEL[fs.priority] || fs.priority)) : null),
|
|
952
1226
|
fs.notes ? h("div", { className: "myt-fsNotes", title: fs.notes }, fs.notes) : null,
|
|
953
1227
|
h("div", { className: "myt-fsActions" },
|
|
954
|
-
next ? h("button", { type: "button", className: "myt-btn", disabled: busy ||
|
|
1228
|
+
next ? h("button", { type: "button", className: "myt-btn", disabled: busy || noWrite, title: noWriteTip, onClick: function () { setStatus(fs, next); } }, busy ? "推进中…" : (col === "planted" ? "推进" : "回收")) : null,
|
|
955
1229
|
col !== "resolved" && col !== "abandoned"
|
|
956
|
-
? h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy ||
|
|
1230
|
+
? h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy || noWrite, title: noWriteTip, onClick: function () { setStatus(fs, "abandoned"); } }, "弃用") : null));
|
|
957
1231
|
}),
|
|
958
1232
|
items.length ? null : h("div", { className: "myt-muted" }, "空"));
|
|
959
1233
|
})) : h(Empty, { text: "foreshadows.yml 还没有伏笔条目" }));
|
|
@@ -985,7 +1259,10 @@ window.__ModuleLoader__.load({
|
|
|
985
1259
|
var world = snap.world || [];
|
|
986
1260
|
var science = snap.science || [];
|
|
987
1261
|
var research = snap.research || [];
|
|
988
|
-
|
|
1262
|
+
// 无原型容器:key 来自数据文件的自由文本(category),值为
|
|
1263
|
+
// constructor/__proto__/toString 等时若用普通 {} 会命中 Object.prototype
|
|
1264
|
+
// 真值 → TypeError → 整面板白屏;Object.create(null) 杜绝。
|
|
1265
|
+
var groups = Object.create(null);
|
|
989
1266
|
world.forEach(function (w) {
|
|
990
1267
|
var key = w.category || "未分类";
|
|
991
1268
|
if (!groups[key]) groups[key] = [];
|
|
@@ -1003,7 +1280,7 @@ window.__ModuleLoader__.load({
|
|
|
1003
1280
|
h("div", { className: "myt-worldHead" },
|
|
1004
1281
|
h("span", { className: "myt-worldName" }, orDash(w.name)),
|
|
1005
1282
|
(w.tags || []).length ? h("span", { className: "myt-tagRow" }, w.tags.map(function (t, j) { return h("span", { key: j, className: "myt-tag" }, t); })) : null),
|
|
1006
|
-
w.description ? h(
|
|
1283
|
+
w.description ? h(MdView, { text: w.description, className: "myt-worldDesc" }) : null);
|
|
1007
1284
|
}));
|
|
1008
1285
|
})) : h(Empty, { text: "world/ 目录还没有设定条目" })),
|
|
1009
1286
|
h("div", { className: "myt-card" },
|
|
@@ -1016,7 +1293,7 @@ window.__ModuleLoader__.load({
|
|
|
1016
1293
|
h("span", { className: "myt-worldName" }, orDash(s.name)),
|
|
1017
1294
|
h("span", { className: "myt-badge myt-sciTag", "data-label": label || "hypothesis", title: s.label && !label ? s.label : undefined },
|
|
1018
1295
|
label ? SCI_LABEL[label] : (s.label || "未标级"))),
|
|
1019
|
-
s.description ? h(
|
|
1296
|
+
s.description ? h(MdView, { text: s.description, className: "myt-worldDesc" }) : null,
|
|
1020
1297
|
(s.references || []).length ? h("div", { className: "myt-refList" },
|
|
1021
1298
|
s.references.map(function (r, j) { return h("span", { key: j, className: "myt-refItem" }, "参考 " + (j + 1) + ":" + r); })) : null);
|
|
1022
1299
|
})) : h(Empty, { text: "science/ 目录还没有科学设定" })),
|
|
@@ -1042,13 +1319,14 @@ window.__ModuleLoader__.load({
|
|
|
1042
1319
|
}
|
|
1043
1320
|
var clues = snap.clues || [];
|
|
1044
1321
|
var memories = snap.memories || [];
|
|
1045
|
-
|
|
1322
|
+
// 无原型容器:kind/category 是数据文件自由文本,见 WorldTab 处说明。
|
|
1323
|
+
var clueGroups = Object.create(null);
|
|
1046
1324
|
clues.forEach(function (c) {
|
|
1047
1325
|
var key = c.kind || "未分类";
|
|
1048
1326
|
if (!clueGroups[key]) clueGroups[key] = [];
|
|
1049
1327
|
clueGroups[key].push(c);
|
|
1050
1328
|
});
|
|
1051
|
-
var memGroups =
|
|
1329
|
+
var memGroups = Object.create(null);
|
|
1052
1330
|
memories.forEach(function (m) {
|
|
1053
1331
|
var key = m.category || "未分类";
|
|
1054
1332
|
if (!memGroups[key]) memGroups[key] = [];
|
|
@@ -1105,24 +1383,26 @@ window.__ModuleLoader__.load({
|
|
|
1105
1383
|
var _tip = useState(null), tip = _tip[0], setTip = _tip[1]; // {x, y, text}
|
|
1106
1384
|
var wrapRef = useRef(null);
|
|
1107
1385
|
var canvasRef = useRef(null);
|
|
1108
|
-
var
|
|
1386
|
+
var tipRafRef = useRef(0); // hover 提示 rAF 句柄:卸载/换图时取消在途合帧
|
|
1109
1387
|
// 数据准备:>30 角色只留 relations 相关节点;角色空时退化为关系端点名。
|
|
1110
|
-
|
|
1111
|
-
|
|
1388
|
+
// useMemo 缓存:hover 提示等重渲染不再重复建图(原渲染体内 IIFE 每帧重建)。
|
|
1389
|
+
// 容器用无原型对象:角色名/关系端点是数据文件自由文本,见 WorldTab 处说明。
|
|
1390
|
+
var graph = useMemo(function () {
|
|
1391
|
+
var byName = Object.create(null);
|
|
1112
1392
|
chars.forEach(function (c) { if (c && c.name && !byName[c.name]) byName[c.name] = c; });
|
|
1113
|
-
var deg =
|
|
1393
|
+
var deg = Object.create(null);
|
|
1114
1394
|
rels.forEach(function (r) { deg[r.a] = (deg[r.a] || 0) + 1; deg[r.b] = (deg[r.b] || 0) + 1; });
|
|
1115
1395
|
var names = chars.length ? chars.map(function (c) { return c.name; }) : Object.keys(deg);
|
|
1116
1396
|
var linked = names.filter(function (n) { return deg[n]; });
|
|
1117
1397
|
var drawNames = names.length > 30 ? linked : names;
|
|
1118
1398
|
var unlinked = names.filter(function (n) { return drawNames.indexOf(n) === -1; });
|
|
1119
1399
|
var nodes = drawNames.map(function (n) { return { id: n, role: byName[n] ? byName[n].role : null, x: 0, y: 0, vx: 0, vy: 0, fixed: false }; });
|
|
1120
|
-
var idx =
|
|
1400
|
+
var idx = Object.create(null);
|
|
1121
1401
|
nodes.forEach(function (nd, i) { idx[nd.id] = i; });
|
|
1122
1402
|
var edges = rels.filter(function (r) { return idx[r.a] !== undefined && idx[r.b] !== undefined; })
|
|
1123
1403
|
.map(function (r) { return { a: idx[r.a], b: idx[r.b], type: r.type || "", desc: r.description || "", w: edgeWidth(r.intensity) }; });
|
|
1124
1404
|
return { nodes: nodes, edges: edges, unlinked: unlinked };
|
|
1125
|
-
})
|
|
1405
|
+
}, [snap]); // chars/rels 均由 snap 派生,snap 不变则图不变
|
|
1126
1406
|
useEffect(function () {
|
|
1127
1407
|
var wrap = wrapRef.current;
|
|
1128
1408
|
var canvas = canvasRef.current;
|
|
@@ -1130,8 +1410,7 @@ window.__ModuleLoader__.load({
|
|
|
1130
1410
|
var ctx = canvas.getContext("2d");
|
|
1131
1411
|
if (!ctx) return undefined;
|
|
1132
1412
|
var dpr = (window.devicePixelRatio || 1) > 0 ? window.devicePixelRatio || 1 : 1;
|
|
1133
|
-
var sim = { zoom: 1, drag: null, raf: 0, frame: 0, w: 0, h: 0, tokens: null
|
|
1134
|
-
simRef.current = sim;
|
|
1413
|
+
var sim = { zoom: 1, drag: null, raf: 0, frame: 0, w: 0, h: 0, tokens: null };
|
|
1135
1414
|
// 令牌缓存:每 30 帧重读 computed style,浅/深色切换约半秒内跟上。
|
|
1136
1415
|
function readTokens() {
|
|
1137
1416
|
var cs = getComputedStyle(document.body);
|
|
@@ -1282,18 +1561,23 @@ window.__ModuleLoader__.load({
|
|
|
1282
1561
|
}
|
|
1283
1562
|
function onMove(e) {
|
|
1284
1563
|
var p = localXY(e);
|
|
1285
|
-
sim.mx = p.x; sim.my = p.y;
|
|
1286
1564
|
if (sim.drag) {
|
|
1287
1565
|
var w = toWorld(p.x, p.y);
|
|
1288
1566
|
sim.drag.node.x = w.x; sim.drag.node.y = w.y;
|
|
1289
1567
|
return;
|
|
1290
1568
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1569
|
+
// hover 提示 rAF 节流:mousemove 高频触发,合帧后再命中检测 + setTip,
|
|
1570
|
+
// 避免逐事件整 tab 重渲染(建图已 useMemo,这里再省掉无效重绘)。
|
|
1571
|
+
if (tipRafRef.current) cancelAnimationFrame(tipRafRef.current);
|
|
1572
|
+
tipRafRef.current = requestAnimationFrame(function () {
|
|
1573
|
+
tipRafRef.current = 0;
|
|
1574
|
+
var e2 = hitEdge(p.x, p.y);
|
|
1575
|
+
if (e2) {
|
|
1576
|
+
setTip({ x: p.x, y: p.y, text: (e2.type ? e2.type + ":" : "") + (e2.desc || "(无描述)") });
|
|
1577
|
+
} else {
|
|
1578
|
+
setTip(function (prev) { return prev ? null : prev; });
|
|
1579
|
+
}
|
|
1580
|
+
});
|
|
1297
1581
|
}
|
|
1298
1582
|
function onUp() { sim.drag = null; canvas.dataset.dragging = "false"; }
|
|
1299
1583
|
function onWheel(e) {
|
|
@@ -1313,13 +1597,13 @@ window.__ModuleLoader__.load({
|
|
|
1313
1597
|
canvas.addEventListener("dblclick", onDbl);
|
|
1314
1598
|
return function () {
|
|
1315
1599
|
cancelAnimationFrame(sim.raf);
|
|
1600
|
+
if (tipRafRef.current) { cancelAnimationFrame(tipRafRef.current); tipRafRef.current = 0; }
|
|
1316
1601
|
if (ro) ro.disconnect();
|
|
1317
1602
|
canvas.removeEventListener("mousedown", onDown);
|
|
1318
1603
|
window.removeEventListener("mousemove", onMove);
|
|
1319
1604
|
window.removeEventListener("mouseup", onUp);
|
|
1320
1605
|
canvas.removeEventListener("wheel", onWheel);
|
|
1321
1606
|
canvas.removeEventListener("dblclick", onDbl);
|
|
1322
|
-
simRef.current = null;
|
|
1323
1607
|
};
|
|
1324
1608
|
}, [snap]);
|
|
1325
1609
|
if (!graph.nodes.length) return h(Empty, { text: "没有角色或关系数据,无法绘图" });
|