@waterwx/dsh-novel-forge 1.7.0 → 1.7.2

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.
Files changed (36) hide show
  1. package/lib/client.js +2537 -1858
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +657 -45
  4. package/lib/index.js.map +1 -1
  5. package/lib/types/client/api.d.ts +2 -0
  6. package/lib/types/client/locales.d.ts +0 -1
  7. package/lib/types/client/panel/FlowGuide.d.ts +21 -0
  8. package/lib/types/client/panel/MangaRoleLibrary.d.ts +18 -0
  9. package/lib/types/client/panel/MangaWorkspace.d.ts +2 -2
  10. package/lib/types/client/panel/StoryboardTab.d.ts +5 -1
  11. package/lib/types/engine.d.ts +15 -8
  12. package/lib/types/protocol.d.ts +124 -0
  13. package/package.json +2 -1
  14. package/src/client/api.ts +5 -0
  15. package/src/client/locales.ts +0 -1
  16. package/src/client/panel/AssetsTab.tsx +22 -22
  17. package/src/client/panel/AssistantTab.tsx +3 -3
  18. package/src/client/panel/BookshelfBar.tsx +1 -1
  19. package/src/client/panel/CreateBookView.tsx +10 -10
  20. package/src/client/panel/FlowGuide.tsx +83 -0
  21. package/src/client/panel/MangaRoleLibrary.tsx +810 -0
  22. package/src/client/panel/MangaWorkspace.tsx +359 -297
  23. package/src/client/panel/NovelPanel.tsx +107 -107
  24. package/src/client/panel/ReaderView.tsx +4 -4
  25. package/src/client/panel/RunPanel.tsx +8 -8
  26. package/src/client/panel/SceneLibrary.tsx +68 -67
  27. package/src/client/panel/StoryboardTab.tsx +105 -41
  28. package/src/client/panel/StyleCard.tsx +13 -13
  29. package/src/client/panel/StyleGallery.tsx +2 -2
  30. package/src/client/panel/WorldTab.tsx +5 -5
  31. package/src/client/panel/panel.module.css +861 -510
  32. package/src/client/panel/views.tsx +24 -24
  33. package/src/engine.ts +482 -51
  34. package/src/protocol.ts +125 -0
  35. package/src/routes.ts +203 -0
  36. package/src/client/panel/RoleVisualPanel.tsx +0 -485
