dsh-maoying 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,7 +73,7 @@ npm install -g dsh-maoying # 或装成全局 maoying 命令
73
73
 
74
74
  DSH 左侧栏底部点「**毛颖创作台**」打开全页面板(改文件、跑流程之外,给肉眼看的驾驶舱):
75
75
 
76
- - **章节**:状态筛选 + 按单写作清单;点章节进整页阅读——返回按钮、章纲对照(Markdown 渲染)、正文与章纲就地编辑保存(textarea 手动保存;文件在编辑期间被外部改过时拒绝保存并放回磁盘最新内容,不覆盖人工改动)、五维叙事字段、一键状态流转。
76
+ - **章节**:状态筛选 + 按单写作清单;点章节进整页阅读——返回按钮、章纲对照(Markdown 渲染)、正文与章纲就地编辑保存(正文 textarea 自动保存——待写空章节打开即直接进编辑器、约 2 秒防抖落盘、回车换行自动加段首两个全角空格缩进;章纲 textarea 手动保存;文件在编辑期间被外部改过时拒绝保存并放回磁盘最新内容,不覆盖人工改动)、五维叙事字段、一键状态流转。
77
77
  - **概览**:书稿卡右上「编辑」按钮展开 project.yml 的书名/作者/题材/篇幅/语言/工作流阶段/共创模式/简介表单化编辑(原「小说设置」tab 并入),保存保留文件里原有注释;表单载入后 project.yml 被外部(作者手改 / agent 推进阶段)改过时拒绝保存并提示重改,不覆盖人工改动。概览另设「分卷规划」卡,列出 volumes.yml 各卷定位与计划章节范围——拍板过的分卷有处可查,卡头「编辑源文件」可整文件编辑 volumes.yml。
78
78
  - **受管文件写面**:角色/设定/科学设定卡与六本账本(关系/时间线/记忆/线索板/伏笔/分卷)均可从面板编辑源文件——墙头「新建」、卡详情「编辑源文件/删除文件」、各账本卡头「编辑源文件」(时间线/关系图空态也给入口);保存时结构校验(账本须是条目数组、实体卡须带 frontmatter 围栏,不合即拒存),外部改动保护与正文编辑同口径;章节阅读页可新建章纲(本章目标/节拍/出场角色/需埋需收伏笔/与前章接口五件骨架预填)、删除章纲与删除章节——删除均确认弹层,不可逆,后悔靠 git,静态体检会把孤儿引用标出来。
79
79
  - **顶栏**:当前版本号常驻;npm 上有新版时亮「有新版 ↗」徽章直连发布页,旁边「一键升级」按钮直接用 pnpm 把最新包装进当前 DSH 档(仅支持 profile 目录安装,pnpm 缺失时自动回落 corepack;检测到全局 `maoying` CLI 时顺带以同一精确版本更新,未装则跳过;装完需重启 DeepSeek 才生效);「检测更新」按钮随时手动现测线上最新版(绕过 1h 缓存——面板打开时只自动拉一次,页面开久了也不聋);齿轮打开系统设置,界面语言中文/English 可切、字号六档(100%–150%)整体缩放,均立即生效并记忆。
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## 0. 定位与边界
6
6
 
7
- 面板管**可视化、状态,以及作者本人的稿件编辑**:小说内容的生产仍归主编(agent)与作者在编辑器/共创流程里协作,面板负责「一眼看清全局 + 状态快捷流转」,同时给作者本人一个带护栏的正文/章纲编辑入口(textarea + 手动保存 + hash 旧版检测,见 §2 的 `chapter-save` / `outline-save`)。0.1.20 起(§4.1「全量可编辑写面」)六本账本 yml、四目录实体卡 md 与章纲新建也开放人工编辑,编辑纪律统一为「保存时结构校验(解析失败/缺围栏 422 拒存)+ 保存后语义体检(`check`)+ git 回滚兜底」——语义规则(状态机、必填章号等)是 agent 纪律不是人工禁区,但写面只服务人。agent 的创作边界不变:共创模式下 agent 依旧不得碰 author 章节的正文(spec §作者稿圣域)——面板编辑是**人**用的刀,不是给 agent 开的新通道。
7
+ 面板管**可视化、状态,以及作者本人的稿件编辑**:小说内容的生产仍归主编(agent)与作者在编辑器/共创流程里协作,面板负责「一眼看清全局 + 状态快捷流转」,同时给作者本人一个带护栏的正文/章纲编辑入口(正文 textarea 自动保存、章纲手动保存 + hash 旧版检测,见 §2 的 `chapter-save` / `outline-save`;正文自动保存见 §4.1「正文编辑直开 + 自动保存 + 段首缩进」条目)。0.1.20 起(§4.1「全量可编辑写面」)六本账本 yml、四目录实体卡 md 与章纲新建也开放人工编辑,编辑纪律统一为「保存时结构校验(解析失败/缺围栏 422 拒存)+ 保存后语义体检(`check`)+ git 回滚兜底」——语义规则(状态机、必填章号等)是 agent 纪律不是人工禁区,但写面只服务人。agent 的创作边界不变:共创模式下 agent 依旧不得碰 author 章节的正文(spec §作者稿圣域)——面板编辑是**人**用的刀,不是给 agent 开的新通道。
8
8
 
9
9
  ## 1. 包接线(单包形态,不建子包)
