dsh-maoying 0.1.19 → 0.1.20

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
@@ -246,6 +246,9 @@ window.__ModuleLoader__.load({
246
246
  if (path === "style-save") return tt("toast.styleSaved");
247
247
  if (path === "chapter-save") return tt("toast.chapterSaved");
248
248
  if (path === "outline-save") return tt("toast.outlineSaved");
249
+ if (path === "file-save") return tf("toast.fileSaved", { rel: (d && d.rel) || "" });
250
+ if (path === "file-create") return tf("toast.fileCreated", { rel: (d && d.rel) || "" });
251
+ if (path === "file-delete") return tf("toast.fileDeleted", { rel: (d && d.rel) || "" });
249
252
  return tt("toast.success");
250
253
  }
251
254
 
@@ -545,8 +548,8 @@ window.__ModuleLoader__.load({
545
548
  }
546
549
 
547
550
  // ── 页面骨架:顶栏(工作区下拉 + 刷新 + 关闭)+ tab 条 + 内容区 ─────────
548
- // 数据流:workspaces 每次打开面板都重拉(懒挂载下关闭不卸载,见下方
549
- // activate 监听);state 跟随选中工作区;写操作 POST 成功后
551
+ // 数据流:workspaces 与 state 每次打开面板都重拉(懒挂载下关闭不卸载,
552
+ // 见下方 activate 监听);state 另跟随选中工作区;写操作 POST 成功后
550
553
  // 整份重拉 state(不乐观更新)。选择记 localStorage('maoying.workspace')。
551
554
  function PageShell(props) {
552
555
  var onClose = props.onClose;
@@ -559,6 +562,8 @@ window.__ModuleLoader__.load({
559
562
  var _tb = useState("overview"), tab = _tb[0], setTab = _tb[1];
560
563
  // 章节阅读页(整页视图,替代原右侧抽屉):null = 列表态。
561
564
  var _rd = useState(null), readerFile = _rd[0], setReaderFile = _rd[1];
565
+ // 面板重开序号:每次 activate 广播自增,透传 ChapterReader 驱动本章重拉。
566
+ var _rs = useState(0), reopenSeq = _rs[0], setReopenSeq = _rs[1];
562
567
  // 系统设置:字号档位持久化 + 面板整体缩放(portal 节点读同一 CSS 变量)。
563
568
  var _sm = useState(false), showSettings = _sm[0], setShowSettings = _sm[1];
564
569
  var _fs = useState(function () {
@@ -754,20 +759,27 @@ window.__ModuleLoader__.load({
754
759
  });
755
760
  }
756
761
  useEffect(function () { loadWorkspaces(); }, [loadWorkspaces]);
757
- // 每次打开面板都重拉工作区列表:懒挂载下关闭只是摘掉 active 属性,
758
- // 组件不卸载,上面的 mount effect 整个页面生命周期只跑一次,期间外部
759
- // (CLI / 其他面板)新建或删除的工作区在重开时看不见。mountPanel 每次
760
- // 打开都会广播 dsh-panel-activate(source: "maoying"),借它触发重拉;
761
- // 首次打开的广播早于本组件挂载(render 异步调度),收不到也无妨——
762
- // mount effect 已覆盖首拉。其余面板的同名广播按 source 过滤掉。
762
+ // 每次打开面板都重拉工作区列表与整份 state 快照:懒挂载下关闭只是摘掉
763
+ // active 属性,组件不卸载,上面的 mount effect 整个页面生命周期只跑一
764
+ // 次,期间外部(CLI / agent / 其他面板)新建/删除的工作区、改动的小说
765
+ // 内容在重开时看不见——state 原本只跟 effectiveWs 走,同工作区重开不
766
+ // 会重拉,用户得手点右上角刷新。mountPanel 每次打开都会广播
767
+ // dsh-panel-activate(source: "maoying"),借它触发三重重拉:workspaces、
768
+ // state 快照、阅读页本章(reopenSeq 自增);首次打开的广播早于本组件挂
769
+ // 载(render 异步调度),收不到也无妨——mount effect 已覆盖首拉。其余
770
+ // 面板的同名广播按 source 过滤掉。重拉不动编辑中的草稿:loadState 本就
771
+ // 不碰草稿,阅读页走 silent 刷新(见 ChapterReader)。
763
772
  useEffect(function () {
764
773
  var onActivate = function (event) {
765
774
  var source = event && event.detail && event.detail.source;
766
- if (source === "maoying") loadWorkspaces();
775
+ if (source !== "maoying") return;
776
+ loadWorkspaces();
777
+ reload(); // 重开即最新小说内容,不必手点右上角刷新
778
+ setReopenSeq(function (n) { return n + 1; });
767
779
  };
768
780
  document.addEventListener(ACTIVATE_EVENT, onActivate);
769
781
  return function () { document.removeEventListener(ACTIVATE_EVENT, onActivate); };
770
- }, [loadWorkspaces]);
782
+ }, [loadWorkspaces, reload]);
771
783
  useEffect(function () { loadState(effectiveWs); }, [effectiveWs, loadState]);
772
784
  // 校正后的选择回写记忆(首次打开 / 记忆失效时自动落位)。
773
785
  useEffect(function () {
@@ -807,7 +819,7 @@ window.__ModuleLoader__.load({
807
819
  });
808
820
  } else if (snap) {
809
821
  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); } });
822
+ if (readerFile) body = h(ChapterReader, { file: readerFile, snap: snap, reload: reload, wsPath: effectiveWs, refreshSeq: reopenSeq, onBack: function () { setReaderFile(null); } });
811
823
  else if (tab === "chapters") body = h(ChaptersTab, Object.assign({ onOpenChapter: function (f) { setReaderFile(f); } }, tabProps));
812
824
  else if (tab === "characters") body = h(CharactersTab, tabProps);
813
825
  else if (tab === "foreshadows") body = h(ForeshadowsTab, tabProps);
@@ -906,6 +918,8 @@ window.__ModuleLoader__.load({
906
918
  var project = snap.project || {};
907
919
  // 编辑态:true 时 hero 卡切换为书稿元信息表单(ProjectMetaForm)。
908
920
  var _ed = useState(false), editing = _ed[0], setEditing = _ed[1];
921
+ // 分卷规划账源文件直达(0.1.20 写面):volumes.yml 整文件可编辑。
922
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
909
923
  var totals = snap.totals || {};
910
924
  var byStatus = totals.byStatus || {};
911
925
  var _b = useState(false), phaseBusy = _b[0], setPhaseBusy = _b[1];
@@ -981,7 +995,8 @@ window.__ModuleLoader__.load({
981
995
  }))),
982
996
  volumes.length ? h("div", { className: "myt-card" },
983
997
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("overview.volumes")),
984
- h("span", { className: "myt-boardCount" }, volumes.length)),
998
+ h("span", { className: "myt-boardCount" }, volumes.length),
999
+ srcEditBtn("volumes.yml", function () { setSrcRel("volumes.yml"); })),
985
1000
  h("div", { className: "myt-stack" }, volumes.map(function (v, i) {
986
1001
  return h("div", { key: v.id || i, className: "myt-worldGroup" },
987
1002
  h("div", { className: "myt-listHead" },
@@ -999,7 +1014,8 @@ window.__ModuleLoader__.load({
999
1014
  h("span", { className: "myt-chPath" }, chWhere(ch)),
1000
1015
  h("span", { className: "myt-wordsCell" }, tf("common.charCount", { n: fmtNum(ch.words) })));
1001
1016
  }))
1002
- : h(Empty, { text: tt("chapters.empty") })));
1017
+ : h(Empty, { text: tt("chapters.empty") })),
1018
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
1003
1019
  }
1004
1020
 
1005
1021
  // ── 轻量 Markdown 渲染(零依赖):先整体 HTML 转义再施加 md 变换,杜绝
