@waterwx/dsh-novel-forge 1.6.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 (41) hide show
  1. package/lib/client.js +37690 -2269
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +767 -56
  4. package/lib/index.js.map +1 -1
  5. package/lib/types/client/api.d.ts +3 -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 +6 -4
  10. package/lib/types/client/panel/RoleVisualPanel.d.ts +5 -3
  11. package/lib/types/client/panel/SceneLibrary.d.ts +1 -1
  12. package/lib/types/client/panel/StoryboardTab.d.ts +6 -2
  13. package/lib/types/engine.d.ts +23 -9
  14. package/lib/types/index.d.ts +6 -4
  15. package/lib/types/protocol.d.ts +171 -3
  16. package/package.json +100 -98
  17. package/src/client/api.ts +9 -0
  18. package/src/client/locales.ts +0 -1
  19. package/src/client/panel/AssetsTab.tsx +22 -22
  20. package/src/client/panel/AssistantTab.tsx +3 -3
  21. package/src/client/panel/BookshelfBar.tsx +1 -1
  22. package/src/client/panel/CreateBookView.tsx +10 -10
  23. package/src/client/panel/FlowGuide.tsx +83 -0
  24. package/src/client/panel/MangaRoleLibrary.tsx +810 -0
  25. package/src/client/panel/MangaWorkspace.tsx +359 -293
  26. package/src/client/panel/NovelPanel.tsx +465 -307
  27. package/src/client/panel/ReaderView.tsx +4 -4
  28. package/src/client/panel/RunPanel.tsx +9 -9
  29. package/src/client/panel/SceneLibrary.tsx +69 -68
  30. package/src/client/panel/ShelfView.tsx +28 -35
  31. package/src/client/panel/StoryboardTab.tsx +106 -42
  32. package/src/client/panel/StyleCard.tsx +13 -13
  33. package/src/client/panel/StyleGallery.tsx +2 -2
  34. package/src/client/panel/WorldTab.tsx +5 -5
  35. package/src/client/panel/panel.module.css +1189 -454
  36. package/src/client/panel/views.tsx +24 -24
  37. package/src/engine.ts +530 -59
  38. package/src/index.ts +37 -6
  39. package/src/protocol.ts +175 -3
  40. package/src/routes.ts +229 -1
  41. package/src/client/panel/RoleVisualPanel.tsx +0 -468