10
10
 
@@ -29,6 +29,7 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
29
29
 
30
30
  - **信任围栏**:isTrustedApiRequest 信任围栏(Host 回环或 webRuntime.trustedHosts + 拒 cross-site + origin 同源校验),403 走 `{ ok: false, error: 'forbidden' }`(错误形状与全局契约同形,客户端形状检查不必对这一条网开一面)。读取 trustedHosts 每请求实时 `ctx.get('webRuntime')?.trustedHosts ?? []`,自捕不抛。
31
31
  - duplicate prefix route 短重试(8 次 × 30ms)。
32
+ - **路由随纤维注销**(0.1.23):register 返回的 disposer 由 apply 挂接 `ctx.effect`——宿主对 `<home>/cordis.patch.yml` 与 profile 级 patch 都有 HMR 热重载(版本升级重写托管块即触发),插件纤维整树重建时路由必须随之出表;否则残留死 handler(inactive context)且新注册被 duplicate 挡住,面板全接口 500。注册完成时纤维已销毁(effect 抛 INACTIVE_EFFECT)则立即手动注销堵竞态窗口。
32
33
  - 所有响应 JSON;错误形状统一 `{ ok: false, error }`(读路由可省 ok,直接 `{ error }` + 状态码)。
33
34
  - **路径安全**:凡是带 `path` 参数的请求,必须 resolve 后与 workspaceRegistry 列出的某个 workspace root **严格相等**(不做子路径、不做 ~ 展开);novel 目录恒为 `<root>/novel`,file 参数 basename 化 + 规范化后必须落在 novel 根内(防穿越)。不满足一律 403。
34
35
  - 全程 try/catch,路由崩溃不弄死宿主;注册失败只 warn(安装器语义不变)。
@@ -131,7 +132,7 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
131
132
  九个 tab(顺序即默认排布):
132
133
 
133
134
  1. **概览**:project 卡(书名大字 + 类型/篇幅/语言 pills + 阶段徽章 + 简介,卡右上「编辑」按钮展开书稿元信息表单——原「小说设置」tab 并入,见 §4.1「小说设置并入概览」条目);统计卡(总字数/章节数/状态分布条);最近更新(chapters 按 mtime 前 5);warnings 黄条;静态体检黄条(check 路由问题清单,见 §4.1「人工改动保护 + 静态体检黄条」(0.1.14)条目);分卷规划卡卡头「编辑源文件」(volumes.yml 整文件编辑弹层,0.1.20 写面)。
134
- 2. **章节**:表格(卷-章/标题/状态徽章/字数/梗概),状态筛选 pills;表格上方「按单写作」区块——有章纲且 status=pending 的章按序列出(章纲节选 + 状态徽章),未建章的孤儿章纲照列(「未建章」徽章 + 「建章」一键:POST chapter-create 落 pending 空章并重拉快照;行点击开章纲全文弹层,0.1.15 起),共创模式的主工作清单;行点击开整页阅读视图(0.1.12 起替代原右侧抽屉——fixed 侧弹层会被 DSH 侧边栏遮挡):返回按钮 + GET chapter 正文 + 章纲对照栏(左右分栏或折叠段)+ 复制文件路径按钮 + 五维字段卡 + 状态流转按钮(当前态 → 下一态一键,pending→writing→review→accepted);正文与章纲对照栏均可就地编辑保存(textarea + 手动保存,POST chapter-save / outline-save 携 base_hash 409 人工改动检测——frontmatter 围栏逐字节保留,外部改动拒写不落盘、草稿保留并亮「载入磁盘版(放弃草稿)/基于最新版重改」两个出口,0.1.19 起、两出口见 §4.1 末条);0.1.20 写面:无章纲空态加「新建章纲」(outlines/<stem>.md 五件骨架预填)、章纲对照栏卡头「删除章纲」、阅读顶栏「删除章节」——后两者均确认弹层(不可逆,后悔靠 git,check 会标孤儿引用)。
135
+ 2. **章节**:表格(卷-章/标题/状态徽章/字数/梗概),状态筛选 pills;表格上方「按单写作」区块——有章纲且 status=pending 的章按序列出(章纲节选 + 状态徽章),未建章的孤儿章纲照列(「未建章」徽章 + 「建章」一键:POST chapter-create 落 pending 空章并重拉快照;行点击开章纲全文弹层,0.1.15 起),共创模式的主工作清单;行点击开整页阅读视图(0.1.12 起替代原右侧抽屉——fixed 侧弹层会被 DSH 侧边栏遮挡):返回按钮 + GET chapter 正文 + 章纲对照栏(左右分栏或折叠段)+ 复制文件路径按钮 + 五维字段卡 + 状态流转按钮(当前态 → 下一态一键,pending→writing→review→accepted);正文与章纲对照栏均可就地编辑保存(POST chapter-save / outline-save 携 base_hash 409 人工改动检测——frontmatter 围栏逐字节保留,外部改动拒写不落盘、草稿保留并亮「载入磁盘版(放弃草稿)/基于最新版重改」两个出口,0.1.19 起、两出口见 §4.1 末条):正文编辑态自动保存(约 2s 防抖,0.1.22 起——写盘内容即新基线、成功不退出编辑态、纯缩进占位不落盘),待写空章节(body 全空白)打开即直接进编辑态、不再经「编辑」按钮中转,回车换行自动加段首两个全角空格缩进,编辑器排版与只读正文一致(所见即所得)并随内容自动长高;章纲对照栏仍手动保存(textarea + 取消/保存);0.1.20 写面:无章纲空态加「新建章纲」(outlines/<stem>.md 五件骨架预填)、章纲对照栏卡头「删除章纲」、阅读顶栏「删除章节」——后两者均确认弹层(不可逆,后悔靠 git,check 会标孤儿引用)。
135
136
  3. **角色**:卡墙(姓名/role 徽章四色/年龄性别/性格一句/动机一行),点击看全卡(含 arc/markers/background);0.1.20 写面:墙头「新建角色」(模板预填)+ 全卡弹层底部「编辑源文件/删除文件」。