@@ -1146,6 +1162,20 @@ window.__ModuleLoader__.load({
1146
1162
  "toast.prefix": "毛颖创作台:{msg}", "toast.success": "操作成功", "toast.indexRebuilt": "索引已重建({n} 章)",
1147
1163
  "toast.chapterStatus": "章节状态已更新", "toast.chapterCreated": "已从章纲建章「{title}」(待写)", "toast.foreshadowStatus": "伏笔状态已更新", "toast.phaseAdvanced": "阶段已推进到「{phase}」",
1148
1164
  "toast.styleSaved": "写作风格已保存", "toast.chapterSaved": "章节正文已保存", "toast.outlineSaved": "章纲已保存",
1165
+ "src.modalAria": "源文件编辑", "src.closeAria": "关闭源文件编辑",
1166
+ "src.editSource": "编辑源文件", "src.editSourceTip": "编辑源文件:{rel}",
1167
+ "src.deleteFile": "删除文件", "src.deleteTip": "删除文件:{rel}",
1168
+ "src.loading": "源文件加载中…", "src.loadFailed": "源文件读取失败:{msg}",
1169
+ "src.editHint": "直接编辑文件全文;保存时做结构校验(账本 yml 须解析为条目数组、md 实体卡须带 frontmatter 围栏),语义问题交给保存后的静态体检。",
1170
+ "src.conflictReloaded": "文件已被外部修改,草稿已丢弃,已放回磁盘最新内容。",
1171
+ "src.slugLabel": "文件名 slug", "src.slugPh": "如 lin-waner(小写字母/数字与 -)", "src.slugInvalid": "slug 只接受小写字母/数字与 -,且以字母或数字开头",
1172
+ "src.createHint": "内容已按模板预填,可修改后再创建。", "src.create": "创建", "src.creating": "创建中…",
1173
+ "src.confirmTitle": "确认删除", "src.confirmText": "即将删除 {rel}。此操作不可逆——后悔靠 git 回滚;删除后静态体检会把指向它的悬空引用(伏笔/时间线/关系等)标为问题。",
1174
+ "src.confirmDelete": "确认删除", "src.deleting": "删除中…",
1175
+ "toast.fileSaved": "已保存 {rel}", "toast.fileCreated": "已创建 {rel}", "toast.fileDeleted": "已删除 {rel}",
1176
+ "reader.createOutline": "新建章纲", "reader.deleteChapter": "删除章节",
1177
+ "outline.deleteBtn": "删除章纲",
1178
+ "char.createBtn": "新建角色", "world.createBtn": "新建设定", "world.createScienceBtn": "新建科学设定",
1149
1179
  "api.notJson": "响应不是 JSON(HTTP {code})", "api.timeout": "请求超时({s} 秒)",
1150
1180
  "status.pending": "待写", "status.writing": "写作中", "status.review": "审校中", "status.accepted": "已定稿", "status.rawSuffix": "({raw})",
1151
1181
  "phase.idea": "立意", "phase.setting": "设定", "phase.outline": "大纲", "phase.writing": "写作", "phase.review": "审校",
@@ -1168,7 +1198,7 @@ window.__ModuleLoader__.load({
1168
1198
  "timeline.empty": "timeline.yml 还没有时间线条目", "timeline.importance": "重要度 {n}", "timeline.linkedChapter": "关联章节:{ch}",
1169
1199
  "world.title": "世界观条目", "world.empty": "world/ 目录还没有设定条目", "world.science": "科学设定",
1170
1200
  "world.scienceEmpty": "science/ 目录还没有科学设定", "world.research": "资料检索", "world.ref": "参考 {n}:{r}",
1171
- "world.researchEmpty": "research/ 目录还没有考据资料", "world.subTabsAria": "设定分区",
1201
+ "world.researchEmpty": "research/ 目录还没有考据资料", "world.subTabsAria": "设定分区", "world.catTabsAria": "世界观分类",
1172
1202
  "relations.title": "角色关系图", "relations.counts": "{n} 节点 · {m} 关系",
1173
1203
  "relations.help": "拖节点固定位置 · 滚轮或 +/- 按钮缩放 · 双击复位 · 悬停边看关系描述 · 右上角按钮全屏",
1174
1204
  "relations.unlinkedTitle": "未入图角色(无关系边)", "relations.noData": "没有角色或关系数据,无法绘图",
@@ -1246,6 +1276,20 @@ window.__ModuleLoader__.load({
1246
1276
  "toast.prefix": "Maoying Studio: {msg}", "toast.success": "Success", "toast.indexRebuilt": "Index rebuilt ({n} chapters)",
1247
1277
  "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
1278
  "toast.styleSaved": "Style baseline saved", "toast.chapterSaved": "Chapter text saved", "toast.outlineSaved": "Outline saved",
1279
+ "src.modalAria": "Source file editor", "src.closeAria": "Close source editor",
1280
+ "src.editSource": "Edit source file", "src.editSourceTip": "Edit source file: {rel}",
1281
+ "src.deleteFile": "Delete file", "src.deleteTip": "Delete file: {rel}",
1282
+ "src.loading": "Loading source file…", "src.loadFailed": "Failed to load source file: {msg}",
1283
+ "src.editHint": "Edit the whole file; saving runs structural validation (ledger yml must parse as an entry array, md entity cards must carry frontmatter fences) — semantics are left to the static check after saving.",
1284
+ "src.conflictReloaded": "The file was changed externally; your draft was dropped and the latest on-disk content has been restored.",
1285
+ "src.slugLabel": "File slug", "src.slugPh": "e.g. lin-waner (lowercase letters/digits and -)", "src.slugInvalid": "Slug accepts lowercase letters/digits and - only, starting with a letter or digit",
1286
+ "src.createHint": "Pre-filled from the template — adjust it, then create.", "src.create": "Create", "src.creating": "Creating…",
1287
+ "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.).",
1288
+ "src.confirmDelete": "Delete", "src.deleting": "Deleting…",
1289
+ "toast.fileSaved": "Saved {rel}", "toast.fileCreated": "Created {rel}", "toast.fileDeleted": "Deleted {rel}",
1290
+ "reader.createOutline": "Create outline", "reader.deleteChapter": "Delete chapter",
1291
+ "outline.deleteBtn": "Delete outline",
1292
+ "char.createBtn": "New character", "world.createBtn": "New world entry", "world.createScienceBtn": "New science entry",
1249
1293
  "api.notJson": "Response is not JSON (HTTP {code})", "api.timeout": "Request timed out ({s} s)",
1250
1294
  "status.pending": "Pending", "status.writing": "Writing", "status.review": "Review", "status.accepted": "Accepted", "status.rawSuffix": "({raw})",
1251
1295
  "phase.idea": "Idea", "phase.setting": "Setting", "phase.outline": "Outline", "phase.writing": "Writing", "phase.review": "Review",
@@ -1268,7 +1312,7 @@ window.__ModuleLoader__.load({
1268
1312
  "timeline.empty": "No entries in timeline.yml yet", "timeline.importance": "Importance {n}", "timeline.linkedChapter": "Related chapter: {ch}",
1269
1313
  "world.title": "Worldbuilding entries", "world.empty": "No worldbuilding entries in world/ yet", "world.science": "Science settings",
1270
1314
  "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",
1315
+ "world.researchEmpty": "No research notes in research/ yet", "world.subTabsAria": "Worldbuilding sections", "world.catTabsAria": "Worldbuilding categories",
1272
1316
  "relations.title": "Character relations", "relations.counts": "{n} nodes · {m} relations",
1273
1317
  "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
1318
  "relations.unlinkedTitle": "Unlinked characters (no relation edges)", "relations.noData": "No character or relation data to draw",
@@ -1370,6 +1414,7 @@ window.__ModuleLoader__.load({
1370
1414
  var wsPath = props.wsPath;
1371
1415
  var snap = props.snap;
1372
1416
  var reload = props.reload;
1417
+ var reopenSeq = props.refreshSeq || 0; // 面板重开序号(PageShell 广播自增)
1373
1418
  var onBack = props.onBack;
1374
1419
  var _d = useState(null), detail = _d[0], setDetail = _d[1]; // {loading, data, err}
1375
1420
  var _b = useState(false), flowBusy = _b[0], setFlowBusy = _b[1];
@@ -1388,6 +1433,21 @@ window.__ModuleLoader__.load({
1388
1433
  // (只在保存冲突路径置位;顶栏刷新、状态流转等其他重拉不动正在编辑的草稿)。
1389
1434
  var bodyResetRef = useRef(false);
1390
1435
  var olResetRef = useRef(false);
1436
+ // 0.1.20 写面:新建章纲(无章纲态)/ 删除章节 / 删除章纲(各带确认弹层)。
1437
+ var stem = file.replace(/\.md$/i, "");
1438
+ var stemOk = /^v\d+-c\d+$/.test(stem); // 章纲 rel 挂钩 vXX-cXXX stem,不合规范不亮按钮
1439
+ var _co = useState(false), coBusy = _co[0], setCoBusy = _co[1];
1440
+ var _dc = useState(false), delChapterOpen = _dc[0], setDelChapterOpen = _dc[1];
1441
+ var _do = useState(false), delOutlineOpen = _do[0], setDelOutlineOpen = _do[1];
1442
+ function createOutline() {
1443
+ if (coBusy || !stemOk) return;
1444
+ setCoBusy(true);
1445
+ // 骨架照 spec 六件章纲的其中五件预填(章尾钩子独立成节,留给写的人
1446
+ // 展开成 `## 钩子`);服务端只验形状,成败提示走 toast 通道。
1447
+ apiPost("file-create", { path: wsPath, rel: "outlines/" + stem + ".md", text: outlineTemplateOf(ch, stem) })
1448
+ .then(function () { return reload().then(function () { return loadDetail(file); }); })
1449
+ .catch(function () {}).finally(function () { setCoBusy(false); });
1450
+ }
1391
1451
  var loadDetail = useCallback(function (f, opts) {
1392
1452
  var seq = ++detailSeqRef.current;
1393
1453
  if (!f) { setDetail(null); return; }
@@ -1404,6 +1464,12 @@ window.__ModuleLoader__.load({
1404
1464
  });
1405
1465
  }, [wsPath]);
1406
1466
  useEffect(function () { loadDetail(file); }, [file, loadDetail]);
1467
+ // 面板重开(reopenSeq 递增):重拉本章让磁盘最新内容即时可见。编辑中
1468
+ // 走 silent——不闪骨架、不打断 textarea,draft 只在 409 冲突重置路径才
1469
+ // 动(上方注释的纪律不变);阅读态非 silent,换血后照常渲染新正文。
1470
+ useEffect(function () {
1471
+ if (reopenSeq > 0) loadDetail(file, { silent: editing });
1472
+ }, [reopenSeq]);
1407
1473
  // 切章必须退出编辑态并清草稿:草稿属于「那一章的那一次编辑」,跨章带过
1408
1474
  // 去会把 A 章的文字存进 B 章文件。
1409
1475
  useEffect(function () {
@@ -1510,15 +1576,31 @@ window.__ModuleLoader__.load({
1510
1576
  type: "button", className: "myt-btn myt-copyBtn",
1511
1577
  title: tt("reader.copyPathTip"), "aria-label": tt("reader.copyPathTip"),
1512
1578
  onClick: copyChapterPath,
1513
- }, ic("IconCopyOutlineRegular", 13), tt("reader.copyPath")))),
1579
+ }, ic("IconCopyOutlineRegular", 13), tt("reader.copyPath")),
1580
+ // 删除章节(0.1.20 写面):服务端不做二次确认,弹层在下方强制;
1581
+ // 删除成功 → 重拉快照 + 退出阅读页回列表(章节已不存在)。
1582
+ h("button", {
1583
+ type: "button", className: "myt-btn myt-btnDanger",
1584
+ title: tt("reader.deleteChapter"), "aria-label": tt("reader.deleteChapter"),
1585
+ onClick: function () { setDelChapterOpen(true); },
1586
+ }, tt("reader.deleteChapter")))),
1514
1587
  h("div", { className: "myt-readerBody" },
1515
1588
  !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
1589
  : detail.err ? h(ErrorStrip, { text: tf("reader.loadFailed", { msg: detail.err }), onRetry: function () { loadDetail(file); } })
1517
1590
  : [
1518
1591
  (function () {
1519
1592
  var ol = detail.data && detail.data.outline;
1520
- return ol ? h(OutlinePanel, { key: "outline", outline: ol, editor: outlineEditor() })
1521
- : h("div", { key: "outline-none", className: "myt-outlineEmpty" }, tt("reader.noOutline"));
1593
+ return ol ? h(OutlinePanel, { key: "outline", outline: ol, editor: outlineEditor(), onDelete: stemOk ? function () { setDelOutlineOpen(true); } : null })
1594
+ : [
1595
+ h("div", { key: "outline-none", className: "myt-outlineEmpty" }, tt("reader.noOutline")),
1596
+ // 新建章纲(0.1.20 写面):outlines/<stem>.md 原不存在才走到
1597
+ // 这里;预填五件内容骨架,建好即重拉详情点亮章纲对照栏。
1598
+ stemOk ? h("button", {
1599
+ key: "outline-create", type: "button", className: "myt-btn",
1600
+ disabled: coBusy, title: tt("reader.createOutline"), "aria-label": tt("reader.createOutline"),
1601
+ onClick: createOutline,
1602
+ }, coBusy ? tt("src.creating") : tt("reader.createOutline")) : null,
1603
+ ];
1522
1604
  })(),
1523
1605
  editing
1524
1606
  ? h("div", { key: "body", className: "myt-bodyEdit" },
@@ -1546,7 +1628,29 @@ window.__ModuleLoader__.load({
1546
1628
  return [h("span", { className: "myt-muted", key: "t" }, tt("reader.statusFlow")),
1547
1629
  h("button", { key: "b", type: "button", className: "myt-btn myt-btnPrimary", disabled: flowBusy, onClick: advance },
1548
1630
  flowBusy ? tt("reader.flowing") : tf("reader.advanceTo", { next: tt("status." + next) }))];
1549
- })()) : null);
1631
+ })()) : null,
1632
+ delChapterOpen ? h(ConfirmModal, {
1633
+ title: tt("reader.deleteChapter"),
1634
+ text: tf("src.confirmText", { rel: "chapters/" + file }),
1635
+ onCancel: function () { setDelChapterOpen(false); },
1636
+ onConfirm: function () {
1637
+ return apiPost("file-delete", { path: wsPath, rel: "chapters/" + file }).then(function () {
1638
+ setDelChapterOpen(false);
1639
+ return reload().then(onBack); // 章已删:重拉快照后退出阅读页回列表
1640
+ });
1641
+ },
1642
+ }) : null,
1643
+ delOutlineOpen ? h(ConfirmModal, {
1644
+ title: tt("outline.deleteBtn"),
1645
+ text: tf("src.confirmText", { rel: "outlines/" + stem + ".md" }),
1646
+ onCancel: function () { setDelOutlineOpen(false); },
1647
+ onConfirm: function () {
1648
+ return apiPost("file-delete", { path: wsPath, rel: "outlines/" + stem + ".md" }).then(function () {
1649
+ setDelOutlineOpen(false);
1650
+ return reload().then(function () { return loadDetail(file); }); // 章纲栏熄灭、空态亮出
1651
+ });
1652
+ },
1653
+ }) : null);
1550
1654
  }
1551
1655
 
1552
1656
  // ── 书稿元信息编辑表单(概览「编辑」按钮展开):可视化 project.yml 平
@@ -1698,6 +1802,13 @@ window.__ModuleLoader__.load({
1698
1802
  onClick: function (e) { e.stopPropagation(); ed.start(); },
1699
1803
  onKeyDown: function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); ed.start(); } },
1700
1804
  }, ic("IconEditOutlineRegular", 12), tt("reader.edit")) : null,
1805
+ // 删除章纲(0.1.20 写面):同 head-span 形态;确认弹层由 ChapterReader 渲染。
1806
+ props.onDelete && ed !== null && !ed.active ? h("span", {
1807
+ className: "myt-outlineEditBtn myt-outlineDelBtn", role: "button", tabIndex: 0,
1808
+ title: tt("outline.deleteBtn"), "aria-label": tt("outline.deleteBtn"),
1809
+ onClick: function (e) { e.stopPropagation(); props.onDelete(); },
1810
+ onKeyDown: function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); e.stopPropagation(); props.onDelete(); } },
1811
+ }, tt("outline.deleteBtn")) : null,
1701
1812
  h("span", { className: "myt-outlineChevron", "aria-hidden": "true" },
1702
1813
  ic(open ? "IconChevronDownOutlineRegular" : "IconChevronRightOutlineRegular", 14))),
