@waterwx/dsh-novel-forge 1.5.0 → 1.6.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.
- package/lib/client.js +3284 -2807
- package/lib/client.js.map +1 -1
- package/lib/index.js +183 -39
- package/lib/index.js.map +1 -1
- package/lib/types/client/panel/MangaWorkspace.d.ts +6 -2
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/client/panel/RoleVisualPanel.d.ts +13 -0
- package/lib/types/client/panel/SceneLibrary.d.ts +14 -0
- package/lib/types/client/panel/StoryboardTab.d.ts +3 -1
- package/lib/types/client/panel/StyleCard.d.ts +8 -0
- package/lib/types/engine.d.ts +1 -1
- package/lib/types/protocol.d.ts +10 -0
- package/package.json +1 -1
- package/src/client/panel/MangaWorkspace.tsx +292 -184
- package/src/client/panel/NovelPanel.tsx +3 -648
- package/src/client/panel/RoleVisualPanel.tsx +468 -0
- package/src/client/panel/SceneLibrary.tsx +395 -0
- package/src/client/panel/StoryboardTab.tsx +369 -286
- package/src/client/panel/StyleCard.tsx +90 -0
- package/src/client/panel/StyleGallery.tsx +57 -108
- package/src/client/panel/panel.module.css +12 -12
- package/src/engine.ts +139 -34
- package/src/protocol.ts +10 -0
- package/src/routes.ts +1 -1
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { NovelApi } from '../api.ts';
|
|
2
2
|
import type { ChapterPlan, ProjectState } from '../../protocol.ts';
|
|
3
|
-
export declare function MangaWorkspace({ api, project, chapters, onProjectChanged, }: {
|
|
3
|
+
export declare function MangaWorkspace({ api, project, chapters, onProjectChanged, imageApiEnabled, onProgress, }: {
|
|
4
4
|
api: NovelApi;
|
|
5
5
|
project: ProjectState | null;
|
|
6
6
|
chapters: ChapterPlan[];
|
|
7
|
-
/**
|
|
7
|
+
/** 方案/资产变更已持久化后触发(刷新项目)。 */
|
|
8
8
|
onProjectChanged?: () => void | Promise<void>;
|
|
9
|
+
/** 是否启用豆包生图。 */
|
|
10
|
+
imageApiEnabled?: boolean;
|
|
11
|
+
/** 上报到「工作进度」控制台(漫剧工作台内所有 LLM/方案操作)。 */
|
|
12
|
+
onProgress?: (text: string, kind?: 'info' | 'done' | 'error') => void;
|
|
9
13
|
}): import("react").JSX.Element;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { NovelApi } from '../api.ts';
|
|
2
2
|
import type { PanelController } from './controller.ts';
|
|
3
3
|
/** The panel's tab identifiers. */
|
|
4
|
-
export type NovelTab = 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings' | 'characters' | 'roles' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'breakdown' | '
|
|
4
|
+
export type NovelTab = 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings' | 'characters' | 'roles' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'breakdown' | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle' | 'run' | 'manhua' | 'book' | 'assets';
|
|
5
5
|
/** Panel shell props. */
|
|
6
6
|
export interface NovelPanelProps {
|
|
7
7
|
/** The panel state owner (open/close/toggle). */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { NovelApi } from '../api.ts';
|
|
2
|
+
import type { ProjectState } from '../../protocol.ts';
|
|
3
|
+
export declare function RoleVisualPanel({ api, project, refresh, styleId, filterId, imageApiEnabled, onGotoRoles, onProgress, }: {
|
|
4
|
+
api: NovelApi;
|
|
5
|
+
project: ProjectState | null;
|
|
6
|
+
refresh: () => void | Promise<void>;
|
|
7
|
+
styleId?: string;
|
|
8
|
+
filterId?: string;
|
|
9
|
+
imageApiEnabled?: boolean;
|
|
10
|
+
onGotoRoles?: () => void;
|
|
11
|
+
/** 上报到「工作进度」控制台(角色形象页 / 漫剧工作台共用)。 */
|
|
12
|
+
onProgress?: (text: string, kind?: 'info' | 'done' | 'error') => void;
|
|
13
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { NovelApi } from '../api.ts';
|
|
2
|
+
import type { ProjectState } from '../../protocol.ts';
|
|
3
|
+
export declare function SceneLibrary({ api, project, refresh, styleId, filterId, onProgress, }: {
|
|
4
|
+
api: NovelApi;
|
|
5
|
+
project: ProjectState | null;
|
|
6
|
+
/** 场景库变更已持久化后触发(刷新项目)。 */
|
|
7
|
+
refresh: () => void | Promise<void>;
|
|
8
|
+
/** 提炼场景时的漫剧基底风格 id(提示词按方案风格措辞)。 */
|
|
9
|
+
styleId?: string;
|
|
10
|
+
/** 可选滤镜风格 id。 */
|
|
11
|
+
filterId?: string;
|
|
12
|
+
/** 上报到「工作进度」控制台。 */
|
|
13
|
+
onProgress?: (text: string, kind?: 'info' | 'done' | 'error') => void;
|
|
14
|
+
}): import("react").JSX.Element;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { NovelApi } from '../api.ts';
|
|
2
2
|
import type { ChapterPlan, ProjectState } from '../../protocol.ts';
|
|
3
|
-
export declare function StoryboardTab({ api, project, chapters, onProjectChanged, styleId, filterId, }: {
|
|
3
|
+
export declare function StoryboardTab({ api, project, chapters, onProjectChanged, styleId, filterId, onProgress, }: {
|
|
4
4
|
api: NovelApi;
|
|
5
5
|
project: ProjectState | null;
|
|
6
6
|
chapters: ChapterPlan[];
|
|
@@ -10,4 +10,6 @@ export declare function StoryboardTab({ api, project, chapters, onProjectChanged
|
|
|
10
10
|
styleId?: string;
|
|
11
11
|
/** 可选滤镜风格 id。 */
|
|
12
12
|
filterId?: string;
|
|
13
|
+
/** 上报到「工作进度」控制台(分镜三步生成)。 */
|
|
14
|
+
onProgress?: (text: string, kind?: 'info' | 'done' | 'error') => void;
|
|
13
15
|
}): import("react").JSX.Element;
|
package/lib/types/engine.d.ts
CHANGED
|
@@ -70,7 +70,7 @@ export declare function refreshPlotlineProgress(ctx: Context, config: NovelConfi
|
|
|
70
70
|
/** ✨ AI 从全书提炼角色库:大纲 + 道藏 + 编年录 + 章节摘要 → 结构化角色清单。 */
|
|
71
71
|
export declare function extractRoles(ctx: Context, config: NovelConfig, project: ProjectState): Promise<RoleRecord[]>;
|
|
72
72
|
/** ✨ AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
|
|
73
|
-
export declare function extractScenes(ctx: Context, config: NovelConfig, project: ProjectState): Promise<SceneCard[]>;
|
|
73
|
+
export declare function extractScenes(ctx: Context, config: NovelConfig, project: ProjectState, styleId?: string, filterId?: string): Promise<SceneCard[]>;
|
|
74
74
|
/** 从 txt/md 全本文本拆章(纯逻辑,不落盘):识别章节头、剥离重复标题、去重、排序并统一重新编号。 */
|
|
75
75
|
export declare function splitBookText(raw: string): Array<{
|
|
76
76
|
no: number;
|
package/lib/types/protocol.d.ts
CHANGED
|
@@ -256,6 +256,8 @@ export interface StoryboardShot {
|
|
|
256
256
|
export interface StoryboardTable {
|
|
257
257
|
chapterNo: number;
|
|
258
258
|
shots: StoryboardShot[];
|
|
259
|
+
/** 生成时注入的场景卡名称(前端标注消费关系)。 */
|
|
260
|
+
usedScenes?: string[];
|
|
259
261
|
}
|
|
260
262
|
/** POST /storyboard/table 请求:骨架 → 分镜表。 */
|
|
261
263
|
export interface StoryboardTableRequest {
|
|
@@ -734,6 +736,8 @@ export interface RoleRecord {
|
|
|
734
736
|
en: string;
|
|
735
737
|
};
|
|
736
738
|
};
|
|
739
|
+
/** 生成形象锚点/精修提示词时的漫剧方案基底风格 id(= 方案 styleId;用于检测旧风格并提示重生成)。 */
|
|
740
|
+
promptStyleId?: string;
|
|
737
741
|
/** 漫画/漫剧重要性:main=必须建卡保证一致;support=有锚点即可;extra=路人随机生成不约束。 */
|
|
738
742
|
importance?: 'main' | 'support' | 'extra';
|
|
739
743
|
}
|
|
@@ -774,10 +778,16 @@ export interface SceneCard {
|
|
|
774
778
|
source?: string;
|
|
775
779
|
/** 场景图集(图像锚点):全景/局部/氛围 等。 */
|
|
776
780
|
gallery?: RoleImage[];
|
|
781
|
+
/** 生成时的漫剧方案基底风格 id(场景卡随方案风格措辞)。 */
|
|
782
|
+
styleId?: string;
|
|
777
783
|
}
|
|
778
784
|
/** POST /scenes 请求:场景库提炼 / 采纳 / 更新 / 删除 / 图集。 */
|
|
779
785
|
export interface ScenesRequest {
|
|
780
786
|
op: 'extract' | 'adopt' | 'update' | 'remove' | 'image' | 'removeImage';
|
|
787
|
+
/** op='extract' 时的漫剧基底风格 id(场景卡按方案风格措辞)。 */
|
|
788
|
+
styleId?: string;
|
|
789
|
+
/** op='extract' 时的可选滤镜风格 id。 */
|
|
790
|
+
filterId?: string;
|
|
781
791
|
/** adopt / update 时传入的场景卡(adopt 可修改后采纳)。 */
|
|
782
792
|
scene?: SceneCard;
|
|
783
793
|
/** remove / image 时的场景名。 */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waterwx/dsh-novel-forge",
|
|
3
3
|
"description": "AI 编译小说工作台 for the dsh web GUI: import a novel outline (docx/text), plan chapters with LLM, generate chapter by chapter (3000-4000 chars each), and save every chapter as Markdown into your chosen output folder.",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.6.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -1,185 +1,293 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
await
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
)}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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, 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
|
+
onProgress,
|
|
23
|
+
}: {
|
|
24
|
+
api: NovelApi
|
|
25
|
+
project: ProjectState | null
|
|
26
|
+
chapters: ChapterPlan[]
|
|
27
|
+
/** 方案/资产变更已持久化后触发(刷新项目)。 */
|
|
28
|
+
onProjectChanged?: () => void | Promise<void>
|
|
29
|
+
/** 是否启用豆包生图。 */
|
|
30
|
+
imageApiEnabled?: boolean
|
|
31
|
+
/** 上报到「工作进度」控制台(漫剧工作台内所有 LLM/方案操作)。 */
|
|
32
|
+
onProgress?: (text: string, kind?: 'info' | 'done' | 'error') => void
|
|
33
|
+
}) {
|
|
34
|
+
const plans = useMemo(() => project?.mangaPlans ?? [], [project?.mangaPlans])
|
|
35
|
+
const activePlan = plans.find(p => p.active) ?? null
|
|
36
|
+
|
|
37
|
+
// 状态A:风格选择页
|
|
38
|
+
const [showCreate, setShowCreate] = useState(false)
|
|
39
|
+
const [cat, setCat] = useState<StyleCategory>('3d')
|
|
40
|
+
const [selStyle, setSelStyle] = useState('')
|
|
41
|
+
const [selFilter, setSelFilter] = useState('')
|
|
42
|
+
const [planName, setPlanName] = useState('')
|
|
43
|
+
|
|
44
|
+
// 状态B:主工作页
|
|
45
|
+
const [section, setSection] = useState<'roles' | 'scenes' | 'storyboard'>('roles')
|
|
46
|
+
const [busy, setBusy] = useState(false)
|
|
47
|
+
const [error, setError] = useState('')
|
|
48
|
+
const [roleCands, setRoleCands] = useState<RoleRecord[] | null>(null)
|
|
49
|
+
|
|
50
|
+
const baseStyles = useMemo(() => STYLE_LIBRARY.filter(s => s.stackable !== true).sort((a, b) => (b.weight ?? 0) - (a.weight ?? 0)), [])
|
|
51
|
+
const filterStyles = useMemo(() => STYLE_LIBRARY.filter(s => s.stackable === true), [])
|
|
52
|
+
const activeStyle = activePlan !== null ? findStyle(activePlan.styleId) : undefined
|
|
53
|
+
const activeFilter = activePlan?.filterId !== undefined ? findStyle(activePlan.filterId) : undefined
|
|
54
|
+
|
|
55
|
+
const refresh = async (): Promise<void> => { await onProjectChanged?.() }
|
|
56
|
+
|
|
57
|
+
const mutate = async (req: import('../../protocol.ts').MangaPlansRequest): Promise<boolean> => {
|
|
58
|
+
const opLabel = req.op === 'create' ? '创建漫剧方案「' + (req.name ?? '') + '」' : req.op === 'activate' ? '切换漫剧方案' : '删除漫剧方案'
|
|
59
|
+
setBusy(true)
|
|
60
|
+
setError('')
|
|
61
|
+
onProgress?.(opLabel + '…')
|
|
62
|
+
try {
|
|
63
|
+
await api.manhuaPlans(req)
|
|
64
|
+
onProgress?.(opLabel + ' 完成', 'done')
|
|
65
|
+
return true
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const m = (err as Error).message
|
|
68
|
+
setError(m)
|
|
69
|
+
onProgress?.(opLabel + ' 失败:' + m, 'error')
|
|
70
|
+
return false
|
|
71
|
+
} finally {
|
|
72
|
+
setBusy(false)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const createPlan = async (): Promise<void> => {
|
|
77
|
+
const n = planName.trim()
|
|
78
|
+
if (n === '' || selStyle === '') {
|
|
79
|
+
setError('请选择基底风格并填写方案名')
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const ok = await mutate({ op: 'create', name: n, styleId: selStyle, filterId: selFilter !== '' ? selFilter : undefined })
|
|
83
|
+
if (ok) {
|
|
84
|
+
setShowCreate(false)
|
|
85
|
+
setPlanName('')
|
|
86
|
+
setSelStyle('')
|
|
87
|
+
setSelFilter('')
|
|
88
|
+
setSection('roles')
|
|
89
|
+
await refresh()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const pickStyle = (id: string): void => {
|
|
94
|
+
setSelStyle(id)
|
|
95
|
+
if (planName.trim() === '') {
|
|
96
|
+
const s = findStyle(id)
|
|
97
|
+
setPlanName(`${project?.bookName ?? '本书'} · ${s?.name ?? ''}版`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 角色区操作
|
|
102
|
+
const extractRoles = async (): Promise<void> => {
|
|
103
|
+
if (activePlan === null) return
|
|
104
|
+
setBusy(true); setError('')
|
|
105
|
+
onProgress?.('按当前方案提炼角色…')
|
|
106
|
+
try {
|
|
107
|
+
const r = await api.roles({ op: 'extract', styleId: activePlan.styleId, filterId: activePlan.filterId })
|
|
108
|
+
setRoleCands(r.candidates ?? [])
|
|
109
|
+
onProgress?.('角色提炼完成:' + (r.candidates ?? []).length + ' 个候选', 'done')
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const m = (err as Error).message
|
|
112
|
+
setError(m)
|
|
113
|
+
onProgress?.('角色提炼失败:' + m, 'error')
|
|
114
|
+
} finally { setBusy(false) }
|
|
115
|
+
}
|
|
116
|
+
const adoptRole = async (r: RoleRecord): Promise<void> => {
|
|
117
|
+
setBusy(true); setError('')
|
|
118
|
+
onProgress?.('采纳角色「' + r.name + '」…')
|
|
119
|
+
try {
|
|
120
|
+
await api.roles({ op: 'adopt', role: r })
|
|
121
|
+
setRoleCands(prev => (prev ?? []).filter(x => x.name !== r.name))
|
|
122
|
+
await refresh()
|
|
123
|
+
onProgress?.('已采纳角色「' + r.name + '」', 'done')
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const m = (err as Error).message
|
|
126
|
+
setError(m)
|
|
127
|
+
onProgress?.('采纳角色「' + r.name + '」失败:' + m, 'error')
|
|
128
|
+
} finally { setBusy(false) }
|
|
129
|
+
}
|
|
130
|
+
const genRoleVisual = async (name: string): Promise<void> => {
|
|
131
|
+
if (activePlan === null) return
|
|
132
|
+
setBusy(true); setError('')
|
|
133
|
+
onProgress?.('生成「' + name + '」形象锚点(按当前方案风格)…')
|
|
134
|
+
try {
|
|
135
|
+
await api.roles({ op: 'visual', name, styleId: activePlan.styleId, filterId: activePlan.filterId })
|
|
136
|
+
await refresh()
|
|
137
|
+
onProgress?.('已生成「' + name + '」形象锚点(按当前方案风格)', 'done')
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const m = (err as Error).message
|
|
140
|
+
setError(m)
|
|
141
|
+
onProgress?.('生成「' + name + '」形象锚点失败:' + m, 'error')
|
|
142
|
+
} finally { setBusy(false) }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (project === null) {
|
|
146
|
+
return <div className={css.card}><span className={css.meta}>请先开书或选择一本书,再进入漫剧工作台。</span></div>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/* ============ 状态A:风格选择页 ============ */
|
|
150
|
+
if (showCreate || plans.length === 0) {
|
|
151
|
+
const list = stylesByCategory(cat)
|
|
152
|
+
return (
|
|
153
|
+
<div className={css.card}>
|
|
154
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
155
|
+
<span className={css.cardTitle}>🎬 漫剧工作台 · 选择视觉风格</span>
|
|
156
|
+
{plans.length > 0 && <button type="button" className={css.button} disabled={busy} onClick={() => { setShowCreate(false); setSection('roles') }}>返回工作台</button>}
|
|
157
|
+
</div>
|
|
158
|
+
<span className={css.meta}>选一个基底风格(影视类可作滤镜叠加),创建方案后进入 角色 / 场景 / 分镜。</span>
|
|
159
|
+
|
|
160
|
+
<div className={css.row} style={{ gap: 8, flexWrap: 'wrap', margin: '10px 0' }}>
|
|
161
|
+
{STYLE_CATEGORIES.map(c => (
|
|
162
|
+
<button key={c.id} type="button" className={`${css.button} ${cat === c.id ? css.buttonPrimary : ''}`} style={{ flex: 1, minWidth: 120 }} onClick={() => { setCat(c.id) }}>
|
|
163
|
+
{c.icon} {c.label}({stylesByCategory(c.id).length})
|
|
164
|
+
</button>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
<div className={css.meta}>{STYLE_CATEGORIES.find(c => c.id === cat)?.desc}</div>
|
|
168
|
+
|
|
169
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12, margin: '10px 0' }}>
|
|
170
|
+
{list.map(s => (
|
|
171
|
+
<StyleCard key={s.id} style={s} selected={selStyle === s.id} onClick={() => { pickStyle(s.id) }} />
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
{filterStyles.length > 0 && (
|
|
176
|
+
<div className={css.field}>
|
|
177
|
+
<label className={css.fieldLabel}>叠加滤镜(可选)</label>
|
|
178
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
179
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${selFilter === '' ? css.buttonPrimary : ''}`} onClick={() => { setSelFilter('') }}>无</button>
|
|
180
|
+
{filterStyles.map(s => (
|
|
181
|
+
<button key={s.id} type="button" className={`${css.button} ${css.buttonSmall} ${selFilter === s.id ? css.buttonPrimary : ''}`} onClick={() => { setSelFilter(s.id) }}>{s.name}</button>
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
<div className={css.field}>
|
|
188
|
+
<label className={css.fieldLabel}>方案名</label>
|
|
189
|
+
<input className={css.input} value={planName} placeholder="例如:《保质期》3D 皮克斯版" onChange={e => { setPlanName(e.target.value) }} />
|
|
190
|
+
</div>
|
|
191
|
+
{error !== '' && <div className={css.importError}>{error}</div>}
|
|
192
|
+
<div className={css.row}>
|
|
193
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || selStyle === '' || planName.trim() === ''} onClick={() => { void createPlan() }}>
|
|
194
|
+
{busy ? '创建中…' : '🎬 创建方案'}
|
|
195
|
+
</button>
|
|
196
|
+
<span className={css.meta}>{selStyle !== '' ? `已选:${findStyle(selStyle)?.name ?? selStyle}` : '点击卡片选择基底风格'}</span>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ============ 状态B:主工作页 ============ */
|
|
203
|
+
return (
|
|
204
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
205
|
+
{/* 方案切换器 */}
|
|
206
|
+
<div className={css.card}>
|
|
207
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
208
|
+
<span className={css.cardTitle}>🎬 {activePlan?.name ?? '漫剧工作台'}</span>
|
|
209
|
+
<div className={css.row}>
|
|
210
|
+
<span className={css.badge}>🎨 {activeStyle?.name ?? activePlan?.styleId}</span>
|
|
211
|
+
{activeFilter !== undefined && <span className={css.badge}>+{activeFilter.name}</span>}
|
|
212
|
+
<select
|
|
213
|
+
className={css.input}
|
|
214
|
+
style={{ width: 'auto', padding: '4px 8px' }}
|
|
215
|
+
value={activePlan?.id ?? ''}
|
|
216
|
+
onChange={async e => { if (await mutate({ op: 'activate', id: e.target.value })) await refresh() }}
|
|
217
|
+
>
|
|
218
|
+
{plans.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
219
|
+
</select>
|
|
220
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setShowCreate(true); setSelStyle(''); setPlanName('') }}>+ 新建</button>
|
|
221
|
+
<button
|
|
222
|
+
type="button"
|
|
223
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
224
|
+
disabled={busy}
|
|
225
|
+
onClick={async () => {
|
|
226
|
+
if (activePlan === null) return
|
|
227
|
+
if (!window.confirm(`删除方案「${activePlan.name}」?该方案的分镜产出不受影响(骨架共享)。`)) return
|
|
228
|
+
if (await mutate({ op: 'remove', id: activePlan.id })) {
|
|
229
|
+
setSection('roles')
|
|
230
|
+
await refresh()
|
|
231
|
+
}
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
🗑 删除当前方案
|
|
235
|
+
</button>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
{/* 三导航 */}
|
|
239
|
+
<div className={css.row} style={{ gap: 6, marginTop: 8, flexWrap: 'wrap' }}>
|
|
240
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${section === 'roles' ? css.buttonPrimary : ''}`} onClick={() => { setSection('roles') }}>
|
|
241
|
+
🖼️ 角色({(project.roles ?? []).length})
|
|
242
|
+
</button>
|
|
243
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${section === 'scenes' ? css.buttonPrimary : ''}`} onClick={() => { setSection('scenes') }}>
|
|
244
|
+
🏞️ 场景({(project.scenes ?? []).length})
|
|
245
|
+
</button>
|
|
246
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${section === 'storyboard' ? css.buttonPrimary : ''}`} onClick={() => { setSection('storyboard') }}>
|
|
247
|
+
🎬 分镜
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
{error !== '' && <div className={css.importError}>{error}</div>}
|
|
253
|
+
|
|
254
|
+
{/* ② 角色 */}
|
|
255
|
+
{section === 'roles' && (
|
|
256
|
+
<RoleVisualPanel
|
|
257
|
+
api={api}
|
|
258
|
+
project={project}
|
|
259
|
+
refresh={() => refresh()}
|
|
260
|
+
styleId={activePlan?.styleId}
|
|
261
|
+
filterId={activePlan?.filterId}
|
|
262
|
+
imageApiEnabled={imageApiEnabled}
|
|
263
|
+
onProgress={onProgress}
|
|
264
|
+
/>
|
|
265
|
+
)}
|
|
266
|
+
|
|
267
|
+
{/* ③ 场景(完整版场景库:提炼/采纳/编辑/图集/详情浮窗) */}
|
|
268
|
+
{section === 'scenes' && (
|
|
269
|
+
<SceneLibrary
|
|
270
|
+
api={api}
|
|
271
|
+
project={project}
|
|
272
|
+
refresh={() => refresh()}
|
|
273
|
+
styleId={activePlan?.styleId}
|
|
274
|
+
filterId={activePlan?.filterId}
|
|
275
|
+
onProgress={onProgress}
|
|
276
|
+
/>
|
|
277
|
+
)}
|
|
278
|
+
|
|
279
|
+
{/* ④ 分镜 */}
|
|
280
|
+
{section === 'storyboard' && (
|
|
281
|
+
<StoryboardTab
|
|
282
|
+
api={api}
|
|
283
|
+
project={project}
|
|
284
|
+
chapters={chapters}
|
|
285
|
+
onProjectChanged={onProjectChanged}
|
|
286
|
+
styleId={activePlan?.styleId}
|
|
287
|
+
filterId={activePlan?.filterId}
|
|
288
|
+
onProgress={onProgress}
|
|
289
|
+
/>
|
|
290
|
+
)}
|
|
291
|
+
</div>
|
|
292
|
+
)
|
|
185
293
|
}
|