@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
@@ -0,0 +1,810 @@
1
+ /**
2
+ * 漫剧角色库(制作角色投影):
3
+ * 从本集分镜提名 → 小说角色库匹配(规则过滤 + LLM 确认两段式)→ 导入建卡。
4
+ * 独立存储于 project.mangaRoles;sourceRoleName 只读追溯,禁止反向写回小说库。
5
+ */
6
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
7
+ import type { NovelApi } from '../api.ts'
8
+ import type { ImageModelConfig, MangaRoleCandidate, MangaRoleCard, ProjectState } from '../../protocol.ts'
9
+ import css from './panel.module.css'
10
+
11
+ const CORE_FUNCTIONS: Array<{ id: MangaRoleCard['coreFunction']; label: string }> = [
12
+ { id: 'protagonist', label: '主角' },
13
+ { id: 'antagonist', label: '反派' },
14
+ { id: 'love_interest', label: '感情线' },
15
+ { id: 'mentor', label: '导师' },
16
+ { id: 'sidekick', label: '搭档' },
17
+ { id: 'informant', label: '线人' },
18
+ { id: 'functional', label: '功能性' },
19
+ ]
20
+
21
+ const RELATIONS: Array<{ id: MangaRoleCard['protagonistRelation']; label: string }> = [
22
+ { id: 'enemy', label: '敌对' },
23
+ { id: 'friend', label: '友方' },
24
+ { id: 'mentor', label: '师徒' },
25
+ { id: 'lover', label: '情感' },
26
+ { id: 'exploit', label: '利用' },
27
+ { id: 'neutral', label: '中立' },
28
+ ]
29
+
30
+ const STATUS_LABEL: Record<MangaRoleCard['status'], string> = {
31
+ pending_match: '待匹配',
32
+ pending_confirm: '待确认',
33
+ imported: '已导入',
34
+ anchored: '已定妆',
35
+ archived: '已归档',
36
+ }
37
+
38
+ const STATUS_CLASS: Record<MangaRoleCard['status'], string> = {
39
+ pending_match: css.badgePending,
40
+ pending_confirm: css.badgePending,
41
+ imported: css.badgeWritten,
42
+ anchored: css.badgeDone,
43
+ archived: css.badgePending,
44
+ }
45
+
46
+ interface Draft {
47
+ name: string
48
+ identity: string
49
+ coreFunction: MangaRoleCard['coreFunction']
50
+ protagonistRelation: MangaRoleCard['protagonistRelation']
51
+ speechStyle: string
52
+ traits: string
53
+ appearance: string
54
+ keyScenes: string
55
+ }
56
+
57
+ const emptyDraft = (): Draft => ({
58
+ name: '',
59
+ identity: '',
60
+ coreFunction: 'functional',
61
+ protagonistRelation: 'neutral',
62
+ speechStyle: '',
63
+ traits: '',
64
+ appearance: '',
65
+ keyScenes: '',
66
+ })
67
+
68
+ const draftFromSuggested = (s: MangaRoleCandidate['suggested']): Draft => ({
69
+ name: s.name,
70
+ identity: s.identity,
71
+ coreFunction: s.coreFunction,
72
+ protagonistRelation: s.protagonistRelation,
73
+ speechStyle: s.speechStyle,
74
+ traits: s.traits.join('、'),
75
+ appearance: s.appearance,
76
+ keyScenes: s.keyScenes.join(';'),
77
+ })
78
+
79
+ const draftFromCard = (c: MangaRoleCard): Draft => ({
80
+ name: c.name,
81
+ identity: c.identity,
82
+ coreFunction: c.coreFunction,
83
+ protagonistRelation: c.protagonistRelation,
84
+ speechStyle: c.speechStyle,
85
+ traits: c.traits.join('、'),
86
+ appearance: c.appearance,
87
+ keyScenes: c.keyScenes.join(';'),
88
+ })
89
+
90
+ /** 草稿 → 漫剧卡(保留已有形象资产;新建时 id 为空由服务端分配)。 */
91
+ const draftToCard = (d: Draft, sourceRoleName: string | undefined, chapterNo: number | undefined, existing?: MangaRoleCard): MangaRoleCard => {
92
+ const now = new Date().toISOString()
93
+ const traits = d.traits.split(/[、,,]/).map(t => t.trim()).filter(t => t !== '').slice(0, 3)
94
+ const keyScenes = d.keyScenes.split(/[;;]/).map(k => k.trim()).filter(k => k !== '').slice(0, 5)
95
+ const episodes = existing !== undefined
96
+ ? (chapterNo !== undefined && chapterNo > 0 && !existing.appearsInEpisodes.includes(chapterNo)
97
+ ? [...existing.appearsInEpisodes, chapterNo].sort((a, b) => a - b)
98
+ : existing.appearsInEpisodes)
99
+ : (chapterNo !== undefined && chapterNo > 0 ? [chapterNo] : [])
100
+ return {
101
+ id: existing?.id ?? '',
102
+ sourceRoleName,
103
+ name: d.name.trim(),
104
+ identity: d.identity.trim(),
105
+ coreFunction: d.coreFunction,
106
+ protagonistRelation: d.protagonistRelation,
107
+ speechStyle: d.speechStyle.trim(),
108
+ traits,
109
+ appearance: d.appearance.trim(),
110
+ keyScenes,
111
+ appearsInEpisodes: episodes,
112
+ status: existing?.status ?? 'imported',
113
+ imagePrompt: existing?.imagePrompt,
114
+ expressions: existing?.expressions,
115
+ promptKit: existing?.promptKit,
116
+ imageUrl: existing?.imageUrl,
117
+ gallery: existing?.gallery,
118
+ promptStyleId: existing?.promptStyleId,
119
+ createdAt: existing?.createdAt ?? now,
120
+ updatedAt: now,
121
+ }
122
+ }
123
+
124
+ export function MangaRoleLibrary({
125
+ api,
126
+ project,
127
+ refresh,
128
+ styleId,
129
+ filterId,
130
+ imageApiEnabled,
131
+ imageModels,
132
+ focus,
133
+ showCards,
134
+ onProgress,
135
+ }: {
136
+ api: NovelApi
137
+ project: ProjectState | null
138
+ refresh: () => void | Promise<void>
139
+ styleId?: string
140
+ filterId?: string
141
+ /** 是否启用生图(豆包等出定妆图)。 */
142
+ imageApiEnabled?: boolean
143
+ /** 生图模型库(出定妆图时可选择用哪条)。 */
144
+ imageModels?: ImageModelConfig[]
145
+ /** 步骤页聚焦:import=展开并滚动到导入区;cards=滚动到已建漫剧卡列表。 */
146
+ focus?: 'import' | 'cards'
147
+ /** 是否显示「已建漫剧卡」列表(第⑤步导入页可隐藏,只留导入区)。 */
148
+ showCards?: boolean
149
+ onProgress?: (text: string, kind?: 'info' | 'done' | 'error') => void
150
+ }) {
151
+ const cards = useMemo(() => project?.mangaRoles ?? [], [project?.mangaRoles])
152
+ /** 已生成过分镜的已写章节(提名入口按章选择)。 */
153
+ const sbChapters = useMemo(() => {
154
+ const withSb = new Set((project?.storyboards ?? []).map(e => e.chapterNo))
155
+ return (project?.chapters ?? [])
156
+ .filter(c => withSb.has(c.no) && c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error')
157
+ .sort((a, b) => a.no - b.no)
158
+ }, [project?.storyboards, project?.chapters])
159
+
160
+ const [busy, setBusy] = useState(false)
161
+ const [actingId, setActingId] = useState('')
162
+ const [error, setError] = useState('')
163
+ const [notice, setNotice] = useState('')
164
+ const [importOpen, setImportOpen] = useState(false)
165
+ const [chapterNo, setChapterNo] = useState<number>(0)
166
+ const [candidates, setCandidates] = useState<MangaRoleCandidate[] | null>(null)
167
+ const [drafts, setDrafts] = useState<Record<string, Draft>>({})
168
+ const [chosen, setChosen] = useState<Record<string, string>>({})
169
+ const [createOpen, setCreateOpen] = useState(false)
170
+ const [createDraft, setCreateDraft] = useState<Draft>(emptyDraft())
171
+ const [editId, setEditId] = useState<string | null>(null)
172
+ const [editDraft, setEditDraft] = useState<Draft | null>(null)
173
+ /** 定妆图上传目标卡 id(null=未打开)。 */
174
+ const [uploadTarget, setUploadTarget] = useState<string | null>(null)
175
+ const [uploadLabel, setUploadLabel] = useState('立绘')
176
+ /** 豆包生成定妆图时选择的模型条目 id('' = 用设置中启用的模型)。 */
177
+ const [imageModelId, setImageModelId] = useState('')
178
+ const fileInputRef = useRef<HTMLInputElement | null>(null)
179
+ /** 步骤页滚动目标(第⑤步导入区 / 第⑥步卡片列表)。 */
180
+ const headerCardRef = useRef<HTMLDivElement | null>(null)
181
+ const cardsCardRef = useRef<HTMLDivElement | null>(null)
182
+
183
+ // 步骤页聚焦:变化时展开/滚动到对应区域。
184
+ useEffect(() => {
185
+ if (focus === 'import') {
186
+ setImportOpen(true)
187
+ window.setTimeout(() => { headerCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, 60)
188
+ } else if (focus === 'cards') {
189
+ setImportOpen(false)
190
+ window.setTimeout(() => { cardsCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, 60)
191
+ }
192
+ }, [focus])
193
+
194
+ const notify = (msg: string): void => { setNotice(msg); setTimeout(() => { setNotice('') }, 4000) }
195
+ const report = (text: string, kind: 'info' | 'done' | 'error' = 'info'): void => { onProgress?.(text, kind) }
196
+ const setDraft = (key: string, patch: Partial<Draft>): void => {
197
+ setDrafts(prev => ({ ...prev, [key]: { ...(prev[key] ?? emptyDraft()), ...patch } }))
198
+ }
199
+
200
+ const runNominate = async (): Promise<void> => {
201
+ if (chapterNo <= 0) return
202
+ setBusy(true)
203
+ setError('')
204
+ report('第' + chapterNo + '章 漫剧角色提名中…')
205
+ try {
206
+ const res = await api.mangaRoles({ op: 'nominate', chapterNo })
207
+ setCandidates(res.candidates ?? null)
208
+ const d: Record<string, Draft> = {}
209
+ const c: Record<string, string> = {}
210
+ for (const cand of res.candidates ?? []) {
211
+ if (cand.verdict === 'already_imported') continue
212
+ d[cand.rawName] = draftFromSuggested(cand.suggested)
213
+ if (cand.matchedRoleName !== undefined) c[cand.rawName] = cand.matchedRoleName
214
+ }
215
+ setDrafts(d)
216
+ setChosen(c)
217
+ report('第' + chapterNo + '章 漫剧角色提名完成(' + (res.candidates ?? []).length + ' 个候选)', 'done')
218
+ } catch (err) {
219
+ const m = (err as Error).message
220
+ setError(m)
221
+ report('第' + chapterNo + '章 漫剧角色提名失败:' + m, 'error')
222
+ } finally {
223
+ setBusy(false)
224
+ }
225
+ }
226
+
227
+ const adoptCandidate = async (cand: MangaRoleCandidate): Promise<void> => {
228
+ const d = drafts[cand.rawName]
229
+ if (d === undefined || d.name.trim() === '') {
230
+ setError('角色名不能为空')
231
+ return
232
+ }
233
+ const source = cand.verdict === 'matched' ? (chosen[cand.rawName] ?? cand.matchedRoleName) : undefined
234
+ setBusy(true)
235
+ setError('')
236
+ report('导入漫剧角色「' + d.name.trim() + '」…')
237
+ try {
238
+ const card = draftToCard(d, source, chapterNo)
239
+ await api.mangaRoles({ op: 'adopt', card })
240
+ setCandidates(prev => (prev ?? []).filter(x => x.rawName !== cand.rawName))
241
+ await refresh()
242
+ report('已导入漫剧角色「' + d.name.trim() + '」', 'done')
243
+ notify('已导入「' + d.name.trim() + '」——可继续生成形象锚点')
244
+ } catch (err) {
245
+ const m = (err as Error).message
246
+ setError(m)
247
+ report('导入漫剧角色失败:' + m, 'error')
248
+ } finally {
249
+ setBusy(false)
250
+ }
251
+ }
252
+
253
+ const genVisual = async (id: string, name: string): Promise<void> => {
254
+ setActingId(id)
255
+ setError('')
256
+ report('生成「' + name + '」形象锚点…')
257
+ try {
258
+ await api.mangaRoles({ op: 'visual', id, styleId, filterId })
259
+ await refresh()
260
+ report('已生成「' + name + '」形象锚点', 'done')
261
+ notify('「' + name + '」已定妆(锚点+精修包已写入)')
262
+ } catch (err) {
263
+ const m = (err as Error).message
264
+ setError(m)
265
+ report('「' + name + '」形象锚点生成失败:' + m, 'error')
266
+ } finally {
267
+ setActingId('')
268
+ }
269
+ }
270
+
271
+ const genKit = async (id: string, name: string): Promise<void> => {
272
+ setActingId(id)
273
+ setError('')
274
+ report('精修「' + name + '」四类生图提示词…')
275
+ try {
276
+ await api.mangaRoles({ op: 'promptKit', id, styleId, filterId })
277
+ await refresh()
278
+ report('已精修「' + name + '」提示词包', 'done')
279
+ } catch (err) {
280
+ const m = (err as Error).message
281
+ setError(m)
282
+ report('「' + name + '」提示词精修失败:' + m, 'error')
283
+ } finally {
284
+ setActingId('')
285
+ }
286
+ }
287
+
288
+ const removeCard = async (c: MangaRoleCard): Promise<void> => {
289
+ if (!window.confirm('删除漫剧角色「' + c.name + '」?只删漫剧卡,不影响小说角色库与分镜。')) return
290
+ setActingId(c.id)
291
+ setError('')
292
+ try {
293
+ await api.mangaRoles({ op: 'remove', id: c.id })
294
+ await refresh()
295
+ report('已删除漫剧角色「' + c.name + '」', 'done')
296
+ } catch (err) {
297
+ const m = (err as Error).message
298
+ setError(m)
299
+ report('删除漫剧角色失败:' + m, 'error')
300
+ } finally {
301
+ setActingId('')
302
+ }
303
+ }
304
+
305
+ /** 定妆图上传:文件 → dataURL → op=image(带标签进图集,立绘同步为参考图)。 */
306
+ const onUploadFile = async (cardId: string, file: File | null): Promise<void> => {
307
+ if (file === null) return
308
+ if (!file.type.startsWith('image/')) {
309
+ setError('请选择图片文件')
310
+ return
311
+ }
312
+ const dataUrl = await new Promise<string>((resolve, reject) => {
313
+ const reader = new FileReader()
314
+ reader.onload = () => resolve(String(reader.result ?? ''))
315
+ reader.onerror = () => reject(new Error('读取图片失败'))
316
+ reader.readAsDataURL(file)
317
+ })
318
+ setActingId(cardId)
319
+ setError('')
320
+ const label = uploadLabel.trim() !== '' ? uploadLabel.trim() : '立绘'
321
+ report('上传「' + label + '」定妆图…')
322
+ try {
323
+ await api.mangaRoles({ op: 'image', id: cardId, dataUrl, label })
324
+ setUploadTarget(null)
325
+ await refresh()
326
+ report('已上传「' + label + '」定妆图', 'done')
327
+ } catch (err) {
328
+ const m = (err as Error).message
329
+ setError(m)
330
+ report('定妆图上传失败:' + m, 'error')
331
+ } finally {
332
+ setActingId('')
333
+ }
334
+ }
335
+
336
+ const removeImage = async (c: MangaRoleCard, label: string): Promise<void> => {
337
+ setActingId(c.id)
338
+ setError('')
339
+ try {
340
+ await api.mangaRoles({ op: 'removeImage', id: c.id, label: label !== '' ? label : undefined })
341
+ await refresh()
342
+ report('已移除「' + c.name + '」的' + (label !== '' ? label + '图' : '参考图'), 'done')
343
+ } catch (err) {
344
+ const m = (err as Error).message
345
+ setError(m)
346
+ report('移除定妆图失败:' + m, 'error')
347
+ } finally {
348
+ setActingId('')
349
+ }
350
+ }
351
+
352
+ /** 豆包等生图接口按形象锚点出定妆图(写回 imageUrl)。 */
353
+ const genReferenceImage = async (c: MangaRoleCard): Promise<void> => {
354
+ setActingId(c.id)
355
+ setError('')
356
+ report('生成「' + c.name + '」定妆图…')
357
+ try {
358
+ await api.mangaRoles({ op: 'imageGenerate', id: c.id, modelId: imageModelId !== '' ? imageModelId : undefined })
359
+ await refresh()
360
+ report('已生成「' + c.name + '」定妆图', 'done')
361
+ } catch (err) {
362
+ const m = (err as Error).message
363
+ setError(m)
364
+ report('定妆图生成失败:' + m, 'error')
365
+ } finally {
366
+ setActingId('')
367
+ }
368
+ }
369
+
370
+ /** 短剧精简模式开关(5-8 上镜角色 / 功能标签 / 关系闭环 / 人设极致化)。 */
371
+ const toggleShortDrama = async (): Promise<void> => {
372
+ setBusy(true)
373
+ setError('')
374
+ const next = project?.shortDramaMode !== true
375
+ report(next ? '开启短剧精简模式…' : '关闭短剧精简模式…')
376
+ try {
377
+ await api.mangaRoles({ op: 'mode', shortDramaMode: next })
378
+ await refresh()
379
+ report(next ? '已开启短剧精简模式' : '已关闭短剧精简模式', 'done')
380
+ } catch (err) {
381
+ const m = (err as Error).message
382
+ setError(m)
383
+ report('切换短剧精简模式失败:' + m, 'error')
384
+ } finally {
385
+ setBusy(false)
386
+ }
387
+ }
388
+
389
+ const createCard = async (): Promise<void> => {
390
+ if (createDraft.name.trim() === '') {
391
+ setError('角色名不能为空')
392
+ return
393
+ }
394
+ setBusy(true)
395
+ setError('')
396
+ report('新建漫剧角色「' + createDraft.name.trim() + '」…')
397
+ try {
398
+ const card = draftToCard(createDraft, undefined, chapterNo > 0 ? chapterNo : undefined)
399
+ await api.mangaRoles({ op: 'adopt', card })
400
+ setCreateOpen(false)
401
+ setCreateDraft(emptyDraft())
402
+ await refresh()
403
+ report('已新建漫剧角色「' + card.name + '」', 'done')
404
+ } catch (err) {
405
+ const m = (err as Error).message
406
+ setError(m)
407
+ report('新建漫剧角色失败:' + m, 'error')
408
+ } finally {
409
+ setBusy(false)
410
+ }
411
+ }
412
+
413
+ const startEdit = (c: MangaRoleCard): void => {
414
+ setEditId(c.id)
415
+ setEditDraft(draftFromCard(c))
416
+ setError('')
417
+ }
418
+
419
+ const saveEdit = async (): Promise<void> => {
420
+ if (editId === null || editDraft === null) return
421
+ if (editDraft.name.trim() === '') {
422
+ setError('角色名不能为空')
423
+ return
424
+ }
425
+ const existing = cards.find(c => c.id === editId)
426
+ if (existing === undefined) return
427
+ setBusy(true)
428
+ setError('')
429
+ try {
430
+ const card = draftToCard(editDraft, existing.sourceRoleName, undefined, existing)
431
+ await api.mangaRoles({ op: 'update', card })
432
+ setEditId(null)
433
+ setEditDraft(null)
434
+ await refresh()
435
+ report('已更新漫剧角色「' + card.name + '」', 'done')
436
+ } catch (err) {
437
+ const m = (err as Error).message
438
+ setError(m)
439
+ report('更新漫剧角色失败:' + m, 'error')
440
+ } finally {
441
+ setBusy(false)
442
+ }
443
+ }
444
+
445
+ /** 漫剧卡通用编辑字段(提名候选 / 新建 / 编辑共用)。 */
446
+ const draftFields = (d: Draft, onChange: (patch: Partial<Draft>) => void): ReactNode => (
447
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 'var(--nf-space-6)' }}>
448
+ <div className={css.field}>
449
+ <label className={css.fieldLabel}>漫剧用名</label>
450
+ <input className={css.input} value={d.name} onChange={e => onChange({ name: e.target.value })} placeholder="可短剧化改名" />
451
+ </div>
452
+ <div className={css.field}>
453
+ <label className={css.fieldLabel}>身份一句话</label>
454
+ <input className={css.input} value={d.identity} onChange={e => onChange({ identity: e.target.value })} placeholder="如:超市收银员" />
455
+ </div>
456
+ <div className={css.field}>
457
+ <label className={css.fieldLabel}>核心功能</label>
458
+ <select className={css.input} value={d.coreFunction} onChange={e => onChange({ coreFunction: e.target.value as MangaRoleCard['coreFunction'] })}>
459
+ {CORE_FUNCTIONS.map(f => <option key={f.id} value={f.id}>{f.label}</option>)}
460
+ </select>
461
+ </div>
462
+ <div className={css.field}>
463
+ <label className={css.fieldLabel}>与主角关系</label>
464
+ <select className={css.input} value={d.protagonistRelation} onChange={e => onChange({ protagonistRelation: e.target.value as MangaRoleCard['protagonistRelation'] })}>
465
+ {RELATIONS.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
466
+ </select>
467
+ </div>
468
+ <div className={css.field}>
469
+ <label className={css.fieldLabel}>性格标签(≤3,顿号分隔)</label>
470
+ <input className={css.input} value={d.traits} onChange={e => onChange({ traits: e.target.value })} />
471
+ </div>
472
+ <div className={css.field}>
473
+ <label className={css.fieldLabel}>辨识度外貌点</label>
474
+ <input className={css.input} value={d.appearance} onChange={e => onChange({ appearance: e.target.value })} />
475
+ </div>
476
+ <div className={css.field}>
477
+ <label className={css.fieldLabel}>口头禅/说话方式</label>
478
+ <input className={css.input} value={d.speechStyle} onChange={e => onChange({ speechStyle: e.target.value })} />
479
+ </div>
480
+ <div className={css.field}>
481
+ <label className={css.fieldLabel}>关键剧情节点(;分隔)</label>
482
+ <input className={css.input} value={d.keyScenes} onChange={e => onChange({ keyScenes: e.target.value })} placeholder="第3章 对峙;第11章 真相" />
483
+ </div>
484
+ </div>
485
+ )
486
+
487
+ const shortDrama = project?.shortDramaMode === true
488
+ const presentFunctions = new Set(cards.map(c => c.coreFunction))
489
+ const presentRelations = new Set(cards.map(c => c.protagonistRelation))
490
+
491
+ if (project === null) {
492
+ return <div className={css.card}><span className={css.meta}>请先开书或选择一本书,再进入漫剧角色库。</span></div>
493
+ }
494
+
495
+ return (
496
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
497
+ <div ref={headerCardRef} className={css.card}>
498
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
499
+ <span className={css.cardTitle}>🎭 漫剧角色库</span>
500
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
501
+ <button
502
+ type="button"
503
+ className={css.button + ' ' + css.buttonSmall + (shortDrama ? ' ' + css.buttonPrimary : '')}
504
+ disabled={busy}
505
+ onClick={() => { void toggleShortDrama() }}
506
+ title="短剧精简:漫剧角色库按 5-8 上镜角色 / 功能标签 / 关系闭环 / 人设极致化约束"
507
+ >
508
+ 📺 短剧精简模式:{shortDrama ? '开' : '关'}
509
+ </button>
510
+ <button
511
+ type="button"
512
+ className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonPrimary}
513
+ disabled={busy}
514
+ onClick={() => {
515
+ setImportOpen(!importOpen)
516
+ if (chapterNo === 0 && sbChapters[0] !== undefined) setChapterNo(sbChapters[0].no)
517
+ setError('')
518
+ }}
519
+ >
520
+ 📥 从本集分镜导入角色
521
+ </button>
522
+ <button
523
+ type="button"
524
+ className={css.button + ' ' + css.buttonSmall}
525
+ disabled={busy}
526
+ onClick={() => { setCreateOpen(!createOpen); setError('') }}
527
+ >
528
+ + 直接新建漫剧卡
529
+ </button>
530
+ </div>
531
+ </div>
532
+ <span className={css.meta}>漫剧角色库 = 上镜角色(要建模/出图/锁一致性)。由分镜提名导入,独立于小说角色库;来源只读追溯、不回写。</span>
533
+ <span className={css.meta} style={{ display: 'block', marginTop: 'var(--nf-space-2)' }}>流程位置:第⑤⑥步 · 前置:分镜已生成(提名来自分镜 characters)· 下一步:回「分镜」生成视频提示词(重新生成后自动带定妆绑定)</span>
534
+ {styleId !== undefined && (
535
+ <span className={css.meta} style={{ display: 'block', marginTop: 'var(--nf-space-4)' }}>
536
+ 🎨 当前方案:{styleId}(锚点/精修按此风格生成)
537
+ </span>
538
+ )}
539
+ {shortDrama && (
540
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', marginTop: 'var(--nf-space-8)', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)' }}>
541
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
542
+ <b>📺 短剧精简检查</b>
543
+ <span className={css.badge + (cards.length >= 5 && cards.length <= 8 ? ' ' + css.badgeDone : ' ' + css.badgePending)}>
544
+ 上镜角色 {cards.length}/5-8
545
+ </span>
546
+ {cards.length > 8 && <span className={css.meta}>⚠ 超员:建议把功能性/低戏份角色归档或删除</span>}
547
+ {cards.length < 5 && cards.length > 0 && <span className={css.meta}>提示:5 人以下张力偏弱,可补关键角色</span>}
548
+ </div>
549
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
550
+ <span className={css.meta}>功能覆盖:</span>
551
+ {CORE_FUNCTIONS.filter(f => f.id !== 'functional').map(f => (
552
+ <span key={f.id} className={css.badge + (presentFunctions.has(f.id) ? ' ' + css.badgeDone : ' ' + css.badgePending)}>
553
+ {f.label}{presentFunctions.has(f.id) ? '' : '(缺)'}
554
+ </span>
555
+ ))}
556
+ </div>
557
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
558
+ <span className={css.meta}>关系闭环(与主角):</span>
559
+ {RELATIONS.map(r => (
560
+ <span key={r.id} className={css.badge + (presentRelations.has(r.id) ? ' ' + css.badgeDone : ' ' + css.badgePending)}>
561
+ {r.label}{presentRelations.has(r.id) ? '' : '(缺)'}
562
+ </span>
563
+ ))}
564
+ </div>
565
+ <span className={css.meta}>人设极致化:提名只保留上镜角色;锚点/精修已注入「性格标签极致化、1-2 个最强辨识度外貌点」提示。</span>
566
+ </div>
567
+ )}
568
+ {error !== '' && <div className={css.importError}>{error}</div>}
569
+ {notice !== '' && <span className={css.meta} style={{ color: 'var(--nf-accent)' }}>{notice}</span>}
570
+
571
+ {importOpen && (
572
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', marginTop: 'var(--nf-space-10)', borderTop: '1px solid var(--nf-border)', paddingTop: 'var(--nf-space-10)' }}>
573
+ <div className={css.row} style={{ flexWrap: 'wrap', alignItems: 'flex-end' }}>
574
+ <div className={css.field} style={{ flex: 1, minWidth: 220 }}>
575
+ <label className={css.fieldLabel}>选择已生成分镜的章节</label>
576
+ <select
577
+ className={css.input}
578
+ value={chapterNo === 0 ? '' : chapterNo}
579
+ onChange={e => { setChapterNo(Number(e.target.value)); setCandidates(null); setError('') }}
580
+ >
581
+ {sbChapters.length === 0 && <option value="">(没有已生成分镜的章节)</option>}
582
+ {sbChapters.map(c => <option key={c.no} value={c.no}>第{c.no}章 {c.title}</option>)}
583
+ </select>
584
+ </div>
585
+ <button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy || chapterNo <= 0} onClick={() => { void runNominate() }}>
586
+ {busy ? '提名中…' : '🔍 提名角色(规则过滤 + LLM 确认)'}
587
+ </button>
588
+ </div>
589
+ <span className={css.meta}>流程:从本章分镜的结构化 characters 提名 → 与小说角色库匹配 → 确认后导入为漫剧卡。</span>
590
+
591
+ {candidates !== null && (
592
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', marginTop: 'var(--nf-space-6)' }}>
593
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
594
+ <b>提名候选({candidates.length})</b>
595
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { setCandidates(null) }}>收起</button>
596
+ </div>
597
+ {candidates.length === 0 && <span className={css.meta}>该章分镜的角色已全部导入漫剧角色库。</span>}
598
+ {candidates.map(cand => {
599
+ if (cand.verdict === 'already_imported') {
600
+ return (
601
+ <div key={cand.rawName} style={{ display: 'flex', gap: 'var(--nf-space-8)', alignItems: 'center', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-6) var(--nf-space-10)', opacity: 0.65 }}>
602
+ <b>{cand.rawName}</b>
603
+ <span className={css.badge + ' ' + css.badgeDone}>已导入</span>
604
+ </div>
605
+ )
606
+ }
607
+ const d = drafts[cand.rawName] ?? emptyDraft()
608
+ const isMatched = cand.verdict === 'matched'
609
+ const isAmbiguous = cand.verdict === 'ambiguous'
610
+ return (
611
+ <div key={cand.rawName} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)' }}>
612
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
613
+ <b>🎭 {cand.rawName}</b>
614
+ {isMatched && <span className={css.badge + ' ' + css.badgeDone}>✓ 匹配:{chosen[cand.rawName] ?? cand.matchedRoleName ?? ''}</span>}
615
+ {isAmbiguous && <span className={css.badge + ' ' + css.badgePending}>?多个候选待定</span>}
616
+ {cand.verdict === 'not_in_library' && cand.novelHint === 'backfill' && (
617
+ <span className={css.badge + ' ' + css.badgePending}>⚠ 小说库未收录(正文有此称谓)</span>
618
+ )}
619
+ {cand.verdict === 'not_in_library' && cand.novelHint !== 'backfill' && (
620
+ <span className={css.badge}>+ 漫剧新增角色</span>
621
+ )}
622
+ </div>
623
+ {(isMatched || isAmbiguous) && cand.matches.length > 1 && (
624
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
625
+ <span className={css.meta}>命中角色:</span>
626
+ <select
627
+ className={css.input}
628
+ style={{ width: 'auto', padding: 'var(--nf-space-4) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)' }}
629
+ value={chosen[cand.rawName] ?? ''}
630
+ onChange={e => { setChosen(prev => ({ ...prev, [cand.rawName]: e.target.value })) }}
631
+ >
632
+ {cand.matches.map(m => <option key={m.roleName} value={m.roleName}>{m.roleName}({m.reason})</option>)}
633
+ </select>
634
+ </div>
635
+ )}
636
+ {cand.verdict === 'not_in_library' && cand.novelHint === 'backfill' && (
637
+ <span className={css.meta}>
638
+ 提示:正文出现过「{cand.rawName}」但小说角色库没提炼到——可先去左侧「角色库」补提炼再重新提名;或直接以漫剧新增角色建卡。
639
+ </span>
640
+ )}
641
+ {draftFields(d, patch => { setDraft(cand.rawName, patch) })}
642
+ <div className={css.row}>
643
+ <button type="button" className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void adoptCandidate(cand) }}>
644
+ 📥 导入为漫剧卡
645
+ </button>
646
+ <span className={css.meta}>导入后可在下方卡片继续生成形象锚点</span>
647
+ </div>
648
+ </div>
649
+ )
650
+ })}
651
+ </div>
652
+ )}
653
+ </div>
654
+ )}
655
+
656
+ {createOpen && (
657
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', marginTop: 'var(--nf-space-10)', borderTop: '1px solid var(--nf-border)', paddingTop: 'var(--nf-space-10)' }}>
658
+ <b>+ 新建漫剧卡(不关联小说角色库)</b>
659
+ {draftFields(createDraft, patch => { setCreateDraft(prev => ({ ...prev, ...patch })) })}
660
+ <div className={css.row}>
661
+ <button type="button" className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void createCard() }}>
662
+ 💾 保存漫剧卡
663
+ </button>
664
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { setCreateOpen(false) }}>取消</button>
665
+ </div>
666
+ </div>
667
+ )}
668
+ </div>
669
+
670
+ {showCards !== false && (
671
+ <div ref={cardsCardRef} className={css.card}>
672
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
673
+ <b>已建漫剧卡({cards.length})</b>
674
+ <span className={css.meta}>{cards.filter(c => c.status === 'anchored').length} 张已定妆</span>
675
+ </div>
676
+ {cards.length === 0 && (
677
+ <div className={css.meta} style={{ marginTop: 'var(--nf-space-8)' }}>
678
+ 还没有漫剧角色卡。点上方「从本集分镜导入角色」从分镜提名导入,或「直接新建」。
679
+ </div>
680
+ )}
681
+ {cards.map(c => {
682
+ const editing = editId === c.id
683
+ const styleMismatch = styleId !== undefined && c.promptStyleId !== undefined && c.promptStyleId !== styleId
684
+ return (
685
+ <div key={c.id} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)', marginTop: 'var(--nf-space-8)' }}>
686
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
687
+ <b>🎭 {c.name}</b>
688
+ <span className={css.badge + ' ' + (STATUS_CLASS[c.status] ?? '')}>{STATUS_LABEL[c.status]}</span>
689
+ <span className={css.badge}>{CORE_FUNCTIONS.find(f => f.id === c.coreFunction)?.label ?? c.coreFunction}</span>
690
+ <span className={css.badge}>{RELATIONS.find(r => r.id === c.protagonistRelation)?.label ?? c.protagonistRelation}</span>
691
+ <span className={css.meta}>{c.appearsInEpisodes.length > 0 ? '上场:' + c.appearsInEpisodes.map(n => '第' + n + '集').join('、') : '尚未标记上场'}</span>
692
+ {c.sourceRoleName !== undefined && <span className={css.meta}>来源:{c.sourceRoleName}</span>}
693
+ </div>
694
+ {styleMismatch && (
695
+ <span className={css.meta} style={{ color: 'var(--nf-warn, #b45309)' }}>
696
+ ⚠ 提示词风格({c.promptStyleId})与当前方案({styleId})不一致,重生成锚点/精修可对齐。
697
+ </span>
698
+ )}
699
+ {editing && editDraft !== null ? (
700
+ <>
701
+ {draftFields(editDraft, patch => { setEditDraft(prev => (prev === null ? prev : { ...prev, ...patch })) })}
702
+ <div className={css.row}>
703
+ <button type="button" className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void saveEdit() }}>💾 保存</button>
704
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { setEditId(null); setEditDraft(null) }}>取消</button>
705
+ </div>
706
+ </>
707
+ ) : (
708
+ <>
709
+ {c.identity !== '' && <span>身份:{c.identity}</span>}
710
+ {c.traits.length > 0 && (
711
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
712
+ <span className={css.meta}>性格:</span>
713
+ {c.traits.map(t => <span key={t} className={css.badge}>{t}</span>)}
714
+ </div>
715
+ )}
716
+ {c.appearance !== '' && <span className={css.meta}>外貌点:{c.appearance}</span>}
717
+ {c.speechStyle !== '' && <span className={css.meta}>说话:{c.speechStyle}</span>}
718
+ {c.keyScenes.length > 0 && <span className={css.meta}>关键剧情:{c.keyScenes.join(';')}</span>}
719
+ {c.imagePrompt !== undefined && (
720
+ <div className={css.importPreview} style={{ marginTop: 'var(--nf-space-4)' }}>
721
+ <span className={css.meta}>🎨 锚点({c.imagePrompt.zh.length} 字):</span>
722
+ <span>{c.imagePrompt.zh.slice(0, 140)}{c.imagePrompt.zh.length > 140 ? '…' : ''}</span>
723
+ </div>
724
+ )}
725
+ {c.promptKit !== undefined && (
726
+ <span className={css.meta}>✨ 精修包:立绘/四视图/表情×{c.promptKit.expressions.length}/细节</span>
727
+ )}
728
+ {c.imageUrl !== undefined && (
729
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--nf-space-8)', alignItems: 'flex-start' }}>
730
+ <img src={c.imageUrl} alt={c.name + ' 定妆图'} style={{ maxHeight: 110, borderRadius: 'var(--nf-radius-8)', border: '1px solid var(--nf-border)' }} />
731
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)' }}>
732
+ <span className={css.meta}>🖼 参考图(定妆图,分镜出图时按卡绑定)</span>
733
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { void removeImage(c, '') }}>移除参考图</button>
734
+ </div>
735
+ </div>
736
+ )}
737
+ {c.gallery !== undefined && c.gallery.length > 0 && (
738
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
739
+ <span className={css.meta}>🖼 图集:</span>
740
+ {c.gallery.map(g => (
741
+ <span key={g.label} className={css.badge}>
742
+ {g.label}
743
+ <button type="button" style={{ marginLeft: 'var(--nf-space-4)', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--nf-text)' }} onClick={() => { void removeImage(c, g.label) }}>✕</button>
744
+ </span>
745
+ ))}
746
+ </div>
747
+ )}
748
+ {uploadTarget === c.id ? (
749
+ <div className={css.row} style={{ flexWrap: 'wrap', alignItems: 'flex-end' }}>
750
+ <div className={css.field} style={{ minWidth: 140 }}>
751
+ <label className={css.fieldLabel}>用途标签</label>
752
+ <input className={css.input} value={uploadLabel} onChange={e => { setUploadLabel(e.target.value) }} placeholder="立绘/四视图/表情-xx" />
753
+ </div>
754
+ <input
755
+ ref={fileInputRef}
756
+ type="file"
757
+ accept="image/*"
758
+ style={{ display: 'none' }}
759
+ onChange={e => { void onUploadFile(c.id, e.target.files?.[0] ?? null); e.target.value = '' }}
760
+ />
761
+ <button type="button" className={css.button + ' ' + css.buttonSmall} disabled={actingId === c.id} onClick={() => { fileInputRef.current?.click() }}>
762
+ {actingId === c.id ? '上传中…' : '📤 选择图片上传'}
763
+ </button>
764
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { setUploadTarget(null) }}>取消</button>
765
+ </div>
766
+ ) : (
767
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
768
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { setUploadTarget(c.id); setUploadLabel('立绘') }}>🖼 上传定妆图</button>
769
+ {(imageModels ?? []).length > 1 && (
770
+ <select
771
+ className={css.input}
772
+ style={{ width: 'auto', padding: 'var(--nf-space-4) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)' }}
773
+ value={imageModelId}
774
+ onChange={e => { setImageModelId(e.target.value) }}
775
+ >
776
+ <option value="">(默认生图模型)</option>
777
+ {imageModels?.map(m => <option key={m.id} value={m.id}>{m.name}</option>)}
778
+ </select>
779
+ )}
780
+ <button
781
+ type="button"
782
+ className={css.button + ' ' + css.buttonSmall}
783
+ disabled={actingId === c.id || c.imagePrompt === undefined || imageApiEnabled !== true}
784
+ title={c.imagePrompt === undefined ? '先生成形象锚点' : imageApiEnabled !== true ? '生图未启用(设置中配置)' : '按形象锚点出定妆图'}
785
+ onClick={() => { void genReferenceImage(c) }}
786
+ >
787
+ {actingId === c.id ? '生成中…' : '⚡ 生成定妆图'}
788
+ </button>
789
+ </div>
790
+ )}
791
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
792
+ <button type="button" className={css.button + ' ' + css.buttonSmall} disabled={actingId === c.id} onClick={() => { void genVisual(c.id, c.name) }}>
793
+ {actingId === c.id ? '生成中…' : '🎨 生成形象锚点'}
794
+ </button>
795
+ <button type="button" className={css.button + ' ' + css.buttonSmall} disabled={actingId === c.id || c.imagePrompt === undefined} onClick={() => { void genKit(c.id, c.name) }}>
796
+ ✨ 精修提示词
797
+ </button>
798
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { startEdit(c) }}>✏️ 编辑</button>
799
+ <button type="button" className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonDanger} disabled={actingId === c.id} onClick={() => { void removeCard(c) }}>🗑 删除</button>
800
+ </div>
801
+ </>
802
+ )}
803
+ </div>
804
+ )
805
+ })}
806
+ </div>
807
+ )}
808
+ </div>
809
+ )
810
+ }