@waterwx/dsh-novel-forge 1.4.1 → 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 +1455 -301
- package/lib/client.js.map +1 -1
- package/lib/index.js +589 -6
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +8 -0
- 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 +19 -3
- package/lib/types/protocol.d.ts +149 -0
- package/lib/types/style-library.d.ts +33 -0
- package/package.json +2 -1
- package/src/client/api.ts +20 -0
- package/src/client/panel/MangaWorkspace.tsx +185 -0
- package/src/client/panel/NovelPanel.tsx +18 -3
- package/src/client/panel/StoryboardTab.tsx +287 -0
- package/src/client/panel/StyleGallery.tsx +109 -0
- package/src/engine.ts +247 -0
- package/src/protocol.ts +164 -0
- package/src/routes.ts +192 -2
- package/src/style-library.ts +78 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分镜工作台:① 编剧级剧情骨架 → ② 导演级分镜表(镜头级)→ ③ 视频提示词(后续版本)。
|
|
3
|
+
* 定位:辅助人工——每级可重新生成、可复制,产出可导出。
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
6
|
+
import type { NovelApi } from '../api.ts'
|
|
7
|
+
import type { ChapterPlan, ProjectState, StoryboardPrompt, StoryboardSkeleton, StoryboardTable } from '../../protocol.ts'
|
|
8
|
+
import css from './panel.module.css'
|
|
9
|
+
|
|
10
|
+
export function StoryboardTab({
|
|
11
|
+
api,
|
|
12
|
+
project,
|
|
13
|
+
chapters,
|
|
14
|
+
onProjectChanged,
|
|
15
|
+
styleId,
|
|
16
|
+
filterId,
|
|
17
|
+
}: {
|
|
18
|
+
api: NovelApi
|
|
19
|
+
project: ProjectState | null
|
|
20
|
+
chapters: ChapterPlan[]
|
|
21
|
+
/** 生成成功且已持久化后触发(刷新项目,切章/重进可恢复)。 */
|
|
22
|
+
onProjectChanged?: () => void | Promise<void>
|
|
23
|
+
/** 漫剧基底风格 id(画面措辞随风格)。 */
|
|
24
|
+
styleId?: string
|
|
25
|
+
/** 可选滤镜风格 id。 */
|
|
26
|
+
filterId?: string
|
|
27
|
+
}) {
|
|
28
|
+
const written = useMemo(() => chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error').sort((a, b) => a.no - b.no), [chapters])
|
|
29
|
+
const [chapterNo, setChapterNo] = useState<number | null>(written[0]?.no ?? null)
|
|
30
|
+
const [busy, setBusy] = useState(false)
|
|
31
|
+
const [error, setError] = useState('')
|
|
32
|
+
const [skeleton, setSkeleton] = useState<StoryboardSkeleton | null>(null)
|
|
33
|
+
const [table, setTable] = useState<StoryboardTable | null>(null)
|
|
34
|
+
const [tableBusy, setTableBusy] = useState(false)
|
|
35
|
+
const [prompts, setPrompts] = useState<StoryboardPrompt[] | null>(null)
|
|
36
|
+
const [promptsBusy, setPromptsBusy] = useState(false)
|
|
37
|
+
const [copied, setCopied] = useState('')
|
|
38
|
+
|
|
39
|
+
// 章节列表变化时保持选中有效章节
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (chapterNo === null || !written.some(c => c.no === chapterNo)) {
|
|
42
|
+
setChapterNo(written[0]?.no ?? null)
|
|
43
|
+
setSkeleton(null)
|
|
44
|
+
setTable(null)
|
|
45
|
+
setPrompts(null)
|
|
46
|
+
}
|
|
47
|
+
}, [written, chapterNo])
|
|
48
|
+
|
|
49
|
+
// 从项目持久化恢复:切章节 / 重新进入本页时,读回已保存的骨架与分镜表(本地已有则不覆盖)。
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (chapterNo === null) return
|
|
52
|
+
const entry = (project?.storyboards ?? []).find(e => e.chapterNo === chapterNo)
|
|
53
|
+
setSkeleton(prev => prev ?? entry?.skeleton ?? null)
|
|
54
|
+
setTable(prev => prev ?? entry?.table ?? null)
|
|
55
|
+
setPrompts(prev => prev ?? entry?.prompts ?? null)
|
|
56
|
+
}, [chapterNo, project?.storyboards])
|
|
57
|
+
|
|
58
|
+
const generate = async (): Promise<void> => {
|
|
59
|
+
if (chapterNo === null) return
|
|
60
|
+
setBusy(true)
|
|
61
|
+
setError('')
|
|
62
|
+
setSkeleton(null)
|
|
63
|
+
setTable(null)
|
|
64
|
+
setPrompts(null)
|
|
65
|
+
try {
|
|
66
|
+
const result = await api.storyboardSkeleton(chapterNo)
|
|
67
|
+
setSkeleton(result.skeleton)
|
|
68
|
+
void onProjectChanged?.()
|
|
69
|
+
} catch (err) {
|
|
70
|
+
setError((err as Error).message)
|
|
71
|
+
} finally {
|
|
72
|
+
setBusy(false)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** ② 生成分镜表(骨架 → 镜头级)。 */
|
|
77
|
+
const generateTable = async (): Promise<void> => {
|
|
78
|
+
if (chapterNo === null || skeleton === null) return
|
|
79
|
+
setTableBusy(true)
|
|
80
|
+
setError('')
|
|
81
|
+
setTable(null)
|
|
82
|
+
try {
|
|
83
|
+
const result = await api.storyboardTable(chapterNo, skeleton, styleId, filterId)
|
|
84
|
+
setTable(result.table)
|
|
85
|
+
void onProjectChanged?.()
|
|
86
|
+
} catch (err) {
|
|
87
|
+
setError((err as Error).message)
|
|
88
|
+
} finally {
|
|
89
|
+
setTableBusy(false)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** ③ 生成视频提示词(分镜表 → 即梦可粘贴)。 */
|
|
94
|
+
const generatePrompts = async (): Promise<void> => {
|
|
95
|
+
if (chapterNo === null || table === null) return
|
|
96
|
+
setPromptsBusy(true)
|
|
97
|
+
setError('')
|
|
98
|
+
setPrompts(null)
|
|
99
|
+
try {
|
|
100
|
+
const result = await api.storyboardPrompts(chapterNo, table, styleId, filterId)
|
|
101
|
+
setPrompts(result.prompts)
|
|
102
|
+
void onProjectChanged?.()
|
|
103
|
+
} catch (err) {
|
|
104
|
+
setError((err as Error).message)
|
|
105
|
+
} finally {
|
|
106
|
+
setPromptsBusy(false)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (project === null) {
|
|
111
|
+
return <div className={css.card}><span className={css.meta}>请先开书或选择一本书,再进入分镜工作台。</span></div>
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className={css.card}>
|
|
116
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
117
|
+
<span className={css.cardTitle}>🎬 分镜工作台</span>
|
|
118
|
+
<span className={css.meta}>① 剧情骨架 → ② 分镜表(本节)→ ③ 视频提示词(后续版本)</span>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<div className={css.row} style={{ alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
|
122
|
+
<div className={css.field} style={{ flex: 1, minWidth: 200 }}>
|
|
123
|
+
<label className={css.fieldLabel}>选择章节</label>
|
|
124
|
+
<select
|
|
125
|
+
className={css.input}
|
|
126
|
+
value={chapterNo ?? ''}
|
|
127
|
+
onChange={e => { setChapterNo(Number(e.target.value)); setError('') }}
|
|
128
|
+
>
|
|
129
|
+
{written.length === 0 && <option value="">(没有已写章节)</option>}
|
|
130
|
+
{written.map(c => (
|
|
131
|
+
<option key={c.no} value={c.no}>第{c.no}章 {c.title}</option>
|
|
132
|
+
))}
|
|
133
|
+
</select>
|
|
134
|
+
</div>
|
|
135
|
+
<button
|
|
136
|
+
type="button"
|
|
137
|
+
className={`${css.button} ${css.buttonPrimary}`}
|
|
138
|
+
disabled={busy || chapterNo === null}
|
|
139
|
+
onClick={() => { void generate() }}
|
|
140
|
+
>
|
|
141
|
+
{busy ? '编剧分析中…' : '✍️ 生成剧情骨架'}
|
|
142
|
+
</button>
|
|
143
|
+
{skeleton !== null && (
|
|
144
|
+
<button type="button" className={css.button} disabled={busy} onClick={() => { void generate() }}>
|
|
145
|
+
🔄 重新生成
|
|
146
|
+
</button>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
{error !== '' && <div className={css.importError}>{error}</div>}
|
|
151
|
+
|
|
152
|
+
{skeleton === null ? (
|
|
153
|
+
<div className={css.meta} style={{ marginTop: 8 }}>
|
|
154
|
+
生成后这里显示本章剧情骨架:弧线 + 节拍链(事件 / 情绪走向 / 叙事功能 / 因果)。
|
|
155
|
+
骨架是「完整剧情」的根——确认骨架没问题后,后续版本再展开为分镜表与视频提示词。
|
|
156
|
+
</div>
|
|
157
|
+
) : (
|
|
158
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
|
|
159
|
+
<div className={css.importPreview}>
|
|
160
|
+
<span>📖 本章弧线:{skeleton.arc}</span>
|
|
161
|
+
</div>
|
|
162
|
+
{skeleton.beats.map((b, i) => (
|
|
163
|
+
<div key={b.id} style={{ display: 'flex', flexDirection: 'column', gap: 3, border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px' }}>
|
|
164
|
+
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
165
|
+
<b>节拍 {i + 1}</b>
|
|
166
|
+
<span className={`${css.badge} ${b.function === '高潮' ? css.badgeDone : b.function === '转折' ? css.badgeWritten : css.badgePending}`}>{b.function}</span>
|
|
167
|
+
<span className={css.meta}>{b.emotion}</span>
|
|
168
|
+
</div>
|
|
169
|
+
<span>{b.event}</span>
|
|
170
|
+
{b.cause !== undefined && <span className={css.meta}>承接:{b.cause}</span>}
|
|
171
|
+
</div>
|
|
172
|
+
))}
|
|
173
|
+
<div className={css.row}>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
177
|
+
onClick={() => {
|
|
178
|
+
const text = `第${skeleton.chapterNo}章 弧线:${skeleton.arc}\n\n` + skeleton.beats.map((b, i) => `${i + 1}. [${b.function}] ${b.event}(情绪:${b.emotion})${b.cause !== undefined ? `[承接:${b.cause}]` : ''}`).join('\n')
|
|
179
|
+
void navigator.clipboard?.writeText(text)
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
📋 复制骨架
|
|
183
|
+
</button>
|
|
184
|
+
<button
|
|
185
|
+
type="button"
|
|
186
|
+
className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
|
|
187
|
+
disabled={tableBusy}
|
|
188
|
+
onClick={() => { void generateTable() }}
|
|
189
|
+
>
|
|
190
|
+
{tableBusy ? '导演分镜中…' : '🎬 ② 生成分镜表'}
|
|
191
|
+
</button>
|
|
192
|
+
<span className={css.meta}>共 {skeleton.beats.length} 个节拍 · 骨架可重新生成(后续版本支持直接编辑)</span>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
{table !== null && (
|
|
198
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 10 }}>
|
|
199
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
200
|
+
<b>🎬 分镜表({table.shots.length} 个镜头)</b>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
204
|
+
onClick={() => {
|
|
205
|
+
const text = table.shots.map(s => {
|
|
206
|
+
const beat = skeleton?.beats.find(b => b.id === s.beatId)
|
|
207
|
+
return `镜头 ${s.id}(节拍 ${beat?.id ?? s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s)\n画面:${s.visual}\n台词:${s.line !== '' ? s.line : '(无)'}\n音效:${s.sound !== '' ? s.sound : '(无)'}\n光效:${s.light !== '' ? s.light : '(无)'}\n承接:${s.prevState} → ${s.nextState}`
|
|
208
|
+
}).join('\n\n')
|
|
209
|
+
void navigator.clipboard?.writeText(`第${table.chapterNo}章分镜表\n\n` + text)
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
📋 复制分镜表
|
|
213
|
+
</button>
|
|
214
|
+
<span className={css.meta}>按骨架节拍展开 · 镜头间状态连续</span>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
|
|
218
|
+
disabled={promptsBusy}
|
|
219
|
+
onClick={() => { void generatePrompts() }}
|
|
220
|
+
>
|
|
221
|
+
{promptsBusy ? '提示词生成中…' : '🎬 ③ 生成视频提示词'}
|
|
222
|
+
</button>
|
|
223
|
+
</div>
|
|
224
|
+
{table.shots.map(s => {
|
|
225
|
+
const beat = skeleton?.beats.find(b => b.id === s.beatId)
|
|
226
|
+
return (
|
|
227
|
+
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', gap: 3, border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px' }}>
|
|
228
|
+
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
229
|
+
<b>镜头 {s.id}</b>
|
|
230
|
+
<span className={css.badge}>{s.shot}</span>
|
|
231
|
+
<span className={css.meta}>{s.camera} · {s.duration}s</span>
|
|
232
|
+
{beat !== undefined && <span className={css.meta}>节拍 {beat.id}「{beat.function}」</span>}
|
|
233
|
+
</div>
|
|
234
|
+
<span>🎞️ {s.visual}</span>
|
|
235
|
+
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
236
|
+
<span className={css.meta}>💬 {s.line !== '' ? s.line : '(无台词)'}</span>
|
|
237
|
+
<span className={css.meta}>🔊 {s.sound !== '' ? s.sound : '(无音效)'}</span>
|
|
238
|
+
<span className={css.meta}>💡 {s.light !== '' ? s.light : '(无光效)'}</span>
|
|
239
|
+
</div>
|
|
240
|
+
<span className={css.meta}>承接:{s.prevState} → {s.nextState}</span>
|
|
241
|
+
</div>
|
|
242
|
+
)
|
|
243
|
+
})}
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
|
|
247
|
+
{prompts !== null && (
|
|
248
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 10 }}>
|
|
249
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
250
|
+
<b>🎬 视频提示词({prompts.length} 个镜头)</b>
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
254
|
+
onClick={() => {
|
|
255
|
+
const text = prompts.map(x => `【镜头 ${x.shotId}】${x.text}`).join('\n\n')
|
|
256
|
+
void navigator.clipboard?.writeText(`第${table?.chapterNo ?? ''}章视频提示词\n\n` + text)
|
|
257
|
+
}}
|
|
258
|
+
>
|
|
259
|
+
📋 复制全部(即梦可逐条粘贴)
|
|
260
|
+
</button>
|
|
261
|
+
</div>
|
|
262
|
+
{prompts.map(x => (
|
|
263
|
+
<div key={x.shotId} style={{ display: 'flex', flexDirection: 'column', gap: 3, border: '1px solid var(--nf-accent)', borderRadius: 10, padding: '8px 10px' }}>
|
|
264
|
+
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
265
|
+
<b>镜头 {x.shotId}</b>
|
|
266
|
+
<span className={css.meta}>即梦/Seedance 提示词</span>
|
|
267
|
+
<div style={{ flex: 1 }} />
|
|
268
|
+
<button
|
|
269
|
+
type="button"
|
|
270
|
+
className={`${css.button} ${css.buttonSmall}`}
|
|
271
|
+
onClick={() => {
|
|
272
|
+
void navigator.clipboard?.writeText(x.text)
|
|
273
|
+
setCopied(x.shotId)
|
|
274
|
+
setTimeout(() => { setCopied('') }, 1500)
|
|
275
|
+
}}
|
|
276
|
+
>
|
|
277
|
+
{copied === x.shotId ? '✅ 已复制' : '📋 复制'}
|
|
278
|
+
</button>
|
|
279
|
+
</div>
|
|
280
|
+
<span style={{ fontSize: 13, lineHeight: 1.7 }}>{x.text}</span>
|
|
281
|
+
</div>
|
|
282
|
+
))}
|
|
283
|
+
</div>
|
|
284
|
+
)}
|
|
285
|
+
</div>
|
|
286
|
+
)
|
|
287
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 风格库页面:4 个分类标签 + 风格卡片网格。
|
|
3
|
+
* 卡片预留:风格效果图占位 + 特点说明 + 关键词(一键复制)。
|
|
4
|
+
* 后续:新建漫剧方案时从这里选风格(基底 + 滤镜)。
|
|
5
|
+
*/
|
|
6
|
+
import { useState } from 'react'
|
|
7
|
+
import { STYLE_CATEGORIES, STYLE_LIBRARY, stylesByCategory, type StyleCategory } from '../../style-library.ts'
|
|
8
|
+
import css from './panel.module.css'
|
|
9
|
+
|
|
10
|
+
export function StyleGallery() {
|
|
11
|
+
const [active, setActive] = useState<StyleCategory>('3d')
|
|
12
|
+
const [copied, setCopied] = useState('')
|
|
13
|
+
|
|
14
|
+
const copyKeywords = async (id: string, keywords: string): Promise<void> => {
|
|
15
|
+
try {
|
|
16
|
+
await navigator.clipboard.writeText(keywords)
|
|
17
|
+
setCopied(id)
|
|
18
|
+
setTimeout(() => { setCopied('') }, 1500)
|
|
19
|
+
} catch { /* 剪贴板不可用时静默 */ }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const list = stylesByCategory(active)
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className={css.card}>
|
|
26
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
27
|
+
<span className={css.cardTitle}>🎨 风格库</span>
|
|
28
|
+
<span className={css.meta}>共 {STYLE_LIBRARY.length} 个内置模板 · 新建漫剧方案时选择(影视类可作滤镜叠加)</span>
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
{/* 分类标签 */}
|
|
32
|
+
<div className={css.row} style={{ gap: 8, flexWrap: 'wrap' }}>
|
|
33
|
+
{STYLE_CATEGORIES.map(cat => (
|
|
34
|
+
<button
|
|
35
|
+
key={cat.id}
|
|
36
|
+
type="button"
|
|
37
|
+
className={`${css.button} ${active === cat.id ? css.buttonPrimary : ''}`}
|
|
38
|
+
style={{ flex: 1, minWidth: 140 }}
|
|
39
|
+
onClick={() => { setActive(cat.id) }}
|
|
40
|
+
>
|
|
41
|
+
{cat.icon} {cat.label}({stylesByCategory(cat.id).length})
|
|
42
|
+
</button>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
<div className={css.meta}>
|
|
46
|
+
{STYLE_CATEGORIES.find(c => c.id === active)?.desc}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
{/* 风格卡片网格 */}
|
|
50
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12, marginTop: 6 }}>
|
|
51
|
+
{list.map(style => (
|
|
52
|
+
<div
|
|
53
|
+
key={style.id}
|
|
54
|
+
style={{
|
|
55
|
+
display: 'flex', flexDirection: 'column', gap: 6,
|
|
56
|
+
border: '1px solid var(--nf-border)', borderRadius: 14,
|
|
57
|
+
padding: 10, background: 'var(--nf-bg-raise)',
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{/* 效果图(竖版 3:4,服务端缩略图 webp;无图回退占位) */}
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
aspectRatio: '3 / 4', borderRadius: 10,
|
|
64
|
+
position: 'relative', overflow: 'hidden',
|
|
65
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
66
|
+
background: 'linear-gradient(145deg, var(--nf-bg-inset), color-mix(in srgb, var(--nf-accent) 12%, var(--nf-bg-inset)))',
|
|
67
|
+
border: '1px solid var(--nf-border)',
|
|
68
|
+
color: 'var(--nf-text-3)', fontSize: 12, textAlign: 'center',
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<img
|
|
72
|
+
src={`/api/dsh-novel-forge/styles/image?id=${style.id}`}
|
|
73
|
+
alt={style.name}
|
|
74
|
+
loading="lazy"
|
|
75
|
+
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }}
|
|
76
|
+
onError={e => { e.currentTarget.style.display = 'none' }}
|
|
77
|
+
/>
|
|
78
|
+
🖼️ 效果图待生成
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
82
|
+
<b>{style.name}</b>
|
|
83
|
+
<span className={css.badge}>{STYLE_CATEGORIES.find(c => c.id === style.category)?.icon} {style.category.toUpperCase()}</span>
|
|
84
|
+
{style.stackable === true && <span className={css.badge}>叠加滤镜</span>}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{/* 说明位置 */}
|
|
88
|
+
<span className={css.meta} style={{ fontSize: 12, lineHeight: 1.6 }}>{style.traits}</span>
|
|
89
|
+
|
|
90
|
+
{/* 关键词词块(一键复制) */}
|
|
91
|
+
<div
|
|
92
|
+
style={{
|
|
93
|
+
fontSize: 11.5, lineHeight: 1.5, color: 'var(--nf-text-2)',
|
|
94
|
+
borderTop: '1px solid var(--nf-border)', paddingTop: 6,
|
|
95
|
+
display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden',
|
|
96
|
+
cursor: 'pointer',
|
|
97
|
+
}}
|
|
98
|
+
title="点击复制关键词"
|
|
99
|
+
onClick={() => { void copyKeywords(style.id, style.keywords) }}
|
|
100
|
+
>
|
|
101
|
+
<span className={css.meta}>关键词:</span>{style.keywords}
|
|
102
|
+
</div>
|
|
103
|
+
{copied === style.id && <span style={{ color: 'var(--nf-ok, #46a758)', fontSize: 12 }}>✅ 已复制</span>}
|
|
104
|
+
</div>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
)
|
|
109
|
+
}
|