@@ -14,6 +14,8 @@ export function StoryboardTab({
14
14
  onProjectChanged,
15
15
  styleId,
16
16
  filterId,
17
+ mode,
18
+ onGoStep,
17
19
  onProgress,
18
20
  }: {
19
21
  api: NovelApi
@@ -25,10 +27,21 @@ export function StoryboardTab({
25
27
  styleId?: string
26
28
  /** 可选滤镜风格 id。 */
27
29
  filterId?: string
28
- /** 上报到「工作进度」控制台(分镜三步生成)。 */
30
+ /** 页面模式:auto=按本章实际进度显示;skeleton/table/prompts=固定显示该步骤(前置不足显示提示,不降级)。 */
31
+ mode?: 'auto' | 'skeleton' | 'table' | 'prompts'
32
+ /** 「下一步」按钮回调(1=骨架→2=分镜表→3=提示词),供外层切步骤页。 */
33
+ onGoStep?: (n: 1 | 2 | 3) => void
34
+ /** 上报到「AI进度」控制台(分镜三步生成)。 */
29
35
  onProgress?: (text: string, kind?: 'info' | 'done' | 'error') => void
30
36
  }) {
31
37
  const written = useMemo(() => chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error').sort((a, b) => a.no - b.no), [chapters])
38
+ /** 漫剧卡 id → 卡名/参考图(定妆绑定展示)。 */
39
+ const mangaById = useMemo(() => {
40
+ const m = new Map<string, { name: string; imageUrl?: string }>()
41
+ for (const c of project?.mangaRoles ?? []) m.set(c.id, { name: c.name, imageUrl: c.imageUrl })
42
+ return m
43
+ }, [project?.mangaRoles])
44
+ const bindingNames = (ids: string[] | undefined): string => (ids ?? []).map(id => mangaById.get(id)?.name ?? id).join('、')
32
45
  const [chapterNo, setChapterNo] = useState<number | null>(written[0]?.no ?? null)
33
46
  const [busy, setBusy] = useState(false)
34
47
  const [error, setError] = useState('')
@@ -149,7 +162,9 @@ export function StoryboardTab({
149
162
  return <div className={css.card}><span className={css.meta}>请先开书或选择一本书,再进入分镜工作台。</span></div>
150
163
  }
151
164
  const maxStep = prompts !== null ? 3 : table !== null ? 2 : skeleton !== null ? 1 : 0
152
- const step = Math.min(stepState, Math.max(maxStep, 1))
165
+ // 固定模式:只显示指定步骤(前置不足由黄条提示,不降级);auto:按本章实际进度显示。
166
+ const modeStep = mode === 'skeleton' ? 1 : mode === 'table' ? 2 : mode === 'prompts' ? 3 : null
167
+ const step = modeStep !== null ? modeStep : Math.min(stepState, Math.max(maxStep, 1))
153
168
 
154
169
  return (
155
170
  <div className={css.card}>
@@ -158,21 +173,13 @@ export function StoryboardTab({
158
173
  <span className={css.meta}>三步向导 · {styleId !== undefined ? '按当前方案风格生成' : '未选方案风格'}</span>
159
174
  </div>
160
175
 
161
- {/* 步骤条 */}
162
- <div className={css.row} style={{ gap: 6, margin: '8px 0', flexWrap: 'wrap' }}>
163
- {[1, 2, 3].map(n => (
164
- <button
165
- key={n}
166
- type="button"
167
- className={`${css.button} ${css.buttonSmall} ${step === n ? css.buttonPrimary : ''}`}
168
- disabled={n > maxStep}
169
- onClick={() => { setStepState(n) }}
170
- >
171
- {n === 1 ? '① 剧情骨架' : n === 2 ? '② 分镜表' : '③ 视频提示词'}
172
- {n <= maxStep && step !== n ? ' ✓' : ''}
173
- </button>
174
- ))}
175
- </div>
176
+ <span className={css.meta} style={{ display: 'block', marginBottom: 'var(--nf-space-6)' }}>
177
+ {step === 1
178
+ ? '这步:从章节正文提炼剧情骨架(弧线/节拍/出场角色)· 前置:已写章节 · 下一步:生成分镜表'
179
+ : step === 2
180
+ ? '这步:骨架展开为镜头级分镜表(景别/机位/台词/每镜头角色+定妆绑定)· 前置:剧情骨架 · 下一步:视频提示词'
181
+ : '这步:分镜表 → 即梦可粘贴视频提示词(带风格词块与定妆绑定)· 前置:分镜表(建议先完成角色定妆)· 下一步:复制到即梦/豆包'}
182
+ </span>
176
183
 
177
184
  <div className={css.row} style={{ alignItems: 'flex-end', flexWrap: 'wrap' }}>
178
185
  <div className={css.field} style={{ flex: 1, minWidth: 200 }}>
@@ -188,35 +195,77 @@ export function StoryboardTab({
188
195
  ))}
189
196
  </select>
190
197
  </div>
191
- <button
192
- type="button"
193
- className={`${css.button} ${css.buttonPrimary}`}
194
- disabled={busy || chapterNo === null}
195
- onClick={() => { void generate(false) }}
196
- >
197
- {busy ? '编剧分析中…' : '✍️ 生成剧情骨架'}
198
- </button>
199
- {skeleton !== null && (
200
- <button type="button" className={css.button} disabled={busy} onClick={() => { void generate(true) }}>
201
- 🔄 重新生成(并继续生成分镜表)
202
- </button>
198
+ {/* 生成/重新生成骨架只属于骨架页(③);分镜表/提示词页由各自内容区的按钮负责 */}
199
+ {mode !== 'table' && mode !== 'prompts' && (
200
+ <>
201
+ <button
202
+ type="button"
203
+ className={`${css.button} ${css.buttonPrimary}`}
204
+ disabled={busy || chapterNo === null}
205
+ onClick={() => { void generate(false) }}
206
+ >
207
+ {busy ? '编剧分析中…' : '✍️ 生成剧情骨架'}
208
+ </button>
209
+ {skeleton !== null && (
210
+ <button type="button" className={css.button} disabled={busy} onClick={() => { void generate(true) }}>
211
+ 🔄 重新生成(并继续生成分镜表)
212
+ </button>
213
+ )}
214
+ </>
203
215
  )}
204
216
  </div>
205
217
 
206
218
  {error !== '' && <div className={css.importError}>{error}</div>}
207
219
 
220
+ {/* 固定模式下的前置不足提示(不静默降级) */}
221
+ {mode === 'table' && skeleton === null && (
222
+ <div style={{ border: '1px solid var(--nf-warn, #b8860b)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)', marginTop: 'var(--nf-space-6)' }}>
223
+ <span className={css.meta}>⚠ 本章还没有剧情骨架——请先到「③ 剧情骨架」页生成骨架,再回来生成分镜表。</span>
224
+ </div>
225
+ )}
226
+ {mode === 'prompts' && table === null && (
227
+ <div style={{ border: '1px solid var(--nf-warn, #b8860b)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)', marginTop: 'var(--nf-space-6)' }}>
228
+ <span className={css.meta}>⚠ 本章还没有分镜表——请先完成「③ 剧情骨架」「④ 分镜表」,再回来生成视频提示词。</span>
229
+ </div>
230
+ )}
231
+ {/* 固定模式:前置已满足但本步产物缺失 → 直接给生成按钮 */}
232
+ {mode === 'table' && skeleton !== null && table === null && (
233
+ <div className={css.row} style={{ marginTop: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
234
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={tableBusy} onClick={() => { void generateTable() }}>
235
+ {tableBusy ? '生成中…' : '🎬 生成分镜表'}
236
+ </button>
237
+ <span className={css.meta}>骨架已就绪,本章还没有分镜表——点按钮生成。</span>
238
+ </div>
239
+ )}
240
+ {mode === 'prompts' && table !== null && prompts === null && (
241
+ <div className={css.row} style={{ marginTop: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
242
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={promptsBusy} onClick={() => { void generatePrompts() }}>
243
+ {promptsBusy ? '生成中…' : '🎬 生成视频提示词'}
244
+ </button>
245
+ <span className={css.meta}>分镜表已就绪,本章还没有视频提示词——点按钮生成。</span>
246
+ </div>
247
+ )}
248
+
208
249
  {step === 1 && (skeleton === null ? (
209
- <div className={css.meta} style={{ marginTop: 8 }}>
250
+ <div className={css.meta} style={{ marginTop: 'var(--nf-space-8)' }}>
210
251
  生成后这里显示本章剧情骨架:弧线 + 节拍链(事件 / 情绪走向 / 叙事功能 / 因果)。
211
252
  骨架是「完整剧情」的根——确认骨架没问题后,进入下一步展开为分镜表。
212
253
  </div>
213
254
  ) : (
214
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
255
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', marginTop: 'var(--nf-space-8)' }}>
215
256
  <div className={css.importPreview}>
216
257
  <span>📖 本章弧线:{skeleton.arc}</span>
217
258
  </div>
259
+ {skeleton.characters !== undefined && skeleton.characters.length > 0 && (
260
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
261
+ <span className={css.meta}>👥 出场角色:</span>
262
+ {skeleton.characters.map(c => (
263
+ <span key={c} className={css.badge}>{c}</span>
264
+ ))}
265
+ </div>
266
+ )}
218
267
  {skeleton.beats.map((b, i) => (
219
- <div key={b.id} style={{ display: 'flex', flexDirection: 'column', gap: 3, border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px' }}>
268
+ <div key={b.id} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)' }}>
220
269
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
221
270
  <b>节拍 {i + 1}</b>
222
271
  <span className={`${css.badge} ${b.function === '高潮' ? css.badgeDone : b.function === '转折' ? css.badgeWritten : css.badgePending}`}>{b.function}</span>
@@ -231,7 +280,7 @@ export function StoryboardTab({
231
280
  type="button"
232
281
  className={`${css.button} ${css.buttonSmall}`}
233
282
  onClick={() => {
234
- 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')
283
+ const text = `第${skeleton.chapterNo}章 弧线:${skeleton.arc}\n出场角色:${skeleton.characters !== undefined && skeleton.characters.length > 0 ? skeleton.characters.join('、') : '(未标注)'}\n\n` + skeleton.beats.map((b, i) => `${i + 1}. [${b.function}] ${b.event}(情绪:${b.emotion})${b.cause !== undefined ? `[承接:${b.cause}]` : ''}`).join('\n')
235
284
  void navigator.clipboard?.writeText(text)
236
285
  }}
237
286
  >
@@ -240,7 +289,7 @@ export function StoryboardTab({
240
289
  <button
241
290
  type="button"
242
291
  className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
243
- onClick={() => { setStepState(2); if (table === null) void generateTable() }}
292
+ onClick={() => { if (table === null) void generateTable(); onGoStep?.(2) }}
244
293
  >
245
294
  🎬 下一步:生成分镜表
246
295
  </button>
@@ -250,7 +299,7 @@ export function StoryboardTab({
250
299
  ))}
251
300
 
252
301
  {step === 2 && table !== null && (
253
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginTop: 10 }}>
302
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)', marginTop: 'var(--nf-space-10)' }}>
254
303
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
255
304
  <b>🎬 分镜表({table.shots.length} 个镜头)</b>
256
305
  <button
@@ -269,7 +318,7 @@ export function StoryboardTab({
269
318
  onClick={() => {
270
319
  const text = table.shots.map(s => {
271
320
  const beat = skeleton?.beats.find(b => b.id === s.beatId)
272
- 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}`
321
+ return `镜头 ${s.id}(节拍 ${beat?.id ?? s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s)\n出场:${s.characters !== undefined && s.characters.length > 0 ? s.characters.join('、') : '(未标注)'}\n定妆:${s.mangaRoleIds !== undefined && s.mangaRoleIds.length > 0 ? bindingNames(s.mangaRoleIds) : '(未绑定漫剧卡)'}\n画面:${s.visual}\n台词:${s.line !== '' ? s.line : '(无)'}\n音效:${s.sound !== '' ? s.sound : '(无)'}\n光效:${s.light !== '' ? s.light : '(无)'}\n承接:${s.prevState} → ${s.nextState}`
273
322
  }).join('\n\n')
274
323
  void navigator.clipboard?.writeText(`第${table.chapterNo}章分镜表\n\n` + text)
275
324
  }}
@@ -277,6 +326,12 @@ export function StoryboardTab({
277
326
  📋 复制分镜表
278
327
  </button>
279
328
  <span className={css.meta}>按骨架节拍展开 · 镜头间状态连续</span>
329
+ {table.characters !== undefined && table.characters.length > 0 && (
330
+ <span className={css.meta}>👥 出场角色:{table.characters.join('、')}</span>
331
+ )}
332
+ {table.mangaRoleIds !== undefined && table.mangaRoleIds.length > 0 && (
333
+ <span className={css.meta}>🎨 定妆绑定:{bindingNames(table.mangaRoleIds)}</span>
334
+ )}
280
335
  {table.usedScenes !== undefined && table.usedScenes.length > 0 && (
281
336
  <span className={css.meta}>🏞️ 使用场景:{table.usedScenes.join('、')}</span>
282
337
  )}
@@ -291,7 +346,7 @@ export function StoryboardTab({
291
346
  <button
292
347
  type="button"
293
348
  className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
294
- onClick={() => { setStepState(3); if (prompts === null) void generatePrompts() }}
349
+ onClick={() => { if (prompts === null) void generatePrompts(); onGoStep?.(3) }}
295
350
  >
296
351
  🎬 下一步:生成视频提示词
297
352
  </button>
@@ -299,12 +354,18 @@ export function StoryboardTab({
299
354
  {table.shots.map(s => {
300
355
  const beat = skeleton?.beats.find(b => b.id === s.beatId)
301
356
  return (
302
- <div key={s.id} style={{ display: 'flex', flexDirection: 'column', gap: 3, border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px' }}>
357
+ <div key={s.id} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)' }}>
303
358
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
304
359
  <b>镜头 {s.id}</b>
305
360
  <span className={css.badge}>{s.shot}</span>
306
361
  <span className={css.meta}>{s.camera} · {s.duration}s</span>
307
362
  {beat !== undefined && <span className={css.meta}>节拍 {beat.id}「{beat.function}」</span>}
363
+ {s.characters !== undefined && s.characters.length > 0 && (
364
+ <span className={css.meta}>👥 {s.characters.join('、')}</span>
365
+ )}
366
+ {s.mangaRoleIds !== undefined && s.mangaRoleIds.length > 0 && (
367
+ <span className={css.badge + ' ' + css.badgeDone}>🎨 定妆 {bindingNames(s.mangaRoleIds)}</span>
368
+ )}
308
369
  </div>
309
370
  <span>🎞️ {s.visual}</span>
310
371
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
@@ -320,7 +381,7 @@ export function StoryboardTab({
320
381
  )}
321
382
 
322
383
  {step === 3 && prompts !== null && (
323
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 10 }}>
384
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', marginTop: 'var(--nf-space-10)' }}>
324
385
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
325
386
  <b>🎬 视频提示词({prompts.length} 个镜头)</b>
326
387
  <button
@@ -335,7 +396,7 @@ export function StoryboardTab({
335
396
  type="button"
336
397
  className={`${css.button} ${css.buttonSmall}`}
337
398
  onClick={() => {
338
- const text = prompts.map(x => `【镜头 ${x.shotId}】${x.text}`).join('\n\n')
399
+ const text = prompts.map(x => `【镜头 ${x.shotId}】${x.mangaRoleIds !== undefined && x.mangaRoleIds.length > 0 ? '定妆:' + bindingNames(x.mangaRoleIds) + ' ' : ''}${x.text}`).join('\n\n')
339
400
  void navigator.clipboard?.writeText(`第${table?.chapterNo ?? ''}章视频提示词\n\n` + text)
340
401
  }}
341
402
  >
@@ -343,10 +404,13 @@ export function StoryboardTab({
343
404
  </button>
344
405
  </div>
345
406
  {prompts.map(x => (
346
- <div key={x.shotId} style={{ display: 'flex', flexDirection: 'column', gap: 3, border: '1px solid var(--nf-accent)', borderRadius: 10, padding: '8px 10px' }}>
407
+ <div key={x.shotId} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)' }}>
347
408
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
348
409
  <b>镜头 {x.shotId}</b>
349
410
  <span className={css.meta}>即梦/Seedance 提示词</span>
411
+ {x.mangaRoleIds !== undefined && x.mangaRoleIds.length > 0 && (
412
+ <span className={css.badge + ' ' + css.badgeDone}>🎨 定妆 {bindingNames(x.mangaRoleIds)}</span>
413
+ )}
350
414
  <div style={{ flex: 1 }} />
351
415
  <button
352
416
  type="button"
@@ -360,7 +424,7 @@ export function StoryboardTab({
360
424
  {copied === x.shotId ? '✅ 已复制' : '📋 复制'}
361
425
  </button>
362
426
  </div>
363
- <span style={{ fontSize: 13, lineHeight: 1.7 }}>{x.text}</span>
427
+ <span style={{ fontSize: 'var(--nf-fs-14)', lineHeight: 1.7 }}>{x.text}</span>
364
428
  </div>
365
429
  ))}
366
430
  </div>
@@ -33,9 +33,9 @@ export function StyleCard({
33
33
  <div
34
34
  onClick={onClick}
35
35
  style={{
36
- display: 'flex', flexDirection: 'column', gap: 6,
36
+ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)',
37
37
  border: `1px solid ${selected === true ? 'var(--nf-accent)' : 'var(--nf-border)'}`,
38
- borderRadius: 14, padding: 10, background: 'var(--nf-bg-raise)',
38
+ borderRadius: 'var(--nf-radius-14)', padding: 'var(--nf-space-10)', background: 'var(--nf-bg-raise)',
39
39
  cursor: onClick !== undefined ? 'pointer' : 'default',
40
40
  boxShadow: selected === true ? '0 0 0 2px var(--nf-accent-soft)' : 'none',
41
41
  }}
@@ -43,12 +43,12 @@ export function StyleCard({
43
43
  {/* 效果图(竖版 3:4,服务端缩略图 webp;无图回退占位) */}
44
44
  <div
45
45
  style={{
46
- aspectRatio: '3 / 4', borderRadius: 10,
46
+ aspectRatio: '3 / 4', borderRadius: 'var(--nf-radius-10)',
47
47
  position: 'relative', overflow: 'hidden',
48
48
  display: 'flex', alignItems: 'center', justifyContent: 'center',
49
49
  background: 'linear-gradient(145deg, var(--nf-bg-inset), color-mix(in srgb, var(--nf-accent) 12%, var(--nf-bg-inset)))',
50
50
  border: '1px solid var(--nf-border)',
51
- color: 'var(--nf-text-3)', fontSize: 12, textAlign: 'center',
51
+ color: 'var(--nf-text-3)', fontSize: 'var(--nf-fs-12)', textAlign: 'center',
52
52
  }}
53
53
  >
54
54
  <img
@@ -60,22 +60,22 @@ export function StyleCard({
60
60
  />
61
61
  🖼️ 效果图待生成
62
62
  {selected === true && (
63
- <span style={{ position: 'absolute', top: 6, right: 6, background: 'var(--nf-accent)', color: '#fff', borderRadius: 999, padding: '2px 8px', fontSize: 11, fontWeight: 700 }}>✓</span>
63
+ <span style={{ position: 'absolute', top: 6, right: 6, background: 'var(--nf-accent)', color: '#fff', borderRadius: 'var(--nf-radius-999)', padding: 'var(--nf-space-2) var(--nf-space-8)', fontSize: 'var(--nf-fs-12)', fontWeight: 700 }}>✓</span>
64
64
  )}
65
65
  </div>
66
66
 
67
- <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
68
- <b style={{ fontSize: 13 }}>{style.name}</b>
69
- <span style={{ fontSize: 10, color: 'var(--nf-text-3)' }}>{cat?.icon} {style.category.toUpperCase()}</span>
70
- {style.stackable === true && <span style={{ fontSize: 10, color: 'var(--nf-info)', border: '1px solid var(--nf-info)', borderRadius: 999, padding: '0 6px' }}>叠加滤镜</span>}
67
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-6)', flexWrap: 'wrap' }}>
68
+ <b style={{ fontSize: 'var(--nf-fs-14)' }}>{style.name}</b>
69
+ <span style={{ fontSize: 'var(--nf-fs-12)', color: 'var(--nf-text-3)' }}>{cat?.icon} {style.category.toUpperCase()}</span>
70
+ {style.stackable === true && <span style={{ fontSize: 'var(--nf-fs-12)', color: 'var(--nf-info)', border: '1px solid var(--nf-info)', borderRadius: 'var(--nf-radius-999)', padding: '0 var(--nf-space-6)' }}>叠加滤镜</span>}
71
71
  </div>
72
72
 
73
- <span style={{ fontSize: 11.5, lineHeight: 1.5, color: 'var(--nf-text-2)' }}>{style.traits}</span>
73
+ <span style={{ fontSize: 'var(--nf-fs-12)', lineHeight: 1.5, color: 'var(--nf-text-2)' }}>{style.traits}</span>
74
74
 
75
75
  <div
76
76
  style={{
77
- fontSize: 11, lineHeight: 1.5, color: 'var(--nf-text-3)',
78
- borderTop: '1px solid var(--nf-border)', paddingTop: 5,
77
+ fontSize: 'var(--nf-fs-12)', lineHeight: 1.5, color: 'var(--nf-text-3)',
78
+ borderTop: '1px solid var(--nf-border)', paddingTop: 'var(--nf-space-6)',
79
79
  display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden',
80
80
  cursor: 'copy',
81
81
  }}
@@ -84,7 +84,7 @@ export function StyleCard({
84
84
  >
85
85
  关键词:{style.keywords}
86
86
  </div>
87
- {copied === style.id && <span style={{ color: 'var(--nf-ok, #46a758)', fontSize: 11 }}>✅ 已复制</span>}
87
+ {copied === style.id && <span style={{ color: 'var(--nf-ok, #46a758)', fontSize: 'var(--nf-fs-12)' }}>✅ 已复制</span>}
88
88
  </div>
89
89
  )
90
90
  }
@@ -30,7 +30,7 @@ export function StyleGallery() {
30
30
  </div>
31
31
 
32
32
  {/* 分类标签 */}
33
- <div className={css.row} style={{ gap: 8, flexWrap: 'wrap' }}>
33
+ <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
34
34
  {STYLE_CATEGORIES.map(cat => (
35
35
  <button
36
36
  key={cat.id}
@@ -48,7 +48,7 @@ export function StyleGallery() {
48
48
  </div>
49
49
 
50
50
  {/* 风格卡片网格 */}
51
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 12, marginTop: 6 }}>
51
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 'var(--nf-space-12)', marginTop: 'var(--nf-space-6)' }}>
52
52
  {list.map(style => (
53
53
  <StyleCard key={style.id} style={style} />
54
54
  ))}
@@ -28,7 +28,7 @@ function EditableRow({
28
28
  detailPlaceholder: string
29
29
  }) {
30
30
  return (
31
- <div style={{ display: 'flex', gap: 6, alignItems: 'flex-start' }}>
31
+ <div style={{ display: 'flex', gap: 'var(--nf-space-6)', alignItems: 'flex-start' }}>
32
32
  <input className={css.input} style={{ flex: 2, minWidth: 0 }} placeholder={namePlaceholder} value={name} onChange={e => { onName(e.target.value) }} />
33
33
  <input className={css.input} style={{ flex: 3, minWidth: 0 }} placeholder={detailPlaceholder} value={detail} onChange={e => { onDetail(e.target.value) }} />
34
34
  <button type="button" className={`${css.button} ${css.buttonSmall}`} title="删除" onClick={onRemove}>×</button>
@@ -89,7 +89,7 @@ export function WorldTab({
89
89
  const setFactions = (factions: WorldFaction[]): void => setDraft(prev => ({ ...prev, factions }))
90
90
 
91
91
  return (
92
- <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
92
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-12)' }}>
93
93
  {error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{error}</span></div>}
94
94
 
95
95
  <div className={css.card}>
@@ -120,7 +120,7 @@ export function WorldTab({
120
120
  {draft.realms.length === 0 ? (
121
121
  <span className={css.meta}>暂无境界体系 — 点击 ✨AI 提炼 或手动添加(由低到高)。</span>
122
122
  ) : (
123
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
123
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
124
124
  {draft.realms.map((realm, i) => (
125
125
  <EditableRow
126
126
  key={i}
@@ -148,7 +148,7 @@ export function WorldTab({
148
148
  {draft.regions.length === 0 ? (
149
149
  <span className={css.meta}>暂无地理区域 — 大陆 / 海域 / 秘境 / 遗迹…</span>
150
150
  ) : (
151
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
151
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
152
152
  {draft.regions.map((region, i) => (
153
153
  <EditableRow
154
154
  key={i}
@@ -176,7 +176,7 @@ export function WorldTab({
176
176
  {draft.factions.length === 0 ? (
177
177
  <span className={css.meta}>暂无势力 — 宗门 / 家族 / 王朝 / 组织…</span>
178
178
  ) : (
179
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
179
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
180
180
  {draft.factions.map((faction, i) => (
181
181
  <EditableRow
182
182
  key={i}