1703
1814
  open ? (ed !== null && ed.active
@@ -1764,6 +1875,171 @@ window.__ModuleLoader__.load({
1764
1875
  : h(MdView, { text: (detail.data && detail.data.body) || tt("outline.empty"), className: "myt-outlineBody" }))));
1765
1876
  }
1766
1877
 
1878
+ // ── 受管文件通用弹层三件套(0.1.20「全量可编辑写面」)────────────────────
1879
+ // 新建实体卡的预填模板(服务端只验形状,内容由客户端模板兜底——spec 各
1880
+ // 文件的最小骨架;数据文件按约定中文书写,不随界面语言切换)。
1881
+ var MANAGED_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/; // 与服务端 managedRelCheck 同一条正则
1882
+ var TPL_CHARACTER = "---\nname: \nrole: supporting\n---\n\n";
1883
+ var TPL_WORLD = "---\ncategory: 地点\n---\n\n";
1884
+ var TPL_SCIENCE = "---\nlabel: known\n---\n\n";
1885
+ function outlineTemplateOf(ch, stem) {
1886
+ var num = ch && ch.num ? ch.num : ((stem.match(/c(\d+)/i) || [])[1] || "?");
1887
+ var title = ch && ch.title ? " " + ch.title : "";
1888
+ return "# 第" + num + "章" + title + " 章纲\n\n- 本章目标:\n- 节拍:\n- 出场角色:\n- 需埋/需收伏笔:\n- 与前章接口:\n";
1889
+ }
1890
+ // 各 tab 复用的「编辑源文件 / 删除文件」小按钮:title 点名文件路径,
1891
+ // 删除走红色按钮(确认弹层由调用方持 state 渲染)。
1892
+ function srcEditBtn(rel, onOpen) {
1893
+ return h("button", {
1894
+ type: "button", className: "myt-btn",
1895
+ title: tf("src.editSourceTip", { rel: rel }), "aria-label": tf("src.editSourceTip", { rel: rel }),
1896
+ onClick: onOpen,
1897
+ }, ic("IconEditOutlineRegular", 13), tt("src.editSource"));
1898
+ }
1899
+ function srcDeleteBtn(rel, onAsk) {
1900
+ return h("button", {
1901
+ type: "button", className: "myt-btn myt-btnDanger",
1902
+ title: tf("src.deleteTip", { rel: rel }), "aria-label": tf("src.deleteTip", { rel: rel }),
1903
+ onClick: onAsk,
1904
+ }, tt("src.deleteFile"));
1905
+ }
1906
+ // 编辑既有受管文件全文。打开即 GET file-source(请求序号防快速开关时的
1907
+ // 过期响应,对照 ChapterReader);保存 POST file-save 带 base_hash 基线,
1908
+ // 409 = 编辑期间文件被外部改过——照 ChapterReader 纪律:草稿丢弃、重拉
1909
+ // 磁盘真值放回编辑器,别让用户在过期基线上盲改。成功后关弹层并触发调用
1910
+ // 方 reload(快照重算),成败提示走 apiPost 的 toast 通道。
1911
+ function SourceEditorModal(props) {
1912
+ var title = props.title;
1913
+ var rel = props.rel;
1914
+ var wsPath = props.wsPath;
1915
+ var onClose = props.onClose;
1916
+ var onSaved = props.onSaved;
1917
+ var _d = useState(null), detail = _d[0], setDetail = _d[1]; // { data, err }
1918
+ var _dr = useState(""), draft = _dr[0], setDraft = _dr[1];
1919
+ var _b = useState(false), busy = _b[0], setBusy = _b[1];
1920
+ var _e = useState(""), err = _e[0], setErr = _e[1];
1921
+ var seqRef = useRef(0);
1922
+ var load = useCallback(function () {
1923
+ var seq = ++seqRef.current;
1924
+ setDetail(null); setErr("");
1925
+ apiGet("file-source", { path: wsPath, rel: rel }).then(function (d) {
1926
+ if (seq !== seqRef.current) return;
1927
+ setDetail({ data: d, err: "" });
1928
+ setDraft(d && typeof d.text === "string" ? d.text : "");
1929
+ }).catch(function (e2) {
1930
+ if (seq !== seqRef.current) return;
1931
+ setDetail({ data: null, err: e2 && e2.message ? e2.message : tt("common.unknownErr") });
1932
+ });
1933
+ }, [wsPath, rel]);
1934
+ useEffect(function () { load(); }, [load]);
1935
+ function save() {
1936
+ if (busy || !detail || !detail.data) return;
1937
+ setBusy(true); setErr("");
1938
+ apiPost("file-save", { path: wsPath, rel: rel, text: draft, base_hash: detail.data.hash }).then(function () {
1939
+ onClose();
1940
+ if (onSaved) onSaved();
1941
+ }).catch(function (e2) {
1942
+ if (e2 && e2.status === 409) { setErr(tt("src.conflictReloaded")); load(); }
1943
+ else setErr(e2 && e2.message ? e2.message : tt("common.unknownErr"));
1944
+ }).finally(function () { setBusy(false); });
1945
+ }
1946
+ return h(MaskPortal, {
1947
+ className: "myt-modalMask",
1948
+ onMouseDown: function (e) { if (e.target === e.currentTarget && !busy) onClose(); },
1949
+ },
1950
+ h("div", { className: "myt-modalCard myt-srcCard", role: "dialog", "aria-modal": "true", "aria-label": tt("src.modalAria") },
1951
+ h("div", { className: "myt-modalHead" },
1952
+ h("h2", { className: "myt-modalTitle" }, title),
1953
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("src.closeAria"), onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
1954
+ h("div", { className: "myt-modalBody" },
1955
+ h("div", { className: "myt-srcRel" }, rel),
1956
+ h("div", { className: "myt-muted myt-srcHint" }, tt("src.editHint")),
1957
+ !detail ? h("div", { className: "myt-muted" }, tt("src.loading"))
1958
+ : detail.err ? h(ErrorStrip, { text: tf("src.loadFailed", { msg: detail.err }), onRetry: load })
1959
+ : [
1960
+ h("textarea", { key: "a", className: "myt-setInput myt-setArea myt-srcArea", rows: 16, spellCheck: false,
1961
+ value: draft, onChange: function (e) { setDraft(e.target.value); } }),
1962
+ err ? h(ErrorStrip, { key: "e", text: err }) : null,
1963
+ h("div", { key: "s", className: "myt-setActions" },
1964
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: onClose }, tt("novel.cancel")),
1965
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save }, busy ? tt("novel.saving") : tt("novel.save"))),
1966
+ ])));
1967
+ }
1968
+ // 新建受管 md 实体卡(角色/设定/科学设定共用):slug 客户端预校(与服务
1969
+ // 端同一条正则,错在敲键时就报),内容按模板预填可改;失败(409 已存在等)
1970
+ // 留在弹层内报错,草稿不丢——建卡是低频操作,丢一遍模板重填太伤。
1971
+ function CreateFileModal(props) {
1972
+ var title = props.title;
1973
+ var dir = props.dir;
1974
+ var template = props.template;
1975
+ var wsPath = props.wsPath;
1976
+ var onClose = props.onClose;
1977
+ var onSaved = props.onSaved;
1978
+ var _s = useState(""), slug = _s[0], setSlug = _s[1];
1979
+ var _t = useState(template), text = _t[0], setText = _t[1];
1980
+ var _b = useState(false), busy = _b[0], setBusy = _b[1];
1981
+ var _e = useState(""), err = _e[0], setErr = _e[1];
1982
+ var slugOk = MANAGED_SLUG_RE.test(slug);
1983
+ function create() {
1984
+ if (busy) return;
1985
+ if (!slugOk) { setErr(tt("src.slugInvalid")); return; }
1986
+ setBusy(true); setErr("");
1987
+ apiPost("file-create", { path: wsPath, rel: dir + "/" + slug + ".md", text: text }).then(function () {
1988
+ onClose();
1989
+ if (onSaved) onSaved();
1990
+ }).catch(function (e2) { setErr(e2 && e2.message ? e2.message : tt("common.unknownErr")); })
1991
+ .finally(function () { setBusy(false); });
1992
+ }
1993
+ return h(MaskPortal, {
1994
+ className: "myt-modalMask",
1995
+ onMouseDown: function (e) { if (e.target === e.currentTarget && !busy) onClose(); },
1996
+ },
1997
+ h("div", { className: "myt-modalCard", role: "dialog", "aria-modal": "true", "aria-label": title },
1998
+ h("div", { className: "myt-modalHead" },
1999
+ h("h2", { className: "myt-modalTitle" }, title),
2000
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("src.closeAria"), onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
2001
+ h("div", { className: "myt-modalBody" },
2002
+ h("label", { className: "myt-setField" },
2003
+ h("span", { className: "myt-setFieldLabel" }, tt("src.slugLabel")),
2004
+ h("input", { type: "text", className: "myt-setInput", placeholder: tt("src.slugPh"), value: slug, spellCheck: false,
2005
+ onChange: function (e) { setSlug(e.target.value); setErr(""); } })),
2006
+ h("div", { className: "myt-muted myt-srcHint" }, tt("src.createHint")),
2007
+ h("textarea", { className: "myt-setInput myt-setArea myt-srcArea", rows: 10, spellCheck: false,
2008
+ value: text, onChange: function (e) { setText(e.target.value); } }),
2009
+ err ? h(ErrorStrip, { text: err }) : null,
2010
+ h("div", { className: "myt-setActions" },
2011
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: onClose }, tt("novel.cancel")),
2012
+ h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy || slug === "", onClick: create }, busy ? tt("src.creating") : tt("src.create"))))));
2013
+ }
2014
+ // 删除二次确认(服务端不做二次确认,UI 层强制——删除不可逆,文案点名后
2015
+ // 悔靠 git、check 会标孤儿引用)。onConfirm 返回 promise,在途禁双击;
2016
+ // 成败提示走 apiPost 的 toast 通道。
2017
+ function ConfirmModal(props) {
2018
+ var title = props.title;
2019
+ var text = props.text;
2020
+ var onCancel = props.onCancel;
2021
+ var onConfirm = props.onConfirm;
2022
+ var _b = useState(false), busy = _b[0], setBusy = _b[1];
2023
+ function confirm() {
2024
+ if (busy) return;
2025
+ setBusy(true);
2026
+ Promise.resolve(onConfirm()).catch(function () {}).finally(function () { setBusy(false); });
2027
+ }
2028
+ return h(MaskPortal, {
2029
+ className: "myt-modalMask",
2030
+ onMouseDown: function (e) { if (e.target === e.currentTarget && !busy) onCancel(); },
2031
+ },
2032
+ h("div", { className: "myt-modalCard myt-confirmCard", role: "alertdialog", "aria-modal": "true", "aria-label": title },
2033
+ h("div", { className: "myt-modalHead" },
2034
+ h("h2", { className: "myt-modalTitle" }, title),
2035
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("src.closeAria"), disabled: busy, onClick: onCancel }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×"))),
2036
+ h("div", { className: "myt-modalBody" },
2037
+ h("div", { className: "myt-confirmText" }, text),
2038
+ h("div", { className: "myt-setActions" },
2039
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: onCancel }, tt("novel.cancel")),
2040
+ h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy, onClick: confirm }, busy ? tt("src.deleting") : tt("src.confirmDelete"))))));
2041
+ }
2042
+
1767
2043
  function ChaptersTab(props) {
1768
2044
  var snap = props.snap;
1769
2045
  var wsPath = props.wsPath;
@@ -1885,10 +2161,20 @@ window.__ModuleLoader__.load({
1885
2161
  ];
1886
2162
  function CharactersTab(props) {
1887
2163
  var characters = props.snap.characters || [];
2164
+ var wsPath = props.wsPath;
2165
+ var reload = props.reload;
1888
2166
  var _m = useState(null), modalFile = _m[0], setModalFile = _m[1];
2167
+ // 受管文件三态:编辑 rel / 新建弹层开关 / 删除确认 rel(0.1.20 写面)。
2168
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
2169
+ var _new = useState(false), createOpen = _new[0], setCreateOpen = _new[1];
2170
+ var _del = useState(null), delRel = _del[0], setDelRel = _del[1];
1889
2171
  var modal = null;
1890
2172
  for (var i = 0; i < characters.length; i++) { if (characters[i].file === modalFile) { modal = characters[i]; break; } }
1891
2173
  return h("div", { className: "myt-stack" },
2174
+ h("div", { className: "myt-cardHead" },
2175
+ h("h3", { className: "myt-cardTitle" }, tt("tabs.characters")),
2176
+ h("span", { className: "myt-boardCount" }, characters.length),
2177
+ h("button", { type: "button", className: "myt-btn", onClick: function () { setCreateOpen(true); } }, tt("char.createBtn"))),
1892
2178
  characters.length ? h("div", { className: "myt-charWall" }, characters.map(function (c) {
1893
2179
  return h("button", { key: c.file, type: "button", className: "myt-card myt-charCard", onClick: function () { setModalFile(c.file); } },
1894
2180
  h("div", { className: "myt-charHead" },
@@ -1923,7 +2209,27 @@ window.__ModuleLoader__.load({
1923
2209
  : "—")),
1924
2210
  h("div", { className: "myt-kvRow" },
1925
2211
  h("span", { className: "myt-kvKey" }, tt("char.file")),
1926
- h("span", { className: "myt-kvVal" }, modal.file || "—"))))) : null);
2212
+ h("span", { className: "myt-kvVal" }, modal.file || "—")),
2213
+ // 源文件直达(0.1.20 写面):rel 由快照 basename 拼目录前缀。
2214
+ h("div", { className: "myt-setActions" },
2215
+ h("button", { type: "button", className: "myt-btn", onClick: function () { setSrcRel("characters/" + modal.file); } },
2216
+ ic("IconEditOutlineRegular", 13), tt("src.editSource")),
2217
+ h("button", { type: "button", className: "myt-btn myt-btnDanger", onClick: function () { setDelRel("characters/" + modal.file); } },
2218
+ tt("src.deleteFile")))))) : null,
2219
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null,
2220
+ createOpen ? h(CreateFileModal, { title: tt("char.createBtn"), dir: "characters", template: TPL_CHARACTER, wsPath: wsPath, onClose: function () { setCreateOpen(false); }, onSaved: reload }) : null,
2221
+ delRel ? h(ConfirmModal, {
2222
+ title: tf("src.deleteTip", { rel: delRel }),
2223
+ text: tf("src.confirmText", { rel: delRel }),
2224
+ onCancel: function () { setDelRel(null); },
2225
+ onConfirm: function () {
2226
+ return apiPost("file-delete", { path: wsPath, rel: delRel }).then(function () {
2227
+ setDelRel(null);
2228
+ if (modalFile && "characters/" + modalFile === delRel) setModalFile(null); // 删的是打开中的卡:详情弹层一并收
2229
+ return reload();
2230
+ });
2231
+ },
2232
+ }) : null);
1927
2233
  }
