@waterwx/dsh-novel-forge 1.4.0 → 1.5.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/assets/styles/arcane-thick-3d.webp +0 -0
- package/assets/styles/chinese-ink.webp +0 -0
- package/assets/styles/clay-stopmotion-3d.webp +0 -0
- package/assets/styles/dark-ghibli.webp +0 -0
- package/assets/styles/dark-gothic-3d.webp +0 -0
- package/assets/styles/film-photo.webp +0 -0
- package/assets/styles/guofeng-heavy.webp +0 -0
- package/assets/styles/horror-game-3d.webp +0 -0
- package/assets/styles/japan-cel-dark.webp +0 -0
- package/assets/styles/korean-webtoon.webp +0 -0
- package/assets/styles/low-cyberpunk-3d.webp +0 -0
- package/assets/styles/nextgen-cg.webp +0 -0
- package/assets/styles/noir-film.webp +0 -0
- package/assets/styles/openworld-survival.webp +0 -0
- package/assets/styles/paper-cut-3d.webp +0 -0
- package/assets/styles/pixar-adult-3d.webp +0 -0
- package/assets/styles/pop-marvel-2d.webp +0 -0
- package/assets/styles/shinkai-anime.webp +0 -0
- package/assets/styles/tactical-stealth.webp +0 -0
- package/assets/styles/vaporwave-3d.webp +0 -0
- package/assets/styles/vhs90.webp +0 -0
- package/lib/client.js +2073 -658
- package/lib/client.js.map +1 -1
- package/lib/index.js +919 -64
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +15 -1
- package/lib/types/client/locales.d.ts +4 -2
- package/lib/types/client/panel/MangaWorkspace.d.ts +9 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/client/panel/StoryboardTab.d.ts +13 -0
- package/lib/types/client/panel/StyleGallery.d.ts +1 -0
- package/lib/types/engine.d.ts +46 -4
- package/lib/types/index.d.ts +2 -0
- package/lib/types/protocol.d.ts +194 -3
- package/lib/types/style-library.d.ts +33 -0
- package/package.json +2 -1
- package/src/client/api.ts +36 -1
- package/src/client/locales.ts +7 -3
- package/src/client/panel/ImportModal.tsx +123 -21
- package/src/client/panel/MangaWorkspace.tsx +185 -0
- package/src/client/panel/NovelPanel.tsx +239 -150
- package/src/client/panel/StoryboardTab.tsx +287 -0
- package/src/client/panel/StyleGallery.tsx +109 -0
- package/src/client/panel/panel.module.css +77 -0
- package/src/engine.ts +457 -39
- package/src/index.ts +6 -0
- package/src/protocol.ts +200 -3
- package/src/routes.ts +280 -13
- package/src/style-library.ts +78 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 导入小说弹窗:两种模式 ——
|
|
3
|
-
* A) 已有项目目录(含 novel-project.json)→
|
|
4
|
-
* B) txt/md 全本 →
|
|
5
|
-
*
|
|
3
|
+
* A) 已有项目目录(含 novel-project.json)→ 登记/激活书架(粘贴服务器本地路径);
|
|
4
|
+
* B) txt/md 全本 → 点击选择文件,浏览器读取内容 → 服务器拆章预览 → 确认后导入。
|
|
5
|
+
* Mode B 也保留「服务器本地路径」高级选项(大文件或服务器端已有文件时用)。
|
|
6
6
|
*/
|
|
7
|
-
import { useState } from 'react'
|
|
7
|
+
import { useRef, useState } from 'react'
|
|
8
8
|
import type { NovelApi } from '../api.ts'
|
|
9
|
+
import type { BookImportTextPreviewResponse } from '../../protocol.ts'
|
|
9
10
|
import css from './panel.module.css'
|
|
10
11
|
|
|
11
12
|
type ImportMode = 'dir' | 'text'
|
|
@@ -30,13 +31,43 @@ export function ImportModal({
|
|
|
30
31
|
onImported: () => void | Promise<void>
|
|
31
32
|
}) {
|
|
32
33
|
const [mode, setMode] = useState<ImportMode>('dir')
|
|
34
|
+
// Mode A
|
|
33
35
|
const [dir, setDir] = useState('')
|
|
36
|
+
// Mode B:文件选择 + 预览
|
|
37
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
|
38
|
+
const [fileName, setFileName] = useState('')
|
|
39
|
+
const [fileText, setFileText] = useState<string | null>(null)
|
|
40
|
+
const [previewing, setPreviewing] = useState(false)
|
|
41
|
+
const [preview, setPreview] = useState<BookImportTextPreviewResponse | null>(null)
|
|
42
|
+
// Mode B:服务器本地路径(高级)
|
|
43
|
+
const [showPath, setShowPath] = useState(false)
|
|
34
44
|
const [filePath, setFilePath] = useState('')
|
|
45
|
+
// 通用
|
|
35
46
|
const [outDir, setOutDir] = useState('')
|
|
36
47
|
const [busy, setBusy] = useState(false)
|
|
37
48
|
const [error, setError] = useState('')
|
|
38
49
|
const [result, setResult] = useState<ImportResult | null>(null)
|
|
39
50
|
|
|
51
|
+
/** 选择文件 → 读取内容 → 调服务器预览拆章。 */
|
|
52
|
+
const onPickFile = async (file: File | undefined) => {
|
|
53
|
+
if (file === undefined) return
|
|
54
|
+
setError('')
|
|
55
|
+
setResult(null)
|
|
56
|
+
setPreview(null)
|
|
57
|
+
setFileName(file.name)
|
|
58
|
+
setPreviewing(true)
|
|
59
|
+
try {
|
|
60
|
+
const text = await file.text()
|
|
61
|
+
setFileText(text)
|
|
62
|
+
const p = await api.bookImportTextPreview(text, file.name)
|
|
63
|
+
setPreview(p)
|
|
64
|
+
} catch (err) {
|
|
65
|
+
setError((err as Error).message)
|
|
66
|
+
} finally {
|
|
67
|
+
setPreviewing(false)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
40
71
|
const runImport = async () => {
|
|
41
72
|
setError('')
|
|
42
73
|
setResult(null)
|
|
@@ -53,13 +84,14 @@ export function ImportModal({
|
|
|
53
84
|
} finally {
|
|
54
85
|
setBusy(false)
|
|
55
86
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
// Mode B:优先上传内容;未选文件时退回服务器路径
|
|
90
|
+
if (fileText !== null && fileText.length > 0) {
|
|
59
91
|
setBusy(true)
|
|
60
92
|
try {
|
|
61
93
|
const o = outDir.trim()
|
|
62
|
-
const r = await api.
|
|
94
|
+
const r = await api.bookImportTextContent(fileText, fileName, o !== '' ? o : undefined)
|
|
63
95
|
setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
|
|
64
96
|
await onImported()
|
|
65
97
|
} catch (err) {
|
|
@@ -67,9 +99,25 @@ export function ImportModal({
|
|
|
67
99
|
} finally {
|
|
68
100
|
setBusy(false)
|
|
69
101
|
}
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
const f = filePath.trim()
|
|
105
|
+
if (f === '') { setError('请先选择文件,或输入服务器本地文件路径'); return }
|
|
106
|
+
setBusy(true)
|
|
107
|
+
try {
|
|
108
|
+
const o = outDir.trim()
|
|
109
|
+
const r = await api.bookImportText(f, o !== '' ? o : undefined)
|
|
110
|
+
setResult({ kind: 'text', bookName: r.bookName, chapters: r.chapters, skipped: r.skipped })
|
|
111
|
+
await onImported()
|
|
112
|
+
} catch (err) {
|
|
113
|
+
setError((err as Error).message)
|
|
114
|
+
} finally {
|
|
115
|
+
setBusy(false)
|
|
70
116
|
}
|
|
71
117
|
}
|
|
72
118
|
|
|
119
|
+
const switchMode = (m: ImportMode) => { setMode(m); setError(''); setResult(null); setPreview(null) }
|
|
120
|
+
|
|
73
121
|
return (
|
|
74
122
|
<div className={css.importModalOverlay} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
|
75
123
|
<div className={css.importModal}>
|
|
@@ -84,7 +132,7 @@ export function ImportModal({
|
|
|
84
132
|
role="tab"
|
|
85
133
|
aria-selected={mode === 'dir'}
|
|
86
134
|
className={`${css.button} ${mode === 'dir' ? css.buttonPrimary : ''}`}
|
|
87
|
-
onClick={() => {
|
|
135
|
+
onClick={() => { switchMode('dir') }}
|
|
88
136
|
>
|
|
89
137
|
📂 A · 已有项目目录
|
|
90
138
|
</button>
|
|
@@ -93,7 +141,7 @@ export function ImportModal({
|
|
|
93
141
|
role="tab"
|
|
94
142
|
aria-selected={mode === 'text'}
|
|
95
143
|
className={`${css.button} ${mode === 'text' ? css.buttonPrimary : ''}`}
|
|
96
|
-
onClick={() => {
|
|
144
|
+
onClick={() => { switchMode('text') }}
|
|
97
145
|
>
|
|
98
146
|
📄 B · txt/md 全本
|
|
99
147
|
</button>
|
|
@@ -118,16 +166,51 @@ export function ImportModal({
|
|
|
118
166
|
</>
|
|
119
167
|
) : (
|
|
120
168
|
<>
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
169
|
+
{/* 文件选择 + 预览 */}
|
|
170
|
+
<input
|
|
171
|
+
ref={fileInputRef}
|
|
172
|
+
type="file"
|
|
173
|
+
accept=".txt,.md,text/plain,text/markdown"
|
|
174
|
+
style={{ display: 'none' }}
|
|
175
|
+
onChange={e => { void onPickFile(e.target.files?.[0]); e.target.value = '' }}
|
|
176
|
+
/>
|
|
177
|
+
<div className={css.importField}>
|
|
178
|
+
<span>全本文本文件(txt / md)</span>
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
className={`${css.button} ${css.buttonPrimary}`}
|
|
182
|
+
onClick={() => { fileInputRef.current?.click() }}
|
|
183
|
+
>
|
|
184
|
+
📄 选择文件
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
{fileName !== '' && (
|
|
188
|
+
<div className={css.importFileInfo}>
|
|
189
|
+
<span>已选择:{fileName}</span>
|
|
190
|
+
{previewing && <span className={css.meta}>正在拆章预览…</span>}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
{preview !== null && (
|
|
194
|
+
<div className={css.importPreview}>
|
|
195
|
+
<span>📖 《{preview.bookName}》 · 识别到 {preview.chapters.length} 章</span>
|
|
196
|
+
{preview.chapters.length > 0 && (
|
|
197
|
+
<ul className={css.importPreviewList}>
|
|
198
|
+
{preview.chapters.slice(0, 5).map(c => (
|
|
199
|
+
<li key={c.no}>第{c.no}章 {c.title}({c.chars} 字)</li>
|
|
200
|
+
))}
|
|
201
|
+
{preview.chapters.length > 5 && <li className={css.meta}>… 共 {preview.chapters.length} 章</li>}
|
|
202
|
+
</ul>
|
|
203
|
+
)}
|
|
204
|
+
{preview.skipped.length > 0 && (
|
|
205
|
+
<span className={css.meta}>
|
|
206
|
+
跳过 {preview.skipped.length} 个过短章节:{preview.skipped.slice(0, 5).join('、')}
|
|
207
|
+
{preview.skipped.length > 5 ? ' 等' : ''}
|
|
208
|
+
</span>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{/* 输出目录 + 高级路径选项 */}
|
|
131
214
|
<label className={css.importField}>
|
|
132
215
|
<span>输出目录(可选,默认 ~/.dsh/novels/书名)</span>
|
|
133
216
|
<input
|
|
@@ -138,8 +221,27 @@ export function ImportModal({
|
|
|
138
221
|
onChange={e => { setOutDir(e.target.value) }}
|
|
139
222
|
/>
|
|
140
223
|
</label>
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
227
|
+
onClick={() => { setShowPath(!showPath) }}
|
|
228
|
+
>
|
|
229
|
+
{showPath ? '▾ 收起高级选项' : '▸ 高级:使用服务器本地文件路径'}
|
|
230
|
+
</button>
|
|
231
|
+
{showPath && (
|
|
232
|
+
<label className={css.importField}>
|
|
233
|
+
<span>服务器本地文件绝对路径(大文件或服务器端已有文件时用)</span>
|
|
234
|
+
<input
|
|
235
|
+
className={css.input}
|
|
236
|
+
type="text"
|
|
237
|
+
placeholder="例如 D:\novels\全本.txt"
|
|
238
|
+
value={filePath}
|
|
239
|
+
onChange={e => { setFilePath(e.target.value) }}
|
|
240
|
+
/>
|
|
241
|
+
</label>
|
|
242
|
+
)}
|
|
141
243
|
<div className={css.importHint}>
|
|
142
|
-
服务器按「第X
|
|
244
|
+
服务器按「第X章/第X回/第X节/第X卷」、中文数字章节(一、二、三…)、序章/楔子/尾声、英文 Chapter N 拆章;正文过短(<50 字)的章节会跳过并列出。
|
|
143
245
|
</div>
|
|
144
246
|
</>
|
|
145
247
|
)}
|
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
)
|
|
185
|
+
}
|