dsh-maoying 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -16,7 +16,7 @@ Once installed, the main agent becomes the **editor-in-chief of your novel**: it
16
16
  - **Context on demand**: the chief reads index.yml for the big picture, then pulls only what the chapter at hand needs (previous chapter's ending ≈1500 chars + its outline + on-stage character cards + active foreshadow excerpts) — never the whole book.
17
17
  - **Narrow tool surface**: the chief sees only "role delegation + internal retrieval + coordination"; helpers carry their line's tools (reviewer read-only, researcher web+notes only) — less per-request tool overhead and context weight.
18
18
  - **Research**: the research helper searches the web for historical/geographic/domain material, files notes under `novel/research/`, separating fact from common lore from legend, with sources — a dividend of the DSH architecture.
19
- - **Creator panel**: a full-page web panel from the DSH sidebar — Overview / Chapters / Characters / Foreshadowing / Relations / Timeline / Worldbuilding / Index Board / Novel Settings; full-page chapter reading (outline side-by-side + five-dimension fields + status flow), form-based project.yml editing, UI language (中文/English), six font-size steps, and automatic new-version detection.
19
+ - **Creator panel**: a full-page web panel from the DSH sidebar — Overview (with in-place manuscript metadata editing) / Chapters / Characters / Foreshadowing / Relations / Timeline / Worldbuilding / Index Board; full-page chapter reading (outline side-by-side + five-dimension fields + status flow), form-based project.yml editing, UI language (中文/English), six font-size steps, and automatic new-version detection.
20
20
  - **CLI fallback**: `maoying init / stats / index / export / brief / check` — six zero-dependency commands for scaffolding, stats, index rebuild, export, per-chapter writing cards and static checks.
21
21
 
22
22
  ## Quick Start
@@ -74,7 +74,7 @@ A reply of "ok / sure / go on" counts as confirmation; the chief advances direct
74
74
  Open "**毛颖创作台**" at the bottom of the DSH sidebar — a full-page cockpit for the human eye, beyond what agents do:
75
75
 
76
76
  - **Chapters**: status filters + the writing queue; click a chapter for full-page reading — back button, outline side-by-side (Markdown rendered), read-only manuscript, five narrative-dimension fields, one-click status flow.
77
- - **Novel Settings**: form-based editing of project.yml — title / author / genre / planned length / language / workflow phase / co-writing mode / synopsis; saving preserves the file's existing comments. If project.yml was changed externally after the form loaded (manual edits or an agent advancing the phase), saving is rejected with a prompt to re-edit from the latest content instead of overwriting.
77
+ - **Overview**: the "Edit" button on the manuscript card expands form-based editing of project.yml — title / author / genre / planned length / language / workflow phase / co-writing mode / synopsis (the former "Novel Settings" tab, merged in); saving preserves the file's existing comments. If project.yml was changed externally after the form loaded (manual edits or an agent advancing the phase), saving is rejected with a prompt to re-edit from the latest content instead of overwriting.
78
78
  - **Top bar**: current version always visible; a "new version ↗" badge links to the release page when npm has a newer one, next to an "Upgrade now" button that installs the latest release into the current DSH profile with pnpm (profile-directory installs only; a DeepSeek restart is required to take effect); the gear opens Settings — UI language (中文/English), six font-size steps (100%–150%), all applied immediately and remembered.
79
79
 
80
80
  The panel only writes state (chapter status, project.yml, index.yml rebuilds), never prose — prose belongs to the author and the chief.
package/README.md CHANGED
@@ -16,7 +16,7 @@
16
16
  - **上下文按需取**:主编先读 index.yml 概览,写到哪章读哪章(前章结尾约 1500 字 + 该章大纲 + 出场角色卡 + 活跃伏笔节选),不把整本书塞进上下文。
17
17
  - **工具面收窄**:主编只见「角色委派 + 内部检索 + 协调」白名单,帮手各带本行工具(审校只读、考据只带联网+落笔记);每次请求的工具开销与上下文占用随之下降。
18
18
  - **素材考据**:research 帮手联网检索历史/地理/专业领域素材,笔记落 `novel/research/`,区分史实/通说/演义并附来源——DSH 架构的红利。
19
- - **创作台面板**:DSH 侧栏一键打开的全页可视化面板——概览/章节/角色/伏笔/关系图/时间线/设定/索引板/小说设置九个 tab;章节整页阅读(章纲对照 + 五维 + 状态流转)、project.yml 表单化编辑、顶栏字号五档缩放与自动新版检测。
19
+ - **创作台面板**:DSH 侧栏一键打开的全页可视化面板——概览(含书稿元信息编辑)/章节/角色/伏笔/关系图/时间线/设定/索引板八个 tab;章节整页阅读(章纲对照 + 五维 + 状态流转)、project.yml 表单化编辑、顶栏字号六档缩放与自动新版检测。
20
20
  - **CLI 兜底**:`maoying init / stats / index / export / brief / check` 六条零依赖命令管脚手架、统计、索引重建、导出、写作卡打包与静态体检。
21
21
 
22
22
  ## 快速开始
@@ -74,7 +74,7 @@ npm install -g dsh-maoying # 或装成全局 maoying 命令
74
74
  DSH 左侧栏底部点「**毛颖创作台**」打开全页面板(改文件、跑流程之外,给肉眼看的驾驶舱):
75
75
 
76
76
  - **章节**:状态筛选 + 按单写作清单;点章节进整页阅读——返回按钮、章纲对照(Markdown 渲染)、只读正文、五维叙事字段、一键状态流转。
77
- - **小说设置**:project.yml 的书名/作者/题材/篇幅/语言/工作流阶段/共创模式/简介表单化编辑,保存保留文件里原有注释;表单载入后 project.yml 被外部(作者手改 / agent 推进阶段)改过时拒绝保存并提示重改,不覆盖人工改动。
77
+ - **概览**:书稿卡右上「编辑」按钮展开 project.yml 的书名/作者/题材/篇幅/语言/工作流阶段/共创模式/简介表单化编辑(原「小说设置」tab 并入),保存保留文件里原有注释;表单载入后 project.yml 被外部(作者手改 / agent 推进阶段)改过时拒绝保存并提示重改,不覆盖人工改动。
78
78
  - **顶栏**:当前版本号常驻;npm 上有新版时亮「有新版 ↗」徽章直连发布页,旁边「一键升级」按钮直接用 pnpm 把最新包装进当前 DSH 档(仅支持 profile 目录安装;装完需重启 DeepSeek 才生效);齿轮打开系统设置,界面语言中文/English 可切、字号六档(100%–150%)整体缩放,均立即生效并记忆。
79
79
 
80
80
  面板只写状态(章节状态、project.yml、index.yml 重建),不改正文——正文永远归作者与主编。
@@ -45,13 +45,15 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
45
45
  | `state` | GET | `path=<root>` | 全量快照(见 §3 契约) |
46
46
  | `check` | GET | `path=<root>` | `{ ok, problems: [{ level, file, message }] }`——静态体检:与 CLI check 同源的 checkProject,读章节 frontmatter 原文(快照会把越枚举 status 归一成 pending,这里透出原始问题清单;level ∈ error/warn,可为空数组),概览「静态体检」黄条取数 |
47
47
  | `chapter` | GET | `path`, `file`(chapters/ 下文件名) | `{ ok, file, frontmatter, body, outline }`——正文全文 + 对应章纲(`{ file, words, body }`,outlines/ 无对应文件时 null),章节抽屉用 |
48
+ | `outline` | GET | `path`, `file`(outlines/ 下 vXX-cXXX 命名的文件名) | `{ ok, file, words, body }`——章纲全文(章纲纯 markdown 无 frontmatter,body 即原文,words 为 CJK 计数),章节 tab 的章纲弹层用;非章纲文件名 422、章纲不存在 404 |
48
49
  | `chapter-status` | POST | `{ path, file, status }` | `{ ok }`——frontmatter status 行原地替换;status 必须 ∈ pending/writing/review/accepted |
50
+ | `chapter-create` | POST | `{ path, file }`(outlines/ 下章纲文件名) | `{ ok, file, title }`——从章纲建章:chapters/ 落同 stem 的 status=pending 空章节(frontmatter 按 chapters/vXX-cXXX.md 模板,summary 与五维留空、正文待写;title 取章纲首条 `#` 标题行、去结尾「章纲」后缀),建后走既有 pending 流转;目标章节已存在 409、章纲不存在 404、非章纲文件名 422 |
49
51
  | `foreshadow-status` | POST | `{ path, id, status }` | `{ ok }`——foreshadows.yml 对应条目 status 行替换;∈ planted/progressing/resolved/abandoned |
50
52
  | `phase` | POST | `{ path, phase }` | `{ ok }`——project.yml phase 行替换;七态枚举 |
51
53
  | `project-save` | POST | `{ path, values, base_hash? }`(values 为 8 平键 title/author/genre/length/language/phase/mode/description;base_hash 为表单载入时的 project.yml 指纹,即快照 `project_hash`) | `{ ok, project_hash }`——mutateProject 逐键改写保行尾注释、缺键按模板补齐;phase/mode 越枚举与空书名 422;base_hash 与写入前现读原文不一致 → 409「外部修改冲突」拒写不落盘(人工改动保护:表单载入后文件被作者/agent 改过即拒绝静默覆盖;缺省 base_hash 跳过检查,兼容旧调用方)。project_hash 为写后新指纹,客户端可作下一轮基线 |
52
54
  | `rebuild-index` | POST | `{ path }` | `{ ok, chapters }`——照 CLI index 同一逻辑重建 index.yml |
53
55
 
54
- 共 13 条。写操作全部「行级原地替换」:只在 frontmatter/目标 yml 的对应行上做字符串手术,写前读、写后校验(替换计数 ≠1 即 409 拒写并报错),不做整文件重排——把对 git diff 的扰动压到最小,也与 agent 的写法兼容。(`self-update` 是例外:它不碰小说文件,跑的是 pnpm 安装。)
56
+ 共 15 条。写操作全部「行级原地替换」:只在 frontmatter/目标 yml 的对应行上做字符串手术,写前读、写后校验(替换计数 ≠1 即 409 拒写并报错),不做整文件重排——把对 git diff 的扰动压到最小,也与 agent 的写法兼容。(`self-update` 是例外:它不碰小说文件,跑的是 pnpm 安装;`chapter-create` 是另一例外:它从章纲落一个全新章节文件,不动任何既有文件的字节。)
55
57
 
56
58
  ### host-debug 端点(现场诊断)
57
59
 
@@ -104,12 +106,12 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
104
106
  挂载:
105
107
  - 左侧栏底部动作位入口「毛颖创作台」(sidebar.footer.action 键控槽,ctx.effect 注入 + MutationObserver 自愈);
106
108
  - 会话中栏全页 overlay:`html[data-dsh-maoying-active]` 驱动,`dsh-panel-activate` 事件与 mnemon/taskboard/ssh 等全页面板互斥,点侧栏会话行自动关闭;
107
- - 顶部工作区选择器(workspaces 路由取数,hasNovel 的排前;无 novel 工作区显示引导卡:一句 `maoying init` 提示 + 复制按钮)。
109
+ - 顶部工作区选择器(workspaces 路由取数,hasNovel 的排前;无 novel 工作区显示引导卡:一句 `maoying init` 提示 + 复制按钮)。面板懒挂载、关闭不卸载,故每次打开都借 `dsh-panel-activate` 广播重拉一次 workspaces——外部新建/删除的工作区重开即见。
108
110
 
109
- 九个 tab(顺序即默认排布,第 9 个为后续迭代新增):
111
+ 八个 tab(顺序即默认排布):
110
112
 
111
- 1. **概览**:project 卡(书名大字 + 类型/篇幅/语言 pills + 阶段徽章 + 简介);统计卡(总字数/章节数/状态分布条);最近更新(chapters 按 mtime 前 5);warnings 黄条;静态体检黄条(check 路由问题清单,见 §4.1「人工改动保护 + 静态体检黄条」(0.1.14)条目)。
112
- 2. **章节**:表格(卷-章/标题/状态徽章/字数/梗概),状态筛选 pills;表格上方「按单写作」区块——有章纲且 status=pending 的章按序列出(章纲节选 + 状态徽章,共创模式的主工作清单);行点击开整页阅读视图(0.1.12 起替代原右侧抽屉——fixed 侧弹层会被 DSH 侧边栏遮挡):返回按钮 + GET chapter 正文(只读,等宽滚动)+ 章纲对照栏(左右分栏或折叠段)+ 复制文件路径按钮 + 五维字段卡 + 状态流转按钮(当前态 → 下一态一键,pending→writing→review→accepted)。
113
+ 1. **概览**:project 卡(书名大字 + 类型/篇幅/语言 pills + 阶段徽章 + 简介,卡右上「编辑」按钮展开书稿元信息表单——原「小说设置」tab 并入,见 §4.1「小说设置并入概览」条目);统计卡(总字数/章节数/状态分布条);最近更新(chapters 按 mtime 前 5);warnings 黄条;静态体检黄条(check 路由问题清单,见 §4.1「人工改动保护 + 静态体检黄条」(0.1.14)条目)。
114
+ 2. **章节**:表格(卷-章/标题/状态徽章/字数/梗概),状态筛选 pills;表格上方「按单写作」区块——有章纲且 status=pending 的章按序列出(章纲节选 + 状态徽章),未建章的孤儿章纲照列(「未建章」徽章 + 「建章」一键:POST chapter-create 落 pending 空章并重拉快照;行点击开章纲全文弹层,0.1.15 起),共创模式的主工作清单;行点击开整页阅读视图(0.1.12 起替代原右侧抽屉——fixed 侧弹层会被 DSH 侧边栏遮挡):返回按钮 + GET chapter 正文(只读,等宽滚动)+ 章纲对照栏(左右分栏或折叠段)+ 复制文件路径按钮 + 五维字段卡 + 状态流转按钮(当前态 → 下一态一键,pending→writing→review→accepted)。
113
115
  3. **角色**:卡墙(姓名/role 徽章四色/年龄性别/性格一句/动机一行),点击看全卡(含 arc/markers/background)。
114
116
  4. **伏笔**:四列看板(埋设/推进/已回收/弃用),卡片含 priority 色点(high 红/normal 灰/low 淡)、`source: backfill` 的「回填」小徽章(账本回填来的条目,区别于写前规划)、埋设章→预期回收章、notes 全文直显(0.1.10 去掉两行截断);卡片按钮:推进到下一状态(planted→progressing→resolved;abandoned 只能从看板拖不动、按钮给「弃用」)。列头计数。
115
117
  5. **关系图**:canvas 力导向图——节点=角色(radius 随出场无关、固定 18px,名字 label 下方),边=relations(intensity 1-5 → 线宽 1-4px,type 标在边中点 hover 显示 description);拖拽节点、空白处滚轮缩放、双击复位;intensity 缺省 3。角色多于 30 个时只画与 relations 相连的节点,其余进「未入图」列表。
@@ -119,7 +121,7 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
119
121
 
120
122
  状态徽章色(全局统一):pending 灰 / writing 蓝 / review 橙 / accepted 绿;phase 七态沿用七色 pill。文案全中文、禁 emoji 图标(用 ui-primitives 图标或纯 CSS)。
121
123
 
122
- ## 4.1 后续迭代记录(0.1.10 – 0.1.14)
124
+ ## 4.1 后续迭代记录(0.1.10 起)
123
125
 
124
126
  - **MaskPortal**:抽屉/弹层统一 portal 到 `document.body`——面板容器挂在宿主会话中栏,中栏 transform/overflow 会把 `position: fixed` 劫持成相对自身并连同裁剪(桌面端「点了没反应」实测病灶);无 createPortal 的老宿主退回原地渲染。
125
127
  - **顶栏版本区**:当前版本号常驻;`update-check` 路由(模仿 dsh-paoding:npm registry → npmmirror → GitHub 三级源,1h 缓存 + 7 天 stale-if-error,全败静默)发现新版时亮「有新版 ↗」徽章。
@@ -131,6 +133,9 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
131
133
  - **字号六档改百分比**(0.1.13):档位从中文名(小/标准/大…)改为 100/110/120/130/140/150% 直读,旧 localStorage 档位值回落 100%。
132
134
  - **人工改动保护 + 静态体检黄条**(0.1.14):快照顶层新增 `project_hash`(project.yml 原文 sha256,读不到为 null)——小说设置保存回传 `base_hash`,表单载入后 project.yml 被作者手改或 agent(如 mutatePhase 推进 phase)动过时 `project-save` 409 拒写并报「外部修改冲突」,客户端重拉快照把表单放回磁盘真值,不再静默覆盖同名键丢人工改动;概览新增「静态体检」黄条(GET `check`,与 CLI check 同源的 checkProject,透出快照归一掩盖的越枚举等问题,含 error 级时整条转错误色)。路由 11 → 12 条;I18N zh/en 各 209 → 211 条(check.title / check.fullHint)。
133
135
  - **面板内一键升级**(0.1.14):`POST self-update`(lib/self-update.mjs,依赖全部可注入)——顶栏新版徽章旁加「一键升级」按钮,安装形态(`<dshHome>/profiles/<profile>/node_modules/dsh-maoying` 真实目录,symlink/dev 检出一票否决)与 pnpm 可执行性(PATH → homebrew/pnpm 官方安装位候选)双闸通过后跑 `pnpm --dir <profileDir> add dsh-maoying@latest`(180s 超时、无 shell、模块级 in-flight 去重),成功后失效版本检测缓存并提示「已安装 {to},重启 DeepSeek 后生效」——**装完只换磁盘包,重启才生效,UI 文案照实说**;安装中按钮禁用(前端该请求超时放宽到 200s),「已安装,待重启」后徽章收起。路由 12 → 13 条;I18N zh/en 各 211 → 219 条(header.upgrade* / toast.selfUpdate.*)。
136
+ - **按单写作兼容孤儿章纲**(0.1.15):章纲先于正文时面板不再零入口——「按单写作」区块在联交(有章纲且 status=pending 的章)之外照列未建章的孤儿章纲(按章纲序保序混排):「未建章」徽章 + 章纲节选 + 字数 + 两个动作(行点击开章纲全文弹层:MaskPortal + MdView 渲染 GET `outline` 的 body、words 一并显示;「建章」按钮 POST `chapter-create` 在 chapters/ 落 status=pending 空章——frontmatter 按 chapters/vXX-cXXX.md 模板、title 取章纲首条标题行去「章纲」后缀,成功 toast 后重拉快照,新章进表格、走既有 pending 流转,孤儿行按新快照自然消失);chapters/ 为空而 outlines/ 非空时空态改提示「下方按单写作已列出未建章章纲」。路由 13 → 15 条(outline GET / chapter-create POST);I18N zh/en 各 219 → 230 条(chapters.outlineRowTip / noChapter / createBtn / creating / createTip / emptyWithOutlines、toast.chapterCreated、outline.modalAria / closeAria / loading / loadFailed)。
137
+ - **小说设置并入概览**(0.1.15):原第 9 个「小说设置」tab 撤销(八个 tab),改为概览书稿卡右上「编辑」按钮——点击后 hero 卡原位切换为书稿元信息表单(ProjectMetaForm,8 平键 + 取消/保存),保存链路不变(`POST project-save` 携 `base_hash` 409 人工改动保护,成功重拉快照并退出编辑态,取消直接退出不落盘)。路由不变;I18N zh/en 各 230 → 232 条(tabs.settings 移除,overview.edit / overview.editTip / novel.cancel 新增)。
138
+ - **role 徽章本地化**(0.1.15):角色卡墙与全卡弹层的 role 徽章不再直出角色卡 frontmatter 原文——契约四态(`protagonist|antagonist|supporting|minor`)按界面语言出标签(主角/反派/配角/龙套;英文面板 Protagonist/Antagonist/Supporting/Minor),role 是自由文本、契约外取值原样展示不翻译;tone 归色逻辑(roleTone 关键词匹配)不变。I18N zh/en 各 232 → 236 条(role.protagonist / antagonist / supporting / minor)。
134
139
 
135
140
  ## 5. 验收基线
136
141
 
package/docs/design.md CHANGED
@@ -172,7 +172,7 @@ persona prefix 内容骨架(agent.cordis.yml 内联,中文书写):
172
172
  - ✅ **技能包(黄金三章/爽点节奏/审校清单/伏笔管理)**——已做:skills/ 四目录随包分发,安装器复制到 `<home>/skills/maoying-*`(逐字节比较幂等 + .installed-version 标记,见 index.mjs installSkills)。
173
173
  - ✅ **导出(MD/TXT/EPUB)**——已做:`maoying export [dir] --format md|txt|epub [--out <目录>]`;EPUB 由 lib/epub.mjs 零依赖打包(手写 stored zip + CRC32 表格法,EPUB 3 最小合法结构)。
174
174
  - ✅ **0.2.1-rc.2+ AgentPreset 形态适配**——已做:presets/maoying/agent.cordis.v2.yml(主编 + 5 角色 preset 的 v2 组合源)+ index.mjs composeManagedRows 按宿主版本双形态切换(v1 内联 / v2 AgentPreset,宿主升降级重启即整块换形状;防漂移由 test/composition.test.mjs 逐字节断言,见 README「兼容面」)。
175
- - ✅ **Web 创作台面板**——已做:/api/maoying/* 十一条路由 + 左侧栏入口 + 会话中栏全页面板,九视图(概览/章节/角色/伏笔看板/关系图/时间线/设定/索引板/小说设置);设计与契约见 docs/creator-panel.md。
175
+ - ✅ **Web 创作台面板**——已做:/api/maoying/* 十五条路由 + 左侧栏入口 + 会话中栏全页面板,八视图(概览(含书稿元信息编辑)/章节/角色/伏笔看板/关系图/时间线/设定/索引板);设计与契约见 docs/creator-panel.md。
176
176
 
177
177
  ## 9. 五期:共创模式(作者亲笔稿)
178
178
 
@@ -206,4 +206,4 @@ project.yml 加 `mode` 字段:`ai-draft`(默认,现状)/ `co-write`(
206
206
 
207
207
  - `maoying brief <dir> <章号>` 写作卡:章纲 + 前章结尾约 1500 字 + 出场角色卡 + 活跃伏笔节选,打成一个 md(stdout 或 --out)。给作者带去任意编辑器/其他 AI;
208
208
  - `maoying check <dir>` 静态体检(**不调模型**——LLM 审校走主编):frontmatter 完整性、五维缺键、状态枚举、章号/卷号规范、伏笔章号引用存在性、时间线年份乱序、角色卡悬空引用;
209
- - 面板三件:章节抽屉加章纲对照栏 + 复制文件路径按钮;伏笔看板卡上 `source: backfill` 显示「回填」徽章;章节 tab 顶部「按单写作」区块(有章纲且 status=pending 的章,按序列章纲节选)。
209
+ - 面板三件:章节抽屉加章纲对照栏 + 复制文件路径按钮;伏笔看板卡上 `source: backfill` 显示「回填」徽章;章节 tab 顶部「按单写作」区块(有章纲且 status=pending 的章,按序列章纲节选;未建章的孤儿章纲照列——可看章纲全文、一键建章,0.1.15 起)。
package/index.mjs CHANGED
@@ -63,6 +63,7 @@ import path from 'node:path'
63
63
  import { fileURLToPath } from 'node:url'
64
64
 
65
65
  import {
66
+ createChapterFromOutline,
66
67
  listWorkspaces,
67
68
  mutateChapterStatus,
68
69
  mutateForeshadowStatus,
@@ -70,6 +71,7 @@ import {
70
71
  mutateProject,
71
72
  parseStateSnapshot,
72
73
  readChapter,
74
+ readOutlineFile,
73
75
  rebuildIndex,
74
76
  resolveNovelRoot,
75
77
  } from './lib/panel-core.mjs'
@@ -940,7 +942,7 @@ export async function registerPrefixRouteWithRetry(webServer, route, { attempts
940
942
  }
941
943
 
942
944
  /**
943
- * 注册创作台全部路由(docs/creator-panel.md §2 的 13 条)。返回宿主 disposer;
945
+ * 注册创作台全部路由(docs/creator-panel.md §2 的 15 条)。返回宿主 disposer;
944
946
  * webServer 缺席(非 web 部署)时抛错,由 apply 侧统一降级为 warn。
945
947
  */
946
948
  export async function registerCreatorRoutes(ctx) {
@@ -1097,6 +1099,15 @@ export async function registerCreatorRoutes(ctx) {
1097
1099
  return
1098
1100
  }
1099
1101
 
1102
+ if (route === 'outline' && req.method === 'GET') {
1103
+ // 章纲全文(0.1.15,章纲弹层用):file 只认 outlines/ 下合 vXX-cXXX
1104
+ // 规范的纯文件名;响应 { ok, file, words, body }——章纲纯 markdown
1105
+ // 无 frontmatter,body 即原文,words 为 CJK 计数。
1106
+ const novelDir = resolveNovelRoot(url.searchParams.get('path'), ctx.workspaceRegistry)
1107
+ json(res, 200, readOutlineFile(novelDir, url.searchParams.get('file')))
1108
+ return
1109
+ }
1110
+
1100
1111
  if (route === 'chapter-status' && req.method === 'POST') {
1101
1112
  const body = await readBody(req, res)
1102
1113
  const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
@@ -1104,6 +1115,16 @@ export async function registerCreatorRoutes(ctx) {
1104
1115
  return
1105
1116
  }
1106
1117
 
1118
+ if (route === 'chapter-create' && req.method === 'POST') {
1119
+ // 从章纲建章(0.1.15,按单写作的未建章章纲一键建章):chapters/
1120
+ // 落 status=pending 空章节(frontmatter 按模板、正文待写),建后走
1121
+ // 既有 pending 流转;已存在 409、章纲缺失 404、非章纲文件名 422。
1122
+ const body = await readBody(req, res)
1123
+ const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
1124
+ json(res, 200, createChapterFromOutline(novelDir, body.file))
1125
+ return
1126
+ }
1127
+
1107
1128
  if (route === 'foreshadow-status' && req.method === 'POST') {
1108
1129
  const body = await readBody(req, res)
1109
1130
  const novelDir = resolveNovelRoot(body.path, ctx.workspaceRegistry)
package/lib/base.css CHANGED
@@ -337,8 +337,10 @@ html[data-dsh-maoying-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
337
337
  @keyframes myt-skel-sweep { to { transform: translateX(100%); } }
338
338
  .myt-skelCard { height: 120px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 14px; background: var(--dsw-alias-bg-layer-3); display: flex; flex-direction: column; gap: 10px; padding: 16px; box-sizing: border-box; }
339
339
  .myt-skelRow { height: 14px; }
340
- /* ── 概览:书稿卡 / 统计卡 / 最近更新 ──────────────────────────────────── */
340
+ /* ── 概览:书稿卡(含「编辑」入口)/ 统计卡 / 最近更新 ────────────────────── */
341
341
  .myt-hero { gap: 12px; }
342
+ /* hero 卡右上「编辑」按钮:占位推到行尾(原小说设置 tab 并入概览)*/
343
+ .myt-heroEdit { margin-left: auto; }
342
344
  .myt-heroTitle { margin: 0; font-size: 24px; line-height: 32px; font-weight: 700; color: var(--dsw-alias-label-primary); overflow-wrap: anywhere; }
343
345
  .myt-heroMeta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
344
346
  .myt-heroDesc { margin: 0; font-size: 13px; line-height: 20px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; white-space: pre-wrap; }
@@ -526,6 +528,22 @@ html[data-dsh-maoying-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
526
528
  text-overflow: ellipsis;
527
529
  }
528
530
 
531
+ /* 孤儿章纲「未建章」徽章(0.1.15:按单写作兼容未建章章纲,章纲先于正文)*/
532
+ .myt-noChapterBadge {
533
+ flex: none;
534
+ padding: 0 6px;
535
+ border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary) 40%, transparent);
536
+ border-radius: 999px;
537
+ background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 12%, transparent);
538
+ color: var(--dsw-alias-state-business-primary);
539
+ font-size: 10px;
540
+ line-height: 14px;
541
+ font-weight: 500;
542
+ white-space: nowrap;
543
+ }
544
+ /* 孤儿章纲行内的「建章」动作按钮:沿用伏笔卡 fsActions 的紧凑尺寸 */
545
+ .myt-writeOrderRow .myt-btn { flex: none; padding: 2px 8px; font-size: 11px; line-height: 16px; }
546
+
529
547
  /* ── 角色 tab:卡墙 + 全卡弹层 ─────────────────────────────────────────── */
530
548
  .myt-charWall { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
531
549
  .myt-charCard { cursor: pointer; gap: 8px; text-align: left; }
@@ -845,7 +863,7 @@ html[data-dsh-maoying-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
845
863
  .myt-md a:hover { text-decoration: underline; }
846
864
  .myt-md strong { color: var(--dsw-alias-label-primary); }
847
865
 
848
- /* ── 小说设置 tab ──────────────────────────────────────────────────────── */
866
+ /* ── 书稿元信息表单(概览「编辑」态展开)────────────────────────────────── */
849
867
  .myt-setGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
850
868
  .myt-setField { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
851
869
  .myt-setFieldLabel { font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); }
package/lib/client.js CHANGED
@@ -69,7 +69,7 @@ window.__ModuleLoader__.load({
69
69
  var TABS = [
70
70
  ["overview", "tabs.overview"], ["chapters", "tabs.chapters"], ["characters", "tabs.characters"],
71
71
  ["foreshadows", "tabs.foreshadows"], ["relations", "tabs.relations"], ["timeline", "tabs.timeline"],
72
- ["world", "tabs.world"], ["index", "tabs.index"], ["settings", "tabs.settings"],
72
+ ["world", "tabs.world"], ["index", "tabs.index"],
73
73
  ];
74
74
 
75
75
  // 角色徽章四色(role 是自由文本):按关键词归 tone,命中即返。
@@ -80,10 +80,15 @@ window.__ModuleLoader__.load({
80
80
  if (/配角|支持|盟友|friend|ally/i.test(r)) return "c";
81
81
  return "d";
82
82
  }
83
+ // 契约四态 role(protagonist|antagonist|supporting|minor)按界面语言出标签,
84
+ // role 是自由文本,契约外取值原样展示不翻译。
85
+ var ROLE_CONTRACT = { protagonist: 1, antagonist: 1, supporting: 1, minor: 1 };
83
86
  function roleBadge(role) {
84
87
  var text = String(role || "").trim();
85
88
  if (!text) return null;
86
- return h("span", { className: "myt-badge myt-roleBadge", "data-tone": roleTone(text) }, text);
89
+ var key = text.toLowerCase();
90
+ if (!ROLE_CONTRACT[key]) key = null;
91
+ return h("span", { className: "myt-badge myt-roleBadge", "data-tone": roleTone(text) }, key ? tt("role." + key) : text);
87
92
  }
88
93
  function statusBadge(status) {
89
94
  var s = STATUS_FLOW.indexOf(status) !== -1 ? status : "pending";
@@ -119,6 +124,15 @@ window.__ModuleLoader__.load({
119
124
  var num = typeof ch.num === "number" ? tf("common.chapterNum", { n: ch.num }) : "";
120
125
  return ch.volume ? String(ch.volume) + " · " + num : num;
121
126
  }
127
+ // 孤儿章纲的「卷-章」定位(0.1.15):从章纲文件名 stem(v01-c003.md)
128
+ // 反推,显示格式与 chWhere 对齐;不合规范的文件名返回空串(调用方退回
129
+ // 文件名兜底显示)。
130
+ function olWhere(ol) {
131
+ var stem = ol && ol.chapter ? String(ol.chapter).replace(/\.md$/i, "") : "";
132
+ var m = /^v(\d+)-c(\d+)$/i.exec(stem);
133
+ if (!m) return "";
134
+ return "v" + m[1] + " · " + tf("common.chapterNum", { n: Number(m[2]) });
135
+ }
122
136
  function chFullTitle(ch) {
123
137
  if (!ch) return "";
124
138
  var where = chWhere(ch);
@@ -223,6 +237,7 @@ window.__ModuleLoader__.load({
223
237
  function apiPostOkText(path, d) {
224
238
  if (path === "rebuild-index") return tf("toast.indexRebuilt", { n: fmtNum(d && d.chapters) });
225
239
  if (path === "chapter-status") return tt("toast.chapterStatus");
240
+ if (path === "chapter-create") return tf("toast.chapterCreated", { title: (d && d.title) || "—" });
226
241
  if (path === "foreshadow-status") return tt("toast.foreshadowStatus");
227
242
  if (path === "phase") return tf("toast.phaseAdvanced", { phase: d && d.phase && PHASES.indexOf(d.phase) !== -1 ? tt("phase." + d.phase) : (d && d.phase) || "—" });
228
243
  return tt("toast.success");
@@ -521,7 +536,8 @@ window.__ModuleLoader__.load({
521
536
  }
522
537
 
523
538
  // ── 页面骨架:顶栏(工作区下拉 + 刷新 + 关闭)+ tab 条 + 内容区 ─────────
524
- // 数据流:workspaces 一次拉取;state 跟随选中工作区;写操作 POST 成功后
539
+ // 数据流:workspaces 每次打开面板都重拉(懒挂载下关闭不卸载,见下方
540
+ // activate 监听);state 跟随选中工作区;写操作 POST 成功后
525
541
  // 整份重拉 state(不乐观更新)。选择记 localStorage('maoying.workspace')。
526
542
  function PageShell(props) {
527
543
  var onClose = props.onClose;
@@ -645,6 +661,20 @@ window.__ModuleLoader__.load({
645
661
  });
646
662
  }
647
663
  useEffect(function () { loadWorkspaces(); }, [loadWorkspaces]);
664
+ // 每次打开面板都重拉工作区列表:懒挂载下关闭只是摘掉 active 属性,
665
+ // 组件不卸载,上面的 mount effect 整个页面生命周期只跑一次,期间外部
666
+ // (CLI / 其他面板)新建或删除的工作区在重开时看不见。mountPanel 每次
667
+ // 打开都会广播 dsh-panel-activate(source: "maoying"),借它触发重拉;
668
+ // 首次打开的广播早于本组件挂载(render 异步调度),收不到也无妨——
669
+ // mount effect 已覆盖首拉。其余面板的同名广播按 source 过滤掉。
670
+ useEffect(function () {
671
+ var onActivate = function (event) {
672
+ var source = event && event.detail && event.detail.source;
673
+ if (source === "maoying") loadWorkspaces();
674
+ };
675
+ document.addEventListener(ACTIVATE_EVENT, onActivate);
676
+ return function () { document.removeEventListener(ACTIVATE_EVENT, onActivate); };
677
+ }, [loadWorkspaces]);
648
678
  useEffect(function () { loadState(effectiveWs); }, [effectiveWs, loadState]);
649
679
  // 校正后的选择回写记忆(首次打开 / 记忆失效时自动落位)。
650
680
  useEffect(function () {
@@ -692,7 +722,6 @@ window.__ModuleLoader__.load({
692
722
  else if (tab === "timeline") body = h(TimelineTab, tabProps);
693
723
  else if (tab === "world") body = h(WorldTab, tabProps);
694
724
  else if (tab === "index") body = h(IndexTab, tabProps);
695
- else if (tab === "settings") body = h(SettingsTab, tabProps);
696
725
  else body = h(OverviewTab, Object.assign({ gotoTab: gotoTab }, tabProps));
697
726
  }
698
727
 
@@ -773,13 +802,16 @@ window.__ModuleLoader__.load({
773
802
  h("div", null, tt("check.fullHint"))));
774
803
  }
775
804
 
776
- // ── 概览 tab:project 卡 + 统计卡 + 最近更新 ───────────────────────────
805
+ // ── 概览 tab:project 卡(含「编辑」书稿元信息,原小说设置并入)+ 统计卡
806
+ // + 最近更新 ─────────────────────────────────────────────────────────────
777
807
  function OverviewTab(props) {
778
808
  var snap = props.snap;
779
809
  var reload = props.reload;
780
810
  var gotoTab = props.gotoTab;
781
811
  var wsPath = props.wsPath;
782
812
  var project = snap.project || {};
813
+ // 编辑态:true 时 hero 卡切换为书稿元信息表单(ProjectMetaForm)。
814
+ var _ed = useState(false), editing = _ed[0], setEditing = _ed[1];
783
815
  var totals = snap.totals || {};
784
816
  var byStatus = totals.byStatus || {};
785
817
  var _b = useState(false), phaseBusy = _b[0], setPhaseBusy = _b[1];
@@ -809,17 +841,24 @@ window.__ModuleLoader__.load({
809
841
  var recent = (snap.chapters || []).slice().sort(function (a, b) { return (b.num || 0) - (a.num || 0); }).slice(0, 5);
810
842
  return h("div", { className: "myt-stack" },
811
843
  checkProblems && checkProblems.length ? CheckStrip(checkProblems) : null,
812
- h("div", { className: "myt-card myt-hero" },
813
- h("div", { className: "myt-heroMeta" },
814
- project.author ? h("span", { className: "myt-muted" }, tf("overview.author", { name: project.author })) : null,
815
- project.genre ? h("span", { className: "myt-badge" }, project.genre) : null,
816
- project.length ? h("span", { className: "myt-badge" }, project.length) : null,
817
- project.language ? h("span", { className: "myt-badge" }, project.language) : null),
818
- h("h2", { className: "myt-heroTitle" }, orDash(project.title)),
819
- h("div", { className: "myt-heroMeta" },
820
- phasePill(project.phase),
821
- 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)),
822
- project.description ? h("p", { className: "myt-heroDesc" }, project.description) : null),
844
+ editing
845
+ // 编辑态:hero 卡整体换成元信息表单(保存/取消在表单卡内)。
846
+ ? h(ProjectMetaForm, { snap: snap, wsPath: wsPath, reload: reload, onDone: function () { setEditing(false); } })
847
+ : h("div", { className: "myt-card myt-hero" },
848
+ [
849
+ h("div", { className: "myt-heroMeta", key: "meta" },
850
+ project.author ? h("span", { className: "myt-muted" }, tf("overview.author", { name: project.author })) : null,
851
+ project.genre ? h("span", { className: "myt-badge" }, project.genre) : null,
852
+ project.length ? h("span", { className: "myt-badge" }, project.length) : null,
853
+ project.language ? h("span", { className: "myt-badge" }, project.language) : null,
854
+ h("button", { type: "button", className: "myt-btn myt-heroEdit", title: tt("overview.editTip"), "aria-label": tt("overview.editTip"), onClick: function () { setEditing(true); } },
855
+ ic("IconEditOutlineRegular", 13), tt("overview.edit"))),
856
+ h("h2", { className: "myt-heroTitle", key: "title" }, orDash(project.title)),
857
+ h("div", { className: "myt-heroMeta", key: "phase" },
858
+ phasePill(project.phase),
859
+ 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
+ project.description ? h("p", { className: "myt-heroDesc", key: "desc" }, project.description) : null,
861
+ ]),
823
862
  h("div", { className: "myt-statRow" },
824
863
  h("div", { className: "myt-card myt-stat" }, h("span", { className: "myt-statNum" }, fmtNum(totals.words)), h("span", { className: "myt-statLabel" }, tt("overview.totalWords"))),
825
864
  h("div", { className: "myt-card myt-stat" }, h("span", { className: "myt-statNum" }, fmtNum(totals.chapters)), h("span", { className: "myt-statLabel" }, tt("overview.totalChapters"))),
@@ -976,7 +1015,7 @@ window.__ModuleLoader__.load({
976
1015
  "header.settings": "系统设置", "header.refresh": "重新拉取", "header.closeAria": "关闭创作台", "header.closeTitle": "关闭",
977
1016
  "tabs.aria": "创作台分区",
978
1017
  "tabs.overview": "概览", "tabs.chapters": "章节", "tabs.characters": "角色", "tabs.foreshadows": "伏笔", "tabs.relations": "关系图",
979
- "tabs.timeline": "时间线", "tabs.world": "设定", "tabs.index": "索引板", "tabs.settings": "小说设置",
1018
+ "tabs.timeline": "时间线", "tabs.world": "设定", "tabs.index": "索引板",
980
1019
  "shell.wsListFailed": "工作区列表拉取失败:{msg}", "shell.stateFailed": "快照拉取失败:{msg}",
981
1020
  "guide.noWsTitle": "还没有可用的小说工作区", "guide.noWsDesc": "任一工作区根目录下出现 novel/project.yml 即自动入选;先用命令初始化目录骨架:",
982
1021
  "guide.notInitTitle": "该工作区尚未初始化小说项目", "guide.notInitDesc": "工作区根目录下没有 novel/project.yml。在该工作区根目录执行:",
@@ -986,7 +1025,7 @@ window.__ModuleLoader__.load({
986
1025
  "common.closeTip": "关闭提示", "common.unknownErr": "未知错误", "common.priorityTitle": "优先级:{pri}", "common.priorityAria": "优先级{pri}",
987
1026
  "common.chapterNum": "第 {n} 章", "pri.high": "高", "pri.normal": "中", "pri.low": "低",
988
1027
  "toast.prefix": "毛颖创作台:{msg}", "toast.success": "操作成功", "toast.indexRebuilt": "索引已重建({n} 章)",
989
- "toast.chapterStatus": "章节状态已更新", "toast.foreshadowStatus": "伏笔状态已更新", "toast.phaseAdvanced": "阶段已推进到「{phase}」",
1028
+ "toast.chapterStatus": "章节状态已更新", "toast.chapterCreated": "已从章纲建章「{title}」(待写)", "toast.foreshadowStatus": "伏笔状态已更新", "toast.phaseAdvanced": "阶段已推进到「{phase}」",
990
1029
  "api.notJson": "响应不是 JSON(HTTP {code})", "api.timeout": "请求超时({s} 秒)",
991
1030
  "status.pending": "待写", "status.writing": "写作中", "status.review": "审校中", "status.accepted": "已定稿", "status.rawSuffix": "({raw})",
992
1031
  "phase.idea": "立意", "phase.setting": "设定", "phase.outline": "大纲", "phase.writing": "写作", "phase.review": "审校",
@@ -1014,28 +1053,34 @@ window.__ModuleLoader__.load({
1014
1053
  "relations.unlinkedTitle": "未入图角色(无关系边)", "relations.noData": "没有角色或关系数据,无法绘图",
1015
1054
  "relations.edgeTipPrefix": "{type}:", "relations.noDesc": "(无描述)",
1016
1055
  "char.empty": "characters/ 目录还没有角色卡",
1056
+ "role.protagonist": "主角", "role.antagonist": "反派", "role.supporting": "配角", "role.minor": "龙套",
1017
1057
  "char.field.age": "年龄", "char.field.gender": "性别", "char.field.appearance": "外貌", "char.field.personality": "性格",
1018
1058
  "char.field.background": "背景", "char.field.motivation": "动机", "char.field.arc": "成长弧",
1019
1059
  "char.years": "{n} 岁", "char.linePersonality": "性格 ", "char.lineMotivation": "动机 ",
1020
1060
  "char.modalAria": "角色详情", "char.closeAria": "关闭角色卡", "char.markers": "标记", "char.file": "文件",
1021
1061
  "overview.author": "作者:{name}", "overview.totalWords": "总字数(CJK)", "overview.totalChapters": "章节总数",
1022
1062
  "overview.characters": "角色数", "overview.openForeshadows": "未回收伏笔", "overview.statusDist": "章节状态分布", "overview.recent": "最近章节",
1063
+ "overview.edit": "编辑", "overview.editTip": "编辑书稿元信息",
1023
1064
  "chapters.empty": "chapters/ 目录还没有章节文件", "chapters.writeQueue": "按单写作", "chapters.rowTip": "点击打开该章",
1024
1065
  "chapters.outlineWords": "章纲 {n} 字", "chapters.all": "全部", "chapters.thVolume": "卷 · 章", "chapters.thTitle": "标题",
1025
1066
  "chapters.thStatus": "状态", "chapters.thWords": "字数", "chapters.thSummary": "梗概", "chapters.rowReadTip": "点击阅读正文",
1026
1067
  "chapters.emptyFiltered": "该状态下没有章节",
1068
+ "chapters.outlineRowTip": "点击查看章纲全文", "chapters.noChapter": "未建章", "chapters.createBtn": "建章", "chapters.creating": "建章中…",
1069
+ "chapters.createTip": "在 chapters/ 下按章纲建一章(status=pending,正文待写)",
1070
+ "chapters.emptyWithOutlines": "chapters/ 目录还没有章节文件;下方「按单写作」已列出未建章章纲",
1027
1071
  "reader.back": "返回", "reader.backTip": "返回章节列表", "reader.copyPath": "复制路径", "reader.copyPathTip": "复制章节文件路径",
1028
1072
  "reader.copied": "已复制章节文件路径", "reader.copyFailed": "复制失败,请手动选择路径文本", "reader.loadFailed": "正文读取失败:{msg}",
1029
1073
  "reader.noOutline": "暂无章纲", "reader.emptyBody": "(空章节)", "reader.finalState": "已定稿(终态)", "reader.statusFlow": "状态流转:",
1030
1074
  "reader.flowing": "流转中…", "reader.advanceTo": "推进到「{next}」",
1031
1075
  "outline.title": "章纲对照", "outline.empty": "(章纲为空)", "outline.collapseTip": "收起章纲", "outline.expandTip": "展开章纲",
1076
+ "outline.modalAria": "章纲全文", "outline.closeAria": "关闭章纲", "outline.loading": "章纲加载中…", "outline.loadFailed": "章纲读取失败:{msg}",
1032
1077
  "novel.metaTitle": "书稿元信息", "novel.metaNote": "对应 novel/project.yml · 行尾注释会原样保留",
1033
1078
  "novel.fTitle": "书名", "novel.titlePh": "如:长夜余火", "novel.fAuthor": "作者", "novel.authorPh": "笔名",
1034
1079
  "novel.fGenre": "题材", "novel.genrePh": "如:东方玄幻 / 都市异能 / 历史架空", "novel.fLength": "预期篇幅",
1035
1080
  "novel.lengthPh": "如:约100万字 / 300章", "novel.fLanguage": "正文语言", "novel.fPhase": "工作流阶段", "novel.fMode": "共创模式",
1036
1081
  "novel.fDesc": "一句话简介", "novel.descPh": "写封面文案时可直接引用", "novel.titleRequired": "书名不能为空",
1037
1082
  "mode.aiDraft": "AI 代笔(AI 写正文)", "mode.coWrite": "逐章共创(按章指派谁写)", "mode.authorDraft": "作者亲笔(AI 只备料审稿)",
1038
- "novel.saving": "保存中…", "novel.save": "保存设置",
1083
+ "novel.saving": "保存中…", "novel.save": "保存设置", "novel.cancel": "取消",
1039
1084
  "settings.title": "系统设置", "settings.closeAria": "关闭设置", "settings.language": "语言",
1040
1085
  "settings.languageHint": "界面文字随语言选项切换,立即生效并记忆。", "settings.fontScale": "字号",
1041
1086
  "settings.fontScaleHint": "按当前字号的百分比缩放整个创作台,立即生效并记忆。",
@@ -1055,7 +1100,7 @@ window.__ModuleLoader__.load({
1055
1100
  "header.settings": "Settings", "header.refresh": "Refresh", "header.closeAria": "Close Maoying Studio", "header.closeTitle": "Close",
1056
1101
  "tabs.aria": "Studio sections",
1057
1102
  "tabs.overview": "Overview", "tabs.chapters": "Chapters", "tabs.characters": "Characters", "tabs.foreshadows": "Foreshadowing", "tabs.relations": "Relations",
1058
- "tabs.timeline": "Timeline", "tabs.world": "Worldbuilding", "tabs.index": "Index Board", "tabs.settings": "Novel Settings",
1103
+ "tabs.timeline": "Timeline", "tabs.world": "Worldbuilding", "tabs.index": "Index Board",
1059
1104
  "shell.wsListFailed": "Failed to load workspace list: {msg}", "shell.stateFailed": "Failed to load snapshot: {msg}",
1060
1105
  "guide.noWsTitle": "No novel workspace available yet", "guide.noWsDesc": "A workspace is picked up automatically once novel/project.yml exists at its root; initialize the directory scaffold with this command first:",
1061
1106
  "guide.notInitTitle": "No novel project in this workspace yet", "guide.notInitDesc": "novel/project.yml is missing at the workspace root. Run this at the workspace root:",
@@ -1065,7 +1110,7 @@ window.__ModuleLoader__.load({
1065
1110
  "common.closeTip": "Dismiss notification", "common.unknownErr": "Unknown error", "common.priorityTitle": "Priority: {pri}", "common.priorityAria": "Priority {pri}",
1066
1111
  "common.chapterNum": "Ch. {n}", "pri.high": "High", "pri.normal": "Normal", "pri.low": "Low",
1067
1112
  "toast.prefix": "Maoying Studio: {msg}", "toast.success": "Success", "toast.indexRebuilt": "Index rebuilt ({n} chapters)",
1068
- "toast.chapterStatus": "Chapter status updated", "toast.foreshadowStatus": "Foreshadowing status updated", "toast.phaseAdvanced": "Phase advanced to {phase}",
1113
+ "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}",
1069
1114
  "api.notJson": "Response is not JSON (HTTP {code})", "api.timeout": "Request timed out ({s} s)",
1070
1115
  "status.pending": "Pending", "status.writing": "Writing", "status.review": "Review", "status.accepted": "Accepted", "status.rawSuffix": "({raw})",
1071
1116
  "phase.idea": "Idea", "phase.setting": "Setting", "phase.outline": "Outline", "phase.writing": "Writing", "phase.review": "Review",
@@ -1093,28 +1138,34 @@ window.__ModuleLoader__.load({
1093
1138
  "relations.unlinkedTitle": "Unlinked characters (no relation edges)", "relations.noData": "No character or relation data to draw",
1094
1139
  "relations.edgeTipPrefix": "{type}: ", "relations.noDesc": "(no description)",
1095
1140
  "char.empty": "No character cards in characters/ yet",
1141
+ "role.protagonist": "Protagonist", "role.antagonist": "Antagonist", "role.supporting": "Supporting", "role.minor": "Minor",
1096
1142
  "char.field.age": "Age", "char.field.gender": "Gender", "char.field.appearance": "Appearance", "char.field.personality": "Personality",
1097
1143
  "char.field.background": "Background", "char.field.motivation": "Motivation", "char.field.arc": "Character arc",
1098
1144
  "char.years": "Age {n}", "char.linePersonality": "Personality ", "char.lineMotivation": "Motivation ",
1099
1145
  "char.modalAria": "Character details", "char.closeAria": "Close character card", "char.markers": "Tags", "char.file": "File",
1100
1146
  "overview.author": "Author: {name}", "overview.totalWords": "Total characters (CJK)", "overview.totalChapters": "Chapters",
1101
1147
  "overview.characters": "Characters", "overview.openForeshadows": "Unresolved foreshadowing", "overview.statusDist": "Chapter status breakdown", "overview.recent": "Recent chapters",
1148
+ "overview.edit": "Edit", "overview.editTip": "Edit manuscript metadata",
1102
1149
  "chapters.empty": "No chapter files in chapters/ yet", "chapters.writeQueue": "Writing Queue", "chapters.rowTip": "Click to open this chapter",
1103
1150
  "chapters.outlineWords": "Outline {n} chars", "chapters.all": "All", "chapters.thVolume": "Vol · Ch", "chapters.thTitle": "Title",
1104
1151
  "chapters.thStatus": "Status", "chapters.thWords": "Words", "chapters.thSummary": "Summary", "chapters.rowReadTip": "Click to read",
1105
1152
  "chapters.emptyFiltered": "No chapters with this status",
1153
+ "chapters.outlineRowTip": "Click to view the full outline", "chapters.noChapter": "No chapter", "chapters.createBtn": "Create", "chapters.creating": "Creating…",
1154
+ "chapters.createTip": "Create a pending chapter in chapters/ from this outline (body to be written)",
1155
+ "chapters.emptyWithOutlines": "No chapter files in chapters/ yet — the Writing Queue below lists outlines without chapters",
1106
1156
  "reader.back": "Back", "reader.backTip": "Back to chapter list", "reader.copyPath": "Copy path", "reader.copyPathTip": "Copy chapter file path",
1107
1157
  "reader.copied": "Copied chapter file path", "reader.copyFailed": "Copy failed — select the path text manually", "reader.loadFailed": "Failed to load chapter text: {msg}",
1108
1158
  "reader.noOutline": "No chapter outline", "reader.emptyBody": "(empty chapter)", "reader.finalState": "Accepted (final)", "reader.statusFlow": "Status:",
1109
1159
  "reader.flowing": "Transitioning…", "reader.advanceTo": "Advance to {next}",
1110
1160
  "outline.title": "Outline", "outline.empty": "(outline is empty)", "outline.collapseTip": "Collapse outline", "outline.expandTip": "Expand outline",
1161
+ "outline.modalAria": "Chapter outline", "outline.closeAria": "Close outline", "outline.loading": "Loading outline…", "outline.loadFailed": "Failed to load outline: {msg}",
1111
1162
  "novel.metaTitle": "Manuscript metadata", "novel.metaNote": "Maps to novel/project.yml · trailing comments are preserved",
1112
1163
  "novel.fTitle": "Title", "novel.titlePh": "e.g. The Long Night", "novel.fAuthor": "Author", "novel.authorPh": "Pen name",
1113
1164
  "novel.fGenre": "Genre", "novel.genrePh": "e.g. Eastern fantasy / Urban powers / Alternate history", "novel.fLength": "Planned length",
1114
1165
  "novel.lengthPh": "e.g. ~1M characters / 300 chapters", "novel.fLanguage": "Text language", "novel.fPhase": "Workflow phase", "novel.fMode": "Co-writing mode",
1115
1166
  "novel.fDesc": "One-line synopsis", "novel.descPh": "Can be quoted directly in cover copy", "novel.titleRequired": "Title is required",
1116
1167
  "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)",
1117
- "novel.saving": "Saving…", "novel.save": "Save",
1168
+ "novel.saving": "Saving…", "novel.save": "Save", "novel.cancel": "Cancel",
1118
1169
  "settings.title": "Settings", "settings.closeAria": "Close settings", "settings.language": "Language",
1119
1170
  "settings.languageHint": "UI text follows the language option; it applies immediately and is remembered.", "settings.fontScale": "Font size",
1120
1171
  "settings.fontScaleHint": "Scales the whole studio as a percentage of the current font size; applies immediately and is remembered.",
@@ -1251,9 +1302,9 @@ window.__ModuleLoader__.load({
1251
1302
  })()) : null);
1252
1303
  }
1253
1304
 
1254
- // ── 小说设置 tab:可视化 project.yml 平键并保存。mode/phase 走枚举
1255
- // 下拉,自由文本走输入框;保存走 project-save 路由(服务端校验 + 保
1256
- // 注释改写),成功后重拉快照让概览同步。 ──
1305
+ // ── 书稿元信息编辑表单(概览「编辑」按钮展开):可视化 project.yml 平
1306
+ // 键并保存。mode/phase 走枚举下拉,自由文本走输入框;保存走 project-save
1307
+ // 路由(服务端校验 + 保注释改写),成功后重拉快照并经 onDone 退出编辑态。
1257
1308
  // 表单初值(与磁盘真值同源):首次载入与 409 重置共用同一形状。
1258
1309
  function settingsFormOf(project) {
1259
1310
  project = project || {};
@@ -1267,10 +1318,11 @@ window.__ModuleLoader__.load({
1267
1318
  }
1268
1319
  // 快照 root 与工作区 path 同源但形态可能有差(尾斜杠等),比对前归一。
1269
1320
  function normRoot(s) { return String(s || "").replace(/[\\/]+$/, ""); }
1270
- function SettingsTab(props) {
1321
+ function ProjectMetaForm(props) {
1271
1322
  var snap = props.snap;
1272
1323
  var wsPath = props.wsPath;
1273
1324
  var reload = props.reload;
1325
+ var onDone = props.onDone;
1274
1326
  var project = snap.project || {};
1275
1327
  var _f = useState(function () { return settingsFormOf(project); }), form = _f[0], setForm = _f[1];
1276
1328
  // 保存基线:表单载入时的 project.yml 指纹(snap.project_hash)。服务端
@@ -1341,6 +1393,10 @@ window.__ModuleLoader__.load({
1341
1393
  h("select", { className: "myt-setInput myt-setSelect", value: value, onChange: function (e) { set(key, e.target.value); } },
1342
1394
  opts.map(function (o) { return h("option", { key: o[0], value: o[0] }, o[1]); })));
1343
1395
  }
1396
+ function cancel() {
1397
+ if (busy) return; // 保存在途不许退出:避免卸载后 setState
1398
+ onDone();
1399
+ }
1344
1400
  return h("div", { className: "myt-stack" },
1345
1401
  h("div", { className: "myt-card" },
1346
1402
  h("div", { className: "myt-cardHead" },
@@ -1357,6 +1413,8 @@ window.__ModuleLoader__.load({
1357
1413
  field(tt("novel.fDesc"), "description", tt("novel.descPh"), true))),
1358
1414
  saveErr ? h(ErrorStrip, { text: saveErr }) : null,
1359
1415
  h("div", { className: "myt-setActions" },
1416
+ h("button", { type: "button", className: "myt-btn", disabled: busy, onClick: cancel },
1417
+ tt("novel.cancel")),
1360
1418
  h("button", { type: "button", className: "myt-btn myt-btnPrimary", disabled: busy, onClick: save },
1361
1419
  busy ? tt("novel.saving") : tt("novel.save"))));
1362
1420
  }
@@ -1398,17 +1456,60 @@ window.__ModuleLoader__.load({
1398
1456
  return node;
1399
1457
  }
1400
1458
 
1459
+ // ── 章纲全文弹层(0.1.15):按单写作的孤儿章纲行点击展开——GET outline
1460
+ // 路由取全文,MdView 渲染,words 一并显示;加载中/失败给简单文案,失败
1461
+ // 态可重试。请求序号防快速开关弹层时的过期响应(对照 ChapterReader)。──
1462
+ function OutlineModal(props) {
1463
+ var wsPath = props.wsPath;
1464
+ var file = props.file;
1465
+ var onClose = props.onClose;
1466
+ var _d = useState(null), detail = _d[0], setDetail = _d[1]; // {loading, data, err}
1467
+ var seqRef = useRef(0);
1468
+ var load = useCallback(function (f) {
1469
+ var seq = ++seqRef.current;
1470
+ if (!f) { setDetail(null); return; }
1471
+ setDetail({ loading: true, data: null, err: "" });
1472
+ apiGet("outline", { path: wsPath, file: f }).then(function (d) {
1473
+ if (seq !== seqRef.current) return; // 过期响应:弹层已换文件/关闭,丢弃
1474
+ setDetail({ loading: false, data: d, err: "" });
1475
+ }).catch(function (err) {
1476
+ if (seq !== seqRef.current) return;
1477
+ setDetail({ loading: false, data: null, err: err.message });
1478
+ });
1479
+ }, [wsPath]);
1480
+ useEffect(function () { load(file); }, [file, load]);
1481
+ var words = detail && detail.data && typeof detail.data.words === "number" ? detail.data.words : null;
1482
+ return h(MaskPortal, {
1483
+ className: "myt-modalMask",
1484
+ onMouseDown: function (e) { if (e.target === e.currentTarget) onClose(); },
1485
+ },
1486
+ h("div", { className: "myt-modalCard", role: "dialog", "aria-modal": "true", "aria-label": tt("outline.modalAria") },
1487
+ h("div", { className: "myt-modalHead" },
1488
+ h("h2", { className: "myt-modalTitle" }, props.title || file),
1489
+ h("div", { className: "myt-heroMeta" },
1490
+ words !== null ? h("span", { className: "myt-outlineWords" }, tf("common.charCount", { n: fmtNum(words) })) : null,
1491
+ h("button", { type: "button", className: "myt-closeBtn", "aria-label": tt("outline.closeAria"), onClick: onClose }, h("span", { className: "myt-closeGlyph", "aria-hidden": "true" }, "×")))),
1492
+ h("div", { className: "myt-modalBody" },
1493
+ !detail || detail.loading ? h("div", { className: "myt-muted" }, tt("outline.loading"))
1494
+ : detail.err ? h(ErrorStrip, { text: tf("outline.loadFailed", { msg: detail.err }), onRetry: function () { load(file); } })
1495
+ : h(MdView, { text: (detail.data && detail.data.body) || tt("outline.empty"), className: "myt-outlineBody" }))));
1496
+ }
1497
+
1401
1498
  function ChaptersTab(props) {
1402
1499
  var snap = props.snap;
1500
+ var wsPath = props.wsPath;
1501
+ var reload = props.reload;
1403
1502
  var onOpen = props.onOpenChapter; // 行点击 → 整页阅读(PageShell.readerFile)
1404
1503
  var _f = useState("all"), filter = _f[0], setFilter = _f[1];
1405
1504
  var chapters = snap.chapters || [];
1406
1505
  var rows = filter === "all" ? chapters : chapters.filter(function (c) { return c.status === filter; });
1407
1506
  var counts = snap.totals && snap.totals.byStatus ? snap.totals.byStatus : {};
1408
1507
  // 「按单写作」(五期):state.outlines 与 chapters 联交——有章纲且
1409
- // status=pending 的章,按章纲(卷-章号)序列出,共创模式的主工作清单;
1410
- // 空块(无待写章纲章)整块不渲染。孤儿章纲(chapter 无对应章节)联交
1411
- // 不中,自然落榜。
1508
+ // status=pending 的章,按章纲(卷-章号)序列出,共创模式的主工作清单。
1509
+ // 孤儿章纲(chapter 无对应章节文件)照列(0.1.15):章纲先于正文是共
1510
+ // 创常态,未建章行挂「未建章」徽章,行点击看章纲全文、卡上「建章」一
1511
+ // 键落 pending 空章(建后走既有 pending 流转)。outlines 快照已按卷-章
1512
+ // 号升序,孤儿行与待写章直接保序混排。
1412
1513
  var outlines = snap.outlines || [];
1413
1514
  var chapterByFile = {};
1414
1515
  chapters.forEach(function (c) { chapterByFile[c.file] = c; });
@@ -1416,7 +1517,22 @@ window.__ModuleLoader__.load({
1416
1517
  outlines.forEach(function (ol) {
1417
1518
  var ch = ol && ol.chapter ? chapterByFile[ol.chapter] : null;
1418
1519
  if (ch && (ch.status || "pending") === "pending") writeOrder.push({ ch: ch, ol: ol });
1520
+ else if (!ch) writeOrder.push({ ol: ol }); // 孤儿章纲:正文未建也入清单
1419
1521
  });
1522
+ // 章纲全文弹层:outline 文件名,null 不渲染;建章 in-flight 的文件名。
1523
+ var _mo = useState(null), outlineModalFile = _mo[0], setOutlineModalFile = _mo[1];
1524
+ var _busy = useState(null), creatingFile = _busy[0], setCreatingFile = _busy[1];
1525
+ function createFromOutline(ol, e) {
1526
+ if (e && typeof e.stopPropagation === "function") e.stopPropagation(); // 别触发行的弹层
1527
+ if (!ol || !ol.file || creatingFile) return;
1528
+ setCreatingFile(ol.file);
1529
+ // 成败提示走 apiPost 的 toast 通道;成功后重拉快照——新章节进表格、
1530
+ // 孤儿行按快照最新联交结果自然消失(与 chapter-status 流转同一模式)。
1531
+ apiPost("chapter-create", { path: wsPath, file: ol.file })
1532
+ .then(function () { return reload(); })
1533
+ .catch(function () {})
1534
+ .finally(function () { setCreatingFile(null); });
1535
+ }
1420
1536
  return h("div", { className: "myt-stack" },
1421
1537
  writeOrder.length ? h("div", { className: "myt-card myt-writeOrder" },
1422
1538
  h("div", { className: "myt-cardHead" },
@@ -1425,6 +1541,27 @@ window.__ModuleLoader__.load({
1425
1541
  h("div", { className: "myt-writeOrderList" }, writeOrder.map(function (item) {
1426
1542
  var ch = item.ch;
1427
1543
  var ol = item.ol;
1544
+ if (!ch) {
1545
+ // 孤儿章纲行:div 承担行点击(button 里不能再嵌 button),键盘
1546
+ // Enter/Space 与 role/tabIndex 补齐可达性。
1547
+ var busy = creatingFile === ol.file;
1548
+ return h("div", {
1549
+ key: "ol:" + ol.file, className: "myt-writeOrderRow", role: "button", tabIndex: 0,
1550
+ title: tt("chapters.outlineRowTip"),
1551
+ onClick: function () { setOutlineModalFile(ol.file); },
1552
+ onKeyDown: function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOutlineModalFile(ol.file); } },
1553
+ },
1554
+ h("span", { className: "myt-chPath" }, olWhere(ol) || ol.file),
1555
+ h("span", { className: "myt-noChapterBadge", title: tt("chapters.createTip") }, tt("chapters.noChapter")),
1556
+ ol && typeof ol.excerpt === "string" && ol.excerpt !== ""
1557
+ ? h("span", { className: "myt-writeOrderExcerpt", title: ol.excerpt }, truncate(ol.excerpt, 60)) : null,
1558
+ ol && ol.words ? h("span", { className: "myt-wordsCell" }, tf("chapters.outlineWords", { n: fmtNum(ol.words) })) : null,
1559
+ h("button", {
1560
+ type: "button", className: "myt-btn", disabled: creatingFile !== null,
1561
+ title: tt("chapters.createTip"),
1562
+ onClick: function (e) { createFromOutline(ol, e); },
1563
+ }, busy ? tt("chapters.creating") : tt("chapters.createBtn")));
1564
+ }
1428
1565
  return h("button", {
1429
1566
  key: ch.file, type: "button", className: "myt-writeOrderRow",
1430
1567
  title: tt("chapters.rowTip"),
@@ -1459,7 +1596,17 @@ window.__ModuleLoader__.load({
1459
1596
  h("td", null, h("span", { className: "myt-wordsCell" }, fmtNum(ch.words))),
1460
1597
  h("td", null, h("div", { className: "myt-summaryCell myt-muted" }, orDash(ch.summary))));
1461
1598
  }))))
1462
- : (chapters.length ? h(Empty, { text: tt("chapters.emptyFiltered") }) : h(Empty, { text: tt("chapters.empty") })));
1599
+ : (chapters.length ? h(Empty, { text: tt("chapters.emptyFiltered") }) : h(Empty, { text: outlines.length ? tt("chapters.emptyWithOutlines") : tt("chapters.empty") })),
1600
+ outlineModalFile ? h(OutlineModal, {
1601
+ wsPath: wsPath,
1602
+ file: outlineModalFile,
1603
+ // 弹层标题用「卷-章」定位(对照 olWhere 显示格式),解析不了退文件名
1604
+ title: (function () {
1605
+ for (var i = 0; i < outlines.length; i++) { if (outlines[i].file === outlineModalFile) return olWhere(outlines[i]) || outlines[i].file; }
1606
+ return outlineModalFile;
1607
+ })(),
1608
+ onClose: function () { setOutlineModalFile(null); },
1609
+ }) : null);
1463
1610
  }
1464
1611
 
1465
1612
  // ── 角色 tab:卡墙 + 全卡弹层 ───────────────────────────────────────────
@@ -709,8 +709,12 @@ 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
- /** 章节 frontmatter 的 volume 规范形态:v + 两位卷号。 */
713
- function canonicalVolume(vol) {
712
+ /**
713
+ * 章节 frontmatter 的 volume 规范形态:v + 两位卷号。面板从章纲建章
714
+ * (panel-core 的 createChapterFromOutline)与本文件 check / collectChapters
715
+ * 共用同一规范形态。
716
+ */
717
+ export function canonicalVolume(vol) {
714
718
  return `v${String(vol).padStart(2, '0')}`
715
719
  }
716
720
 
@@ -11,15 +11,20 @@
11
11
  * (五期起含 outlines 章纲清单)
12
12
  * readChapter(novelDir, file) 章节正文全文(抽屉用;五期起带对应章纲
13
13
  * outline 字段)
14
+ * readOutlineFile(novelDir, file) 章纲全文(章纲弹层用;0.1.15 起按单
15
+ * 写作的未建章章纲可看全文)
14
16
  * projectFileHash(novelDir) project.yml 原文 sha256 hex(快照
15
17
  * project_hash / 保存旧版检测共用基线)
16
18
  *
17
- * 写入面(全部「读原文 → 行级字符串手术 → 写后校验替换计数 === 1」;
18
- * 计数不符或目标行缺失一律 409 拒写不落盘,把对 git diff 的扰动压到最小)
19
+ * 写入面(行级原地替换三件套:读原文 → 手术 → 写后校验替换计数 === 1;
20
+ * 计数不符或目标行缺失一律 409 拒写不落盘,把对 git diff 的扰动压到最小。
21
+ * createChapterFromOutline 是唯一例外——它从章纲落一个全新章节文件,不动
22
+ * 任何既有文件的字节)
19
23
  * mutateChapterStatus 章节 frontmatter 的 status 行
20
24
  * mutateForeshadowStatus foreshadows.yml 对应 id 条目块内的 status 行
21
25
  * mutatePhase project.yml 的 phase 行
22
26
  * rebuildIndex 复用 lib/novel-parse 的 buildIndexYaml 重建
27
+ * createChapterFromOutline 从章纲建章(chapters/ 落 pending 空章,0.1.15)
23
28
  *
24
29
  * 错误约定:本模块抛 PanelError(带 statusCode),路由层按 statusCode 回
25
30
  * JSON(403 拒绝 / 404 找不到 / 409 拒写 / 422 入参不合法);未带状态码的
@@ -31,10 +36,10 @@
31
36
  */
32
37
 
33
38
  import { createHash } from 'node:crypto'
34
- import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
39
+ import { mkdirSync, existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
35
40
  import path from 'node:path'
36
41
 
37
- import { buildIndexYaml, chapterSortKey, collectChapters, countCjk, parseFlatYamlArray, parseScalarText, splitFrontmatter, stripInlineComment } from './novel-parse.mjs'
42
+ import { buildIndexYaml, canonicalVolume, chapterSortKey, collectChapters, countCjk, FIVE_DIM_KEYS, parseFlatYamlArray, parseScalarText, splitFrontmatter, stripInlineComment } from './novel-parse.mjs'
38
43
  import { loadYamlLib, resolveDshHome } from './yaml-loader.mjs'
39
44
 
40
45
  /** 章节状态机(与 lib/novel-parse 的表同源:只取键)。 */
@@ -617,6 +622,74 @@ export function readChapter(novelDir, file, options = {}) {
617
622
  return { ok: true, file: name, frontmatter: meta, body, outline }
618
623
  }
619
624
 
625
+ /**
626
+ * 章纲文件名解析(readOutlineFile / createChapterFromOutline 共用的入口
627
+ * 校验):file 过 safeFileName 后必须 .md 结尾且 stem 合乎 vXX-cXXX 规范
628
+ * (与 collectOutlines 的收录口径一致——不合规范的面板本就不列,路由侧
629
+ * 一律 422 拒掉,不给穿越/探测留口子)。
630
+ */
631
+ function outlineStemOf(file) {
632
+ const name = safeFileName(file, 'file')
633
+ if (!/\.md$/i.test(name)) fail(422, `非章纲文件名(只认 .md):${name}`)
634
+ const stem = name.replace(/\.md$/i, '')
635
+ if (chapterSortKey(stem) === null) fail(422, `非章纲文件名(stem 不合 vXX-cXXX 规范):${name}`)
636
+ return stem
637
+ }
638
+
639
+ /**
640
+ * 章纲全文(0.1.15 起面板「章纲弹层」用):按单写作兼容未建章章纲后,
641
+ * 孤儿章纲也要能看全文。章纲纯 markdown 无 frontmatter,body 即原文;
642
+ * words 为全文 CJK 计数(口径与快照 outlines 一致)。章纲不存在 404。
643
+ */
644
+ export function readOutlineFile(novelDir, file) {
645
+ const stem = outlineStemOf(file)
646
+ const outline = readOutline(novelDir, stem)
647
+ if (outline === null) fail(404, `章纲不存在:outlines/${stem}.md`)
648
+ return { ok: true, file: outline.file, words: outline.words, body: outline.body }
649
+ }
650
+
651
+ /**
652
+ * 章纲标题提取(createChapterFromOutline 用):取章纲正文首条 `#` 标题行
653
+ * 文本,去结尾「章纲」二字(大纲师标题惯用后缀)后 trim;空则「(无标题)」
654
+ * (与 collectChapters 的缺省标题同形)。
655
+ */
656
+ function outlineTitleOf(body) {
657
+ const hit = /^#[ \t]+(.+?)[ \t]*$/m.exec(String(body ?? ''))
658
+ const title = (hit === null ? '' : hit[1]).replace(/章纲$/, '').trim()
659
+ return title !== '' ? title : '(无标题)'
660
+ }
661
+
662
+ /**
663
+ * 从章纲建章(0.1.15,面板「按单写作」的孤儿章纲一键建章):在 chapters/
664
+ * 下落一个 status=pending 的空章节——frontmatter 按 templates/novel/README.md
665
+ * 的章节模板(volume/num/title/status/summary + 五维五键,值待写留空),
666
+ * 正文留空。title 取章纲首条标题行(outlineTitleOf)。目标章节已存在 409
667
+ * (先到先建,绝不覆盖既有正文);章纲不存在 404。写前确保 chapters/ 目录
668
+ * 在(手工项目 / 测试夹具可能还没建目录)。建章后走既有 pending 流转。
669
+ */
670
+ export function createChapterFromOutline(novelDir, file) {
671
+ const stem = outlineStemOf(file)
672
+ const key = chapterSortKey(stem)
673
+ const target = path.join(path.resolve(novelDir), 'chapters', `${stem}.md`)
674
+ if (existsSync(target)) fail(409, `章节已存在:chapters/${stem}.md——无需重复建章`)
675
+ const outline = readOutline(novelDir, stem)
676
+ if (outline === null) fail(404, `章纲不存在:outlines/${stem}.md`)
677
+ const title = outlineTitleOf(outline.body)
678
+ const lines = [
679
+ '---',
680
+ `volume: ${canonicalVolume(key.vol)}`,
681
+ `num: ${key.num}`,
682
+ `title: ${yamlScalar(title)}`,
683
+ 'status: pending',
684
+ `summary: ${yamlScalar('')}`,
685
+ ...FIVE_DIM_KEYS.map((k) => `${k}: ${yamlScalar('')}`),
686
+ '---',
687
+ ]
688
+ mkdirSync(path.dirname(target), { recursive: true })
689
+ writeAtomic(target, `${lines.join('\n')}\n`)
690
+ return { ok: true, file: `${stem}.md`, title }
691
+ }
692
+
620
693
  // ── 写操作:行级原地替换 ────────────────────────────────────────────────────
621
694
 
622
695
  /**
@@ -716,7 +789,8 @@ export function mutatePhase(novelDir, phase) {
716
789
  const PROJECT_MODE_KEYS = ['ai-draft', 'co-write', 'author-draft']
717
790
 
718
791
  /**
719
- * 小说设置:project.yml 平键批量改写(创作台「小说设置」tab)。逐键做
792
+ * 书稿元信息保存:project.yml 平键批量改写(创作台「概览」的「编辑」态)。
793
+ * 逐键做
720
794
  * 「单行值替换、行尾注释原样保留」(与 mutatePhase 同一保注释刀法);文件里
721
795
  * 没有的键按模板顺序补在末尾(带模板注释)。校验:phase/mode 越枚举 422、
722
796
  * title 空白 422。值统一经 yamlScalar 规范成可回读的 YAML 标量。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-maoying",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "毛颖 — DSH 网文创作辅助插件:主编 + 五帮手编排(大纲/写手/审校/设定/考据),novel/ 结构化文件为数据库",
5
5
  "type": "module",
6
6
  "license": "MIT",