1928
2234
 
1929
2235
  // ── 伏笔 tab:四列看板(列头计数 + 推进按钮 + priority 色点)────────────
@@ -1939,7 +2245,12 @@ window.__ModuleLoader__.load({
1939
2245
  .then(reload).catch(function () {}).finally(function () { setBusyId(null); });
1940
2246
  }
1941
2247
  var list = snap.foreshadows || [];
2248
+ // 伏笔台账源文件直达(0.1.20 写面):foreshadows.yml 整文件可编辑。
2249
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
1942
2250
  return h("div", { className: "myt-stack" },
2251
+ h("div", { className: "myt-cardHead" },
2252
+ h("h3", { className: "myt-cardTitle" }, tt("tabs.foreshadows")),
2253
+ srcEditBtn("foreshadows.yml", function () { setSrcRel("foreshadows.yml"); })),
1943
2254
  list.length ? h("div", { className: "myt-board" }, FS_ORDER.map(function (col) {
1944
2255
  var items = list.filter(function (f) { return (f.status || "planted") === col; });
1945
2256
  return h("div", { key: col, className: "myt-boardCol" },
@@ -1973,17 +2284,33 @@ window.__ModuleLoader__.load({
1973
2284
  ? h("button", { type: "button", className: "myt-btn myt-btnDanger", disabled: busy || noWrite, title: noWriteTip, onClick: function () { setStatus(fs, "abandoned"); } }, tt("foreshadow.abandon")) : null));
1974
2285
  }),
1975
2286
  items.length ? null : h("div", { className: "myt-muted" }, tt("foreshadow.colEmpty")));
1976
- })) : h(Empty, { text: tt("foreshadow.empty") }));
2287
+ })) : h(Empty, { text: tt("foreshadow.empty") }),
2288
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
1977
2289
  }