136
137
  4. **伏笔**:四列看板(埋设/推进/已回收/弃用),卡片含 priority 色点(high 红/normal 灰/low 淡)、`source: backfill` 的「回填」小徽章(账本回填来的条目,区别于写前规划)、埋设章→预期回收章、notes 全文直显(0.1.10 去掉两行截断);卡片按钮:推进到下一状态(planted→progressing→resolved;abandoned 只能从看板拖不动、按钮给「弃用」)。列头计数;0.1.20 写面:卡头「编辑源文件」(foreshadows.yml)。
137
138
  5. **关系图**:canvas 力导向图——节点=角色(radius 随出场无关、固定 18px,名字 label 下方),边=relations(intensity 1-5 → 线宽 1-4px,type 标在边中点 hover 显示 description);节点按角色配色(0.1.20 起):主角 #f53f3f 红 / 配角 #165dff 蓝 / 反派 #1d2129 黑 / 龙套与未标注 #fadc19 黄(查表 miss 落 minor,全节点恒有色),拖拽固定态统一加粗描边;脚注带四色图例;拖拽节点、空白处滚轮缩放、双击复位;右上角 +/- 缩放按钮(钳制同滚轮 0.4-2.5)与全屏按钮(Fullscreen API 能力检测,不支持则不渲染;进入全屏走 ResizeObserver 自动重铺);intensity 缺省 3。角色多于 30 个时只画与 relations 相连的节点,其余进「未入图」列表。防重叠(0.1.20 起):节点最小间距 62 硬碰撞分离(含名字 label 纵向净空,力式斥力之上兜底,弹簧 rest 120→130 配合多留缝隙);边为二次曲线——按两侧空旷度选朝向(迟滞防抖)绕开途中节点,同一对角色的重复边偏移放大 + 交替朝向互相分开(type 标签随曲线移到 t=0.5 点);hover 命中按曲线几何采样。0.1.20 写面:卡头「编辑源文件」(relations.yml,空态同样给入口)。
