dsh-maoying 0.1.16 → 0.1.18

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
@@ -53,14 +53,17 @@ window.__ModuleLoader__.load({
53
53
  var CSS_KEY = "maoying-creator/base";
54
54
  var WS_STORAGE_KEY = "maoying.workspace";
55
55
  var API_TIMEOUT_MS = 60000;
56
- // 一键升级单独放宽:服务端 pnpm add 超时上限 180s,统一 60s 会先掐断,
57
- // 故 self-update 请求走 200s(留 20s 余量),其余请求仍用 API_TIMEOUT_MS。
58
- var SELF_UPDATE_TIMEOUT_MS = 200000;
56
+ // 一键升级单独放宽:服务端 profile pnpm add 超时上限 180s + 全局 npm i -g
57
+ // 独立 120s(顺带更新全局 maoying CLI),统一 60s 会先掐断,故 self-update
58
+ // 请求走 340s(300s + 40s 余量),其余请求仍用 API_TIMEOUT_MS。
59
+ var SELF_UPDATE_TIMEOUT_MS = 340000;
59
60
 
60
61
  var STATUS_FLOW = ["pending", "writing", "review", "accepted"];
61
62
  var FS_ORDER = ["planted", "progressing", "resolved", "abandoned"];
62
63
  var FS_FLOW = { planted: "progressing", progressing: "resolved" };
63
64
  var PHASES = ["idea", "setting", "outline", "writing", "review", "consistency", "export"];
65
+ // 共创模式三态(key → mode.* / modeShort.* 词典后缀):概览书稿卡展示用。
66
+ var MODES = [["ai-draft", "aiDraft"], ["co-write", "coWrite"], ["author-draft", "authorDraft"]];
64
67
  // clue.* 词典键是小驼峰,数据里的 kind 按契约(templates/novel/README.md、
65
68
  // docs/spec.md)是 clue|red-herring|deduction|question——此前键表误按
66
69
  // snake_case + inference 写,红鲱鱼/推理两组永远查不中、只能裸显英文原值。
@@ -283,7 +286,10 @@ window.__ModuleLoader__.load({
283
286
  return toast.subscribe(function () { setItems(toast.snapshot()); });
284
287
  }, []);
285
288
  if (!items.length) return null;
286
- return h("div", { className: "myt-toastHost", "aria-live": "polite" },
289
+ // portal 到 body:与弹层同坐标系。中栏 transform/overflow 会把 fixed 劫持
290
+ // 成相对中栏定位,锚区变量(视口坐标)在此会被二次偏移;挂 body 后
291
+ // fixed 恒等于视口,--myt-anchor-* 直接可用(base.css 按锚区居中)。
292
+ return h(MaskPortal, { className: "myt-toastHost", "aria-live": "polite" },
287
293
  items.map(function (it) {
288
294
  return h("div", {
289
295
  key: it.id,
@@ -571,6 +577,66 @@ window.__ModuleLoader__.load({
571
577
  lsSet(FONT_SCALE_KEY, fontScale);
572
578
  }, [fontScale]);
573
579
 
580
+ // 弹层锚区:弹层 portal 到 body 后 position:fixed 以全窗为居中基准,
581
+ // 宿主右侧边栏一开,中栏变窄左移,弹窗仍按全窗居中,右半被侧边栏盖住。
582
+ // 这里持续测量面板容器(data-dsh-maoying-view)的视口矩形写进 CSS 变量,
583
+ // 弹层/Toast 改按锚区居中(base.css);测量不到时移除变量回落全窗,行
584
+ // 为与旧版一致。宿主侧边栏开合会改中栏宽度,ResizeObserver + resize 双
585
+ // 通道跟随,rAF 合帧防抖。
586
+ // 两组变量:--myt-anchor-* 视觉 px(Toast 未 zoom,直接用);--myt-mask-*
587
+ // 已除以面板 zoom 的 CSS px(mask 带 zoom,fixed 内 px 会被放大,须预
588
+ // 除——JS 算好,避开 calc(px / var()) 的老 Chromium 兼容雷)。zoom 每次
589
+ // 现读 computed style,字号换档自动跟随,无需额外依赖。
590
+ useEffect(function () {
591
+ if (typeof document === "undefined" || typeof window === "undefined") return undefined;
592
+ var docEl = document.documentElement;
593
+ if (!docEl || !docEl.style) return undefined;
594
+ var ALL_VARS = [
595
+ "--myt-anchor-left", "--myt-anchor-width", "--myt-anchor-bottom",
596
+ "--myt-mask-top", "--myt-mask-right", "--myt-mask-bottom", "--myt-mask-left",
597
+ ];
598
+ var clear = function () {
599
+ ALL_VARS.forEach(function (v) { docEl.style.removeProperty(v); });
600
+ };
601
+ var px = function (n) { return (Math.round(n * 100) / 100) + "px"; };
602
+ var apply = function () {
603
+ raf = 0;
604
+ var el = document.querySelector("[data-dsh-maoying-view]");
605
+ if (!el || !el.isConnected) { clear(); return; }
606
+ var r = el.getBoundingClientRect();
607
+ if (!r || r.width < 2 || r.height < 2) { clear(); return; } // display:none 等异常态不锚定
608
+ docEl.style.setProperty("--myt-anchor-left", px(r.left));
609
+ docEl.style.setProperty("--myt-anchor-width", px(r.width));
610
+ docEl.style.setProperty("--myt-anchor-bottom", px(Math.max(0, window.innerHeight - r.bottom)));
611
+ var zoom = 1;
612
+ try {
613
+ var cs = window.getComputedStyle(el);
614
+ var pz = cs && cs.zoom ? parseFloat(cs.zoom) : NaN;
615
+ if (pz > 0) zoom = pz;
616
+ } catch (err) { /* 老宿主无 computed zoom:按 1 处理 */ }
617
+ docEl.style.setProperty("--myt-mask-top", px(r.top / zoom));
618
+ docEl.style.setProperty("--myt-mask-right", px(Math.max(0, window.innerWidth - r.right) / zoom));
619
+ docEl.style.setProperty("--myt-mask-bottom", px(Math.max(0, window.innerHeight - r.bottom) / zoom));
620
+ docEl.style.setProperty("--myt-mask-left", px(r.left / zoom));
621
+ };
622
+ var raf = 0;
623
+ var schedule = function () {
624
+ if (raf) return;
625
+ raf = window.requestAnimationFrame(apply);
626
+ };
627
+ apply();
628
+ window.addEventListener("resize", schedule);
629
+ var ro = typeof ResizeObserver === "function" ? new ResizeObserver(schedule) : null;
630
+ var el = document.querySelector("[data-dsh-maoying-view]");
631
+ if (ro && el) ro.observe(el);
632
+ return function () {
633
+ if (raf) window.cancelAnimationFrame(raf);
634
+ window.removeEventListener("resize", schedule);
635
+ if (ro) ro.disconnect();
636
+ clear();
637
+ };
638
+ }, []);
639
+
574
640
  // 选中校正:记忆的工作区必须仍在列表且 hasNovel,否则落回首个 hasNovel。
575
641
  var effectiveWs = (function () {
576
642
  if (!workspaces) return "";
@@ -633,24 +699,48 @@ window.__ModuleLoader__.load({
633
699
  }, []);
634
700
  // 一键升级状态机:""(可点)→ "installing"(安装中,按钮禁用)→
635
701
  // "installed"(已装完待重启,按钮永久禁用、新版徽章收起)。可预期失败
636
- // (dev-link / pnpm-missing / pnpm 出错)回落 "" 允许重试。
702
+ // (dev-link / pnpm-missing / pnpm 出错)回落 "" 允许重试;服务端短路
703
+ // up-to-date(本地已不落后线上)也回落 "",按钮恢复可点、不进 installed 态。
704
+ // 全局 maoying CLI 顺带更新结果(服务端 globalCli 字段)拼成 toast 第二句
705
+ // (\n 换行,配合 base.css myt-toastText 的 pre-line 显示):
706
+ // - ok 且真更新了(from → to):「全局 maoying CLI a → b」;
707
+ // - skipped 'missing'(用户没装全局 CLI)/ 'same'(已同版):不提,零噪音;
708
+ // - ok:false:如实报「全局 CLI 更新失败:{error}」,不改动主文案的成功语义
709
+ // (profile 已装好,重启照常生效)。
710
+ function globalCliNote(d) {
711
+ var g = d && d.globalCli;
712
+ if (!g) return "";
713
+ if (g.ok) {
714
+ if (g.skipped) return "";
715
+ return "\n" + tf("toast.selfUpdate.globalOk", { from: g.from || "?", to: g.to || "?" });
716
+ }
717
+ return "\n" + tf("toast.selfUpdate.globalFail", { error: g.error || tt("common.unknownErr") });
718
+ }
637
719
  var _up = useState(""), upState = _up[0], setUpState = _up[1];
638
720
  function runSelfUpdate() {
639
721
  if (upState !== "") return;
640
722
  setUpState("installing");
641
723
  // 不走 apiPost:成功提示要带新版本号、失败按 kind 分流文案,且超时
642
- // 单独放宽(服务端 pnpm 最长 180s,统一 60s 会先掐断,见常量处注释)。
724
+ // 单独放宽(服务端 profile pnpm 最长 180s + 全局 npm 独立 120s,统一
725
+ // 60s 会先掐断,见常量处注释)。
643
726
  maoyingApi("self-update", {}, SELF_UPDATE_TIMEOUT_MS).then(function (d) {
644
- if (d && d.ok) {
727
+ if (d && d.ok && d.kind === "up-to-date") {
728
+ // 短路:profile 没装任何东西,但全局 CLI 仍可能要跟进(globalCliNote
729
+ // 拼第二句);按钮恢复可点;版本徽章留给 update-check 自行收敛。
730
+ setUpState("");
731
+ toast.ok(tf("toast.selfUpdate.upToDate", { ver: (d && d.from) || "?" }) + globalCliNote(d));
732
+ } else if (d && d.ok) {
645
733
  setUpState("installed");
646
734
  // 硬约束照实说:装完只是磁盘换新,运行中的进程仍是旧代码。
647
- toast.ok(tf("toast.selfUpdate.ok", { ver: (d && d.to) || "?" }));
735
+ toast.ok(tf("toast.selfUpdate.ok", { ver: (d && d.to) || "?" }) + globalCliNote(d));
648
736
  } else if (d && d.kind === "dev-link") {
649
737
  setUpState("");
650
738
  toast.warn(tt("toast.selfUpdate.devLink"));
651
739
  } else if (d && d.kind === "pnpm-missing") {
740
+ // 服务端 message 已带可操作出路(corepack enable / 手动命令),直显不截断;
741
+ // 拿不到 message 才回落本地通用文案。
652
742
  setUpState("");
653
- toast.warn(tt("toast.selfUpdate.pnpmMissing"));
743
+ toast.err(tf("toast.prefix", { msg: (d && d.error) || tt("toast.selfUpdate.pnpmMissing") }));
654
744
  } else {
655
745
  setUpState("");
656
746
  toast.err(tf("toast.prefix", { msg: (d && d.error) || tt("common.unknownErr") }));
@@ -817,6 +907,9 @@ window.__ModuleLoader__.load({
817
907
  var _b = useState(false), phaseBusy = _b[0], setPhaseBusy = _b[1];
818
908
  var phaseIdx = PHASES.indexOf(project.phase);
819
909
  var nextPhase = phaseIdx !== -1 && phaseIdx < PHASES.length - 1 ? PHASES[phaseIdx + 1] : null;
910
+ // 共创模式徽章:快照 mode 缺失(老项目无该键)或越枚举时不显示。
911
+ var modeEntry = null;
912
+ for (var mi = 0; mi < MODES.length; mi++) { if (MODES[mi][0] === project.mode) { modeEntry = MODES[mi]; break; } }
820
913
  // 静态体检黄条:仅 found 时拉 check 路由(未初始化没有可查的东西)。
821
914
  // 工作区切换与每次 reload 都换新 snap → effect 重跑即重查;请求序号防
822
915
  // 过期响应(快速切工作区时在途旧响应不得落进新工作区的黄条)。
@@ -839,6 +932,7 @@ window.__ModuleLoader__.load({
839
932
  apiPost("phase", { path: props.wsPath, phase: nextPhase }).then(reload).catch(function () {}).finally(function () { setPhaseBusy(false); });
840
933
  }
841
934
  var recent = (snap.chapters || []).slice().sort(function (a, b) { return (b.num || 0) - (a.num || 0); }).slice(0, 5);
935
+ var volumes = snap.volumes || [];
842
936
  return h("div", { className: "myt-stack" },
843
937
  checkProblems && checkProblems.length ? CheckStrip(checkProblems) : null,
844
938
  editing
@@ -856,6 +950,10 @@ window.__ModuleLoader__.load({
856
950
  h("h2", { className: "myt-heroTitle", key: "title" }, orDash(project.title)),
857
951
  h("div", { className: "myt-heroMeta", key: "phase" },
858
952
  phasePill(project.phase),
953
+ modeEntry ? [
954
+ h("span", { className: "myt-muted", key: "modeLabel" }, tt("novel.fMode")),
955
+ h("span", { className: "myt-badge", key: "modeBadge", title: tt("mode." + modeEntry[1]) }, tt("modeShort." + modeEntry[1])),
956
+ ] : null,
859
957
  nextPhase ? h("button", { type: "button", className: "myt-btn", disabled: phaseBusy, onClick: advancePhase, title: tf("phase.advanceTitle", { next: tt("phase." + nextPhase) }) }, phaseBusy ? tt("phase.advancing") : tt("phase.advance")) : h("span", { className: "myt-muted" }, project.phase ? tt("phase.final") : null)),
860
958
  project.description ? h("p", { className: "myt-heroDesc", key: "desc" }, project.description) : null,
861
959
  ]),
@@ -877,6 +975,16 @@ window.__ModuleLoader__.load({
877
975
  h("div", { className: "myt-heroMeta" }, STATUS_FLOW.map(function (s) {
878
976
  return h("span", { key: s, className: "myt-muted" }, tt("status." + s) + " " + (byStatus[s] || 0));
879
977
  }))),
978
+ volumes.length ? h("div", { className: "myt-card" },
979
+ h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("overview.volumes")),
980
+ h("span", { className: "myt-boardCount" }, volumes.length)),
981
+ h("div", { className: "myt-stack" }, volumes.map(function (v, i) {
982
+ return h("div", { key: v.id || i, className: "myt-worldGroup" },
983
+ h("div", { className: "myt-listHead" },
984
+ h("span", { className: "myt-worldName" }, (v.id ? v.id + " · " : "") + orDash(v.title)),
985
+ v.chapters ? h("span", { className: "myt-tag" }, v.chapters) : null),
986
+ v.summary ? h("div", { className: "myt-worldDesc" }, v.summary) : null);
987
+ }))) : null,
880
988
  h("div", { className: "myt-card" },
881
989
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("overview.recent"))),
882
990
  recent.length
@@ -1010,6 +1118,8 @@ window.__ModuleLoader__.load({
1010
1118
  "header.upgradeTitle": "用 pnpm 安装 npm 最新版(最长约 3 分钟),装完需重启 DeepSeek 生效",
1011
1119
  "header.upgradedTitle": "已安装新版,重启 DeepSeek 后生效",
1012
1120
  "toast.selfUpdate.ok": "已安装 {ver},重启 DeepSeek 后生效",
1121
+ "toast.selfUpdate.upToDate": "已是最新版({ver})",
1122
+ "toast.selfUpdate.globalOk": "全局 maoying CLI {from} → {to}", "toast.selfUpdate.globalFail": "全局 CLI 更新失败:{error}",
1013
1123
  "toast.selfUpdate.devLink": "开发目录安装,请 git pull 更新", "toast.selfUpdate.pnpmMissing": "未找到 pnpm,请手动安装",
1014
1124
  "header.workspace": "工作区", "header.wsAria": "选择工作区", "header.noNovel": "(无 novel)", "header.untitled": "未命名",
1015
1125
  "header.settings": "系统设置", "header.refresh": "重新拉取", "header.closeAria": "关闭创作台", "header.closeTitle": "关闭",
@@ -1060,6 +1170,7 @@ window.__ModuleLoader__.load({
1060
1170
  "char.modalAria": "角色详情", "char.closeAria": "关闭角色卡", "char.markers": "标记", "char.file": "文件",
1061
1171
  "overview.author": "作者:{name}", "overview.totalWords": "总字数(CJK)", "overview.totalChapters": "章节总数",
1062
1172
  "overview.characters": "角色数", "overview.openForeshadows": "未回收伏笔", "overview.statusDist": "章节状态分布", "overview.recent": "最近章节",
1173
+ "overview.volumes": "分卷规划",
1063
1174
  "overview.edit": "编辑", "overview.editTip": "编辑书稿元信息",
1064
1175
  "chapters.empty": "chapters/ 目录还没有章节文件", "chapters.writeQueue": "按单写作", "chapters.rowTip": "点击打开该章",
1065
1176
  "chapters.outlineWords": "章纲 {n} 字", "chapters.all": "全部", "chapters.thVolume": "卷 · 章", "chapters.thTitle": "标题",
@@ -1080,6 +1191,7 @@ window.__ModuleLoader__.load({
1080
1191
  "novel.lengthPh": "如:约100万字 / 300章", "novel.fLanguage": "正文语言", "novel.fPhase": "工作流阶段", "novel.fMode": "共创模式",
1081
1192
  "novel.fDesc": "一句话简介", "novel.descPh": "写封面文案时可直接引用", "novel.titleRequired": "书名不能为空",
1082
1193
  "mode.aiDraft": "AI 代笔(AI 写正文)", "mode.coWrite": "逐章共创(按章指派谁写)", "mode.authorDraft": "作者亲笔(AI 只备料审稿)",
1194
+ "modeShort.aiDraft": "AI 代笔", "modeShort.coWrite": "逐章共创", "modeShort.authorDraft": "作者亲笔",
1083
1195
  "novel.saving": "保存中…", "novel.save": "保存设置", "novel.cancel": "取消",
1084
1196
  "settings.title": "系统设置", "settings.closeAria": "关闭设置", "settings.language": "语言",
1085
1197
  "settings.languageHint": "界面文字随语言选项切换,立即生效并记忆。", "settings.fontScale": "字号",
@@ -1095,6 +1207,8 @@ window.__ModuleLoader__.load({
1095
1207
  "header.upgradeTitle": "Install the latest npm release with pnpm (up to ~3 min); takes effect after restarting DeepSeek",
1096
1208
  "header.upgradedTitle": "New version installed — takes effect after restarting DeepSeek",
1097
1209
  "toast.selfUpdate.ok": "Installed {ver} — takes effect after restarting DeepSeek",
1210
+ "toast.selfUpdate.upToDate": "Already up to date ({ver})",
1211
+ "toast.selfUpdate.globalOk": "Global maoying CLI {from} → {to}", "toast.selfUpdate.globalFail": "Global CLI update failed: {error}",
1098
1212
  "toast.selfUpdate.devLink": "Installed from a dev directory — update with git pull", "toast.selfUpdate.pnpmMissing": "pnpm not found — install it manually",
1099
1213
  "header.workspace": "Workspace", "header.wsAria": "Select workspace", "header.noNovel": "(no novel)", "header.untitled": "Untitled",
1100
1214
  "header.settings": "Settings", "header.refresh": "Refresh", "header.closeAria": "Close Maoying Studio", "header.closeTitle": "Close",
@@ -1145,6 +1259,7 @@ window.__ModuleLoader__.load({
1145
1259
  "char.modalAria": "Character details", "char.closeAria": "Close character card", "char.markers": "Tags", "char.file": "File",
1146
1260
  "overview.author": "Author: {name}", "overview.totalWords": "Total characters (CJK)", "overview.totalChapters": "Chapters",
1147
1261
  "overview.characters": "Characters", "overview.openForeshadows": "Unresolved foreshadowing", "overview.statusDist": "Chapter status breakdown", "overview.recent": "Recent chapters",
1262
+ "overview.volumes": "Volume plan",
1148
1263
  "overview.edit": "Edit", "overview.editTip": "Edit manuscript metadata",
1149
1264
  "chapters.empty": "No chapter files in chapters/ yet", "chapters.writeQueue": "Writing Queue", "chapters.rowTip": "Click to open this chapter",
1150
1265
  "chapters.outlineWords": "Outline {n} chars", "chapters.all": "All", "chapters.thVolume": "Vol · Ch", "chapters.thTitle": "Title",
@@ -1165,6 +1280,7 @@ window.__ModuleLoader__.load({
1165
1280
  "novel.lengthPh": "e.g. ~1M characters / 300 chapters", "novel.fLanguage": "Text language", "novel.fPhase": "Workflow phase", "novel.fMode": "Co-writing mode",
1166
1281
  "novel.fDesc": "One-line synopsis", "novel.descPh": "Can be quoted directly in cover copy", "novel.titleRequired": "Title is required",
1167
1282
  "mode.aiDraft": "AI ghostwriting (AI drafts the prose)", "mode.coWrite": "Chapter-by-chapter co-writing (assign a writer per chapter)", "mode.authorDraft": "Author writes (AI only preps and reviews)",
1283
+ "modeShort.aiDraft": "AI draft", "modeShort.coWrite": "Co-write", "modeShort.authorDraft": "Author writes",
1168
1284
  "novel.saving": "Saving…", "novel.save": "Save", "novel.cancel": "Cancel",
1169
1285
  "settings.title": "Settings", "settings.closeAria": "Close settings", "settings.language": "Language",
1170
1286
  "settings.languageHint": "UI text follows the language option; it applies immediately and is remembered.", "settings.fontScale": "Font size",
@@ -1774,8 +1890,13 @@ window.__ModuleLoader__.load({
1774
1890
  research.length ? h("div", { className: "myt-card" },
1775
1891
  h("div", { className: "myt-cardHead" }, h("h3", { className: "myt-cardTitle" }, tt("world.research")),
1776
1892
  h("span", { className: "myt-boardCount" }, research.length)),
1777
- h("div", { className: "myt-tagRow" }, research.map(function (r, i) {
1778
- return h("span", { key: r.file || i, className: "myt-tag", title: r.file || "" }, orDash(r.topic) || r.file);
1893
+ h("div", { className: "myt-stack" }, research.map(function (r, i) {
1894
+ return h("div", { key: r.file || i, className: "myt-worldGroup" },
1895
+ h("div", { className: "myt-listHead" },
1896
+ h("span", { className: "myt-worldName", title: r.file || "" }, orDash(r.topic) || r.file)),
1897
+ r.description ? h(MdView, { text: r.description, className: "myt-worldDesc" }) : null,
1898
+ (r.sources || []).length ? h("div", { className: "myt-refList" },
1899
+ 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);
1779
1900
  }))) : null);
1780
1901
  }
1781
1902
 
@@ -709,6 +709,9 @@ const FORESHADOW_STATUSES = ['planted', 'progressing', 'resolved', 'abandoned']
709
709
  /** project.yml 的共创模式枚举(五期 §9.1)。 */
710
710
  const PROJECT_MODES = ['ai-draft', 'co-write', 'author-draft']
711
711
 
712
+ /** 七阶段工作流枚举(与 project.yml 的 phase 键一致,顺序即推进序)。 */
713
+ export const PROJECT_PHASES = ['idea', 'setting', 'outline', 'writing', 'review', 'consistency', 'export']
714
+
712
715
  /**
713
716
  * 章节 frontmatter 的 volume 规范形态:v + 两位卷号。面板从章纲建章
714
717
  * (panel-core 的 createChapterFromOutline)与本文件 check / collectChapters
@@ -947,7 +950,10 @@ export function checkProject(novelDir) {
947
950
  }
948
951
  }
949
952
 
950
- // —— project.yml:mode 枚举 ——
953
+ // —— project.yml:mode/phase 枚举 + 阶段就绪判据 ——
954
+ // 判据与 docs/spec.md §2 同源:phase 到 outline 及之后(setting 已过),核心设定件
955
+ // 必须在案——缺了说明写作流程被硬闯,报 error 提示回上一阶段补齐。
956
+ // phase 键缺失(无 project.yml 或旧文件)不查——stats 已提示「未设置」,此处不重复计账。
951
957
  const projectRaw = parseFlatYamlArray(readTextOrNull(path.join(novelDir, 'project.yml')))
952
958
  if (projectRaw !== null && !Array.isArray(projectRaw)) {
953
959
  const mode = projectRaw.mode
@@ -955,6 +961,32 @@ export function checkProject(novelDir) {
955
961
  push('error', 'project.yml', `mode 越枚举:${mode}(合法值 ${PROJECT_MODES.join('|')})`)
956
962
  }
957
963
  }
964
+ const phase = projectRaw !== null && !Array.isArray(projectRaw) ? projectRaw.phase : undefined
965
+ if (typeof phase === 'string' && phase !== '') {
966
+ if (!PROJECT_PHASES.includes(phase)) {
967
+ push('error', 'project.yml', `phase 越枚举:${phase}(合法值 ${PROJECT_PHASES.join('|')})`)
968
+ } else if (PROJECT_PHASES.indexOf(phase) >= PROJECT_PHASES.indexOf('outline')) {
969
+ if (!cards.some((c) => c.meta.role === 'protagonist')) {
970
+ push('error', 'characters/', 'phase 已过 setting 但 characters/ 缺 role: protagonist 主角卡(setting → outline 判据:核心角色卡须落盘)')
971
+ }
972
+ if (!cards.some((c) => c.meta.role === 'antagonist')) {
973
+ push('error', 'characters/', 'phase 已过 setting 但 characters/ 缺 role: antagonist 核心反派卡(setting → outline 判据:核心角色卡须落盘)')
974
+ }
975
+ const hasWorldLore =
976
+ listMdFiles(path.join(novelDir, 'world')).length > 0 || listMdFiles(path.join(novelDir, 'science')).length > 0
977
+ if (!hasWorldLore) {
978
+ push('error', 'world/', 'phase 已过 setting 但 world/ 与 science/ 均无条目(setting → outline 判据:世界观核心条目须落盘)')
979
+ }
980
+ if (readYamlArray(novelDir, 'volumes.yml').length === 0) {
981
+ push('error', 'volumes.yml', 'phase 已过 setting 但 volumes.yml 为空或缺失(setting → outline 判据:分卷规划须在案)')
982
+ }
983
+ // 关系网判据(六期补):两张及以上角色卡时 relations.yml 不得为空——
984
+ // 空关系网意味着设定阶段漏登关系,关系图与大纲师的戏剧权重都无从谈起。
985
+ if (cards.length >= 2 && readYamlArray(novelDir, 'relations.yml').length === 0) {
986
+ push('error', 'relations.yml', 'phase 已过 setting 但 relations.yml 为空(setting → outline 判据:两张及以上角色卡时关系网须登记)')
987
+ }
988
+ }
989
+ }
958
990
 
959
991
  const summary = {
960
992
  chapters: chapterFiles.length,
@@ -553,9 +553,18 @@ export function parseStateSnapshot(novelDir, options = {}) {
553
553
  references: strArray(meta.references),
554
554
  }))
555
555
 
556
- const research = collectMdEntities(dir, 'research', yamlLib, warnings).map(({ file, meta }) => ({
556
+ const research = collectMdEntities(dir, 'research', yamlLib, warnings).map(({ file, meta, body }) => ({
557
557
  file,
558
558
  topic: strOrNull(meta.topic),
559
+ sources: strArray(meta.sources),
560
+ description: typeof body === 'string' ? body.trim() : '',
561
+ }))
562
+
563
+ const volumes = collectArrayYml(dir, 'volumes.yml', yamlLib, warnings).map((v) => ({
564
+ id: strOrNull(v.id),
565
+ title: strOrNull(v.title),
566
+ summary: strOrNull(v.summary),
567
+ chapters: strOrNull(v.chapters),
559
568
  }))
560
569
 
561
570
  const outlines = collectOutlines(dir, warnings)
@@ -581,6 +590,7 @@ export function parseStateSnapshot(novelDir, options = {}) {
581
590
  timeline,
582
591
  memories,
583
592
  clues,
593
+ volumes,
584
594
  world,
585
595
  science,
586
596
  research,