1978
2290
 
1979
2291
  // ── 时间线 tab:垂直轴 + importance 点位(1-5 → 大小/深浅)──────────────
1980
2292
  function TimelineTab(props) {
1981
2293
  var items = (props.snap.timeline || []).slice();
2294
+ var wsPath = props.wsPath;
2295
+ var reload = props.reload;
2296
+ // 时间线账本源文件直达(0.1.20 写面):空态也保留入口——账本空不等于
2297
+ // 不可编辑,恰恰是第一条要从这里写进去。
2298
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
2299
+ var head = h("div", { className: "myt-cardHead" },
2300
+ h("h3", { className: "myt-cardTitle" }, tt("tabs.timeline")),
2301
+ srcEditBtn("timeline.yml", function () { setSrcRel("timeline.yml"); }));
1982
2302
  if (items.length) {
1983
2303
  items.sort(function (a, b) { return String(a.year || "").localeCompare(String(b.year || ""), "zh-CN", { numeric: true }); });
1984
2304
  }
1985
- if (!items.length) return h(Empty, { text: tt("timeline.empty") });
1986
- return h("div", { className: "myt-card" },
2305
+ if (!items.length) {
2306
+ return h("div", { className: "myt-stack" },
2307
+ head,
2308
+ h(Empty, { text: tt("timeline.empty") }),
2309
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
2310
+ }
2311
+ return h("div", { className: "myt-stack" },
2312
+ head,
2313
+ h("div", { className: "myt-card" },
1987
2314
  h("div", { className: "myt-timeline" }, items.map(function (t, i) {
1988
2315
  var imp = Math.min(5, Math.max(1, typeof t.importance === "number" ? t.importance : 3));
1989
2316
  return h("div", { key: i, className: "myt-tlRow" },
@@ -1993,7 +2320,8 @@ window.__ModuleLoader__.load({
1993
2320
  h("div", { className: "myt-tlTitle" }, orDash(t.title)),
1994
2321
  t.description ? h("div", { className: "myt-tlDesc" }, t.description) : null,
1995
2322
  t.chapter ? h("div", { className: "myt-tlChapter" }, tf("timeline.linkedChapter", { ch: t.chapter })) : null));
1996
- })));
2323
+ }))),
2324
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
1997
2325
  }
1998
2326
 
1999
2327
  // ── 设定 tab:world 按 category 分组 + science 三色标签 ─────────────────
