@waterwx/dsh-novel-forge 1.3.1-alpha → 1.3.2-alpha

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.
@@ -1,143 +1,171 @@
1
- /**
2
- * 运行状态芯片:固定在页面顶部的「小说工坊生成状态」小徽标。
3
- * 轮询 /run/status,实时显示 生成中/暂停/完成/停止/出错 + 当前章号;
4
- * 点击可打开/关闭小说工坊面板。独立 DOM 注入,不触碰主面板逻辑,挂载失败静默降级。
5
- *
6
- * P0 重写说明(原实现的三个问题):
7
- * 1. 整块背景染状态色 + 固定白字 → 浅色主题下「白字配亮底」对比度崩坏。
8
- * 现在改为「玻璃底 + 主题前景色文字 + 左侧状态色点」,状态只由色点承载,
9
- * 文字永远跟随主题,任何深浅色下都可读。
10
- * 2. 样式写死在 JS 的 inline style 里,切主题纹丝不动。
11
- * 现在改用 CSS Module class,配色走 --nf-chip-* 令牌,
12
- * 由 body[data-ds-dark-theme] 跟随宿主深浅色。
13
- * 3. role="button" 却没有 tabindex、没有键盘处理,键盘用户完全用不了。
14
- * 现在补 tabindex + Enter/Space 激活 + aria-label/live。
15
- */
16
- import type { NovelApi } from './api.ts'
17
- import type { PanelController } from './panel/controller.ts'
18
- import type { RunState } from '../protocol.ts'
19
- import css from './panel/panel.module.css'
20
-
21
- const REFRESH_MS = 4000
22
-
23
- /** 状态文案。状态语义由左侧色点承载,这里不再放 emoji(避免与色点重复表意)。 */
24
- function label(s: RunState): string {
25
- const no = s.currentNo
26
- switch (s.status) {
27
- case 'running': return `小说工坊 生成中 · 第${no}章`
28
- case 'paused': return `小说工坊 已暂停 · 第${no}章`
29
- case 'done': return `小说工坊 批完成 ${s.startNo}–${s.endNo}`
30
- case 'stopped': return `小说工坊 已停止 · 第${no}章`
31
- case 'error': return `小说工坊 出错 · 第${no}章`
32
- default: return '小说工坊'
33
- }
34
- }
35
-
36
- /** 生成一个固定顶部的状态芯片并开始轮询。返回 dispose。 */
37
- export function mountRunStatus(controller: PanelController, api: NovelApi): () => void {
38
- const chip = document.createElement('div')
39
- chip.className = css.runChip
40
- chip.setAttribute('role', 'button')
41
- // P0: role="button" 必须可聚焦、可键盘激活,否则对键盘/读屏用户等同不存在。
42
- chip.setAttribute('tabindex', '0')
43
- chip.setAttribute('title', '小说工坊生成状态(点击打开工坊)')
44
- // 状态是异步轮询出来的,用 aria-live 让读屏用户也能感知变化。
45
- chip.setAttribute('aria-live', 'polite')
46
-
47
- const dot = document.createElement('span')
48
- dot.className = css.runChipDot
49
- // 纯装饰,颜色信息已由文字表达,不要让读屏重复播报。
50
- dot.setAttribute('aria-hidden', 'true')
51
-
52
- const text = document.createElement('span')
53
- text.className = css.runChipText
54
-
55
- chip.append(dot, text)
56
-
57
- const toggle = (): void => { controller.toggle() }
58
-
59
- // 自由拖拽 + 位置记忆(与 AI 进度浮窗一致)
60
- const POS_KEY = 'dsh-novel-forge.runChip.pos'
61
- let dragging = false, moved = false, sx = 0, sy = 0, ox = 0, oy = 0
62
- const readPos = (): { x: number; y: number } | null => {
63
- try { const p = window.localStorage.getItem(POS_KEY); return p === null ? null : JSON.parse(p) as { x: number; y: number } } catch { return null }
64
- }
65
- const writePos = (x: number, y: number): void => {
66
- try { window.localStorage.setItem(POS_KEY, JSON.stringify({ x, y })) } catch { /* ignore */ }
67
- }
68
- const clampX = (x: number): number => Math.max(0, Math.min(Math.max(0, window.innerWidth - 40), x))
69
- const clampY = (y: number): number => Math.max(0, Math.min(Math.max(0, window.innerHeight - 40), y))
70
- const applyPos = (x: number, y: number): void => {
71
- chip.style.left = clampX(x) + 'px'
72
- chip.style.top = clampY(y) + 'px'
73
- chip.style.right = 'auto'
74
- }
75
- chip.addEventListener('pointerdown', (e) => {
76
- if (e.button !== 0) return
77
- dragging = true; moved = false; sx = e.clientX; sy = e.clientY
78
- ox = chip.offsetLeft; oy = chip.offsetTop
79
- try { chip.setPointerCapture(e.pointerId) } catch { /* ignore */ }
80
- })
81
- chip.addEventListener('pointermove', (e) => {
82
- if (!dragging) return
83
- const dx = e.clientX - sx, dy = e.clientY - sy
84
- if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true
85
- if (moved) {
86
- applyPos(ox + dx, oy + dy)
87
- writePos(chip.offsetLeft, chip.offsetTop)
88
- }
89
- })
90
- const endDrag = (): void => { dragging = false }
91
- chip.addEventListener('pointerup', endDrag)
92
- chip.addEventListener('pointercancel', endDrag)
93
- // 拖拽后松开不触发开合
94
- chip.addEventListener('click', (e) => {
95
- if (moved) { e.preventDefault(); e.stopImmediatePropagation(); moved = false; return }
96
- toggle()
97
- })
98
- chip.addEventListener('keydown', (e) => {
99
- if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
100
- e.preventDefault()
101
- toggle()
102
- }
103
- })
104
-
105
- document.body.appendChild(chip)
106
-
107
- // 恢复上次拖拽位置(若曾移动过)
108
- const saved = readPos()
109
- if (saved !== null) applyPos(saved.x, saved.y)
110
-
111
- let disposed = false
112
- let timer: number | undefined
113
-
114
- const render = (s: RunState | null): void => {
115
- if (s === null) {
116
- chip.dataset.visible = 'false'
117
- return
118
- }
119
- let body = label(s)
120
- if (s.status === 'running' && s.stats.error > 0) body += ` · ×${s.stats.error}`
121
- text.textContent = body
122
- chip.dataset.status = s.status
123
- chip.dataset.visible = 'true'
124
- chip.setAttribute('aria-label', body)
125
- }
126
-
127
- const tick = async (): Promise<void> => {
128
- if (disposed) return
129
- try {
130
- const s = await api.runStatus()
131
- render(s)
132
- } catch { /* best-effort */ }
133
- if (!disposed) timer = window.setTimeout(() => { void tick() }, REFRESH_MS)
134
- }
135
-
136
- void tick()
137
-
138
- return () => {
139
- disposed = true
140
- if (timer !== undefined) window.clearTimeout(timer)
141
- chip.remove()
142
- }
143
- }
1
+ /**
2
+ * 运行状态芯片:固定在页面顶部的「小说工坊生成状态」小徽标。
3
+ * 轮询 /run/status,实时显示 生成中/暂停/完成/停止/出错 + 当前章号;
4
+ * 点击可打开/关闭小说工坊面板。独立 DOM 注入,不触碰主面板逻辑,挂载失败静默降级。
5
+ * 显示偏好(关 / 仅生成中暂停时 / 总是)存在 localStorage,见 chip-prefs.ts:
6
+ * 书架页「设置 · 全局 → 外观与主题」里有对应下拉,选择即时生效(跨标签页也同步)。
7
+ *
8
+ * P0 重写说明(原实现的三个问题):
9
+ * 1. 整块背景染状态色 + 固定白字 → 浅色主题下「白字配亮底」对比度崩坏。
10
+ * 现在改为「玻璃底 + 主题前景色文字 + 左侧状态色点」,状态只由色点承载,
11
+ * 文字永远跟随主题,任何深浅色下都可读。
12
+ * 2. 样式写死在 JS 的 inline style 里,切主题纹丝不动。
13
+ * 现在改用 CSS Module class,配色走 --nf-chip-* 令牌,
14
+ * 由 body[data-ds-dark-theme] 跟随宿主深浅色。
15
+ * 3. role="button" 却没有 tabindex、没有键盘处理,键盘用户完全用不了。
16
+ * 现在补 tabindex + Enter/Space 激活 + aria-label/live。
17
+ */
18
+ import type { NovelApi } from './api.ts'
19
+ import type { PanelController } from './panel/controller.ts'
20
+ import type { RunState } from '../protocol.ts'
21
+ import css from './panel/panel.module.css'
22
+ import { isRunChipVisible, readRunChipMode, subscribeRunChipMode, type RunChipMode } from './chip-prefs.ts'
23
+
24
+ const REFRESH_MS = 4000
25
+
26
+ /** 状态文案。状态语义由左侧色点承载,这里不再放 emoji(避免与色点重复表意)。 */
27
+ function label(s: RunState): string {
28
+ const no = s.currentNo
29
+ // 带书名的原因:状态是"全局单实例 + 恢复时会扫书架",不带书名根本不知道是谁的批次。
30
+ const book = typeof s.bookName === 'string' && s.bookName.trim() !== '' ? `《${s.bookName.trim()}》` : ''
31
+ const head = book === '' ? '小说工坊' : book
32
+ switch (s.status) {
33
+ case 'running': return `${head} 生成中 · 第${no}章`
34
+ case 'paused': return `${head} 已暂停 · 第${no}章`
35
+ case 'done': return `${head} 批完成 ${s.startNo}–${s.endNo}`
36
+ case 'stopped': return `${head} 已停止 · 第${no}章`
37
+ case 'error': return `${head} 出错 · 第${no}章`
38
+ default: return book === '' ? '小说工坊' : book
39
+ }
40
+ }
41
+
42
+ /** 生成一个固定顶部的状态芯片并开始轮询。返回 dispose。 */
43
+ export function mountRunStatus(controller: PanelController, api: NovelApi): () => void {
44
+ const chip = document.createElement('div')
45
+ chip.className = css.runChip
46
+ chip.setAttribute('role', 'button')
47
+ // P0: role="button" 必须可聚焦、可键盘激活,否则对键盘/读屏用户等同不存在。
48
+ chip.setAttribute('tabindex', '0')
49
+ chip.setAttribute('title', '小说工坊生成状态(点击打开工坊)')
50
+ // 状态是异步轮询出来的,用 aria-live 让读屏用户也能感知变化。
51
+ chip.setAttribute('aria-live', 'polite')
52
+
53
+ const dot = document.createElement('span')
54
+ dot.className = css.runChipDot
55
+ // 纯装饰,颜色信息已由文字表达,不要让读屏重复播报。
56
+ dot.setAttribute('aria-hidden', 'true')
57
+
58
+ const text = document.createElement('span')
59
+ text.className = css.runChipText
60
+
61
+ chip.append(dot, text)
62
+
63
+ const toggle = (): void => { controller.toggle() }
64
+
65
+ // 自由拖拽 + 位置记忆(与 AI 进度浮窗一致)
66
+ const POS_KEY = 'dsh-novel-forge.runChip.pos'
67
+ let dragging = false, moved = false, sx = 0, sy = 0, ox = 0, oy = 0
68
+ const readPos = (): { x: number; y: number } | null => {
69
+ try { const p = window.localStorage.getItem(POS_KEY); return p === null ? null : JSON.parse(p) as { x: number; y: number } } catch { return null }
70
+ }
71
+ const writePos = (x: number, y: number): void => {
72
+ try { window.localStorage.setItem(POS_KEY, JSON.stringify({ x, y })) } catch { /* ignore */ }
73
+ }
74
+ const clampX = (x: number): number => Math.max(0, Math.min(Math.max(0, window.innerWidth - 40), x))
75
+ const clampY = (y: number): number => Math.max(0, Math.min(Math.max(0, window.innerHeight - 40), y))
76
+ const applyPos = (x: number, y: number): void => {
77
+ chip.style.left = clampX(x) + 'px'
78
+ chip.style.top = clampY(y) + 'px'
79
+ chip.style.right = 'auto'
80
+ }
81
+ chip.addEventListener('pointerdown', (e) => {
82
+ if (e.button !== 0) return
83
+ dragging = true; moved = false; sx = e.clientX; sy = e.clientY
84
+ ox = chip.offsetLeft; oy = chip.offsetTop
85
+ try { chip.setPointerCapture(e.pointerId) } catch { /* ignore */ }
86
+ })
87
+ chip.addEventListener('pointermove', (e) => {
88
+ if (!dragging) return
89
+ const dx = e.clientX - sx, dy = e.clientY - sy
90
+ if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true
91
+ if (moved) {
92
+ applyPos(ox + dx, oy + dy)
93
+ writePos(chip.offsetLeft, chip.offsetTop)
94
+ }
95
+ })
96
+ const endDrag = (): void => { dragging = false }
97
+ chip.addEventListener('pointerup', endDrag)
98
+ chip.addEventListener('pointercancel', endDrag)
99
+ // 拖拽后松开不触发开合
100
+ chip.addEventListener('click', (e) => {
101
+ if (moved) { e.preventDefault(); e.stopImmediatePropagation(); moved = false; return }
102
+ toggle()
103
+ })
104
+ chip.addEventListener('keydown', (e) => {
105
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
106
+ e.preventDefault()
107
+ toggle()
108
+ }
109
+ })
110
+
111
+ document.body.appendChild(chip)
112
+
113
+ // 恢复上次拖拽位置(若曾移动过)
114
+ const saved = readPos()
115
+ if (saved !== null) applyPos(saved.x, saved.y)
116
+
117
+ let disposed = false
118
+ let timer: number | undefined
119
+ let mode: RunChipMode = readRunChipMode()
120
+ /** 最近一次拿到的生产单状态:切换偏好时据此立刻重算可见性,不必等下一轮询。 */
121
+ let last: RunState | null = null
122
+
123
+ const render = (s: RunState | null): void => {
124
+ last = s
125
+ if (!isRunChipVisible(mode, s)) {
126
+ chip.dataset.visible = 'false'
127
+ return
128
+ }
129
+ let body = label(s!)
130
+ if (s!.status === 'running' && s!.stats.error > 0) body += ` · ×${s!.stats.error}`
131
+ text.textContent = body
132
+ chip.dataset.status = s!.status
133
+ chip.dataset.visible = 'true'
134
+ // 芯片单行且限宽:书名过长会省略号截断,全名与归属目录放进 title / aria-label。
135
+ const full = `${body}(第 ${s!.startNo}-${s!.endNo} 章)`
136
+ chip.setAttribute('aria-label', full)
137
+ chip.setAttribute('title', full + ' · 点击打开小说工坊')
138
+ }
139
+
140
+ const tick = async (): Promise<void> => {
141
+ if (disposed) return
142
+ try {
143
+ const s = await api.runStatus()
144
+ render(s)
145
+ } catch { /* best-effort */ }
146
+ if (!disposed && mode !== 'off') timer = window.setTimeout(() => { void tick() }, REFRESH_MS)
147
+ }
148
+
149
+ /** 偏好变化:立即重算可见性;「关闭」时停掉轮询,重新打开时续上。 */
150
+ const applyMode = (next: RunChipMode): void => {
151
+ const wasOff = mode === 'off'
152
+ mode = next
153
+ render(last)
154
+ if (mode === 'off') {
155
+ if (timer !== undefined) { window.clearTimeout(timer); timer = undefined }
156
+ return
157
+ }
158
+ if (wasOff && timer === undefined) void tick()
159
+ }
160
+
161
+ const unsubscribe = subscribeRunChipMode(applyMode)
162
+
163
+ void tick()
164
+
165
+ return () => {
166
+ disposed = true
167
+ unsubscribe()
168
+ if (timer !== undefined) window.clearTimeout(timer)
169
+ chip.remove()
170
+ }
171
+ }
package/src/protocol.ts CHANGED
@@ -1579,6 +1579,12 @@ export interface StatusResponse {
1579
1579
  /** 生产单状态(生产单 = 区间批量生产执行器:计划补足 + 逐章生成 + 被拒分级处理)。 */
1580
1580
  export interface RunState {
1581
1581
  runId: string
1582
+ /**
1583
+ * 本批次归属的书名(可选,兼容旧 run-state.json)。
1584
+ * 生产单是全局单实例,而 /run/status 恢复时会扫书架取第一个有 run-state 的目录,
1585
+ * 因此"这是哪本书的批次"必须显式带上,否则芯片/面板只能显示一个无法归属的状态。
1586
+ */
1587
+ bookName?: string
1582
1588
  /** 区间起点(含)。 */
1583
1589
  startNo: number
1584
1590
  /** 区间终点(含)。 */
package/src/run.ts CHANGED
@@ -34,6 +34,30 @@ function runStateFile(outputDir: string): string {
34
34
  return join(outputDir, 'run-state.json')
35
35
  }
36
36
 
37
+ /**
38
+ * 解析某个输出目录的书名(用于旧 run-state.json 的兜底)。
39
+ * 优先项目文件里的 bookName,其次目录名——两者都没有时返回空串。
40
+ * @param outputDir - 该书输出目录。
41
+ */
42
+ export function resolveRunBookName(outputDir: string): string {
43
+ const fromProject = loadProject(outputDir)?.bookName
44
+ if (typeof fromProject === 'string' && fromProject.trim() !== '') return fromProject.trim()
45
+ const base = outputDir.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
46
+ return base.trim()
47
+ }
48
+
49
+ /**
50
+ * 一句话描述"正在跑的是哪本书的哪一章"。
51
+ * 面板状态行与 409 报错共用,避免两处各写各的(也便于单测覆盖)。
52
+ * @param state - 运行中的生产单状态。
53
+ */
54
+ export function describeRunningBatch(state: RunState): string {
55
+ const name = typeof state.bookName === 'string' && state.bookName.trim() !== ''
56
+ ? `《${state.bookName.trim()}》`
57
+ : '未标注书目'
58
+ return `${name}第${state.currentNo}章`
59
+ }
60
+
37
61
  export class ProductionRunner {
38
62
  private state: RunState | null = null
39
63
  private working = false
@@ -60,6 +84,11 @@ export class ProductionRunner {
60
84
  if (parsed?.runId === undefined) continue
61
85
  // 重启恢复:若之前 running,则视为可续跑(loop 未在跑)。
62
86
  if (parsed.status === 'running') parsed.status = 'paused'
87
+ // 旧 run-state.json 没有 bookName:用该目录的项目名兜底,再退回目录名,
88
+ // 保证"这是哪本书"永远有答案(否则芯片/面板会显示一个无法归属的状态)。
89
+ if (parsed.bookName === undefined || parsed.bookName === '') {
90
+ parsed.bookName = resolveRunBookName(outputDir)
91
+ }
63
92
  this.state = parsed
64
93
  this.bookDir = outputDir
65
94
  return this.state
@@ -85,7 +114,13 @@ export class ProductionRunner {
85
114
  /** 启动/续跑生产单:startNo..endNo 区间,endNo 超出计划时先自动补计划。 */
86
115
  async start(startNo: number, endNo: number, runDir?: string): Promise<RunState> {
87
116
  const config = this.deps.getConfig()
88
- if (this.working) throw new Error('生产单正在运行中,请先暂停或停止')
117
+ if (this.working) {
118
+ // 报错要说清"在跑哪本书的哪一章":全局单实例,含糊的提示会让多书用户无从下手。
119
+ const running = this.state
120
+ throw new Error(running === null
121
+ ? '生产单正在运行中,请先暂停或停止'
122
+ : `生产单正在运行中(${describeRunningBatch(running)}),请先暂停或停止`)
123
+ }
89
124
  const outputDir = runDir ?? config.outputDir
90
125
  let project = loadProject(outputDir)
91
126
  if (project === undefined) throw new Error('输出目录中没有项目')
@@ -104,6 +139,7 @@ export class ProductionRunner {
104
139
 
105
140
  this.state = {
106
141
  runId: `run-${Date.now().toString(36)}`,
142
+ bookName: project.bookName,
107
143
  startNo,
108
144
  endNo,
109
145
  status: 'running',