dsh-maoying 0.1.11 → 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/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
- var _pc = useState(null), pendingChapter = _pc[0], setPendingChapter = _pc[1];
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,16 +565,22 @@ 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 () { setLoading(false); });
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();
@@ -543,7 +591,7 @@ window.__ModuleLoader__.load({
543
591
  var _ver = useState(null), ver = _ver[0], setVer = _ver[1];
544
592
  useEffect(function () {
545
593
  apiGet("update-check").then(function (d) {
546
- if (d && d.updateAvailable) setVer(d);
594
+ if (d && d.current) setVer(d); // 常态也显示当前版本号;有新版再亮徽章
547
595
  }).catch(function () {});
548
596
  }, []);
549
597
  useEffect(function () { loadWorkspaces(); }, [loadWorkspaces]);
@@ -560,7 +608,7 @@ window.__ModuleLoader__.load({
560
608
  }
561
609
  function gotoTab(next, chapterFile) {
562
610
  setTab(next);
563
- if (next === "chapters") setPendingChapter(chapterFile || null);
611
+ setReaderFile(next === "chapters" && chapterFile ? chapterFile : null);
564
612
  }
565
613
 
566
614
  var novelWorkspaces = (workspaces || []).filter(function (w) { return w.hasNovel; });
@@ -586,13 +634,15 @@ window.__ModuleLoader__.load({
586
634
  });
587
635
  } else if (snap) {
588
636
  var tabProps = { snap: snap, reload: reload, wsPath: effectiveWs };
589
- if (tab === "chapters") body = h(ChaptersTab, Object.assign({ initialFile: pendingChapter, onConsumeInitial: function () { setPendingChapter(null); } }, tabProps));
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));
590
639
  else if (tab === "characters") body = h(CharactersTab, tabProps);
591
640
  else if (tab === "foreshadows") body = h(ForeshadowsTab, tabProps);
592
641
  else if (tab === "relations") body = h(RelationsTab, tabProps);
593
642
  else if (tab === "timeline") body = h(TimelineTab, tabProps);
594
643
  else if (tab === "world") body = h(WorldTab, tabProps);
595
644
  else if (tab === "index") body = h(IndexTab, tabProps);
645
+ else if (tab === "settings") body = h(SettingsTab, tabProps);
596
646
  else body = h(OverviewTab, Object.assign({ gotoTab: gotoTab }, tabProps));
597
647
  }
598
648
 