@@ -2141,9 +2469,16 @@ window.__ModuleLoader__.load({
2141
2469
 
2142
2470
  function WorldTab(props) {
2143
2471
  var snap = props.snap;
2472
+ var wsPath = props.wsPath;
2473
+ var reload = props.reload;
2144
2474
  var world = snap.world || [];
2145
2475
  var science = snap.science || [];
2146
2476
  var research = snap.research || [];
2477
+ // 受管文件三态:编辑 rel / 新建分区(world|science)/ 删除确认 rel。
2478
+ // research 是调研笔记目录,v1 无面板入口(快照有卡,写面暂不开放)。
2479
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
2480
+ var _new = useState(null), createDir = _new[0], setCreateDir = _new[1];
2481
+ var _del = useState(null), delRel = _del[0], setDelRel = _del[1];
2147
2482
  // 无原型容器:key 来自数据文件的自由文本(category),值为
2148
2483
  // constructor/__proto__/toString 等时若用普通 {} 会命中 Object.prototype
2149
2484
  // 真值 → TypeError → 整面板白屏;Object.create(null) 杜绝。
@@ -2158,11 +2493,16 @@ window.__ModuleLoader__.load({
2158
2493
  // state 存在本组件(切走再切回不丢;卡头 h3 随卡一起只画激活分区)。
2159
2494
  // research 空时原实现整卡隐藏,现 tab 恒在、空态走 world.researchEmpty。
2160
2495
  var _st = useState("world"), subTab = _st[0], setSubTab = _st[1];
2496
+ // world 分区内的二级分类 tab:state 只存用户上次的偏好,快照重拉后该
2497
+ // 分类可能已不存在(条目被删/改名),此时回落第一个分类;null = 首选
2498
+ // 第一个。单分类时不出 tab 条、直接平铺。
2499
+ var _cat = useState(null), catPref = _cat[0], setCatPref = _cat[1];
2161
2500
  var subDefs = [
2162
2501
  ["world", tt("world.title"), world.length],
2163
2502
  ["science", tt("world.science"), science.length],
2164
2503
  ["research", tt("world.research"), research.length],
2165
2504
  ];
2505
+ var activeCat = catPref !== null && groupKeys.indexOf(catPref) !== -1 ? catPref : groupKeys[0];
2166
2506
  return h("div", { className: "myt-stack" },
2167
2507
  h("nav", { className: "myt-subTabs", "aria-label": tt("world.subTabsAria") },
2168
2508
  subDefs.map(function (s) {
@@ -2172,21 +2512,32 @@ window.__ModuleLoader__.load({
2172
2512
  }, s[1], h("span", { className: "myt-boardCount" }, String(s[2])));
2173
2513
  })),
2174
2514
  subTab === "world" ? h("div", { className: "myt-card" },
2175
- h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.title"))),
2176
- groupKeys.length ? h("div", { className: "myt-stack" }, groupKeys.map(function (key) {
2177
- return h("div", { key: key, className: "myt-worldGroup" },
2178
- h("div", { className: "myt-listHead" }, key, h("span", { className: "myt-boardCount" }, groups[key].length)),
2179
- groups[key].map(function (w, i) {
2180
- return h("div", { key: w.file || i, className: "myt-worldItem" },
2181
- h("div", { className: "myt-worldHead" },
2182
- h("span", { className: "myt-worldName" }, orDash(w.name)),
2183
- (w.tags || []).length ? h("span", { className: "myt-tagRow" }, w.tags.map(function (t, j) { return h("span", { key: j, className: "myt-tag" }, t); })) : null),
2184
- w.description ? h(MdView, { text: w.description, className: "myt-worldDesc" }) : null);
2185
- }));
2515
+ h("div", { className: "myt-cardHead" },
2516
+ h("h3", { className: "myt-cardTitle" }, tt("world.title")),
2517
+ h("button", { type: "button", className: "myt-btn", onClick: function () { setCreateDir("world"); } }, tt("world.createBtn"))),
2518
+ // 多分类:二级子 tab 条(分类名 + 条目计数),分组头不再需要——tab
2519
+ // 已承载分类名与计数;单分类/无分类不出 tab 条直接平铺。
2520
+ groupKeys.length > 1 ? h("nav", { className: "myt-subTabs myt-subTabsInner", "aria-label": tt("world.catTabsAria") },
2521
+ groupKeys.map(function (key) {
2522
+ return h("button", {
2523
+ key: key, type: "button", className: "myt-subTab", "data-active": activeCat === key ? "true" : undefined,
2524
+ onClick: function () { setCatPref(key); },
2525
+ }, key, h("span", { className: "myt-boardCount" }, String(groups[key].length)));
2526
+ })) : null,
2527
+ groupKeys.length ? h("div", { className: "myt-stack" }, groups[activeCat].map(function (w, i) {
2528
+ return h("div", { key: w.file || i, className: "myt-worldItem" },
2529
+ h("div", { className: "myt-worldHead" },
2530
+ h("span", { className: "myt-worldName" }, orDash(w.name)),
2531
+ (w.tags || []).length ? h("span", { className: "myt-tagRow" }, w.tags.map(function (t, j) { return h("span", { key: j, className: "myt-tag" }, t); })) : null),
2532
+ w.description ? h(MdView, { text: w.description, className: "myt-worldDesc" }) : null,
2533
+ h("div", { className: "myt-itemActions" },
2534
+ srcEditBtn("world/" + w.file, function () { setSrcRel("world/" + w.file); }),
2535
+ srcDeleteBtn("world/" + w.file, function () { setDelRel("world/" + w.file); })));
2186
2536
  })) : h(Empty, { text: tt("world.empty") })) : null,
2187
2537
  subTab === "science" ? h("div", { className: "myt-card" },
2188
2538
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.science")),
2189
- h("span", { className: "myt-boardCount" }, science.length)),
2539
+ h("span", { className: "myt-boardCount" }, science.length),
2540
+ h("button", { type: "button", className: "myt-btn", onClick: function () { setCreateDir("science"); } }, tt("world.createScienceBtn"))),
2190
2541
  science.length ? h("div", { className: "myt-stack" }, science.map(function (s, i) {
2191
2542
  var label = ["known", "extrapolation", "hypothesis"].indexOf(s.label) !== -1 ? s.label : null;
2192
2543
  return h("div", { key: s.file || i, className: "myt-worldItem" },
@@ -2196,7 +2547,10 @@ window.__ModuleLoader__.load({
2196
2547
  label ? tt("sci." + label) : (s.label || tt("world.unlabeled")))),
2197
2548
  s.description ? h(MdView, { text: s.description, className: "myt-worldDesc" }) : null,
2198
2549
  (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);
2550
+ s.references.map(function (r, j) { return h("span", { key: j, className: "myt-refItem" }, tf("world.ref", { n: j + 1, r: r })); })) : null,
2551
+ h("div", { className: "myt-itemActions" },
2552
+ srcEditBtn("science/" + s.file, function () { setSrcRel("science/" + s.file); }),
2553
+ srcDeleteBtn("science/" + s.file, function () { setDelRel("science/" + s.file); })));
2200
2554
  })) : h(Empty, { text: tt("world.scienceEmpty") })) : null,
2201
2555
  subTab === "research" ? h("div", { className: "myt-card" },
2202
2556
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.research")),
@@ -2208,7 +2562,25 @@ window.__ModuleLoader__.load({
2208
2562
  r.description ? h(MdView, { text: r.description, className: "myt-worldDesc" }) : null,
2209
2563
  (r.sources || []).length ? h("div", { className: "myt-refList" },
2210
2564
  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);
2565
+ })) : h(Empty, { text: tt("world.researchEmpty") })) : null,
2566
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null,
2567
+ createDir ? h(CreateFileModal, {
2568
+ title: createDir === "science" ? tt("world.createScienceBtn") : tt("world.createBtn"),
2569
+ dir: createDir,
2570
+ template: createDir === "science" ? TPL_SCIENCE : TPL_WORLD,
2571
+ wsPath: wsPath, onClose: function () { setCreateDir(null); }, onSaved: reload,
2572
+ }) : null,
2573
+ delRel ? h(ConfirmModal, {
2574
+ title: tf("src.deleteTip", { rel: delRel }),
2575
+ text: tf("src.confirmText", { rel: delRel }),
2576
+ onCancel: function () { setDelRel(null); },
2577
+ onConfirm: function () {
2578
+ return apiPost("file-delete", { path: wsPath, rel: delRel }).then(function () {
2579
+ setDelRel(null);
2580
+ return reload();
2581
+ });
2582
+ },
2583
+ }) : null);
2212
2584
  }
2213
2585
 
2214
2586
  // ── 索引板 tab:clues 按 kind 分组(resolved 划线)+ memories 按 category
@@ -2239,6 +2611,8 @@ window.__ModuleLoader__.load({
2239
2611
  memGroups[key].push(m);
2240
2612
  });
2241
2613
  function groupKey(a, b) { return a.localeCompare(b, "zh-CN"); }
2614
+ // 两本索引账源文件直达(0.1.20 写面):clue-board.yml / memories.yml。
2615
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
2242
2616
  return h("div", { className: "myt-stack" },
2243
2617
  h("div", { className: "myt-cardHead" },
2244
2618
  h("h3", { className: "myt-cardTitle" }, tt("index.title")),
@@ -2246,7 +2620,8 @@ window.__ModuleLoader__.load({
2246
2620
  rebuildBusy ? tt("index.rebuilding") : tt("index.rebuild"))),
2247
2621
  h("div", { className: "myt-indexCols" },
2248
2622
  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)),
2623
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.clueBoard")), h("span", { className: "myt-boardCount" }, clues.length),
2624
+ srcEditBtn("clue-board.yml", function () { setSrcRel("clue-board.yml"); })),
2250
2625
  clues.length ? Object.keys(clueGroups).sort(groupKey).map(function (kind) {
2251
2626
  var ck = CLUE_KIND_KEYS[kind];
2252
2627
  var label = ck ? tt("clue." + ck) : kind;
@@ -2259,7 +2634,8 @@ window.__ModuleLoader__.load({
2259
2634
  }));
2260
2635
  }) : h(Empty, { text: tt("index.clueEmpty") })),
2261
2636
  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)),
2637
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("index.memBoard")), h("span", { className: "myt-boardCount" }, memories.length),
2638
+ srcEditBtn("memories.yml", function () { setSrcRel("memories.yml"); })),
2263
2639
  memories.length ? Object.keys(memGroups).sort(groupKey).map(function (cat) {
2264
2640
  var isPromise = /promise|承诺/i.test(cat);
2265
2641
  return h("div", { key: cat, className: "myt-worldGroup" },
@@ -2271,13 +2647,18 @@ window.__ModuleLoader__.load({
2271
2647
  h("span", { className: "myt-clueText" }, orDash(m.content)),
2272
2648
  m.source_chapter ? h("span", { className: "myt-memChapter" }, m.source_chapter) : null);
2273
2649
  }));
2274
- }) : h(Empty, { text: tt("index.memEmpty") }))));
2650
+ }) : h(Empty, { text: tt("index.memEmpty") }))),
2651
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
2275
2652
  }