@@ -1,297 +1,359 @@
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
- )
297
- }
1
+ /**
2
+ * 漫剧工作台(方案X:9 步页容器):
3
+ * 顶部常驻:方案切换器 + 全流程步骤条(唯一导航);
4
+ * 主体按步骤渲染独立页面:①创建方案 ②视觉规则 ③剧情骨架 ④分镜表 ⑤导入角色 ⑥角色定妆 ⑦视频提示词 ⑧场景库 ⑨导出使用。
5
+ * 每步页面只显示自己的内容;前置不足显示明确提示,不静默降级、不夹带别区内容。
6
+ */
7
+ import { useMemo, useState } from 'react'
8
+ import type { NovelApi } from '../api.ts'
9
+ import type { ChapterPlan, ImageModelConfig, MangaPlan, ProjectState } from '../../protocol.ts'
10
+ import { STYLE_CATEGORIES, STYLE_LIBRARY, findStyle, stylesByCategory, type StyleCategory } from '../../style-library.ts'
11
+ import { StyleCard } from './StyleCard.tsx'
12
+ import { StoryboardTab } from './StoryboardTab.tsx'
13
+ import { MangaRoleLibrary } from './MangaRoleLibrary.tsx'
14
+ import { SceneLibrary } from './SceneLibrary.tsx'
15
+ import { FlowGuide, type FlowTarget } from './FlowGuide.tsx'
16
+ import css from './panel.module.css'
17
+
18
+ /** 步骤页编号:1建方案 2视觉规则 3骨架 4分镜表 5导入 6定妆 7提示词 8场景 9导出。 */
19
+ type StepView = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
20
+
21
+ const FLOW_TARGET_TO_VIEW: Record<FlowTarget, StepView> = {
22
+ plan: 1,
23
+ rules: 2,
24
+ skeleton: 3,
25
+ table: 4,
26
+ import: 5,
27
+ makeup: 6,
28
+ prompts: 7,
29
+ scenes: 8,
30
+ export: 9,
31
+ }
32
+
33
+ export function MangaWorkspace({
34
+ api,
35
+ project,
36
+ chapters,
37
+ onProjectChanged,
38
+ imageApiEnabled,
39
+ imageModels,
40
+ onProgress,
41
+ }: {
42
+ api: NovelApi
43
+ project: ProjectState | null
44
+ chapters: ChapterPlan[]
45
+ /** 方案/资产变更已持久化后触发(刷新项目)。 */
46
+ onProjectChanged?: () => void | Promise<void>
47
+ /** 是否启用生图(漫剧卡出定妆图)。 */
48
+ imageApiEnabled?: boolean
49
+ /** 生图模型库(出定妆图时可选择模型)。 */
50
+ imageModels?: ImageModelConfig[]
51
+ /** 上报到「AI进度」控制台(漫剧工作台内所有 LLM/方案操作)。 */
52
+ onProgress?: (text: string, kind?: 'info' | 'done' | 'error') => void
53
+ }) {
54
+ const plans = useMemo(() => project?.mangaPlans ?? [], [project?.mangaPlans])
55
+ const activePlan = plans.find(p => p.active) ?? null
56
+
57
+ const [view, setView] = useState<StepView>(3)
58
+ const [busy, setBusy] = useState(false)
59
+ const [error, setError] = useState('')
60
+
61
+ // 风格选择表单(第①步页)
62
+ const [cat, setCat] = useState<StyleCategory>('3d')
63
+ const [selStyle, setSelStyle] = useState('')
64
+ const [selFilter, setSelFilter] = useState('')
65
+ const [planName, setPlanName] = useState('')
66
+
67
+ // 无方案时强制停在创建方案页。
68
+ const effectiveView: StepView = plans.length === 0 ? 1 : view
69
+
70
+ const baseStyles = useMemo(() => STYLE_LIBRARY.filter(s => s.stackable !== true).sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0)), [])
71
+ const filterStyles = useMemo(() => STYLE_LIBRARY.filter(s => s.stackable === true), [])
72
+ const activeStyle = activePlan !== null ? findStyle(activePlan.styleId) : undefined
73
+ const activeFilter = activePlan?.filterId !== undefined ? findStyle(activePlan.filterId) : undefined
74
+
75
+ const refresh = async (): Promise<void> => { await onProjectChanged?.() }
76
+
77
+ const mutate = async (req: import('../../protocol.ts').MangaPlansRequest): Promise<boolean> => {
78
+ const opLabel = req.op === 'create' ? '创建漫剧方案「' + (req.name ?? '') + '」' : req.op === 'activate' ? '切换漫剧方案' : '删除漫剧方案'
79
+ setBusy(true)
80
+ setError('')
81
+ onProgress?.(opLabel + '…')
82
+ try {
83
+ await api.manhuaPlans(req)
84
+ onProgress?.(opLabel + ' 完成', 'done')
85
+ return true
86
+ } catch (err) {
87
+ const m = (err as Error).message
88
+ setError(m)
89
+ onProgress?.(opLabel + ' 失败:' + m, 'error')
90
+ return false
91
+ } finally {
92
+ setBusy(false)
93
+ }
94
+ }
95
+
96
+ const createPlan = async (): Promise<void> => {
97
+ const n = planName.trim()
98
+ if (n === '' || selStyle === '') {
99
+ setError('请选择基底风格并填写方案名')
100
+ return
101
+ }
102
+ const ok = await mutate({ op: 'create', name: n, styleId: selStyle, filterId: selFilter !== '' ? selFilter : undefined })
103
+ if (ok) {
104
+ setPlanName('')
105
+ setSelStyle('')
106
+ setSelFilter('')
107
+ await refresh()
108
+ setView(2)
109
+ }
110
+ }
111
+
112
+ const pickStyle = (id: string): void => {
113
+ setSelStyle(id)
114
+ if (planName.trim() === '') {
115
+ const s = findStyle(id)
116
+ setPlanName((project?.bookName ?? '本书') + ' · ' + (s?.name ?? '') + '版')
117
+ }
118
+ }
119
+
120
+ /** 流程第②步:从道藏提炼视觉规则(注入所有提示词)。 */
121
+ const extractRules = async (): Promise<void> => {
122
+ setBusy(true)
123
+ setError('')
124
+ onProgress?.('从道藏提炼视觉规则…')
125
+ try {
126
+ const r = await api.visualRules({ op: 'extract' })
127
+ await refresh()
128
+ onProgress?.('已提炼 ' + r.rules.length + ' 条视觉规则(已注入所有提示词)', 'done')
129
+ } catch (err) {
130
+ const m = (err as Error).message
131
+ setError(m)
132
+ onProgress?.('提炼视觉规则失败:' + m, 'error')
133
+ } finally {
134
+ setBusy(false)
135
+ }
136
+ }
137
+
138
+ /** 全流程步骤条导航:步骤 → 对应页面。 */
139
+ const navigateFlow = (target: FlowTarget): void => {
140
+ setError('')
141
+ setView(FLOW_TARGET_TO_VIEW[target])
142
+ }
143
+
144
+ /** 分镜「下一步」回调:1骨架→3、2分镜表→4、3提示词→7(对应步骤页编号)。 */
145
+ const goStep = (n: 1 | 2 | 3): void => {
146
+ setView(n === 1 ? 3 : n === 2 ? 4 : 7)
147
+ }
148
+
149
+ if (project === null) {
150
+ return <div className={css.card}><span className={css.meta}>请先开书或选择一本书,再进入漫剧工作台。</span></div>
151
+ }
152
+
153
+ const rules = project.visualRules ?? []
154
+ const anchoredCards = (project.mangaRoles ?? []).filter(c => c.status === 'anchored')
155
+
156
+ return (
157
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
158
+ {/* 顶部常驻:方案切换器 + 全流程步骤条(唯一导航) */}
159
+ <div className={css.card}>
160
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
161
+ <span className={css.cardTitle}>🎬 {activePlan?.name ?? '漫剧工作台'}</span>
162
+ <div className={css.row}>
163
+ <span className={css.badge}>🎨 {activeStyle?.name ?? activePlan?.styleId}</span>
164
+ {activeFilter !== undefined && <span className={css.badge}>+{activeFilter.name}</span>}
165
+ <select
166
+ className={css.input}
167
+ style={{ width: 'auto', padding: 'var(--nf-space-4) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)', borderRadius: 'var(--nf-radius-8)' }}
168
+ value={activePlan?.id ?? ''}
169
+ onChange={async e => { if (await mutate({ op: 'activate', id: e.target.value })) await refresh() }}
170
+ >
171
+ {plans.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
172
+ </select>
173
+ <button type="button" className={css.button + ' ' + css.buttonSmall} style={{ minWidth: 84 }} onClick={() => { setSelStyle(''); setPlanName(''); setView(1) }}>+ 新建</button>
174
+ <button
175
+ type="button"
176
+ className={css.button + ' ' + css.buttonSmall}
177
+ disabled={busy}
178
+ onClick={async () => {
179
+ if (activePlan === null) return
180
+ if (!window.confirm('删除方案「' + activePlan.name + '」?该方案的分镜产出不受影响(骨架共享)。')) return
181
+ if (await mutate({ op: 'remove', id: activePlan.id })) {
182
+ await refresh()
183
+ setView(1)
184
+ }
185
+ }}
186
+ >
187
+ 🗑 删除当前方案
188
+ </button>
189
+ </div>
190
+ </div>
191
+ <FlowGuide project={project} onNavigate={navigateFlow} />
192
+ </div>
193
+
194
+ {error !== '' && <div className={css.importError}>{error}</div>}
195
+
196
+ {/* ① 创建方案页 */}
197
+ {effectiveView === 1 && (
198
+ <div className={css.card}>
199
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
200
+ <span className={css.cardTitle}>① 创建方案 · 选择视觉风格</span>
201
+ {plans.length > 0 && <button type="button" className={css.button} disabled={busy} onClick={() => { setView(3) }}>返回工作台</button>}
202
+ </div>
203
+ <span className={css.meta}>选一个基底风格(影视类可叠加滤镜)并命名。此后所有提示词按此风格生成。</span>
204
+
205
+ <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap', margin: '10px 0' }}>
206
+ {STYLE_CATEGORIES.map(c => (
207
+ <button key={c.id} type="button" className={css.button + (cat === c.id ? ' ' + css.buttonPrimary : '')} style={{ flex: 1, minWidth: 120 }} onClick={() => { setCat(c.id) }}>
208
+ {c.icon} {c.label}({stylesByCategory(c.id).length})
209
+ </button>
210
+ ))}
211
+ </div>
212
+ <div className={css.meta}>{STYLE_CATEGORIES.find(c => c.id === cat)?.desc}</div>
213
+
214
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 'var(--nf-space-12)', margin: '10px 0' }}>
215
+ {stylesByCategory(cat).map(s => (
216
+ <StyleCard key={s.id} style={s} selected={selStyle === s.id} onClick={() => { pickStyle(s.id) }} />
217
+ ))}
218
+ </div>
219
+
220
+ {filterStyles.length > 0 && (
221
+ <div className={css.field}>
222
+ <label className={css.fieldLabel}>叠加滤镜(可选)</label>
223
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
224
+ <button type="button" className={css.button + ' ' + css.buttonSmall + (selFilter === '' ? ' ' + css.buttonPrimary : '')} onClick={() => { setSelFilter('') }}>无</button>
225
+ {filterStyles.map(s => (
226
+ <button key={s.id} type="button" className={css.button + ' ' + css.buttonSmall + (selFilter === s.id ? ' ' + css.buttonPrimary : '')} onClick={() => { setSelFilter(s.id) }}>{s.name}</button>
227
+ ))}
228
+ </div>
229
+ </div>
230
+ )}
231
+
232
+ <div className={css.field}>
233
+ <label className={css.fieldLabel}>方案名</label>
234
+ <input className={css.input} value={planName} placeholder="例如:《保质期》3D 皮克斯版" onChange={e => { setPlanName(e.target.value) }} />
235
+ </div>
236
+ <div className={css.row}>
237
+ <button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy || selStyle === '' || planName.trim() === ''} onClick={() => { void createPlan() }}>
238
+ {busy ? '创建中…' : '🎬 创建方案'}
239
+ </button>
240
+ <span className={css.meta}>{selStyle !== '' ? '已选:' + (findStyle(selStyle)?.name ?? selStyle) : '点击卡片选择基底风格'}</span>
241
+ </div>
242
+ </div>
243
+ )}
244
+
245
+ {/* ② 视觉规则页 */}
246
+ {effectiveView === 2 && (
247
+ <div className={css.card}>
248
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
249
+ <span className={css.cardTitle}>② 视觉规则</span>
250
+ <button
251
+ type="button"
252
+ className={css.button + ' ' + css.buttonSmall}
253
+ disabled={busy}
254
+ onClick={() => { void extractRules() }}
255
+ title="从道藏提炼 3-6 条视觉世界观规则,自动注入所有生图/生视频提示词"
256
+ >
257
+ {busy ? '提炼中…' : '⚠️ 提炼视觉规则(从道藏)'}
258
+ </button>
259
+ </div>
260
+ <span className={css.meta} style={{ display: 'block', marginTop: 'var(--nf-space-4)' }}>
261
+ AI 从道藏提炼 3-6 条「画面必须遵守」的视觉世界观规则(如:货架上的商品都是活人),自动注入分镜表、角色锚点、视频提示词等所有提示词。
262
+ </span>
263
+ {rules.length > 0 ? (
264
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-4)', marginTop: 'var(--nf-space-8)' }}>
265
+ <span className={css.meta} style={{ fontSize: 'var(--nf-fs-12)' }}>已内嵌到所有提示词:</span>
266
+ {rules.map((r, i) => (
267
+ <span key={i} style={{ border: '1px solid var(--nf-warn, #b8860b)', borderRadius: 'var(--nf-radius-999)', padding: '0 var(--nf-space-8)', fontSize: 'var(--nf-fs-12)', color: 'var(--nf-warn, #b8860b)' }}>{r}</span>
268
+ ))}
269
+ </div>
270
+ ) : (
271
+ <span className={css.meta} style={{ marginTop: 'var(--nf-space-4)' }}>还没提炼——点上方按钮生成第一条视觉规则。</span>
272
+ )}
273
+ <div className={css.row} style={{ marginTop: 'var(--nf-space-10)' }}>
274
+ <button type="button" className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonPrimary} onClick={() => { setView(3) }}>下一步:③ 剧情骨架</button>
275
+ </div>
276
+ </div>
277
+ )}
278
+
279
+ {/* ③④⑦ 分镜步骤页(同一实例,mode 切换) */}
280
+ {(effectiveView === 3 || effectiveView === 4 || effectiveView === 7) && (
281
+ <StoryboardTab
282
+ api={api}
283
+ project={project}
284
+ chapters={chapters}
285
+ onProjectChanged={onProjectChanged}
286
+ styleId={activePlan?.styleId}
287
+ filterId={activePlan?.filterId}
288
+ mode={effectiveView === 3 ? 'skeleton' : effectiveView === 4 ? 'table' : 'prompts'}
289
+ onGoStep={goStep}
290
+ onProgress={onProgress}
291
+ />
292
+ )}
293
+
294
+ {/* ⑤⑥ 角色页(同一实例,focus 切换) */}
295
+ {(effectiveView === 5 || effectiveView === 6) && (
296
+ <MangaRoleLibrary
297
+ api={api}
298
+ project={project}
299
+ refresh={() => refresh()}
300
+ styleId={activePlan?.styleId}
301
+ filterId={activePlan?.filterId}
302
+ imageApiEnabled={imageApiEnabled}
303
+ imageModels={imageModels}
304
+ focus={effectiveView === 5 ? 'import' : 'cards'}
305
+ showCards={effectiveView !== 5}
306
+ onProgress={onProgress}
307
+ />
308
+ )}
309
+
310
+ {/* ⑧ 场景库页 */}
311
+ {effectiveView === 8 && (
312
+ <SceneLibrary
313
+ api={api}
314
+ project={project}
315
+ refresh={() => refresh()}
316
+ styleId={activePlan?.styleId}
317
+ filterId={activePlan?.filterId}
318
+ onProgress={onProgress}
319
+ />
320
+ )}
321
+
322
+ {/* ⑨ 导出使用页 */}
323
+ {effectiveView === 9 && (
324
+ <div className={css.card}>
325
+ <span className={css.cardTitle}>⑨ 导出使用</span>
326
+ <span className={css.meta} style={{ display: 'block', marginTop: 'var(--nf-space-4)' }}>
327
+ 把视频提示词复制到外部工具(即梦 / 豆包 / ComfyUI)出图生视频:
328
+ </span>
329
+ <ol style={{ margin: '6px 0 0', paddingLeft: 'var(--nf-space-20)', fontSize: 'var(--nf-fs-12)', lineHeight: 1.9, color: 'var(--nf-text)' }}>
330
+ <li>去「⑦ 视频提示词」页复制——每条已带风格词块、视觉规则与定妆绑定标注</li>
331
+ <li>出图/生视频时,按镜头绑定的漫剧卡取定妆参考图(IP-Adapter 类方案锁脸锁服装)</li>
332
+ <li>场景卡与视觉规则已内嵌在提示词里,无需额外粘贴</li>
333
+ </ol>
334
+ <div className={css.row} style={{ marginTop: 'var(--nf-space-10)', flexWrap: 'wrap' }}>
335
+ <button type="button" className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonPrimary} onClick={() => { setView(7) }}>去复制视频提示词</button>
336
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { setView(6) }}>查看定妆卡</button>
337
+ </div>
338
+ {anchoredCards.length > 0 && (
339
+ <div style={{ marginTop: 'var(--nf-space-10)' }}>
340
+ <span className={css.meta}>已定妆角色({anchoredCards.length}):</span>
341
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)', marginTop: 'var(--nf-space-6)' }}>
342
+ {anchoredCards.map(c => (
343
+ <div key={c.id} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', alignItems: 'center', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-6)' }}>
344
+ {c.imageUrl !== undefined ? (
345
+ <img src={c.imageUrl} alt={c.name} style={{ maxHeight: 90, borderRadius: 'var(--nf-radius-6)' }} />
346
+ ) : (
347
+ <span style={{ fontSize: 'var(--nf-fs-12)', color: 'var(--nf-text-dim, #888)' }}>(无定妆图)</span>
348
+ )}
349
+ <span style={{ fontSize: 'var(--nf-fs-12)' }}>{c.name}</span>
350
+ </div>
351
+ ))}
352
+ </div>
353
+ </div>
354
+ )}
355
+ </div>
356
+ )}
357
+ </div>
358
+ )
359
+ }