@@ -177,6 +178,8 @@ webServer.register({ kind: 'prefix', path: '/api/maoying', handler: async (req,
177
178
 
178
179
  - **写面 409 冲突两出口**:正文(chapter-save)/章纲(outline-save)/受管文件(file-save)/写作风格(style-save)四处编辑保存遇 409 外部改动冲突时,不再静默重拉把草稿重置掉——草稿绝不丢:冲突提示下方亮「载入磁盘版(放弃草稿)/基于最新版重改」两个出口,前者重拉磁盘真值回填编辑器并重置基线,后者只把基线换成磁盘最新(拿新 hash),草稿原样留在编辑器、改完再存即用新基线;重拉失败冲突态保留、出口仍在可重试。project-save 表单维持原「409 重拉放回磁盘真值」路径(表单无草稿语义)。纯前端,路由不变;I18N zh/en 各 298 → 301 条(edit.conflict409 / edit.conflictDrop / edit.conflictKeep)。同轮服务端契约补齐:围栏 403 与全部错误统一 `{ ok: false, error }` 形状(routes 测试有断言);self-update 新增失败 kind `verify-failed`(装后对账不符,见 §2 路由表);快照 warnings 补 yaml 真/降级解析告警(见 §3)。
179
180
 
181
+ - **正文编辑直开 + 自动保存 + 段首缩进**(0.1.22):章节阅读页正文编辑三处升级——①**空章节直开编辑态**:待写章节(body 全空白)打开即直接进正文编辑态,不再经顶部「编辑」按钮中转;预填段首两个全角空格让首段也有缩进,基线与预填同值(不判脏,退出确认与自动保存均不误触);正在编辑绝不打扰(409 放弃草稿等编辑中重拉被 editingRef 守卫排除),同章本轮只自动进一次,切章/重开面板算新一轮可再进,手动「取消」退出后本轮不再自动重进。②**正文自动保存**:编辑中改动 2s 防抖落盘(调度 effect 无 dep 数组每轮渲染重排定时器,连续打字永远差 2s 才保存;409 冲突/保存在途不排),调用点快照 draft 防在途打字被误标已保存,**写盘内容即新基线**(连续自动保存不假脏),成功不退出编辑态、不清草稿,编辑 hint 缀「已自动保存(HH:MM:SS)」时刻标;纯缩进占位(trim 为空)不落盘;409 人工改动检测语义不变——冲突即停排、草稿保留、两出口照旧。③**回车自动段首缩进**:编辑器内回车换行自动插入两个全角空格(U+3000×2,网文段落缩进惯例),光标落到缩进之后(draft 提交后下一轮渲染恢复选区);IME 组态中的回车(keyCode 229 / isComposing)是选词确认不劫持,取不到 selectionStart 的退化环境交给浏览器默认行为。编辑器排版与只读正文一致(同 UI 字体、13px/22px 行高,所见即所得)并随内容自动长高(≥320px,overflow hidden + resize none),主题色边框保留可辨识仍在编辑;脏退出确认兜底不动(自动保存窗口之外切章/取消/返回仍先确认)。纯前端,路由不变;I18N zh/en 各 301 → 302 条(reader.autoSaved;reader.bodyEditHint 文案同步改写)。
182
+
180
183
  ## 5. 验收基线
181
184
 
182
185
  - Node 侧:临时 workspace(/tmp)+ 假 workspaceRegistry 不行——路由从 ctx.workspaceRegistry 取;单测直接 import lib/panel-core.mjs 的函数(parseStateSnapshot / mutateChapterStatus / mutateForeshadowStatus / mutatePhase / rebuildIndex)跑断言;路由层用 node 起一个最小 ctx 假件(webServer.register 收集 handler,workspaceRegistry 返回临时 root)模拟请求验证围栏 403 / 状态流转 / 防穿越。
package/index.mjs CHANGED
@@ -49,7 +49,9 @@
49
49
  * 注册点(inject webServer + workspaceRegistry,见 registerCreatorRoutes)。
50
50
  * 路由段与安装段彼此独立、各失败各的:路由注册/请求处理失败只 warn,
51
51
  * 安装器语义分毫不动。要点:信任围栏、duplicate prefix route 短重试、
52
- * 限额 JSON 读体、client.css 现读下发。
52
+ * 限额 JSON 读体、client.css 现读下发、disposer 挂 ctx.effect(宿主对
53
+ * cordis.patch.yml 的 HMR 热重载会重建插件纤维,路由必须随纤维注销,
54
+ * 否则残留死 handler 报 inactive context,见 apply 内注释)。
53
55
  */
54
56
 
55
57
  import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
@@ -1005,8 +1007,9 @@ export async function registerPrefixRouteWithRetry(webServer, route, { attempts
1005
1007
  }
1006
1008
 
1007
1009
  /**
1008
- * 注册创作台全部路由(docs/creator-panel.md §2 的 23 条)。返回宿主 disposer;
1009
- * webServer 缺席(非 web 部署)时抛错,由 apply 侧统一降级为 warn。
1010
+ * 注册创作台全部路由(docs/creator-panel.md §2 的 23 条)。返回宿主 disposer
1011
+ *(由 apply 挂接 ctx.effect——纤维销毁时注销路由,防 HMR 热重载后残留死
1012
+ * handler);webServer 缺席(非 web 部署)时抛错,由 apply 侧统一降级为 warn。
1010
1013
  */
1011
1014
  export async function registerCreatorRoutes(ctx) {
1012
1015
  const webServer = ctx?.webServer
@@ -1481,11 +1484,46 @@ export function apply(ctx = {}, _config = {}) {
1481
1484
  // 注:0.2.1-rc.2+ 的内联委派行守卫已由 v2 AgentPreset 形态适配(composeManagedRows
1482
1485
  // 按宿主版本自动选形状,所选形态带在安装日志里),不再需要降级提示。
1483
1486
 
1487
+ // 路由 disposer 挂接宿主生命周期(0.1.23,修「inactive context」面板瘫):
1488
+ // 宿主 dsh CLI 的 runProfile 对 <home>/cordis.patch.yml 与 profile 级
1489
+ // cordis.patch.yml 都挂了 HMR 热重载(dsh-app-boot watchUserPatches),本插件
1490
+ // 安装器恰会在版本变化时重写 home patch 托管块——插件纤维随即被整树重建。
1491
+ // 若 disposer 不挂 ctx.effect,旧纤维销毁后 /api/maoying 路由残留在
1492
+ // webServer 表里(handler 闭包攥着死 ctx),新纤维 apply 又被 duplicate
1493
+ // prefix route 挡住,面板请求全落死 handler,报
1494
+ // `cannot get required service "workspaceRegistry" in inactive context`。
1495
+ // 挂接语义:ctx.effect 可用则挂(纤维销毁时 cordis 自动注销路由);注册
1496
+ // 完成时纤维已销毁(effect 抛 INACTIVE_EFFECT 等)则立即手动注销,堵住
1497
+ // 「注册与销毁竞态」的残留窗口;宿主连 effect/on 都不提供时只 warn 提示
1498
+ // 路由可能残留(不主动注销——此时纤维活着,路由该留)。注销本身自吞错:
1499
+ // 卸载路径的失败不能炸宿主。
1500
+ const wireRouteDisposal = (dispose) => {
1501
+ const removeRoute = () => {
1502
+ try { dispose() } catch { /* 卸载路径绝不抛 */ }
1503
+ }
1504
+ try {
1505
+ if (typeof ctx.effect === 'function') {
1506
+ ctx.effect(() => removeRoute)
1507
+ return
1508
+ }
1509
+ if (typeof ctx.on === 'function') {
1510
+ ctx.on('dispose', removeRoute)
1511
+ return
1512
+ }
1513
+ } catch {
1514
+ // 注册完成时纤维已销毁(cordis effect 对 inactive context 抛
1515
+ // INACTIVE_EFFECT):路由立即手动注销,不留残留。
1516
+ removeRoute()
1517
+ return
1518
+ }
1519
+ logger.warn?.('maoying-installer: 宿主 ctx 无 effect/on 生命周期接口,创作台路由在插件卸载后可能残留')
1520
+ }
1521
+
1484
1522
  // 创作台路由(三期):与安装段彼此独立——注册失败只 warn,面板不可用不
1485
1523
  // 拖累安装器语义。registerCreatorRoutes 是 async 函数,同步阶段不可能 throw
1486
1524
  //(异常一律进 rejected promise),外层 try 对它是永假防御——只挂 .catch
1487
1525
  // 一条兜底通道即可,异常不会冒出 apply。
1488
- registerCreatorRoutes(ctx).catch((error) => {
1526
+ registerCreatorRoutes(ctx).then(wireRouteDisposal, (error) => {
1489
1527
  logger.warn?.(`毛颖创作台路由注册失败(面板不可用,其余功能不受影响): ${error?.message ?? error}`)
1490
1528
  })
1491
1529
  }
package/lib/base.css CHANGED
@@ -461,9 +461,11 @@ html[data-dsh-maoying-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
461
461
  .myt-dimLabel { font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); margin-bottom: 2px; }
462
462
  .myt-dimText { font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; }
463
463
 
464
- /* ── 0.1.19:正文/章纲面板编辑态(textarea + 手动保存)────────────────────
465
- 与只读态的区分要一眼可辨:全宽等宽 textarea + 主题色边框强调;只读态是
466
- 无边框淡底的 pre(.myt-chapterBody)与 MdView。 */
464
+ /* ── 0.1.19 引入正文/章纲面板编辑态;0.1.22 起正文自动保存(2s 防抖)─────
465
+ 编辑态与只读态的区分靠主题色边框强调(只读态是无边框淡底的
466
+ .myt-chapterBody pre);正文 textarea 排版与只读正文对齐(同 UI 字体、
467
+ 13px/22px 行高)所见即所得,高度随内容自动长高——当年的等宽字体 +
468
+ resize: vertical 已成史。章纲编辑区(.myt-outlineArea)仍手动保存。 */
467
469
  .myt-bodyEdit { display: flex; flex-direction: column; gap: 8px; }
468
470
  .myt-chapterEditArea {
469
471
  width: 100%;
@@ -473,10 +475,11 @@ html[data-dsh-maoying-active]:not([data-dsh-mnemon-active]):not([data-dsh-taskbo
473
475
  border-radius: 10px;
474
476
  background: var(--dsw-alias-bg-layer-1, var(--dsw-alias-bg-layer-2));
475
477
  color: var(--dsw-alias-label-primary);
476
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
478
+ font-family: inherit; /* 所见即所得:与 .myt-chapterBody 同 UI 字体 */
477
479
  font-size: 13px;
478
- line-height: 1.7;
479
- resize: vertical;
480
+ line-height: 22px; /* 与 .myt-chapterBody 一致 */
481
+ resize: none; /* 高度由自动长高 effect 接管 */
482
+ overflow: hidden;
480
483
  min-height: 320px;
481
484
  }
482
485
  .myt-editHint { font-size: 11px; line-height: 16px; color: var(--dsw-alias-label-tertiary); }
package/lib/client.js CHANGED
@@ -1365,7 +1365,7 @@ window.__ModuleLoader__.load({
1365
1365
  "chapters.emptyWithOutlines": "chapters/ 目录还没有章节文件;下方「按单写作」已列出未建章章纲",
1366
1366
  "reader.back": "返回", "reader.backTip": "返回章节列表", "reader.copyPath": "复制路径", "reader.copyPathTip": "复制章节文件路径",
1367
1367
  "reader.edit": "编辑", "reader.editTip": "编辑章节正文(frontmatter 保留不动)",
1368
- "reader.save": "保存", "reader.bodyEditHint": "直接编辑正文;frontmatter(--- 围栏)保留不动。编辑期间文件被作者或 agent 改过时保存会被拒绝,并放回磁盘最新内容。",
1368
+ "reader.save": "保存", "reader.bodyEditHint": "直接编辑正文:改动约 2 秒自动保存;回车换行自动加段首两个全角空格缩进。frontmatter(--- 围栏)保留不动;文件被外部改过时保存会被拒绝并给出处理出口。", "reader.autoSaved": "已自动保存({time})",
1369
1369
  "outline.editTip": "编辑章纲", "outline.editHint": "直接编辑章纲全文;编辑期间文件被外部改过时保存会被拒绝,并放回磁盘最新内容。",
1370
1370
  "reader.copied": "已复制章节文件路径", "reader.copyFailed": "复制失败,请手动选择路径文本", "reader.loadFailed": "正文读取失败:{msg}",
1371
1371
  "reader.noOutline": "暂无章纲", "reader.emptyBody": "(空章节)", "reader.finalState": "已定稿(终态)", "reader.statusFlow": "状态流转:",
@@ -1483,7 +1483,7 @@ window.__ModuleLoader__.load({
1483
1483
  "chapters.emptyWithOutlines": "No chapter files in chapters/ yet — the Writing Queue below lists outlines without chapters",
1484
1484
  "reader.back": "Back", "reader.backTip": "Back to chapter list", "reader.copyPath": "Copy path", "reader.copyPathTip": "Copy chapter file path",
1485
1485
  "reader.edit": "Edit", "reader.editTip": "Edit the chapter text (frontmatter is preserved)",
1486
- "reader.save": "Save", "reader.bodyEditHint": "Edit the chapter text directly; the frontmatter (--- fence) is preserved. If the file was changed externally while editing, saving is rejected and the latest content is restored.",
1486
+ "reader.save": "Save", "reader.bodyEditHint": "Edit the chapter text directly: changes auto-save after ~2s; Enter starts a new paragraph indented with two full-width spaces. The frontmatter (--- fence) is preserved; if the file was changed externally, saving is rejected with recovery options.", "reader.autoSaved": "Auto-saved ({time})",
1487
1487
  "outline.editTip": "Edit outline", "outline.editHint": "Edit the outline text directly; if the file was changed externally while editing, saving is rejected and the latest content is restored.",
1488
1488
  "reader.copied": "Copied chapter file path", "reader.copyFailed": "Copy failed — select the path text manually", "reader.loadFailed": "Failed to load chapter text: {msg}",
1489
1489
  "reader.noOutline": "No chapter outline", "reader.emptyBody": "(empty chapter)", "reader.finalState": "Accepted (final)", "reader.statusFlow": "Status:",
@@ -1553,8 +1553,8 @@ window.__ModuleLoader__.load({
1553
1553
  // ── 章节阅读页(整页视图,替代右侧抽屉):返回按钮 + 章纲对照 + 正文 +
1554
1554
  // 五维 + 状态流转。原抽屉是 position:fixed 侧弹层,在桌面端会被侧边栏
1555
1555
  // 遮挡;整页视图天然在面板文档流内,无遮挡问题。0.1.19 起正文/章纲可在
1556
- // 本页编辑保存(textarea + 手动保存,无自动保存——IME 组合词风险天然规
1557
- // 避;409 人工改动保护链路照 StyleTab)。──
1556
+ // 本页编辑保存;0.1.22 起正文改自动保存(2s 防抖 + 回车自动段首缩进 +
1557
+ // 空章节直开编辑态),章纲仍手动保存;409 人工改动保护链路照 StyleTab。──
1558
1558
  function ChapterReader(props) {
1559
1559
  var file = props.file;
1560
1560
  var wsPath = props.wsPath;
@@ -1578,6 +1578,9 @@ window.__ModuleLoader__.load({
1578
1578
  // 草稿绝不丢(见 saveBody/saveOutline 的 catch)。
1579
1579
  var _bc = useState(false), bodyConflict = _bc[0], setBodyConflict = _bc[1];
1580
1580
  var _oc = useState(false), olConflict = _oc[0], setOlConflict = _oc[1];
1581
+ // 正文自动保存(0.1.22):最近一次自动保存成功的时刻(HH:MM:SS),缀在编辑
1582
+ // hint 后;手动保存成功或切章时清零。
1583
+ var _as = useState(""), autoSavedAt = _as[0], setAutoSavedAt = _as[1];
1581
1584
  var detailSeqRef = useRef(0); // chapter 请求序号:快速切章时丢弃过期响应
1582
1585
  // 409 后的一次性重置标记:loadDetail 带回磁盘真值时把草稿放回最新内容
1583
1586
  // (只在保存冲突「放弃草稿」出口置位;顶栏刷新、状态流转等其他重拉不动
@@ -1591,6 +1594,17 @@ window.__ModuleLoader__.load({
1591
1594
  // 不一致则跳过 UI 状态回填,防旧章数据覆写新章视图(B 标题+A正文)。
1592
1595
  var fileRef = useRef(file);
1593
1596
  fileRef.current = file;
1597
+ // editing 实时值:loadDetail 是 useCallback 闭包,直接读 editing state 是
1598
+ // 过期值——空章节直开编辑态的守卫必须走 ref。
1599
+ var editingRef = useRef(false);
1600
+ editingRef.current = editing;
1601
+ // 空章节直开去重:记本次访问已自动进入过编辑态的 file,同章不反复打扰
1602
+ // (手动取消退出后不再自动重进);切章清空,切走再切回可再次进入。
1603
+ var autoEditRef = useRef("");
1604
+ // 回车缩进的光标恢复:setDraft 提交换行+缩进后 DOM 未更新,下一轮渲染
1605
+ // (draft 已就位)再把光标挪到插入的缩进之后;areaRef 指向正文 textarea。
1606
+ var pendingCaretRef = useRef(null);
1607
+ var areaRef = useRef(null);
1594
1608
  // 0.1.20 写面:新建章纲(无章纲态)/ 删除章节 / 删除章纲(各带确认弹层)。
1595
1609
  var stem = file.replace(/\.md$/i, "");
1596
1610
  var stemOk = /^v\d+-c\d+$/.test(stem); // 章纲 rel 挂钩 vXX-cXXX stem,不合规范不亮按钮
@@ -1629,6 +1643,18 @@ window.__ModuleLoader__.load({
1629
1643
  olBaseRef.current = ol0;
1630
1644
  setOlDraft(ol0);
1631
1645
  }
1646
+ // 空章节直开编辑态(0.1.22):正文待写(body 全空白)的章打开即进编辑,
1647
+ // 不再经顶部「编辑」按钮中转。三重守卫缺一不可——正在编辑绝不打扰
1648
+ // (409 放弃草稿等编辑中重拉天然被排除)、同章本轮只自动进一次、
1649
+ // 非空章节照常走「编辑」按钮。预填两个全角空格让首段也有段首缩进,
1650
+ // 基线与预填同值(不判脏,纯缩进占位也不会触发自动保存落盘)。
1651
+ if (!editingRef.current && autoEditRef.current !== f && d && typeof d.body === "string" && d.body.trim() === "") {
1652
+ autoEditRef.current = f;
1653
+ bodyBaseRef.current = "  ";
1654
+ setDraft("  ");
1655
+ setEditing(true);
1656
+ setBodyErr("");
1657
+ }
1632
1658
  }).catch(function (err) {
1633
1659
  if (seq !== detailSeqRef.current) return;
1634
1660
  setDetail({ loading: false, data: null, err: err.message });
@@ -1639,7 +1665,8 @@ window.__ModuleLoader__.load({
1639
1665
  // 走 silent——不闪骨架、不打断 textarea,draft 只在 409 冲突重置路径才
1640
1666
  // 动(上方注释的纪律不变);阅读态非 silent,换血后照常渲染新正文。
1641
1667
  useEffect(function () {
1642
- if (reopenSeq > 0) loadDetail(file, { silent: editing });
1668
+ // 重开面板算新一轮访问:直开去重清空,空章节重开再次自动进编辑态。
1669
+ if (reopenSeq > 0) { autoEditRef.current = ""; loadDetail(file, { silent: editing }); }
1643
1670
  }, [reopenSeq]);
1644
1671
  // 切章必须退出编辑态并清草稿:草稿属于「那一章的那一次编辑」,跨章带过
1645
1672
  // 去会把 A 章的文字存进 B 章文件。
@@ -1647,6 +1674,7 @@ window.__ModuleLoader__.load({
1647
1674
  setEditing(false); setDraft(""); setBodyErr(""); setBodyBusy(false); bodyResetRef.current = false; setBodyConflict(false);
1648
1675
  setOlEditing(false); setOlDraft(""); setOlErr(""); setOlBusy(false); olResetRef.current = false; setOlConflict(false);
1649
1676
  bodyBaseRef.current = ""; olBaseRef.current = "";
1677
+ autoEditRef.current = ""; setAutoSavedAt(""); // 直开去重与自动保存时刻随切章清零,新章新一轮
1650
1678
  }, [file]);
1651
1679
  // 脏标记上报:Shell 的切 tab 守卫(gotoTab)读 DIRTY_EXIT 拦截;本组件
1652
1680
  // 不随切 tab 卸载(整页视图),标记随每轮渲染同步,卸载时清零。
@@ -1680,6 +1708,7 @@ window.__ModuleLoader__.load({
1680
1708
  function startBodyEdit() {
1681
1709
  if (!detail.data || typeof detail.data.body !== "string") return;
1682
1710
  setBodyErr("");
1711
+ setAutoSavedAt(""); // 上一次编辑会话的自动保存时刻不带进新一轮
1683
1712
  bodyBaseRef.current = detail.data.body; // 进入编辑态即记基线,供脏检查
1684
1713
  setDraft(detail.data.body);
1685
1714
  setEditing(true);
@@ -1696,21 +1725,29 @@ window.__ModuleLoader__.load({
1696
1725
  }
1697
1726
  onBack();
1698
1727
  }
1699
- function saveBody() {
1728
+ function saveBody(opts) {
1729
+ var auto = !!(opts && opts.auto); // 自动保存(2s 防抖调度):成功不退出编辑态、不清草稿
1700
1730
  if (bodyBusy || !detail.data) return;
1701
1731
  setBodyBusy(true); setBodyErr("");
1732
+ var bodyToSave = draft; // 调用点快照:在途期间继续打字的文本不该被误标已保存
1702
1733
  // 失败经卡内 bodyErr 呈现(silent 抑制 apiPost 同文 toast);成功提示仍走 toast。
1703
- apiPost("chapter-save", { path: wsPath, file: file, body: draft, base_hash: detail.data.hash }, { silent: true }).then(function (res) {
1734
+ apiPost("chapter-save", { path: wsPath, file: file, body: bodyToSave, base_hash: detail.data.hash }, { silent: true }).then(function (res) {
1704
1735
  // 时序守卫:保存期间切了章——旧章结果不得覆写当前 detail(快照 reload 无害照做)。
1705
1736
  if (fileRef.current === file) {
1706
1737
  // 写后指纹即下一轮基线(连续保存不再被自家 409 拦住);本地 data 同步
1707
1738
  // 为新正文,退出编辑态立即看到刚落盘的内容(不等重拉,防闪回旧文)。
1708
1739
  setDetail({ loading: false, data: Object.assign({}, detail.data, {
1709
- body: draft,
1740
+ body: bodyToSave,
1710
1741
  hash: res && typeof res.hash === "string" ? res.hash : detail.data.hash,
1711
1742
  }), err: "" });
1743
+ bodyBaseRef.current = bodyToSave; // 写盘内容即新基线:连续自动保存不假脏
1712
1744
  setBodyConflict(false);
1713
- setEditing(false); setDraft("");
1745
+ if (auto) {
1746
+ // 自动保存:停留编辑态续写,时刻缀在 hint;手动保存维持原行为退出。
1747
+ setAutoSavedAt(new Date().toTimeString().slice(0, 8));
1748
+ } else {
1749
+ setEditing(false); setDraft(""); setAutoSavedAt("");
1750
+ }
1714
1751
  }
1715
1752
  return reload(); // 字数/统计随正文变,快照由 reload 刷新
1716
1753
  }).catch(function (err) {
@@ -1726,6 +1763,38 @@ window.__ModuleLoader__.load({
1726
1763
  }
1727
1764
  }).finally(function () { setBodyBusy(false); });
1728
1765
  }
1766
+ // 正文自动保存调度(0.1.22):无 dep 数组,每轮渲染重排——任何一次渲染都先
1767
+ // 清掉上一个 2s 定时器再按最新 draft 重排,连续打字永远差 2s 才落盘;切章、
1768
+ // 409 冲突、保存在途不排。draft 回到基线(刚保存/刚进编辑)不动作;纯缩进
1769
+ // 占位(trim 为空)不落盘,且旧「已保存」时刻一并撤下。setAutoSavedAt("")
1770
+ // 传同值时 React 不重渲,不会循环。
1771
+ useEffect(function () {
1772
+ if (!editing || bodyConflict || bodyBusy) return;
1773
+ if (!detail || !detail.data) return;
1774
+ if (draft === bodyBaseRef.current || draft.trim() === "") {
1775
+ if (draft !== bodyBaseRef.current) setAutoSavedAt(""); // 纯缩进占位:旧时刻不亮
1776
+ return;
1777
+ }
1778
+ setAutoSavedAt(""); // 新改动入列,清掉旧的「已保存」标
1779
+ var t = setTimeout(function () { if (fileRef.current === file) saveBody({ auto: true }); }, 2000);
1780
+ return function () { clearTimeout(t); };
1781
+ });
1782
+ // 回车缩进的光标恢复:DOM 换新后把光标放到插入的「\n  」缩进之后,
1783
+ // 打字位置即视觉位置;无待恢复请求(普通打字)直接跳过。
1784
+ useEffect(function () {
1785
+ if (pendingCaretRef.current == null) return;
1786
+ var pos = pendingCaretRef.current; pendingCaretRef.current = null;
1787
+ var ta = areaRef.current;
1788
+ if (ta) { ta.selectionStart = ta.selectionEnd = pos; }
1789
+ }, [draft]);
1790
+ // 编辑器自动长高(0.1.22):排版对齐只读正文(同 UI 字体 13px/22px 行高),
1791
+ // 高度随内容长上去,至少 320px;非编辑态(textarea 已卸载)跳过。
1792
+ useEffect(function () {
1793
+ var ta = areaRef.current;
1794
+ if (!editing || !ta) return;
1795
+ ta.style.height = "auto";
1796
+ ta.style.height = Math.max(320, ta.scrollHeight + 2) + "px";
1797
+ }, [draft, editing]);
1729
1798
  // 409 出口①:放弃草稿——磁盘真值放回编辑器(bodyResetRef 一次性重置链路)。
1730
1799
  function dropBodyDraft() {
1731
1800
  if (bodyBusy) return;
@@ -1859,10 +1928,27 @@ window.__ModuleLoader__.load({
1859
1928
  editing
1860
1929
  ? h("div", { key: "body", className: "myt-bodyEdit" },
1861
1930
  h("textarea", {
1931
+ ref: areaRef,
1862
1932
  className: "myt-chapterEditArea", rows: 20, spellCheck: false,
1863
1933
  value: draft, onChange: function (e) { setDraft(e.target.value); },
1934
+ onKeyDown: function (e) {
1935
+ // 回车 = 换行 + 段首两个全角空格(网文段落缩进惯例);IME
1936
+ // 组态中的回车是选词确认,不得劫持。
1937
+ if (e.key !== "Enter") return;
1938
+ if (e.keyCode === 229) return; // IME 组态中
1939
+ if (e.nativeEvent && e.nativeEvent.isComposing) return; // 中文输入法回车选词
1940
+ var ta = e.target;
1941
+ var s = ta.selectionStart, en = ta.selectionEnd;
1942
+ if (typeof s !== "number" || typeof en !== "number") return; // 退化环境交给默认
1943
+ e.preventDefault();
1944
+ var next = ta.value.slice(0, s) + "\n  " + ta.value.slice(en);
1945
+ pendingCaretRef.current = s + 3; // "\n" + 两个全角空格
1946
+ setDraft(next);
1947
+ },
1864
1948
  }),
1865
- h("div", { className: "myt-editHint" }, tt("reader.bodyEditHint")),
1949
+ h("div", { className: "myt-editHint" },
1950
+ tt("reader.bodyEditHint"),
1951
+ autoSavedAt ? " · " + tf("reader.autoSaved", { time: autoSavedAt }) : null),
1866
1952
  bodyErr ? h(ErrorStrip, { text: bodyErr }) : null,
1867
1953
  bodyConflict ? h("div", { className: "myt-setActions" },
1868
1954
  h("button", { type: "button", className: "myt-btn", disabled: bodyBusy, onClick: dropBodyDraft }, tt("edit.conflictDrop")),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-maoying",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "毛颖 — DSH 网文创作辅助插件:主编 + 五帮手编排(大纲/写手/审校/设定/考据),novel/ 结构化文件为数据库",
5
5
  "type": "module",
6
6
  "license": "MIT",