2276
2653
 
2277
- // ── 关系图 tab:canvas 力导向(斥力 + 弹簧 + 中心引力,rAF 循环)─────────
2278
- // 节点固定 18px、名字 label 下方;边宽 = intensity 1-5 → 1-4px(缺省 3),
2279
- // type 标在边中点、hover 显示 description;拖拽固定、滚轮缩放、双击复位;
2280
- // 角色多于 30 个时只画 relations 相关节点,其余进「未入图」。
2654
+ // ── 关系图 tab:canvas 力导向(斥力 + 弹簧 + 中心引力 + 硬碰撞分离,rAF 循环)─
2655
+ // 节点固定 18px、名字 label 下方,最小间距 62 位置级互推保证静止帧不压叠;
2656
+ // 边为二次曲线(绕开途中节点、重复边互相分开),type 标在曲线 t=0.5 点、
2657
+ // hover 显示 description(命中检测按曲线几何采样);拖拽固定、滚轮缩放、
2658
+ // 双击复位;角色多于 30 个时只画 relations 相关节点,其余进「未入图」。
2659
+ // 关系图节点角色配色:主角红 / 配角蓝 / 反派黑 / 龙套黄(用户指定四色);
2660
+ // 越枚举/缺省 role 一律并入龙套黄(查表 miss 落 minor),全节点恒有色。
2661
+ var NODE_ROLE_COLORS = { protagonist: "#f53f3f", supporting: "#165dff", antagonist: "#1d2129", minor: "#fadc19" };
2281
2662
  function edgeWidth(intensity) {
2282
2663
  var n = typeof intensity === "number" && isFinite(intensity) ? Math.round(intensity) : 3;
2283
2664
  n = Math.min(5, Math.max(1, n));
@@ -2285,6 +2666,10 @@ window.__ModuleLoader__.load({
2285
2666
  }
2286
2667
  function RelationsTab(props) {
2287
2668
  var snap = props.snap;
2669
+ var wsPath = props.wsPath;
2670
+ var reload = props.reload;
2671
+ // 关系账本源文件直达(0.1.20 写面):relations.yml 整文件可编辑。
2672
+ var _src = useState(null), srcRel = _src[0], setSrcRel = _src[1];
2288
2673
  var chars = snap.characters || [];
2289
2674
  var rels = (snap.relations || []).filter(function (r) { return r && r.a && r.b && r.a !== r.b; });
2290
2675
  var _tip = useState(null), tip = _tip[0], setTip = _tip[1]; // {x, y, text}
@@ -2311,6 +2696,14 @@ window.__ModuleLoader__.load({
2311
2696
  nodes.forEach(function (nd, i) { idx[nd.id] = i; });
2312
2697
  var edges = rels.filter(function (r) { return idx[r.a] !== undefined && idx[r.b] !== undefined; })
2313
2698
  .map(function (r) { return { a: idx[r.a], b: idx[r.b], type: r.type || "", desc: r.description || "", w: edgeWidth(r.intensity) }; });
2699
+ // 同一对节点的重复边编号:dup = 按 (a,b) 无序对分组后的组内序号(0 起)。
2700
+ // draw 的 edgeCtrl 按 dup 放大曲线偏移并交替朝向,重复边互相分开不重合。
2701
+ var dupCount = Object.create(null);
2702
+ edges.forEach(function (e) {
2703
+ var key = Math.min(e.a, e.b) + "-" + Math.max(e.a, e.b);
2704
+ e.dup = dupCount[key] || 0;
2705
+ dupCount[key] = e.dup + 1;
2706
+ });
2314
2707
  return { nodes: nodes, edges: edges, unlinked: unlinked };
2315
2708
  }, [snap]); // chars/rels 均由 snap 派生,snap 不变则图不变
2316
2709
  useEffect(function () {
@@ -2352,7 +2745,50 @@ window.__ModuleLoader__.load({
2352
2745
  nd.vx = nd.vy = 0;
2353
2746
  });
2354
2747
  function toWorld(sx, sy) { return { x: (sx - sim.w / 2) / sim.zoom, y: (sy - sim.h / 2) / sim.zoom }; }
2355
- function toScreen(x, y) { return { x: sim.w / 2 + x * sim.zoom, y: sim.h / 2 + y * sim.zoom }; }
2748
+ // 每条边的二次贝塞尔控制点:bow 大小随边长缩放、钳 [12,28] 世界像素;
2749
+ // side ∈ {-1,+1} 曲线朝向(挂边对象上帧间保留,做迟滞防抖);同一对
2750
+ // 节点的重复边按 dup 序号放大偏移并交替朝向,互相分开不重合。
2751
+ function edgeCtrl(e) {
2752
+ var a = graph.nodes[e.a], b = graph.nodes[e.b];
2753
+ var dx = b.x - a.x, dy = b.y - a.y;
2754
+ var len = Math.sqrt(dx * dx + dy * dy) || 1;
2755
+ var ux = -dy / len, uy = dx / len; // 单位法向
2756
+ var mag = Math.min(28, Math.max(12, len * 0.1));
2757
+ var k = e.dup || 0;
2758
+ var m = mag * (1 + 0.6 * k);
2759
+ var side = (e.side || 1) * (k % 2 ? -1 : 1);
2760
+ return { cx: (a.x + b.x) / 2 + ux * m * side, cy: (a.y + b.y) / 2 + uy * m * side };
2761
+ }
2762
+ // 曲线(A、C、B 三点二次贝塞尔)采样 9 个点到各无关节点(跳过两端)
2763
+ // 的最小距离:值越大说明这一侧越空旷。
2764
+ function clearance(e, cx, cy) {
2765
+ var a = graph.nodes[e.a], b = graph.nodes[e.b];
2766
+ var best = 999;
2767
+ for (var t = 1; t <= 9; t++) {
2768
+ var tt = t / 10, it = 1 - tt;
2769
+ var px = it * it * a.x + 2 * it * tt * cx + tt * tt * b.x;
2770
+ var py = it * it * a.y + 2 * it * tt * cy + tt * tt * b.y;
2771
+ for (var ni = 0; ni < graph.nodes.length; ni++) {
2772
+ if (ni === e.a || ni === e.b) continue;
2773
+ var nd = graph.nodes[ni];
2774
+ var ddx = px - nd.x, ddy = py - nd.y;
2775
+ var d = Math.sqrt(ddx * ddx + ddy * ddy);
2776
+ if (d < best) best = d;
2777
+ }
2778
+ }
2779
+ return best;
2780
+ }
2781
+ // 朝向迟滞更新:算当前侧与取反侧的 clearance,只有另一侧明显更空
2782
+ // (+12 世界像素)才翻转——力导向抖动时曲线不左右闪。
2783
+ function updateSide(e) {
2784
+ var side = e.side || 1;
2785
+ var c1 = edgeCtrl(e);
2786
+ var cur = clearance(e, c1.cx, c1.cy);
2787
+ e.side = -side;
2788
+ var c2 = edgeCtrl(e);
2789
+ var alt = clearance(e, c2.cx, c2.cy);
2790
+ if (alt <= cur + 12) e.side = side; // 另一侧不明显更空:保持原朝向
2791
+ }
2356
2792
  function hitNode(sx, sy) {
2357
2793
  var p = toWorld(sx, sy);
2358
2794
  for (var i = graph.nodes.length - 1; i >= 0; i--) {
@@ -2366,13 +2802,23 @@ window.__ModuleLoader__.load({
2366
2802
  var p = toWorld(sx, sy);
2367
2803
  var best = null, bestD = 10;
2368
2804
  graph.edges.forEach(function (e) {
2805
+ // 命中跟曲线走:二次贝塞尔采样 9 段(10 点含两端),点到各线段
2806
+ // 距离取最小;edgeCtrl 与 draw 同源(side 存边对象上,天然一致)。
2369
2807
  var a = graph.nodes[e.a], b = graph.nodes[e.b];
2370
- var ax = a.x, ay = a.y, bx = b.x - a.x, by = b.y - a.y;
2371
- var len2 = bx * bx + by * by || 1;
2372
- var t = Math.min(1, Math.max(0, ((p.x - ax) * bx + (p.y - ay) * by) / len2));
2373
- var dx = p.x - (ax + bx * t), dy = p.y - (ay + by * t);
2374
- var d = Math.sqrt(dx * dx + dy * dy);
2375
- if (d < bestD) { bestD = d; best = e; }
2808
+ var c = edgeCtrl(e);
2809
+ var qx = a.x, qy = a.y; // 上一采样点,首点 = 端点 A
2810
+ for (var k = 1; k <= 9; k++) {
2811
+ var tt = k / 9, it = 1 - tt;
2812
+ var nx = it * it * a.x + 2 * it * tt * c.cx + tt * tt * b.x;
2813
+ var ny = it * it * a.y + 2 * it * tt * c.cy + tt * tt * b.y;
2814
+ var segx = nx - qx, segy = ny - qy;
2815
+ var len2 = segx * segx + segy * segy || 1;
2816
+ var u = Math.min(1, Math.max(0, ((p.x - qx) * segx + (p.y - qy) * segy) / len2));
2817
+ var dx = p.x - (qx + segx * u), dy = p.y - (qy + segy * u);
2818
+ var d = Math.sqrt(dx * dx + dy * dy);
2819
+ if (d < bestD) { bestD = d; best = e; }
2820
+ qx = nx; qy = ny;
2821
+ }
2376
2822
  });
2377
2823
  return best;
2378
2824
  }
@@ -2394,7 +2840,7 @@ window.__ModuleLoader__.load({
2394
2840
  var a = nodes[e.a], b = nodes[e.b];
2395
2841
  var dx = b.x - a.x, dy = b.y - a.y;
2396
2842
  var d = Math.sqrt(dx * dx + dy * dy) || 1;
2397
- var f = (d - 120) * 0.02; // 弹簧,rest 120
2843
+ var f = (d - 130) * 0.02; // 弹簧,rest 130(原 120,多留缝隙配合硬碰撞分离)
2398
2844
  var fx = (dx / d) * f, fy = (dy / d) * f;
2399
2845
  a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
2400
2846
  });
@@ -2406,6 +2852,33 @@ window.__ModuleLoader__.load({
2406
2852
  nd.x += Math.max(-8, Math.min(8, nd.vx));
2407
2853
  nd.y += Math.max(-8, Math.min(8, nd.vy));
2408
2854
  });
2855
+ // 硬碰撞分离:力式斥力有上限(f 封顶 4),拥挤时节点仍会压叠;这里在
2856
+ // 速度积分之后按最小间距 62(两节点半径 18×2 + 名字 label 纵向净空)
2857
+ // 做位置级互推,保证静止帧节点(含下方 label)不重叠。固定节点
2858
+ // (fixed 或正在拖拽)不动、推对方,双固定跳过;位移不写回速度——
2859
+ // 纯位置修正,靠阻尼自然收敛,不与力导向抢劲。两轮 pass 处理推挤
2860
+ // 连锁(A 推开 B 后 B 又压到 C)。
2861
+ var MIN_DIST = 62;
2862
+ for (var pass = 0; pass < 2; pass++) {
2863
+ for (var ci = 0; ci < n; ci++) {
2864
+ for (var cj = ci + 1; cj < n; cj++) {
2865
+ var na = nodes[ci], nb = nodes[cj];
2866
+ var cdx = nb.x - na.x, cdy = nb.y - na.y;
2867
+ var cd2 = cdx * cdx + cdy * cdy;
2868
+ if (cd2 >= MIN_DIST * MIN_DIST) continue;
2869
+ var cd = Math.sqrt(cd2);
2870
+ if (cd < 0.01) { cdx = 1; cdy = 0; cd = 1; } // 完全重合:任意方向分开
2871
+ var push = (MIN_DIST - cd) / cd; // 归一化差向量:差多少补多少
2872
+ var aFixed = na.fixed || na === (sim.drag && sim.drag.node);
2873
+ var bFixed = nb.fixed || nb === (sim.drag && sim.drag.node);
2874
+ if (aFixed && bFixed) continue;
2875
+ var mA = aFixed ? 0 : (bFixed ? 1 : 0.5); // 固定方不动,另一方全担
2876
+ var mB = bFixed ? 0 : (aFixed ? 1 : 0.5);
2877
+ na.x -= cdx * push * mA; na.y -= cdy * push * mA;
2878
+ nb.x += cdx * push * mB; nb.y += cdy * push * mB;
2879
+ }
2880
+ }
2881
+ }
2409
2882
  }
2410
2883
  function draw() {
2411
2884
  if (sim.frame % 30 === 0 || !sim.tokens) sim.tokens = readTokens();
@@ -2417,29 +2890,30 @@ window.__ModuleLoader__.load({
2417
2890
  ctx.scale(sim.zoom, sim.zoom);
2418
2891
  graph.edges.forEach(function (e) {
2419
2892
  var a = graph.nodes[e.a], b = graph.nodes[e.b];
2893
+ updateSide(e); // 朝向迟滞:另一侧明显更空才翻,防抖(详见 updateSide)
2894
+ var c = edgeCtrl(e);
2420
2895
  ctx.strokeStyle = tk.line;
2421
2896
  ctx.globalAlpha = 0.8;
2422
2897
  ctx.lineWidth = e.w;
2423
- ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
2898
+ ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.quadraticCurveTo(c.cx, c.cy, b.x, b.y); ctx.stroke();
2424
2899
  if (e.type && sim.zoom >= 0.7) {
2425
2900
  ctx.globalAlpha = 1;
2426
2901
  ctx.fillStyle = tk.faint;
2427
2902
  ctx.font = "10px sans-serif";
2428
2903
  ctx.textAlign = "center";
2429
- ctx.fillText(e.type, (a.x + b.x) / 2, (a.y + b.y) / 2 - 3);
2904
+ // type 标在曲线 t=0.5 点(二次贝塞尔中点),与曲线本体一致
2905
+ 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
2906
  }
2431
2907
  });
2432
2908
  ctx.globalAlpha = 1;
2433
2909
  graph.nodes.forEach(function (nd) {
2434
- var s = toScreen(nd.x, nd.y);
2435
2910
  var r = 18; // 世界坐标画:半径随 zoom 缩放
2911
+ var rc = NODE_ROLE_COLORS[nd.role] || NODE_ROLE_COLORS.minor;
2436
2912
  ctx.beginPath();
2437
2913
  ctx.arc(nd.x, nd.y, r, 0, Math.PI * 2);
2438
- ctx.fillStyle = nd.fixed ? tk.accent : tk.card;
2439
- ctx.globalAlpha = nd.fixed ? 0.25 : 1;
2914
+ ctx.fillStyle = rc;
2440
2915
  ctx.fill();
2441
- ctx.globalAlpha = 1;
2442
- ctx.lineWidth = 1.5;
2916
+ ctx.lineWidth = nd.fixed ? 3 : 1.5; // 固定态=加粗 accent 描边(全节点恒有色后的唯一固定指示)
2443
2917
  ctx.strokeStyle = tk.accent;
2444
2918
  ctx.stroke();
2445
2919
  ctx.fillStyle = tk.label;
@@ -2559,12 +3033,20 @@ window.__ModuleLoader__.load({
2559
3033
  }
2560
3034
  } catch (e) { /* 全屏 API 个别浏览器仍有策略性拒绝,静默 */ }
2561
3035
  }
2562
- if (!graph.nodes.length) return h(Empty, { text: tt("relations.noData") });
3036
+ if (!graph.nodes.length) {
3037
+ return h("div", { className: "myt-stack" },
3038
+ h("div", { className: "myt-cardHead" },
3039
+ h("h3", { className: "myt-cardTitle" }, tt("relations.title")),
3040
+ srcEditBtn("relations.yml", function () { setSrcRel("relations.yml"); })),
3041
+ h(Empty, { text: tt("relations.noData") }),
3042
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
3043
+ }
2563
3044
  return h("div", { className: "myt-stack" },
2564
3045
  h("div", { className: "myt-card" },
2565
3046
  h("div", { className: "myt-cardHead" },
2566
3047
  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 }))),
3048
+ h("span", { className: "myt-muted" }, tf("relations.counts", { n: graph.nodes.length, m: graph.edges.length })),
3049
+ srcEditBtn("relations.yml", function () { setSrcRel("relations.yml"); })),
2568
3050
  h("div", { className: "myt-graphWrap", ref: wrapRef },
2569
3051
  h("canvas", { className: "myt-graphCanvas", ref: canvasRef, "data-dragging": "false" }),
2570
3052
  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 +3054,19 @@ window.__ModuleLoader__.load({
2572
3054
  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
3055
  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
3056
  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" }, tt("relations.help"))),
3057
+ h("div", { className: "myt-graphFoot" },
3058
+ tt("relations.help"),
3059
+ h("span", { className: "myt-graphLegend" },
3060
+ h("span", { className: "myt-legendItem" }, h("span", { className: "myt-legendDot", "data-role": "protagonist" }), tt("role.protagonist")),
3061
+ h("span", { className: "myt-legendItem" }, h("span", { className: "myt-legendDot", "data-role": "supporting" }), tt("role.supporting")),
3062
+ h("span", { className: "myt-legendItem" }, h("span", { className: "myt-legendDot", "data-role": "antagonist" }), tt("role.antagonist")),
3063
+ h("span", { className: "myt-legendItem" }, h("span", { className: "myt-legendDot", "data-role": "minor" }), tt("role.minor"))))),
2576
3064
  graph.unlinked.length ? h("div", { className: "myt-card" },
2577
3065
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("relations.unlinkedTitle"))),
2578
3066
  h("div", { className: "myt-unlinked" }, graph.unlinked.map(function (n, i) {
2579
3067
  return h("span", { key: n || i, className: "myt-unlinkedTag" }, n);
2580
- }))) : null);
3068
+ }))) : null,
3069
+ srcRel ? h(SourceEditorModal, { title: tt("src.editSource"), rel: srcRel, wsPath: wsPath, onClose: function () { setSrcRel(null); }, onSaved: reload }) : null);
2581
3070
  }
2582
3071
 
2583
3072
  // ── cordis client 插件入口 ──────────────────────────────────────────────