@@ -600,6 +650,7 @@ window.__ModuleLoader__.load({
600
650
  h("header", { className: "myt-header" },
601
651
  h("div", null,
602
652
  h("h1", { className: "myt-title" }, "毛颖创作台",
653
+ ver && ver.current ? h("span", { className: "myt-verTag", title: "当前版本" }, ver.current) : null,
603
654
  ver && ver.updateAvailable
604
655
  ? h("a", { className: "myt-verBadge", href: ver.releaseUrl || "#", target: "_blank", rel: "noreferrer", title: "当前 " + ver.current + ",点击打开新版发布页" },
605
656
  "有新版 " + ver.latest + " ↗")
@@ -614,9 +665,10 @@ window.__ModuleLoader__.load({
614
665
  (w.hasNovel ? "" : "(无 novel)") + (w.name || w.path || "未命名"));
615
666
  }))]
616
667
  : null,
668
+ h("button", { type: "button", className: "myt-btn myt-btnIcon", title: "系统设置", "aria-label": "系统设置", onClick: function () { setShowSettings(true); } }, ic("IconSettingsOutlineRegular", 14)),
617
669
  h("button", { type: "button", className: "myt-btn myt-btnIcon", title: "重新拉取", "aria-label": "重新拉取", disabled: loading, onClick: reload }, ic("IconRefreshOutlineRegular", 14)),
618
670
  h("button", { type: "button", className: "myt-closeBtn", "aria-label": "关闭创作台", title: "关闭", onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")))),
619
- h("nav", { className: "myt-tabs", "aria-label": "创作台分区" },
671
+ readerFile ? null : h("nav", { className: "myt-tabs", "aria-label": "创作台分区" },
620
672
  TABS.map(function (t) {
621
673
  return h("button", {
622
674
  key: t[0], type: "button", className: "myt-tab", "data-active": tab === t[0] ? "true" : undefined,
@@ -626,7 +678,9 @@ window.__ModuleLoader__.load({
626
678
  h("div", { className: "myt-body" },
627
679
  h("div", { className: "myt-page" },
628
680
  snap && Array.isArray(snap.warnings) && snap.warnings.length ? WarningsStrip(snap.warnings) : null,
629
- body)),
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,
630
684
  h(ToastHost));
631
685
  }
632
686
 
@@ -699,6 +753,287 @@ window.__ModuleLoader__.load({
699
753
  : h(Empty, { text: "chapters/ 目录还没有章节文件" })));
700
754
  }
701
755
 
756
+ // ── 轻量 Markdown 渲染(零依赖):先整体 HTML 转义再施加 md 变换,杜绝
757
+ // 注入;支持标题/加粗/斜体/删除线/行内码/围栏码/引用/有序无序列表/分隔线/
758
+ // 链接/段落换行。供章纲、世界观条目等 md 阅读位使用,正文手稿仍走 pre。 ──
759
+ function escapeHtml(s) {
760
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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*&gt;\s?/.test(line)) {
810
+ var quote = [];
811
+ while (i < lines.length && /^\s*&gt;\s?/.test(lines[i])) { quote.push(inlineMd(lines[i].replace(/^\s*&gt;\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*&gt;|^\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
+
702
1037
  // ── 章节 tab:状态筛选 pills + 表格 + 行抽屉(只读正文 + 五维 + 流转)────
703
1038
  var FIVE_DIMS = [
704
1039
  ["cognitive_frame", "认知框架"], ["emotional_anchor", "情感锚点"], ["world_texture", "世界质感"],
@@ -722,7 +1057,7 @@ window.__ModuleLoader__.load({
722
1057
  typeof (outline && outline.words) === "number" ? h("span", { className: "myt-outlineWords" }, fmtNum(outline.words) + " 字") : null,
723
1058
  h("span", { className: "myt-outlineChevron", "aria-hidden": "true" },
724
1059
  ic(open ? "IconChevronDownOutlineRegular" : "IconChevronRightOutlineRegular", 14))),
725
- open ? h("pre", { className: "myt-outlineBody" }, body) : null);
1060
+ open ? h(MdView, { text: body, className: "myt-outlineBody" }) : null);
726
1061
  }
727
1062
  // 抽屉/弹层专用 Portal:面板容器挂在宿主会话中栏里,中栏的 transform/
728
1063
  // overflow 会把 position:fixed 劫持成相对自身并连同裁剪——抽屉渲染了也
@@ -738,45 +1073,11 @@ window.__ModuleLoader__.load({
738
1073
 
739
1074
  function ChaptersTab(props) {
740
1075
  var snap = props.snap;
741
- var reload = props.reload;
742
- var wsPath = props.wsPath;
1076
+ var onOpen = props.onOpenChapter; // 行点击 → 整页阅读(PageShell.readerFile)
743
1077
  var _f = useState("all"), filter = _f[0], setFilter = _f[1];
744
- var _o = useState(null), openFile = _o[0], setOpenFile = _o[1];
745
- var _d = useState(null), detail = _d[0], setDetail = _d[1]; // {loading, data, err}
746
- var _b = useState(false), flowBusy = _b[0], setFlowBusy = _b[1];
747
- // 概览「最近章节」点进来:initialFile 只消费一次(吃掉后回置空,防止
748
- // 下次进 tab 时把旧抽屉又顶开)。
749
- useEffect(function () {
750
- if (props.initialFile) {
751
- setOpenFile(props.initialFile);
752
- if (typeof props.onConsumeInitial === "function") props.onConsumeInitial();
753
- }
754
- }, [props.initialFile]);
755
- var loadDetail = useCallback(function (file) {
756
- if (!file) { setDetail(null); return; }
757
- setDetail({ loading: true, data: null, err: "" });
758
- apiGet("chapter", { path: wsPath, file: file }).then(function (d) {
759
- setDetail({ loading: false, data: d, err: "" });
760
- }).catch(function (err) {
761
- setDetail({ loading: false, data: null, err: err.message });
762
- });
763
- }, [wsPath]);
764
- useEffect(function () { loadDetail(openFile); }, [openFile, loadDetail]);
765
- function advance(ch) {
766
- var idx = STATUS_FLOW.indexOf(ch.status);
767
- var next = idx !== -1 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null;
768
- if (!next || flowBusy) return;
769
- setFlowBusy(true);
770
- apiPost("chapter-status", { path: wsPath, file: ch.file, status: next })
771
- .then(function () { return reload(); })
772
- .then(function () { if (openFile === ch.file) return loadDetail(ch.file); })
773
- .catch(function () {}).finally(function () { setFlowBusy(false); });
774
- }
775
1078
  var chapters = snap.chapters || [];
776
1079
  var rows = filter === "all" ? chapters : chapters.filter(function (c) { return c.status === filter; });
777
1080
  var counts = snap.totals && snap.totals.byStatus ? snap.totals.byStatus : {};
778
- var openCh = null;
779
- for (var i = 0; i < chapters.length; i++) { if (chapters[i].file === openFile) { openCh = chapters[i]; break; } }
780
1081
  // 「按单写作」(五期):state.outlines 与 chapters 联交——有章纲且
781
1082
  // status=pending 的章,按章纲(卷-章号)序列出,共创模式的主工作清单;
782
1083
  // 空块(无待写章纲章)整块不渲染。孤儿章纲(chapter 无对应章节)联交
@@ -789,12 +1090,6 @@ window.__ModuleLoader__.load({
789
1090
  var ch = ol && ol.chapter ? chapterByFile[ol.chapter] : null;
790
1091
  if (ch && (ch.status || "pending") === "pending") writeOrder.push({ ch: ch, ol: ol });
791
1092
  });
792
- function copyChapterPath() {
793
- copyText(chapterAbsPath(snap && snap.novelDir, openFile)).then(function (ok) {
794
- if (ok) toast.ok("已复制章节文件路径");
795
- else toast.warn("复制失败,请手动选择路径文本");
796
- });
797
- }
798
1093
  return h("div", { className: "myt-stack" },
799
1094
  writeOrder.length ? h("div", { className: "myt-card myt-writeOrder" },
800
1095
  h("div", { className: "myt-cardHead" },
@@ -805,8 +1100,8 @@ window.__ModuleLoader__.load({
805
1100
  var ol = item.ol;
806
1101
  return h("button", {
807
1102
  key: ch.file, type: "button", className: "myt-writeOrderRow",
808
- title: "点击打开该章抽屉",
809
- onClick: function () { setOpenFile(ch.file); },
1103
+ title: "点击打开该章",
1104
+ onClick: function () { onOpen(ch.file); },
810
1105
  },
811
1106
  h("span", { className: "myt-chPath" }, chWhere(ch) || ch.file),
812
1107
  h("span", { className: "myt-chTitle" }, orDash(ch.title)),
@@ -830,55 +1125,14 @@ window.__ModuleLoader__.load({
830
1125
  h("thead", null, h("tr", null,
831
1126
  h("th", null, "卷 · 章"), h("th", null, "标题"), h("th", null, "状态"), h("th", null, "字数"), h("th", null, "梗概"))),
832
1127
  h("tbody", null, rows.map(function (ch) {
833
- return h("tr", { key: ch.file, onClick: function () { setOpenFile(ch.file); }, title: "点击查看正文" },
1128
+ return h("tr", { key: ch.file, onClick: function () { onOpen(ch.file); }, title: "点击阅读正文" },
834
1129
  h("td", null, h("span", { className: "myt-chPath" }, chWhere(ch) || "—")),
835
1130
  h("td", null, h("span", { className: "myt-chTitle" }, orDash(ch.title))),
836
1131
  h("td", null, statusBadge(ch.status)),
837
1132
  h("td", null, h("span", { className: "myt-wordsCell" }, fmtNum(ch.words))),
838
1133
  h("td", null, h("div", { className: "myt-summaryCell myt-muted" }, orDash(ch.summary))));
839
1134
  }))))
840
- : (chapters.length ? h(Empty, { text: "该状态下没有章节" }) : h(Empty, { text: "chapters/ 目录还没有章节文件" })),
841
- openFile ? h(MaskPortal, {
842
- className: "myt-drawerMask",
843
- onMouseDown: function (e) { if (e.target === e.currentTarget) setOpenFile(null); },
844
- },
845
- h("aside", { className: "myt-drawer", role: "dialog", "aria-modal": "true", "aria-label": "章节详情" },
846
- h("div", { className: "myt-drawerHead" },
847
- h("h2", { className: "myt-drawerTitle" }, openCh ? chFullTitle(openCh) : openFile),
848
- h("div", { className: "myt-heroMeta" }, openCh ? statusBadge(openCh.status) : null,
849
- h("button", {
850
- type: "button", className: "myt-btn myt-copyBtn",
851
- title: "复制章节文件路径", "aria-label": "复制章节文件路径",
852
- onClick: copyChapterPath,
853
- }, ic("IconCopyOutlineRegular", 13), "复制路径"),
854
- h("button", { type: "button", className: "myt-closeBtn", "aria-label": "关闭抽屉", onClick: function () { setOpenFile(null); } }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")))),
855
- h("div", { className: "myt-drawerBody" },
856
- !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%" } }))
857
- : detail.err ? h(ErrorStrip, { text: "正文读取失败:" + detail.err, onRetry: function () { loadDetail(openFile); } })
858
- : [
859
- (function () {
860
- var ol = detail.data && detail.data.outline;
861
- // 有章纲:对照栏置顶(可折叠,默认展开);无章纲:一行
862
- // 淡字提示——共创模式下这是「先要章纲」的信号。
863
- return ol ? h(OutlinePanel, { key: "outline", outline: ol })
864
- : h("div", { key: "outline-none", className: "myt-outlineEmpty" }, "暂无章纲");
865
- })(),
866
- h("pre", { key: "body", className: "myt-chapterBody" }, (detail.data && detail.data.body) || "(空章节)"),
867
- ],
868
- openCh ? h("div", { className: "myt-dimGrid" }, FIVE_DIMS.map(function (dim) {
869
- return h("div", { key: dim[0], className: "myt-dim" },
870
- h("div", { className: "myt-dimLabel" }, dim[1]),
871
- h("div", { className: "myt-dimText" }, orDash(openCh[dim[0]])));
872
- })) : null),
873
- openCh ? h("div", { className: "myt-drawerFoot" },
874
- (function () {
875
- var idx = STATUS_FLOW.indexOf(openCh.status);
876
- var next = idx !== -1 && idx < STATUS_FLOW.length - 1 ? STATUS_FLOW[idx + 1] : null;
877
- if (!next) return h("span", { className: "myt-muted" }, "已定稿(终态)");
878
- return [h("span", { className: "myt-muted", key: "t" }, "状态流转:"),
879
- h("button", { key: "b", type: "button", className: "myt-btn myt-btnPrimary", disabled: flowBusy, onClick: function () { advance(openCh); } },
880
- flowBusy ? "流转中…" : "推进到「" + STATUS_LABEL[next] + "」")];
881
- })()) : null)) : null);
1135
+ : (chapters.length ? h(Empty, { text: "该状态下没有章节" }) : h(Empty, { text: "chapters/ 目录还没有章节文件" })));
882
1136
  }
883
1137
 
884
1138
  // ── 角色 tab:卡墙 + 全卡弹层 ───────────────────────────────────────────
@@ -952,20 +1206,28 @@ window.__ModuleLoader__.load({
952
1206
  items.map(function (fs) {
953
1207
  var next = FS_FLOW[col] || null;
954
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";
955
1216
  var chRange = [fs.planted_chapter || "埋设章未标", fs.expected_resolve].filter(Boolean).join(" → ");
956
1217
  return h("div", { key: fs.id || fs.title, className: "myt-fsCard" },
957
1218
  h("div", { className: "myt-fsTitle" },
958
1219
  priDot(fs.priority), h("span", null, orDash(fs.title) || orDash(fs.id)),
959
1220
  // 回填徽章(五期账本回填):区别于写前规划条目,悬浮见来源。
960
- 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),
961
1223
  h("div", { className: "myt-fsMeta" },
962
1224
  h("span", null, col === "resolved" && fs.resolved_chapter ? "回收于 " + fs.resolved_chapter : chRange),
963
1225
  fs.priority && fs.priority !== "normal" ? h("span", null, "优先级 " + (PRI_LABEL[fs.priority] || fs.priority)) : null),
964
1226
  fs.notes ? h("div", { className: "myt-fsNotes", title: fs.notes }, fs.notes) : null,
965
1227
  h("div", { className: "myt-fsActions" },
966
- next ? h("button", { type: "button", className: "myt-btn", disabled: busy || !fs.id, title: fs.id ? undefined : "该条目缺 id,无法写状态", onClick: function () { setStatus(fs, next); } }, busy ? "推进中…" : (col === "planted" ? "推进" : "回收")) : null,
1228
+ next ? h("button", { type: "button", className: "myt-btn", disabled: busy || noWrite, title: noWriteTip, onClick: function () { setStatus(fs, next); } }, busy ? "推进中…" : (col === "planted" ? "推进" : "回收")) : null,
967
1229
  col !== "resolved" && col !== "abandoned"
968
- ? h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy || !fs.id, onClick: function () { setStatus(fs, "abandoned"); } }, "弃用") : null));
1230
+ ? h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy || noWrite, title: noWriteTip, onClick: function () { setStatus(fs, "abandoned"); } }, "弃用") : null));
969
1231
  }),
970
1232
  items.length ? null : h("div", { className: "myt-muted" }, "空"));
971
1233
  })) : h(Empty, { text: "foreshadows.yml 还没有伏笔条目" }));
@@ -997,7 +1259,10 @@ window.__ModuleLoader__.load({
997
1259
  var world = snap.world || [];
998
1260
  var science = snap.science || [];
999
1261
  var research = snap.research || [];
1000
- var groups = {};
1262
+ // 无原型容器:key 来自数据文件的自由文本(category),值为
1263
+ // constructor/__proto__/toString 等时若用普通 {} 会命中 Object.prototype
1264
+ // 真值 → TypeError → 整面板白屏;Object.create(null) 杜绝。
1265
+ var groups = Object.create(null);
1001
1266
  world.forEach(function (w) {
1002
1267
  var key = w.category || "未分类";
1003
1268
  if (!groups[key]) groups[key] = [];
@@ -1015,7 +1280,7 @@ window.__ModuleLoader__.load({
1015
1280
  h("div", { className: "myt-worldHead" },
1016
1281
  h("span", { className: "myt-worldName" }, orDash(w.name)),
1017
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),
1018
- w.description ? h("div", { className: "myt-worldDesc" }, w.description) : null);
1283
+ w.description ? h(MdView, { text: w.description, className: "myt-worldDesc" }) : null);
1019
1284
  }));
1020
1285
  })) : h(Empty, { text: "world/ 目录还没有设定条目" })),
1021
1286
  h("div", { className: "myt-card" },
@@ -1028,7 +1293,7 @@ window.__ModuleLoader__.load({
1028
1293
  h("span", { className: "myt-worldName" }, orDash(s.name)),
1029
1294
  h("span", { className: "myt-badge myt-sciTag", "data-label": label || "hypothesis", title: s.label && !label ? s.label : undefined },
1030
1295
  label ? SCI_LABEL[label] : (s.label || "未标级"))),
1031
- s.description ? h("div", { className: "myt-worldDesc" }, s.description) : null,
1296
+ s.description ? h(MdView, { text: s.description, className: "myt-worldDesc" }) : null,
1032
1297
  (s.references || []).length ? h("div", { className: "myt-refList" },
1033
1298
  s.references.map(function (r, j) { return h("span", { key: j, className: "myt-refItem" }, "参考 " + (j + 1) + ":" + r); })) : null);
1034
1299
  })) : h(Empty, { text: "science/ 目录还没有科学设定" })),
@@ -1054,13 +1319,14 @@ window.__ModuleLoader__.load({
1054
1319
  }
1055
1320
  var clues = snap.clues || [];
1056
1321
  var memories = snap.memories || [];
1057
- var clueGroups = {};
1322
+ // 无原型容器:kind/category 是数据文件自由文本,见 WorldTab 处说明。
1323
+ var clueGroups = Object.create(null);
1058
1324
  clues.forEach(function (c) {
1059
1325
  var key = c.kind || "未分类";
1060
1326
  if (!clueGroups[key]) clueGroups[key] = [];
1061
1327
  clueGroups[key].push(c);
1062
1328
  });
1063
- var memGroups = {};
1329
+ var memGroups = Object.create(null);
1064
1330
  memories.forEach(function (m) {
1065
1331
  var key = m.category || "未分类";
1066
1332
  if (!memGroups[key]) memGroups[key] = [];
@@ -1117,24 +1383,26 @@ window.__ModuleLoader__.load({
1117
1383
  var _tip = useState(null), tip = _tip[0], setTip = _tip[1]; // {x, y, text}
1118
1384
  var wrapRef = useRef(null);
1119
1385
  var canvasRef = useRef(null);
1120
- var simRef = useRef(null);
1386
+ var tipRafRef = useRef(0); // hover 提示 rAF 句柄:卸载/换图时取消在途合帧
1121
1387
  // 数据准备:>30 角色只留 relations 相关节点;角色空时退化为关系端点名。
1122
- var graph = (function () {
1123
- var byName = {};
1388
+ // useMemo 缓存:hover 提示等重渲染不再重复建图(原渲染体内 IIFE 每帧重建)。
1389
+ // 容器用无原型对象:角色名/关系端点是数据文件自由文本,见 WorldTab 处说明。
1390
+ var graph = useMemo(function () {
1391
+ var byName = Object.create(null);
1124
1392
  chars.forEach(function (c) { if (c && c.name && !byName[c.name]) byName[c.name] = c; });
1125
- var deg = {};
1393
+ var deg = Object.create(null);
1126
1394
  rels.forEach(function (r) { deg[r.a] = (deg[r.a] || 0) + 1; deg[r.b] = (deg[r.b] || 0) + 1; });
1127
1395
  var names = chars.length ? chars.map(function (c) { return c.name; }) : Object.keys(deg);
1128
1396
  var linked = names.filter(function (n) { return deg[n]; });
1129
1397
  var drawNames = names.length > 30 ? linked : names;
1130
1398
  var unlinked = names.filter(function (n) { return drawNames.indexOf(n) === -1; });
1131
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 }; });
1132
- var idx = {};
1400
+ var idx = Object.create(null);
1133
1401
  nodes.forEach(function (nd, i) { idx[nd.id] = i; });
1134
1402
  var edges = rels.filter(function (r) { return idx[r.a] !== undefined && idx[r.b] !== undefined; })
1135
1403
  .map(function (r) { return { a: idx[r.a], b: idx[r.b], type: r.type || "", desc: r.description || "", w: edgeWidth(r.intensity) }; });
1136
1404
  return { nodes: nodes, edges: edges, unlinked: unlinked };
1137
- })();
1405
+ }, [snap]); // chars/rels 均由 snap 派生,snap 不变则图不变
1138
1406
  useEffect(function () {
1139
1407
  var wrap = wrapRef.current;
1140
1408
  var canvas = canvasRef.current;
@@ -1142,8 +1410,7 @@ window.__ModuleLoader__.load({
1142
1410
  var ctx = canvas.getContext("2d");
1143
1411
  if (!ctx) return undefined;
1144
1412
  var dpr = (window.devicePixelRatio || 1) > 0 ? window.devicePixelRatio || 1 : 1;
1145
- var sim = { zoom: 1, drag: null, raf: 0, frame: 0, w: 0, h: 0, tokens: null, mx: -1, my: -1 };
1146
- simRef.current = sim;
1413
+ var sim = { zoom: 1, drag: null, raf: 0, frame: 0, w: 0, h: 0, tokens: null };
1147
1414
  // 令牌缓存:每 30 帧重读 computed style,浅/深色切换约半秒内跟上。
1148
1415
  function readTokens() {
1149
1416
  var cs = getComputedStyle(document.body);
@@ -1294,18 +1561,23 @@ window.__ModuleLoader__.load({
1294
1561
  }
1295
1562
  function onMove(e) {
1296
1563
  var p = localXY(e);
1297
- sim.mx = p.x; sim.my = p.y;
1298
1564
  if (sim.drag) {
1299
1565
  var w = toWorld(p.x, p.y);
1300
1566
  sim.drag.node.x = w.x; sim.drag.node.y = w.y;
1301
1567
  return;
1302
1568
  }
1303
- var e2 = hitEdge(p.x, p.y);
1304
- if (e2) {
1305
- setTip({ x: p.x, y: p.y, text: (e2.type ? e2.type + ":" : "") + (e2.desc || "(无描述)") });
1306
- } else {
1307
- setTip(function (prev) { return prev ? null : prev; });
1308
- }
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
+ });
1309
1581
  }
1310
1582
  function onUp() { sim.drag = null; canvas.dataset.dragging = "false"; }
1311
1583
  function onWheel(e) {
@@ -1325,13 +1597,13 @@ window.__ModuleLoader__.load({
1325
1597
  canvas.addEventListener("dblclick", onDbl);
1326
1598
  return function () {
1327
1599
  cancelAnimationFrame(sim.raf);
1600
+ if (tipRafRef.current) { cancelAnimationFrame(tipRafRef.current); tipRafRef.current = 0; }
1328
1601
  if (ro) ro.disconnect();
1329
1602
  canvas.removeEventListener("mousedown", onDown);
1330
1603
  window.removeEventListener("mousemove", onMove);
1331
1604
  window.removeEventListener("mouseup", onUp);
1332
1605
  canvas.removeEventListener("wheel", onWheel);
1333
1606
  canvas.removeEventListener("dblclick", onDbl);
1334
- simRef.current = null;
1335
1607
  };
1336
1608
  }, [snap]);
1337
1609
  if (!graph.nodes.length) return h(Empty, { text: "没有角色或关系数据,无法绘图" });