@waterwx/dsh-novel-forge 1.5.0 → 1.7.0

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,185 +1,297 @@
1
- /**
2
- * 漫剧工作台:方案管理(基底风格 + 可选滤镜)→ 分镜(骨架/分镜表,按方案风格生成)。
3
- * 骨架为故事层(所有方案共享);分镜表画面措辞随方案风格。
4
- */
5
- import { useMemo, useState } from 'react'
6
- import type { NovelApi } from '../api.ts'
7
- import type { ChapterPlan, MangaPlan, ProjectState } from '../../protocol.ts'
8
- import { STYLE_LIBRARY, findStyle } from '../../style-library.ts'
9
- import { StoryboardTab } from './StoryboardTab.tsx'
10
- import css from './panel.module.css'
11
-
12
- export function MangaWorkspace({
13
- api,
14
- project,
15
- chapters,
16
- onProjectChanged,
17
- }: {
18
- api: NovelApi
19
- project: ProjectState | null
20
- chapters: ChapterPlan[]
21
- /** 方案变更已持久化后触发(刷新项目)。 */
22
- onProjectChanged?: () => void | Promise<void>
23
- }) {
24
- const plans = useMemo(() => project?.mangaPlans ?? [], [project?.mangaPlans])
25
- const activePlan = plans.find(p => p.active) ?? null
26
- const [showCreate, setShowCreate] = useState(false)
27
- const [name, setName] = useState('')
28
- const [styleId, setStyleId] = useState('')
29
- const [filterId, setFilterId] = useState('')
30
- const [busy, setBusy] = useState(false)
31
- const [error, setError] = useState('')
32
-
33
- // 基底候选:非 stackable 风格按 weight 降序(推荐池),+ 全部基底风格
34
- const baseStyles = useMemo(() => STYLE_LIBRARY.filter(s => s.stackable !== true).sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0)), [])
35
- const filterStyles = useMemo(() => STYLE_LIBRARY.filter(s => s.stackable === true), [])
36
-
37
- const mutate = async (req: import('../../protocol.ts').MangaPlansRequest): Promise<boolean> => {
38
- setBusy(true)
39
- setError('')
40
- try {
41
- await api.manhuaPlans(req)
42
- return true
43
- } catch (err) {
44
- setError((err as Error).message)
45
- return false
46
- } finally {
47
- setBusy(false)
48
- }
49
- }
50
-
51
- const createPlan = async (): Promise<void> => {
52
- const n = name.trim()
53
- if (n === '' || styleId === '') {
54
- setError('请填写方案名并选择基底风格')
55
- return
56
- }
57
- const ok = await mutate({ op: 'create', name: n, styleId, filterId: filterId !== '' ? filterId : undefined })
58
- if (ok) {
59
- setShowCreate(false)
60
- setName('')
61
- setStyleId('')
62
- setFilterId('')
63
- await refreshProject()
64
- }
65
- }
66
-
67
- const refreshProject = async (): Promise<void> => {
68
- await onProjectChanged?.()
69
- }
70
-
71
- if (project === null) {
72
- return <div className={css.card}><span className={css.meta}>请先开书或选择一本书,再进入漫剧工作台。</span></div>
73
- }
74
-
75
- return (
76
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
77
- {/* ① 方案管理 */}
78
- <div className={css.card}>
79
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
80
- <span className={css.cardTitle}>🎬 漫剧方案</span>
81
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { setShowCreate(!showCreate) }}>
82
- {showCreate ? '收起' : '+ 新建方案'}
83
- </button>
84
- </div>
85
- <span className={css.meta}>同一本书可建多套视觉演绎(基底风格 + 可选滤镜);分镜画面措辞随激活方案。</span>
86
-
87
- {showCreate && (
88
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8, border: '1px solid var(--nf-info)', borderRadius: 12, padding: 10, marginTop: 8 }}>
89
- <div className={css.field}>
90
- <label className={css.fieldLabel}>方案名</label>
91
- <input className={css.input} value={name} placeholder="例如:《保质期》3D 皮克斯版" onChange={e => { setName(e.target.value) }} />
92
- </div>
93
- <div className={css.field}>
94
- <label className={css.fieldLabel}>基底风格(按推荐度排序)</label>
95
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
96
- {baseStyles.map(s => (
97
- <button
98
- key={s.id}
99
- type="button"
100
- className={`${css.button} ${css.buttonSmall} ${styleId === s.id ? css.buttonPrimary : ''}`}
101
- title={s.traits}
102
- onClick={() => { setStyleId(s.id) }}
103
- >
104
- {s.name}
105
- </button>
106
- ))}
107
- </div>
108
- </div>
109
- {filterStyles.length > 0 && (
110
- <div className={css.field}>
111
- <label className={css.fieldLabel}>叠加滤镜(可选)</label>
112
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
113
- <button type="button" className={`${css.button} ${css.buttonSmall} ${filterId === '' ? css.buttonPrimary : ''}`} onClick={() => { setFilterId('') }}>无</button>
114
- {filterStyles.map(s => (
115
- <button
116
- key={s.id}
117
- type="button"
118
- className={`${css.button} ${css.buttonSmall} ${filterId === s.id ? css.buttonPrimary : ''}`}
119
- title={s.traits}
120
- onClick={() => { setFilterId(s.id) }}
121
- >
122
- {s.name}
123
- </button>
124
- ))}
125
- </div>
126
- </div>
127
- )}
128
- {error !== '' && <div className={css.importError}>{error}</div>}
129
- <div className={css.row}>
130
- <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || name.trim() === '' || styleId === ''} onClick={() => { void createPlan() }}>
131
- 创建方案
132
- </button>
133
- </div>
134
- </div>
135
- )}
136
-
137
- {plans.length === 0 ? (
138
- <div className={css.meta} style={{ marginTop: 8 }}>尚无漫剧方案——新建一个方案,选择基底风格开始。</div>
139
- ) : (
140
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
141
- {plans.map(p => {
142
- const style = findStyle(p.styleId)
143
- const filter = p.filterId !== undefined ? findStyle(p.filterId) : undefined
144
- return (
145
- <div
146
- key={p.id}
147
- style={{
148
- display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
149
- border: `1px solid ${p.active ? 'var(--nf-accent)' : 'var(--nf-border)'}`, borderRadius: 10, padding: '8px 10px',
150
- }}
151
- >
152
- <b>{p.name}</b>
153
- <span className={css.badge}>{style?.name ?? p.styleId}</span>
154
- {filter !== undefined && <span className={css.badge}>+{filter.name}</span>}
155
- {p.active && <span className={`${css.badge} ${css.badgeDone}`}>激活中</span>}
156
- <div style={{ flex: 1 }} />
157
- {!p.active && (
158
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={async () => { if (await mutate({ op: 'activate', id: p.id })) await refreshProject() }}>
159
- 激活
160
- </button>
161
- )}
162
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={async () => { if (await mutate({ op: 'remove', id: p.id })) await refreshProject() }}>
163
- 删除
164
- </button>
165
- </div>
166
- )
167
- })}
168
- </div>
169
- )}
170
- </div>
171
-
172
- {/* ② 分镜(按激活方案风格) */}
173
- {plans.length === 0 ? null : (
174
- <StoryboardTab
175
- api={api}
176
- project={project}
177
- chapters={chapters}
178
- onProjectChanged={onProjectChanged}
179
- styleId={activePlan?.styleId}
180
- filterId={activePlan?.filterId}
181
- />
182
- )}
183
- </div>
184
- )
1
+ /**
2
+ * 漫剧工作台:
3
+ * 状态A(无方案)→ 风格选择页(风格库内嵌,创建方案)
4
+ * 状态B(有方案)→ 主工作页:方案切换器 + ②角色 | ③场景 | ④分镜 三导航
5
+ */
6
+ import { useMemo, useState } from 'react'
7
+ import type { NovelApi } from '../api.ts'
8
+ import type { ChapterPlan, ImageModelConfig, MangaPlan, ProjectState, RoleRecord } from '../../protocol.ts'
9
+ import { STYLE_CATEGORIES, STYLE_LIBRARY, findStyle, stylesByCategory, type StyleCategory } from '../../style-library.ts'
10
+ import { StyleCard } from './StyleCard.tsx'
11
+ import { StoryboardTab } from './StoryboardTab.tsx'
12
+ import { RoleVisualPanel } from './RoleVisualPanel.tsx'
13
+ import { SceneLibrary } from './SceneLibrary.tsx'
14
+ import css from './panel.module.css'
15
+
16
+ export function MangaWorkspace({
17
+ api,
18
+ project,
19
+ chapters,
20
+ onProjectChanged,
21
+ imageApiEnabled,
22
+ imageModels,
23
+ onProgress,
24
+ }: {
25
+ api: NovelApi
26
+ project: ProjectState | null
27
+ chapters: ChapterPlan[]
28
+ /** 方案/资产变更已持久化后触发(刷新项目)。 */
29
+ onProjectChanged?: () => void | Promise<void>
30
+ /** 是否启用生图。 */
31
+ imageApiEnabled?: boolean
32
+ /** 生图模型库(AI 生图可选模型)。 */
33
+ imageModels?: ImageModelConfig[]
34
+ /** 上报到「AI进度」控制台(漫剧工作台内所有 LLM/方案操作)。 */
35
+ onProgress?: (text: string, kind?: 'info' | 'done' | 'error') => void
36
+ }) {
37
+ const plans = useMemo(() => project?.mangaPlans ?? [], [project?.mangaPlans])
38
+ const activePlan = plans.find(p => p.active) ?? null
39
+
40
+ // 状态A:风格选择页
41
+ const [showCreate, setShowCreate] = useState(false)
42
+ const [cat, setCat] = useState<StyleCategory>('3d')
43
+ const [selStyle, setSelStyle] = useState('')
44
+ const [selFilter, setSelFilter] = useState('')
45
+ const [planName, setPlanName] = useState('')
46
+
47
+ // 状态B:主工作页
48
+ const [section, setSection] = useState<'roles' | 'scenes' | 'storyboard'>('roles')
49
+ const [busy, setBusy] = useState(false)
50
+ const [error, setError] = useState('')
51
+ const [roleCands, setRoleCands] = useState<RoleRecord[] | null>(null)
52
+
53
+ const baseStyles = useMemo(() => STYLE_LIBRARY.filter(s => s.stackable !== true).sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0)), [])
54
+ const filterStyles = useMemo(() => STYLE_LIBRARY.filter(s => s.stackable === true), [])
55
+ const activeStyle = activePlan !== null ? findStyle(activePlan.styleId) : undefined
56
+ const activeFilter = activePlan?.filterId !== undefined ? findStyle(activePlan.filterId) : undefined
57
+
58
+ const refresh = async (): Promise<void> => { await onProjectChanged?.() }
59
+
60
+ const mutate = async (req: import('../../protocol.ts').MangaPlansRequest): Promise<boolean> => {
61
+ const opLabel = req.op === 'create' ? '创建漫剧方案「' + (req.name ?? '') + '」' : req.op === 'activate' ? '切换漫剧方案' : '删除漫剧方案'
62
+ setBusy(true)
63
+ setError('')
64
+ onProgress?.(opLabel + '…')
65
+ try {
66
+ await api.manhuaPlans(req)
67
+ onProgress?.(opLabel + ' 完成', 'done')
68
+ return true
69
+ } catch (err) {
70
+ const m = (err as Error).message
71
+ setError(m)
72
+ onProgress?.(opLabel + ' 失败:' + m, 'error')
73
+ return false
74
+ } finally {
75
+ setBusy(false)
76
+ }
77
+ }
78
+
79
+ const createPlan = async (): Promise<void> => {
80
+ const n = planName.trim()
81
+ if (n === '' || selStyle === '') {
82
+ setError('请选择基底风格并填写方案名')
83
+ return
84
+ }
85
+ const ok = await mutate({ op: 'create', name: n, styleId: selStyle, filterId: selFilter !== '' ? selFilter : undefined })
86
+ if (ok) {
87
+ setShowCreate(false)
88
+ setPlanName('')
89
+ setSelStyle('')
90
+ setSelFilter('')
91
+ setSection('roles')
92
+ await refresh()
93
+ }
94
+ }
95
+
96
+ const pickStyle = (id: string): void => {
97
+ setSelStyle(id)
98
+ if (planName.trim() === '') {
99
+ const s = findStyle(id)
100
+ setPlanName(`${project?.bookName ?? '本书'} · ${s?.name ?? ''}版`)
101
+ }
102
+ }
103
+
104
+ // 角色区操作
105
+ const extractRoles = async (): Promise<void> => {
106
+ if (activePlan === null) return
107
+ setBusy(true); setError('')
108
+ onProgress?.('按当前方案提炼角色…')
109
+ try {
110
+ const r = await api.roles({ op: 'extract', styleId: activePlan.styleId, filterId: activePlan.filterId })
111
+ setRoleCands(r.candidates ?? [])
112
+ onProgress?.('角色提炼完成:' + (r.candidates ?? []).length + ' 个候选', 'done')
113
+ } catch (err) {
114
+ const m = (err as Error).message
115
+ setError(m)
116
+ onProgress?.('角色提炼失败:' + m, 'error')
117
+ } finally { setBusy(false) }
118
+ }
119
+ const adoptRole = async (r: RoleRecord): Promise<void> => {
120
+ setBusy(true); setError('')
121
+ onProgress?.('采纳角色「' + r.name + '」…')
122
+ try {
123
+ await api.roles({ op: 'adopt', role: r })
124
+ setRoleCands(prev => (prev ?? []).filter(x => x.name !== r.name))
125
+ await refresh()
126
+ onProgress?.('已采纳角色「' + r.name + '」', 'done')
127
+ } catch (err) {
128
+ const m = (err as Error).message
129
+ setError(m)
130
+ onProgress?.('采纳角色「' + r.name + '」失败:' + m, 'error')
131
+ } finally { setBusy(false) }
132
+ }
133
+ const genRoleVisual = async (name: string): Promise<void> => {
134
+ if (activePlan === null) return
135
+ setBusy(true); setError('')
136
+ onProgress?.('生成「' + name + '」形象锚点(按当前方案风格)…')
137
+ try {
138
+ await api.roles({ op: 'visual', name, styleId: activePlan.styleId, filterId: activePlan.filterId })
139
+ await refresh()
140
+ onProgress?.('已生成「' + name + '」形象锚点(按当前方案风格)', 'done')
141
+ } catch (err) {
142
+ const m = (err as Error).message
143
+ setError(m)
144
+ onProgress?.('生成「' + name + '」形象锚点失败:' + m, 'error')
145
+ } finally { setBusy(false) }
146
+ }
147
+
148
+ if (project === null) {
149
+ return <div className={css.card}><span className={css.meta}>请先开书或选择一本书,再进入漫剧工作台。</span></div>
150
+ }
151
+
152
+ /* ============ 状态A:风格选择页 ============ */
153
+ if (showCreate || plans.length === 0) {
154
+ const list = stylesByCategory(cat)
155
+ return (
156
+ <div className={css.card}>
157
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
158
+ <span className={css.cardTitle}>🎬 漫剧工作台 · 选择视觉风格</span>
159
+ {plans.length > 0 && <button type="button" className={css.button} disabled={busy} onClick={() => { setShowCreate(false); setSection('roles') }}>返回工作台</button>}
160
+ </div>
161
+ <span className={css.meta}>选一个基底风格(影视类可作滤镜叠加),创建方案后进入 角色 / 场景 / 分镜。</span>
162
+
163
+ <div className={css.row} style={{ gap: 8, flexWrap: 'wrap', margin: '10px 0' }}>
164
+ {STYLE_CATEGORIES.map(c => (
165
+ <button key={c.id} type="button" className={`${css.button} ${cat === c.id ? css.buttonPrimary : ''}`} style={{ flex: 1, minWidth: 120 }} onClick={() => { setCat(c.id) }}>
166
+ {c.icon} {c.label}({stylesByCategory(c.id).length})
167
+ </button>
168
+ ))}
169
+ </div>
170
+ <div className={css.meta}>{STYLE_CATEGORIES.find(c => c.id === cat)?.desc}</div>
171
+
172
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12, margin: '10px 0' }}>
173
+ {list.map(s => (
174
+ <StyleCard key={s.id} style={s} selected={selStyle === s.id} onClick={() => { pickStyle(s.id) }} />
175
+ ))}
176
+ </div>
177
+
178
+ {filterStyles.length > 0 && (
179
+ <div className={css.field}>
180
+ <label className={css.fieldLabel}>叠加滤镜(可选)</label>
181
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
182
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${selFilter === '' ? css.buttonPrimary : ''}`} onClick={() => { setSelFilter('') }}>无</button>
183
+ {filterStyles.map(s => (
184
+ <button key={s.id} type="button" className={`${css.button} ${css.buttonSmall} ${selFilter === s.id ? css.buttonPrimary : ''}`} onClick={() => { setSelFilter(s.id) }}>{s.name}</button>
185
+ ))}
186
+ </div>
187
+ </div>
188
+ )}
189
+
190
+ <div className={css.field}>
191
+ <label className={css.fieldLabel}>方案名</label>
192
+ <input className={css.input} value={planName} placeholder="例如:《保质期》3D 皮克斯版" onChange={e => { setPlanName(e.target.value) }} />
193
+ </div>
194
+ {error !== '' && <div className={css.importError}>{error}</div>}
195
+ <div className={css.row}>
196
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || selStyle === '' || planName.trim() === ''} onClick={() => { void createPlan() }}>
197
+ {busy ? '创建中…' : '🎬 创建方案'}
198
+ </button>
199
+ <span className={css.meta}>{selStyle !== '' ? `已选:${findStyle(selStyle)?.name ?? selStyle}` : '点击卡片选择基底风格'}</span>
200
+ </div>
201
+ </div>
202
+ )
203
+ }
204
+
205
+ /* ============ 状态B:主工作页 ============ */
206
+ return (
207
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
208
+ {/* 方案切换器 */}
209
+ <div className={css.card}>
210
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
211
+ <span className={css.cardTitle}>🎬 {activePlan?.name ?? '漫剧工作台'}</span>
212
+ <div className={css.row}>
213
+ <span className={css.badge}>🎨 {activeStyle?.name ?? activePlan?.styleId}</span>
214
+ {activeFilter !== undefined && <span className={css.badge}>+{activeFilter.name}</span>}
215
+ <select
216
+ className={css.input}
217
+ style={{ width: 'auto', padding: '4px 8px' }}
218
+ value={activePlan?.id ?? ''}
219
+ onChange={async e => { if (await mutate({ op: 'activate', id: e.target.value })) await refresh() }}
220
+ >
221
+ {plans.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
222
+ </select>
223
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setShowCreate(true); setSelStyle(''); setPlanName('') }}>+ 新建</button>
224
+ <button
225
+ type="button"
226
+ className={`${css.button} ${css.buttonSmall}`}
227
+ disabled={busy}
228
+ onClick={async () => {
229
+ if (activePlan === null) return
230
+ if (!window.confirm(`删除方案「${activePlan.name}」?该方案的分镜产出不受影响(骨架共享)。`)) return
231
+ if (await mutate({ op: 'remove', id: activePlan.id })) {
232
+ setSection('roles')
233
+ await refresh()
234
+ }
235
+ }}
236
+ >
237
+ 🗑 删除当前方案
238
+ </button>
239
+ </div>
240
+ </div>
241
+ {/* 三导航 */}
242
+ <div className={css.row} style={{ gap: 6, marginTop: 8, flexWrap: 'wrap' }}>
243
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${section === 'roles' ? css.buttonPrimary : ''}`} onClick={() => { setSection('roles') }}>
244
+ 🖼️ 角色({(project.roles ?? []).length})
245
+ </button>
246
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${section === 'scenes' ? css.buttonPrimary : ''}`} onClick={() => { setSection('scenes') }}>
247
+ 🏞️ 场景({(project.scenes ?? []).length})
248
+ </button>
249
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${section === 'storyboard' ? css.buttonPrimary : ''}`} onClick={() => { setSection('storyboard') }}>
250
+ 🎬 分镜
251
+ </button>
252
+ </div>
253
+ </div>
254
+
255
+ {error !== '' && <div className={css.importError}>{error}</div>}
256
+
257
+ {/* ② 角色 */}
258
+ {section === 'roles' && (
259
+ <RoleVisualPanel
260
+ api={api}
261
+ project={project}
262
+ refresh={() => refresh()}
263
+ styleId={activePlan?.styleId}
264
+ filterId={activePlan?.filterId}
265
+ imageApiEnabled={imageApiEnabled}
266
+ imageModels={imageModels}
267
+ onProgress={onProgress}
268
+ />
269
+ )}
270
+
271
+ {/* ③ 场景(完整版场景库:提炼/采纳/编辑/图集/详情浮窗) */}
272
+ {section === 'scenes' && (
273
+ <SceneLibrary
274
+ api={api}
275
+ project={project}
276
+ refresh={() => refresh()}
277
+ styleId={activePlan?.styleId}
278
+ filterId={activePlan?.filterId}
279
+ onProgress={onProgress}
280
+ />
281
+ )}
282
+
283
+ {/* ④ 分镜 */}
284
+ {section === 'storyboard' && (
285
+ <StoryboardTab
286
+ api={api}
287
+ project={project}
288
+ chapters={chapters}
289
+ onProjectChanged={onProjectChanged}
290
+ styleId={activePlan?.styleId}
291
+ filterId={activePlan?.filterId}
292
+ onProgress={onProgress}
293
+ />
294
+ )}
295
+ </div>
296
+ )
185
297
  }