@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,223 +1,236 @@
1
- /**
2
- * 生产单面板:区间批量生产(计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑)。
3
- * 与「AI进度」悬浮窗不同:这是标准流水线设备,一键下单、实时进度、日志可查。
4
- */
5
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
- import type { NovelApi } from '../api.ts'
7
- import type { RunState } from '../../protocol.ts'
8
- import css from './panel.module.css'
9
- import { PageHeader } from './PageHeader.tsx'
10
-
11
- export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters: number }) {
12
- const [run, setRun] = useState<RunState | null>(null)
13
- const [startNo, setStartNo] = useState<number>(1)
14
- const [endNo, setEndNo] = useState<number>(0)
15
- const [count, setCount] = useState<number>(30)
16
- const [mode, setMode] = useState<'count' | 'range'>('count')
17
- const [busy, setBusy] = useState(false)
18
- const [err, setErr] = useState('')
19
- const logRef = useRef<HTMLDivElement>(null)
20
-
21
- // 默认起点:最后一章 + 1;count 模式终点 = 起点 + count - 1
22
- useEffect(() => {
23
- setStartNo(totalChapters + 1)
24
- setEndNo(totalChapters + 30)
25
- }, [totalChapters])
26
-
27
- const poll = useCallback(async () => {
28
- try {
29
- const s = await api.runStatus()
30
- setRun(s)
31
- } catch { /* 静默 */ }
32
- }, [api])
33
-
34
- // 运行期间每 5 秒轮询一次状态。
35
- useEffect(() => {
36
- void poll()
37
- const timer = window.setInterval(() => { void poll() }, 5000)
38
- return () => window.clearInterval(timer)
39
- }, [poll])
40
-
41
- // 日志自动滚底。
42
- useEffect(() => {
43
- logRef.current?.scrollTo({ top: logRef.current.scrollHeight })
44
- }, [run?.log.length])
45
-
46
-
47
- const handleStart = async (): Promise<void> => {
48
- setBusy(true)
49
- setErr('')
50
- try {
51
- const req = mode === 'count'
52
- ? { startNo, count }
53
- : { startNo, endNo }
54
- const s = await api.runStart(req)
55
- setRun(s)
56
- } catch (e) {
57
- setErr((e as Error).message)
58
- } finally {
59
- setBusy(false)
60
- }
61
- }
62
-
63
- const handleControl = async (action: 'pause' | 'resume' | 'stop'): Promise<void> => {
64
- setBusy(true)
65
- setErr('')
66
- try {
67
- const s = await api.runControl(action)
68
- if (s !== null) setRun(s)
69
- } catch (e) {
70
- setErr((e as Error).message)
71
- } finally {
72
- setBusy(false)
73
- }
74
- }
75
-
76
- const statusLabel = useMemo(() => {
77
- if (run === null) return '未启动'
78
- return {
79
- running: ' 生产中',
80
- paused: '⏸ 已暂停',
81
- done: ' 已完成',
82
- stopped: '⏹ 已停止',
83
- error: ' 异常',
84
- }[run.status] ?? run.status
85
- }, [run])
86
-
87
- const ratio = run !== null && run.endNo > run.startNo
88
- ? Math.min(Math.max((run.currentNo - run.startNo) / (run.endNo - run.startNo), 0), 1)
89
- : 0
90
-
91
- return (
92
- <div className={css.card} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
93
- <PageHeader
94
- eyebrow="Production"
95
- title="生产单"
96
- actions={(
97
- <span className={`${css.badge} ${run?.status === 'running' ? css.badgeWritten : run?.status === 'done' ? css.badgeDone : css.badgePending}`}>{statusLabel}</span>
98
- )}
99
- />
100
- {run === null || run.status !== 'running' ? (
101
- <span className={css.meta}>标准流水线:计划 → 生成 → 审稿 → 分级处理。一键下单,自动补计划,逐章过质量门(生成→摘要+事实→审稿→作者复盘),被拒章分级处理,中断可断点续跑。</span>
102
- ) : null}
103
-
104
- {/* v4 B4 控制台折叠:生产中下单区收起为一行状态摘要,进度与日志成为主体。 */}
105
- {run !== null && run.status === 'running' ? (
106
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
107
- <span className={css.meta}> 生产中 · 第 {run.startNo}-{run.endNo} 章 · 当前第 {run.currentNo} 章 · {Math.round(ratio * 100)}%</span>
108
- <div className={css.row} style={{ gap: 'var(--nf-space-6)' }}>
109
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleControl('pause') }}>
110
- ⏸ 暂停
111
- </button>
112
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleControl('stop') }}>
113
- ⏹ 停止
114
- </button>
115
- </div>
116
- </div>
117
- ) : (
118
- <>
119
- {/* 下单区 */}
120
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)', alignItems: 'flex-end' }}>
121
- <div style={{ display: 'flex', gap: 'var(--nf-space-4)', alignItems: 'center' }}>
122
- <button
123
- type="button"
124
- className={`${css.button} ${css.buttonSmall} ${mode === 'count' ? css.buttonPrimary : ''}`}
125
- onClick={() => { setMode('count') }}
126
- title="从当前末章 +1 起,新增 N 章"
127
- >
128
- 新增 N 章
129
- </button>
130
- <button
131
- type="button"
132
- className={`${css.button} ${css.buttonSmall} ${mode === 'range' ? css.buttonPrimary : ''}`}
133
- onClick={() => { setMode('range') }}
134
- title="指定起止章号区间"
135
- >
136
- 指定区间
137
- </button>
138
- </div>
139
- {mode === 'count' ? (
140
- <>
141
- <div className={css.field} style={{ flex: 'none', minWidth: 90 }}>
142
- <label className={css.fieldLabel}>起始章</label>
143
- <input className={css.input} type="number" min={1} value={startNo} onChange={e => { setStartNo(Math.max(1, Number(e.target.value) || 1)) }} />
144
- </div>
145
- <div className={css.field} style={{ flex: 'none', minWidth: 90 }}>
146
- <label className={css.fieldLabel}>新增章数</label>
147
- <input className={css.input} type="number" min={1} max={200} value={count} onChange={e => { setCount(Math.max(1, Math.min(200, Number(e.target.value) || 1))) }} />
148
- </div>
149
- </>
150
- ) : (
151
- <>
152
- <div className={css.field} style={{ flex: 'none', minWidth: 90 }}>
153
- <label className={css.fieldLabel}>起始章</label>
154
- <input className={css.input} type="number" min={1} value={startNo} onChange={e => { setStartNo(Math.max(1, Number(e.target.value) || 1)) }} />
155
- </div>
156
- <div className={css.field} style={{ flex: 'none', minWidth: 90 }}>
157
- <label className={css.fieldLabel}>结束章</label>
158
- <input className={css.input} type="number" min={1} value={endNo} onChange={e => { setEndNo(Math.max(1, Number(e.target.value) || 1)) }} />
159
- </div>
160
- </>
161
- )}
162
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || (run?.status === 'running')} onClick={() => { void handleStart() }} title="启动生产单">
163
- {run?.status === 'running' ? '生产中…' : run?.status === 'paused' ? '▶ 继续' : '▶ 下单生产'}
164
- </button>
165
- {run !== null && run.status !== 'done' && (
166
- <>
167
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || run.status !== 'running'} onClick={() => { void handleControl('pause') }}>
168
- ⏸ 暂停
169
- </button>
170
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || run.status === 'stopped'} onClick={() => { void handleControl('stop') }}>
171
- ⏹ 停止
172
- </button>
173
- </>
174
- )}
175
- {run !== null && run.status === 'paused' && (
176
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleControl('resume') }}>
177
- ▶ 继续
178
- </button>
179
- )}
180
- </div>
181
- </>
182
- )}
183
-
184
- {err !== '' && <div style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-12)' }}>{err}</div>}
185
-
186
- {/* 进度区 */}
187
- {run !== null && (
188
- <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
189
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
190
- <span className={css.meta}>范围:第 {run.startNo} - {run.endNo} 章 · 当前:第 {run.currentNo} 章</span>
191
- <span className={css.meta}>新生成 {run.stats?.generated ?? 0} · 修订通过 {run.stats?.revised ?? 0} · 豁免 {run.stats?.exempted ?? 0} · 重生成 {run.stats?.regenerated ?? 0} · 失败 {run.stats?.error ?? 0}</span>
192
- </div>
193
- <div className={css.bigProgressBar}>
194
- <div className={css.bigProgressBarFill} style={{ width: `${Math.round(ratio * 100)}%` }} />
195
- </div>
196
- {run.pendingManual.length > 0 && (
197
- <div className={css.meta} style={{ color: 'var(--nf-warn)' }}>
198
- 待人工:第 {run.pendingManual.join('、')} 章(两轮修订仍不过,保留草稿,可在章节列表处理)
199
- </div>
200
- )}
201
- {run.status === 'done' && <div className={css.meta} style={{ color: 'var(--nf-success)' }}> 生产单完成:{run.startNo}-{run.endNo} 章处理完毕,待人工 {run.pendingManual.length} 章。</div>}
202
- {run.error !== undefined && <div className={css.meta} style={{ color: 'var(--nf-error)' }}>异常:{run.error}</div>}
203
- </div>
204
- )}
205
-
206
- {/* 日志区(终端风) */}
207
- <div className={`${css.meta} ${css.titleBlock}`} style={{ gap: 0 }}>
208
- <span className={css.eyebrow}>Run Log</span>
209
- <span className={css.cardTitle}>运行日志({run?.log.length ?? 0})</span>
210
- </div>
211
- <div ref={logRef} className={css.runLog}>
212
- {run === null || run.log.length === 0 ? (
213
- <span className={css.meta}>尚无日志——下单后这里会实时显示每章进度。</span>
214
- ) : run.log.map((l, i) => (
215
- <div key={i} className={css.runLogLine}>
216
- <span className={css.runLogTime}>{new Date(l.at).toLocaleTimeString('zh-CN', { hour12: false })}</span>
217
- <span>{l.text}</span>
218
- </div>
219
- ))}
220
- </div>
221
- </div>
222
- )
223
- }
1
+ /**
2
+ * 生产单面板:区间批量生产(计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑)。
3
+ * 与「AI进度」悬浮窗不同:这是标准流水线设备,一键下单、实时进度、日志可查。
4
+ */
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
6
+ import type { NovelApi } from '../api.ts'
7
+ import type { RunState } from '../../protocol.ts'
8
+ import css from './panel.module.css'
9
+ import { PageHeader } from './PageHeader.tsx'
10
+
11
+ export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters: number }) {
12
+ const [run, setRun] = useState<RunState | null>(null)
13
+ const [startNo, setStartNo] = useState<number>(1)
14
+ const [endNo, setEndNo] = useState<number>(0)
15
+ const [count, setCount] = useState<number>(30)
16
+ const [mode, setMode] = useState<'count' | 'range'>('count')
17
+ const [busy, setBusy] = useState(false)
18
+ const [err, setErr] = useState('')
19
+ const logRef = useRef<HTMLDivElement>(null)
20
+
21
+ // 默认起点:最后一章 + 1;count 模式终点 = 起点 + count - 1
22
+ useEffect(() => {
23
+ setStartNo(totalChapters + 1)
24
+ setEndNo(totalChapters + 30)
25
+ }, [totalChapters])
26
+
27
+ const poll = useCallback(async () => {
28
+ try {
29
+ const s = await api.runStatus()
30
+ setRun(s)
31
+ } catch { /* 静默 */ }
32
+ }, [api])
33
+
34
+ // 运行期间每 5 秒轮询一次状态。
35
+ useEffect(() => {
36
+ void poll()
37
+ const timer = window.setInterval(() => { void poll() }, 5000)
38
+ return () => window.clearInterval(timer)
39
+ }, [poll])
40
+
41
+ // 日志自动滚底。
42
+ useEffect(() => {
43
+ logRef.current?.scrollTo({ top: logRef.current.scrollHeight })
44
+ }, [run?.log.length])
45
+
46
+
47
+ const handleStart = async (): Promise<void> => {
48
+ setBusy(true)
49
+ setErr('')
50
+ try {
51
+ const req = mode === 'count'
52
+ ? { startNo, count }
53
+ : { startNo, endNo }
54
+ const s = await api.runStart(req)
55
+ setRun(s)
56
+ } catch (e) {
57
+ setErr((e as Error).message)
58
+ } finally {
59
+ setBusy(false)
60
+ }
61
+ }
62
+
63
+ const handleControl = async (action: 'pause' | 'resume' | 'stop'): Promise<void> => {
64
+ setBusy(true)
65
+ setErr('')
66
+ try {
67
+ const s = await api.runControl(action)
68
+ if (s !== null) setRun(s)
69
+ } catch (e) {
70
+ setErr((e as Error).message)
71
+ } finally {
72
+ setBusy(false)
73
+ }
74
+ }
75
+
76
+ const statusLabel = useMemo(() => {
77
+ if (run === null) return '未启动'
78
+ return {
79
+ running: ' 生产中',
80
+ paused: '⏸ 已暂停',
81
+ done: ' 已完成',
82
+ stopped: '⏹ 已停止',
83
+ error: ' 异常',
84
+ }[run.status] ?? run.status
85
+ }, [run])
86
+
87
+ const ratio = run !== null && run.endNo > run.startNo
88
+ ? Math.min(Math.max((run.currentNo - run.startNo) / (run.endNo - run.startNo), 0), 1)
89
+ : 0
90
+
91
+ return (
92
+ <div className={css.card} style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
93
+ <PageHeader
94
+ eyebrow="Production"
95
+ title="生产单"
96
+ actions={(
97
+ <span className={`${css.badge} ${run?.status === 'running' ? css.badgeWritten : run?.status === 'done' ? css.badgeDone : css.badgePending}`}>{statusLabel}</span>
98
+ )}
99
+ />
100
+ {run === null || run.status !== 'running' ? (
101
+ <>
102
+ <span className={css.meta}>标准流水线:计划 → 生成 → 审稿 → 分级处理。一键下单,自动补计划,逐章过质量门(生成→摘要+事实→审稿→作者复盘),被拒章分级处理,中断可断点续跑。</span>
103
+ {/* 运行约束说明:并发/绑定/暂停语义/同章冲突,都是操作上会踩的点,放在下单区之前 */}
104
+ <details className={css.meta} style={{ fontSize: 'var(--nf-fs-12)' }}>
105
+ <summary style={{ cursor: 'pointer' }}>运行说明(并发 · 绑定书目 · 暂停续跑)</summary>
106
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 4, lineHeight: 1.6 }}>
107
+ <span>· <b>一次一本</b>:生产单是全局单实例,逐章串行;换书不会打断,也不会切换到别的批次。</span>
108
+ <span>· <b>绑定下单时的书</b>:本批次只读写下单时那本书的目录,与当前激活书无关。</span>
109
+ <span>· 起点默认 = 最后一章 +1;区间内<b>已通过审稿</b>的章会自动快进跳过。</span>
110
+ <span>· <b>暂停</b>可随时续跑(从断点章继续);<b>停止</b>会终结本批次,再次下单即新建批次。</span>
111
+ <span>· ⚠️ 批次运行中仍可手动生成/修订<b>其它章</b>,但请勿对正在处理的<b>同一章</b>同时手动操作——两个写入会互相覆盖。</span>
112
+ </div>
113
+ </details>
114
+ </>
115
+ ) : null}
116
+
117
+ {/* v4 B4 控制台折叠:生产中下单区收起为一行状态摘要,进度与日志成为主体。 */}
118
+ {run !== null && run.status === 'running' ? (
119
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
120
+ <span className={css.meta}> 生产中 · 第 {run.startNo}-{run.endNo} 章 · 当前第 {run.currentNo} 章 · {Math.round(ratio * 100)}%</span>
121
+ <div className={css.row} style={{ gap: 'var(--nf-space-6)' }}>
122
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleControl('pause') }}>
123
+ ⏸ 暂停
124
+ </button>
125
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleControl('stop') }}>
126
+ ⏹ 停止
127
+ </button>
128
+ </div>
129
+ </div>
130
+ ) : (
131
+ <>
132
+ {/* 下单区 */}
133
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)', alignItems: 'flex-end' }}>
134
+ <div style={{ display: 'flex', gap: 'var(--nf-space-4)', alignItems: 'center' }}>
135
+ <button
136
+ type="button"
137
+ className={`${css.button} ${css.buttonSmall} ${mode === 'count' ? css.buttonPrimary : ''}`}
138
+ onClick={() => { setMode('count') }}
139
+ title="从当前末章 +1 起,新增 N 章"
140
+ >
141
+ 新增 N 章
142
+ </button>
143
+ <button
144
+ type="button"
145
+ className={`${css.button} ${css.buttonSmall} ${mode === 'range' ? css.buttonPrimary : ''}`}
146
+ onClick={() => { setMode('range') }}
147
+ title="指定起止章号区间"
148
+ >
149
+ 指定区间
150
+ </button>
151
+ </div>
152
+ {mode === 'count' ? (
153
+ <>
154
+ <div className={css.field} style={{ flex: 'none', minWidth: 90 }}>
155
+ <label className={css.fieldLabel}>起始章</label>
156
+ <input className={css.input} type="number" min={1} value={startNo} onChange={e => { setStartNo(Math.max(1, Number(e.target.value) || 1)) }} />
157
+ </div>
158
+ <div className={css.field} style={{ flex: 'none', minWidth: 90 }}>
159
+ <label className={css.fieldLabel}>新增章数</label>
160
+ <input className={css.input} type="number" min={1} max={200} value={count} onChange={e => { setCount(Math.max(1, Math.min(200, Number(e.target.value) || 1))) }} />
161
+ </div>
162
+ </>
163
+ ) : (
164
+ <>
165
+ <div className={css.field} style={{ flex: 'none', minWidth: 90 }}>
166
+ <label className={css.fieldLabel}>起始章</label>
167
+ <input className={css.input} type="number" min={1} value={startNo} onChange={e => { setStartNo(Math.max(1, Number(e.target.value) || 1)) }} />
168
+ </div>
169
+ <div className={css.field} style={{ flex: 'none', minWidth: 90 }}>
170
+ <label className={css.fieldLabel}>结束章</label>
171
+ <input className={css.input} type="number" min={1} value={endNo} onChange={e => { setEndNo(Math.max(1, Number(e.target.value) || 1)) }} />
172
+ </div>
173
+ </>
174
+ )}
175
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || (run?.status === 'running')} onClick={() => { void handleStart() }} title="启动生产单">
176
+ {run?.status === 'running' ? '生产中…' : run?.status === 'paused' ? '▶ 继续' : '▶ 下单生产'}
177
+ </button>
178
+ {run !== null && run.status !== 'done' && (
179
+ <>
180
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || run.status !== 'running'} onClick={() => { void handleControl('pause') }}>
181
+ ⏸ 暂停
182
+ </button>
183
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || run.status === 'stopped'} onClick={() => { void handleControl('stop') }}>
184
+ ⏹ 停止
185
+ </button>
186
+ </>
187
+ )}
188
+ {run !== null && run.status === 'paused' && (
189
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleControl('resume') }}>
190
+ ▶ 继续
191
+ </button>
192
+ )}
193
+ </div>
194
+ </>
195
+ )}
196
+
197
+ {err !== '' && <div style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-12)' }}>{err}</div>}
198
+
199
+ {/* 进度区 */}
200
+ {run !== null && (
201
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
202
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
203
+ <span className={css.meta}>{run.bookName !== undefined && run.bookName !== '' ? `本书:《${run.bookName}》 · ` : ''}范围:第 {run.startNo} - {run.endNo} 章 · 当前:第 {run.currentNo} 章</span>
204
+ <span className={css.meta}>新生成 {run.stats?.generated ?? 0} · 修订通过 {run.stats?.revised ?? 0} · 豁免 {run.stats?.exempted ?? 0} · 重生成 {run.stats?.regenerated ?? 0} · 失败 {run.stats?.error ?? 0}</span>
205
+ </div>
206
+ <div className={css.bigProgressBar}>
207
+ <div className={css.bigProgressBarFill} style={{ width: `${Math.round(ratio * 100)}%` }} />
208
+ </div>
209
+ {run.pendingManual.length > 0 && (
210
+ <div className={css.meta} style={{ color: 'var(--nf-warn)' }}>
211
+ 待人工:第 {run.pendingManual.join('、')} 章(两轮修订仍不过,保留草稿,可在章节列表处理)
212
+ </div>
213
+ )}
214
+ {run.status === 'done' && <div className={css.meta} style={{ color: 'var(--nf-success)' }}> 生产单完成:{run.startNo}-{run.endNo} 章处理完毕,待人工 {run.pendingManual.length} 章。</div>}
215
+ {run.error !== undefined && <div className={css.meta} style={{ color: 'var(--nf-error)' }}>异常:{run.error}</div>}
216
+ </div>
217
+ )}
218
+
219
+ {/* 日志区(终端风) */}
220
+ <div className={`${css.meta} ${css.titleBlock}`} style={{ gap: 0 }}>
221
+ <span className={css.eyebrow}>Run Log</span>
222
+ <span className={css.cardTitle}>运行日志({run?.log.length ?? 0})</span>
223
+ </div>
224
+ <div ref={logRef} className={css.runLog}>
225
+ {run === null || run.log.length === 0 ? (
226
+ <span className={css.meta}>尚无日志——下单后这里会实时显示每章进度。</span>
227
+ ) : run.log.map((l, i) => (
228
+ <div key={i} className={css.runLogLine}>
229
+ <span className={css.runLogTime}>{new Date(l.at).toLocaleTimeString('zh-CN', { hour12: false })}</span>
230
+ <span>{l.text}</span>
231
+ </div>
232
+ ))}
233
+ </div>
234
+ </div>
235
+ )
236
+ }