@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
@@ -9,6 +9,7 @@ import type { ReactElement } from 'react'
9
9
  import type { NovelApi } from '../api.ts'
10
10
  import type { PanelController } from './controller.ts'
11
11
  import { tt } from './helpers.ts'
12
+ import { BarChart3, Book, BookMarked, BookOpen, Brain, Clapperboard, Factory, FileText, Folder, GitBranch, Library, MessageSquare, Palette, PenLine, PlugZap, RotateCcw, ScrollText, Search, Settings, Sparkles, Wrench } from 'lucide-react'
12
13
  import { AssistantTab } from './AssistantTab.tsx'
13
14
  import { AssetsTab } from './AssetsTab.tsx'
14
15
  import { ShelfView } from './ShelfView.tsx'
@@ -17,7 +18,6 @@ import { RunPanel } from './RunPanel.tsx'
17
18
  import { CreateBookView } from './CreateBookView.tsx'
18
19
  import { ImportModal } from './ImportModal.tsx'
19
20
  import { WorldTab } from './WorldTab.tsx'
20
- import { StoryboardTab } from './StoryboardTab.tsx'
21
21
  import { MangaWorkspace } from './MangaWorkspace.tsx'
22
22
  import { AuditIssueRow, PlotlineCard, PlotlineHealthPanel, PlotlinePlanPanel, PlotlineSuggestionPanel, RoleCandidateRow, RoleCard, StatCell, TodoRow } from './views.tsx'
23
23
  import { extractDocxTextFromBuffer } from '../docx.ts'
@@ -26,6 +26,7 @@ import type {
26
26
  BookshelfSnapshot,
27
27
  ChapterPlan,
28
28
  Foreshadow,
29
+ ImageModelConfig,
29
30
  JobFrame,
30
31
  NovelConfig,
31
32
  Plotline,
@@ -67,46 +68,55 @@ interface ProgressLine {
67
68
  }
68
69
 
69
70
  /** The navigation groups (AI-Novel-Writing-Assistant style grouping). */
70
- const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArray<{ id: NovelTab; label: string; icon: string }> }> = [
71
+ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArray<{ id: NovelTab; label: string; icon: ReactElement }> }> = [
71
72
  {
72
73
  id: 'create',
73
74
  label: '创作',
74
75
  items: [
75
- { id: 'workflow', label: tt('tab.workflow'), icon: '🛠️' },
76
- { id: 'overview', label: tt('tab.overview'), icon: '📄' },
77
- { id: 'blurb', label: '简介 / 封面', icon: '📖' },
78
- { id: 'plan', label: tt('tab.plan'), icon: '📚' },
79
- { id: 'plotlines', label: '长线管理', icon: '📜' },
80
- { id: 'book', label: '本书设定', icon: '📚' },
76
+ { id: 'workflow', label: tt('tab.workflow'), icon: <Wrench size={16} /> },
77
+ { id: 'overview', label: tt('tab.overview'), icon: <FileText size={16} /> },
78
+ { id: 'blurb', label: '简介 / 封面', icon: <BookOpen size={16} /> },
79
+ { id: 'plan', label: tt('tab.plan'), icon: <BookMarked size={16} /> },
80
+ { id: 'plotlines', label: '长线管理', icon: <ScrollText size={16} /> },
81
+ { id: 'book', label: '本书设定', icon: <Library size={16} /> },
81
82
  ],
82
83
  },
83
84
  {
84
85
  id: 'tools',
85
86
  label: '工具',
86
87
  items: [
87
- { id: 'assistant', label: tt('tab.assistant'), icon: '💬' },
88
- { id: 'progress', label: '工作进度', icon: '📊' },
89
- { id: 'breakdown', label: '拆书分析', icon: '🔍' },
90
- { id: 'manhua', label: '漫剧工作台', icon: '🎬' },
91
- { id: 'run', label: '生产单', icon: '🏭' },
88
+ { id: 'assistant', label: tt('tab.assistant'), icon: <MessageSquare size={16} /> },
89
+ { id: 'progress', label: 'AI 进度', icon: <BarChart3 size={16} /> },
90
+ { id: 'breakdown', label: '拆书分析', icon: <Search size={16} /> },
91
+ { id: 'manhua', label: '漫剧工作台', icon: <Clapperboard size={16} /> },
92
+ { id: 'run', label: '生产单', icon: <Factory size={16} /> },
92
93
  ],
93
94
  },
94
95
  {
95
96
  id: 'assets',
96
97
  label: '资产',
97
98
  items: [
98
- { id: 'assets', label: '创作资产', icon: '🧰' },
99
+ { id: 'assets', label: '创作资产', icon: <Wrench size={16} /> },
99
100
  ],
100
101
  },
101
102
  ]
102
103
 
103
104
  /** Settings tab — pinned to the bottom of the nav rail. */
104
- const SETTINGS_TAB: { id: NovelTab; label: string; icon: string } = { id: 'settings', label: tt('tab.settings'), icon: '⚙️' }
105
+ const SETTINGS_TAB: { id: NovelTab; label: string; icon: ReactElement } = { id: 'settings', label: tt('tab.settings'), icon: <Settings size={16} /> }
105
106
 
106
107
  /** Common DeepSeek model presets shown in settings; users can also type any model id. */
107
108
  const MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
108
109
  const REASONING_OPTIONS = ['off', 'low', 'high', 'max'] as const
109
110
 
111
+ /** 设置页内子导航分组。 */
112
+ const SETTINGS_SECTIONS: ReadonlyArray<{ id: 'model' | 'writing' | 'image' | 'files' | 'appearance'; label: string; icon: ReactElement }> = [
113
+ { id: 'model', label: '模型与推理', icon: <Brain size={16} /> },
114
+ { id: 'writing', label: '写作与审稿', icon: <PenLine size={16} /> },
115
+ { id: 'image', label: '生图模型', icon: <Palette size={16} /> },
116
+ { id: 'files', label: '路径与文件', icon: <Folder size={16} /> },
117
+ { id: 'appearance', label: '外观与主题', icon: <Sparkles size={16} /> },
118
+ ]
119
+
110
120
  /** 构建时注入的插件版本(tsdown define 替换为字符串字面量)。 */
111
121
  declare const __NOVEL_FORGE_VERSION__: string | undefined
112
122
  const PLUGIN_VERSION: string = typeof __NOVEL_FORGE_VERSION__ !== 'undefined' ? __NOVEL_FORGE_VERSION__ : '0.0.0'
@@ -215,7 +225,7 @@ function paragraphDiff(oldText: string, newText: string): DiffRow[] {
215
225
  function renderBeats(beats: string): ReactElement {
216
226
  const lines = beats.split('\n')
217
227
  return (
218
- <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
228
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-2)' }}>
219
229
  {lines.map((line, i) => {
220
230
  const trimmed = line.trim()
221
231
  const match = /^([^::]{2,14})[::]/.exec(trimmed)
@@ -371,17 +381,61 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
371
381
  setEditorFontSize(v)
372
382
  try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
373
383
  }
374
- /** 面板主题(localStorage 记忆):'liquid'=iOS 液态玻璃(绿) / 'classic'=经典毛玻璃(蓝) / 'neumorph'=新拟物(浅色)。 */
375
- const [panelTheme, setPanelTheme] = useState<'liquid' | 'classic' | 'neumorph'>(() => {
384
+ /** 面板主题(localStorage 记忆):'liquid'=iOS 液态玻璃(绿) / 'neumorph'=新拟物(浅色) / 'macos'=macOS 玻璃(蓝,随外观自动浅深) / 'clay'=粘土拟态。 */
385
+ const [panelTheme, setPanelTheme] = useState<'liquid' | 'neumorph' | 'macos' | 'clay'>(() => {
376
386
  try {
377
387
  const v = window.localStorage.getItem('dsh-novel-forge.theme')
378
- return v === 'classic' || v === 'neumorph' ? v : 'liquid'
388
+ return v === 'neumorph' || v === 'macos' || v === 'clay' ? v : 'liquid'
379
389
  } catch { return 'liquid' }
380
390
  })
381
- const changePanelTheme = (next: 'liquid' | 'classic' | 'neumorph'): void => {
391
+ const changePanelTheme = (next: 'liquid' | 'neumorph' | 'macos' | 'clay'): void => {
382
392
  setPanelTheme(next)
383
393
  try { window.localStorage.setItem('dsh-novel-forge.theme', next) } catch { /* ignore */ }
384
394
  }
395
+ /** 显示模式(跟随系统 / 强制浅色 / 强制深色),localStorage 记忆,只作用于小说工坊面板。 */
396
+ const [themeMode, setThemeMode] = useState<'system' | 'light' | 'dark'>(() => {
397
+ try {
398
+ const v = window.localStorage.getItem('dsh-novel-forge.theme.mode')
399
+ return v === 'light' || v === 'dark' ? v : 'system'
400
+ } catch { return 'system' }
401
+ })
402
+ const changeThemeMode = (next: 'system' | 'light' | 'dark'): void => {
403
+ setThemeMode(next)
404
+ try { window.localStorage.setItem('dsh-novel-forge.theme.mode', next) } catch { /* ignore */ }
405
+ }
406
+ /** 界面密度(舒适 / 紧凑 / 宽松),localStorage 记忆。 */
407
+ const [themeDensity, setThemeDensity] = useState<'comfort' | 'compact' | 'spacious'>(() => {
408
+ try {
409
+ const v = window.localStorage.getItem('dsh-novel-forge.theme.density')
410
+ return v === 'compact' || v === 'spacious' ? v : 'comfort'
411
+ } catch { return 'comfort' }
412
+ })
413
+ const changeThemeDensity = (next: 'comfort' | 'compact' | 'spacious'): void => {
414
+ setThemeDensity(next)
415
+ try { window.localStorage.setItem('dsh-novel-forge.theme.density', next) } catch { /* ignore */ }
416
+ }
417
+ /** 恢复默认主题:清掉主题/模式/密度记忆。 */
418
+ const resetTheme = (): void => {
419
+ try {
420
+ window.localStorage.removeItem('dsh-novel-forge.theme')
421
+ window.localStorage.removeItem('dsh-novel-forge.theme.mode')
422
+ window.localStorage.removeItem('dsh-novel-forge.theme.density')
423
+ } catch { /* ignore */ }
424
+ setPanelTheme('liquid')
425
+ setThemeMode('system')
426
+ setThemeDensity('comfort')
427
+ }
428
+ /** 设置页内子导航:当前分组(localStorage 记忆)。 */
429
+ const [settingsTab, setSettingsTab] = useState<'model' | 'writing' | 'image' | 'files' | 'appearance'>(() => {
430
+ try {
431
+ const v = window.localStorage.getItem('dsh-novel-forge.settings.tab')
432
+ return v === 'writing' || v === 'image' || v === 'files' || v === 'appearance' ? v : 'model'
433
+ } catch { return 'model' }
434
+ })
435
+ const changeSettingsTab = (next: 'model' | 'writing' | 'image' | 'files' | 'appearance'): void => {
436
+ setSettingsTab(next)
437
+ try { window.localStorage.setItem('dsh-novel-forge.settings.tab', next) } catch { /* ignore */ }
438
+ }
385
439
  /** 有未采纳草稿的章节号(refresh 后检测到遗留草稿时提示)。 */
386
440
  const [draftNo, setDraftNo] = useState<number | null>(null)
387
441
  /** 大纲页「更新大纲」编辑区是否展开。 */
@@ -509,9 +563,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
509
563
  }, [])
510
564
  /** AI 助手悬浮窗:是否打开。 */
511
565
  const [assistantOpen, setAssistantOpen] = useState(false)
512
- /** 工作进度悬浮窗:是否打开。 */
566
+ /** AI进度悬浮窗:是否打开。 */
513
567
  const [progressOpen, setProgressOpen] = useState(false)
514
- /** 工作进度悬浮窗位置(localStorage 记忆)。 */
568
+ /** AI进度悬浮窗位置(localStorage 记忆)。 */
515
569
  const [progressPos, setProgressPos] = useState(() => {
516
570
  try {
517
571
  const raw = window.localStorage.getItem('dsh-novel-forge.progress.float')
@@ -522,7 +576,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
522
576
  } catch { /* ignore */ }
523
577
  return { x: 60, y: 120 }
524
578
  })
525
- /** 工作进度悬浮窗尺寸(localStorage 记忆)。 */
579
+ /** AI进度悬浮窗尺寸(localStorage 记忆)。 */
526
580
  const [progressSize, setProgressSize] = useState(() => {
527
581
  try {
528
582
  const raw = window.localStorage.getItem('dsh-novel-forge.progress.size')
@@ -555,7 +609,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
555
609
  } catch { /* ignore */ }
556
610
  return { w: 420, h: 460 }
557
611
  })
558
- /** 拖拽/缩放状态(target 区分 AI 助手 / 工作进度两个悬浮窗)。 */
612
+ /** 拖拽/缩放状态(target 区分 AI 助手 / AI进度两个悬浮窗)。 */
559
613
  const dragState = useRef<{ type: 'move' | 'resize'; target: 'assistant' | 'progress'; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null)
560
614
 
561
615
  /** 悬浮窗位置/尺寸持久化。 */
@@ -1798,7 +1852,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1798
1852
  lastDeltaChars.current = 0
1799
1853
  liveChars.current = 0
1800
1854
  currentJobNo.current = frame.no
1801
- // 任务开始:不自动弹出进度悬浮窗(导航「工作进度」显示呼吸绿点提示,
1855
+ // 任务开始:不自动弹出进度悬浮窗(导航「AI进度」显示呼吸绿点提示,
1802
1856
  // 想看进度时手动点导航打开;liveBar 数据照常累积)。
1803
1857
  setProject(prev => prev === null ? prev : {
1804
1858
  ...prev,
@@ -2010,6 +2064,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2010
2064
  imageApiKey: configDraft.imageApiKey,
2011
2065
  imageApiModel: configDraft.imageApiModel,
2012
2066
  imageApiEnabled: configDraft.imageApiEnabled ?? false,
2067
+ imageModels: configDraft.imageModels ?? [],
2013
2068
  })
2014
2069
  setConfig(result.config)
2015
2070
  setConfigDraft(result.config)
@@ -2023,6 +2078,42 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2023
2078
  }
2024
2079
  }
2025
2080
 
2081
+ /** 生图接口测试状态(模型 id → 结果)。 */
2082
+ const [imageTestState, setImageTestState] = useState<Record<string, { testing: boolean; ok?: boolean; ms?: number; message?: string; modelFound?: boolean }>>({})
2083
+ /** 测试一条生图模型:连通 + 延迟。 */
2084
+ const testImageModel = async (m: ImageModelConfig): Promise<void> => {
2085
+ if (m.baseURL.trim() === '' || m.apiKey.trim() === '') {
2086
+ setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: '请先填写接口地址和 API Key' } }))
2087
+ return
2088
+ }
2089
+ setImageTestState(prev => ({ ...prev, [m.id]: { testing: true } }))
2090
+ try {
2091
+ const r = await api.imageTest({ baseURL: m.baseURL, apiKey: m.apiKey, model: m.model })
2092
+ setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: r.ok, ms: r.ms, message: r.message } }))
2093
+ } catch (err) {
2094
+ setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: (err as Error).message } }))
2095
+ }
2096
+ }
2097
+ /** 更新一条生图模型配置。 */
2098
+ const updateImageModel = (id: string, patch: Partial<ImageModelConfig>): void => {
2099
+ setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => m.id === id ? { ...m, ...patch } : m) })
2100
+ }
2101
+ /** 新增一条生图模型(第一条默认启用)。 */
2102
+ const addImageModel = (): void => {
2103
+ setConfigDraft(prev => prev === null ? prev : {
2104
+ ...prev,
2105
+ imageModels: [...(prev.imageModels ?? []), { id: 'img-' + Date.now().toString(36), name: '', baseURL: '', apiKey: '', model: '', enabled: (prev.imageModels ?? []).length === 0 }],
2106
+ })
2107
+ }
2108
+ /** 删除一条生图模型。 */
2109
+ const removeImageModel = (id: string): void => {
2110
+ setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).filter(m => m.id !== id) })
2111
+ }
2112
+ /** 启用一条(其余自动停用)。 */
2113
+ const enableImageModel = (id: string): void => {
2114
+ setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => ({ ...m, enabled: m.id === id })) })
2115
+ }
2116
+
2026
2117
  /** Export the book. */
2027
2118
  const handleExport = async (format: 'txt' | 'md'): Promise<void> => {
2028
2119
  setBusy(true)
@@ -2274,7 +2365,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2274
2365
  })()
2275
2366
 
2276
2367
  return (
2277
- <div className={css.panel} data-nf-theme={panelTheme}>
2368
+ <div className={css.panel} data-nf-theme={panelTheme} data-nf-mode={themeMode === 'system' ? undefined : themeMode} data-nf-density={themeDensity}>
2278
2369
  {viewMode === 'shelf' ? (
2279
2370
  /* 书架首页:默认视图,选择一本书进入工作台或阅读页 */
2280
2371
  <ShelfView
@@ -2376,7 +2467,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2376
2467
  {tab.id === 'workflow' && chapters.length > 0 && (
2377
2468
  <span className={`${css.navTabBadge} ${css.navTabBadgeDone}`}>{journeyPercent}%</span>
2378
2469
  )}
2379
- {/* 任务进行中:工作进度导航显示呼吸绿点(悬停看任务名),不自动弹窗 */}
2470
+ {/* 任务进行中:AI进度导航显示呼吸绿点(悬停看任务名),不自动弹窗 */}
2380
2471
  {tab.id === 'progress' && busy && (
2381
2472
  <span
2382
2473
  className={`${css.navTabBadge} ${css.navTabBadgeLive}`}
@@ -2483,7 +2574,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2483
2574
  <div className={css.card} style={{ borderColor: 'var(--nf-info)' }}>
2484
2575
  <div className={css.busyRow}>
2485
2576
  <span style={{ color: 'var(--nf-info)' }}>第 {draftNo} 章有未采纳的润色/修订草稿(原稿未被改动)</span>
2486
- <span style={{ display: 'flex', gap: 8 }}>
2577
+ <span style={{ display: 'flex', gap: 'var(--nf-space-8)' }}>
2487
2578
  <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void openWorkspace(draftNo) }}>
2488
2579
  打开工作区
2489
2580
  </button>
@@ -2510,7 +2601,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2510
2601
  )}
2511
2602
  <span className={css.cardTitle}>第 {workspace.no} 章《{workspace.title}》</span>
2512
2603
  <span className={css.meta}>{workspace.original.length} 字</span>
2513
- <span style={{ display: 'flex', gap: 4, alignItems: 'center', marginLeft: 'auto' }}>
2604
+ <span style={{ display: 'flex', gap: 'var(--nf-space-4)', alignItems: 'center', marginLeft: 'auto' }}>
2514
2605
  <button type="button" className={css.iconButton} title="减小字号" aria-label="减小字号" onClick={() => { changeEditorFontSize(editorFontSize - 1) }}>A−</button>
2515
2606
  <span className={css.meta}>{editorFontSize}px</span>
2516
2607
  <button type="button" className={css.iconButton} title="增大字号" aria-label="增大字号" onClick={() => { changeEditorFontSize(editorFontSize + 1) }}>A+</button>
@@ -2625,12 +2716,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2625
2716
  </div>
2626
2717
  <div className={css.meta}><b>总评:</b>{wsCheckReport.verdict}</div>
2627
2718
  {wsCheckReport.issues.length > 0 && (
2628
- <ul style={{ margin: 0, paddingLeft: 16, display: 'flex', flexDirection: 'column', gap: 3, fontSize: editorFontSize - 1, maxHeight: '45vh', overflowY: 'auto' }}>
2719
+ <ul style={{ margin: 0, paddingLeft: 'var(--nf-space-16)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', fontSize: editorFontSize - 1, maxHeight: '45vh', overflowY: 'auto' }}>
2629
2720
  {wsCheckReport.issues.map((issue, i) => (
2630
- <li key={i} style={{ color: severityColor(issue.severity), display: 'flex', gap: 6, alignItems: 'flex-start' }}>
2721
+ <li key={i} style={{ color: severityColor(issue.severity), display: 'flex', gap: 'var(--nf-space-6)', alignItems: 'flex-start' }}>
2631
2722
  <input
2632
2723
  type="checkbox"
2633
- style={{ marginTop: 1 }}
2724
+ style={{ marginTop: 'var(--nf-space-2)' }}
2634
2725
  checked={wsChecked.includes(i)}
2635
2726
  onChange={e => {
2636
2727
  setWsChecked(prev => e.target.checked ? [...prev, i] : prev.filter(x => x !== i))
@@ -2656,7 +2747,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2656
2747
  <div className={css.wsPreview} style={{ flex: 1, minHeight: 0 }}>
2657
2748
  <div className={css.busyRow}>
2658
2749
  <span className={css.meta}>优化预览({workspace.draft.length} 字)</span>
2659
- <span style={{ display: 'flex', gap: 8 }}>
2750
+ <span style={{ display: 'flex', gap: 'var(--nf-space-8)' }}>
2660
2751
  <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWsShowDiff(v => !v) }}>
2661
2752
  {wsShowDiff ? '显示文本' : '查看对比'}
2662
2753
  </button>
@@ -2779,17 +2870,17 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2779
2870
  </button>
2780
2871
  </div>
2781
2872
  {auditStatus?.status === 'running' && (
2782
- <div className={css.meta} style={{ marginTop: 8 }}>
2873
+ <div className={css.meta} style={{ marginTop: 'var(--nf-space-8)' }}>
2783
2874
  🔍 全书质检中:{auditStatus.completedBatches}/{auditStatus.totalBatches > 0 ? auditStatus.totalBatches : '…'} 批
2784
2875
  {auditStatus.totalBatches > 0 && (
2785
- <div className={css.dashJourneyBar} style={{ marginTop: 4 }}>
2876
+ <div className={css.dashJourneyBar} style={{ marginTop: 'var(--nf-space-4)' }}>
2786
2877
  <div className={css.dashJourneyFill} style={{ width: `${Math.round((auditStatus.completedBatches / auditStatus.totalBatches) * 100)}%` }} />
2787
2878
  </div>
2788
2879
  )}
2789
2880
  </div>
2790
2881
  )}
2791
2882
  {auditStatus?.status === 'error' && auditStatus.error !== undefined && (
2792
- <div className={css.meta} style={{ color: 'var(--nf-error)', marginTop: 8 }}>全书质检失败:{auditStatus.error}</div>
2883
+ <div className={css.meta} style={{ color: 'var(--nf-error)', marginTop: 'var(--nf-space-8)' }}>全书质检失败:{auditStatus.error}</div>
2793
2884
  )}
2794
2885
  <div className={css.assetGrid}>
2795
2886
  <StatCell
@@ -2821,7 +2912,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2821
2912
  </div>
2822
2913
 
2823
2914
  {/* 剧情线进度 | 作者复盘趋势:精简摘要卡,并排(点击跳转详情页) */}
2824
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 14, alignItems: 'stretch' }}>
2915
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 'var(--nf-space-14)', alignItems: 'stretch' }}>
2825
2916
  <div className={css.card}>
2826
2917
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
2827
2918
  <span className={css.cardTitle}>🧵 {tt('plotlines.workflowTitle')}({(project?.plotlines ?? []).length} 条)</span>
@@ -2900,7 +2991,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2900
2991
  </button>
2901
2992
  </div>
2902
2993
  {auditIssues.length > 0 && (
2903
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
2994
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
2904
2995
  {auditIssues.map((issue, i) => (
2905
2996
  <AuditIssueRow
2906
2997
  key={i}
@@ -2914,7 +3005,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2914
3005
  </div>
2915
3006
  )}
2916
3007
 
2917
- {/* 待办队列(活动输出已移入「工具 → 工作进度」悬浮窗) */}
3008
+ {/* 待办队列(活动输出已移入「工具 → AI进度」悬浮窗) */}
2918
3009
  <div className={css.card}>
2919
3010
  <span className={css.cardTitle}>待办队列</span>
2920
3011
  {todos.length === 0 ? (
@@ -3032,7 +3123,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3032
3123
  ) : (
3033
3124
  /* 只读展示:大纲是开书时的出生证明 */
3034
3125
  <>
3035
- <div className={css.meta} style={{ marginBottom: 6 }}>
3126
+ <div className={css.meta} style={{ marginBottom: 'var(--nf-space-6)' }}>
3036
3127
  大纲是本书的「出生证明」;已写章节可点右上角「🔄 反推大纲」从正文反推,或「更新大纲」手动修订。
3037
3128
  </div>
3038
3129
  <pre className={css.outlineReadonly}>{project.outline}</pre>
@@ -3095,7 +3186,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3095
3186
  </button>
3096
3187
  </div>
3097
3188
  {/* 封面 */}
3098
- <div className={css.row} style={{ alignItems: 'flex-start', gap: 14 }}>
3189
+ <div className={css.row} style={{ alignItems: 'flex-start', gap: 'var(--nf-space-14)' }}>
3099
3190
  <div className={css.coverPreview}>
3100
3191
  {coverDataUrl !== null ? (
3101
3192
  <img src={coverDataUrl} alt="封面" />
@@ -3103,7 +3194,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3103
3194
  <span className={css.coverPlaceholder}>暂无封面</span>
3104
3195
  )}
3105
3196
  </div>
3106
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
3197
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)' }}>
3107
3198
  <div className={css.row}>
3108
3199
  <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || project === null} onClick={() => { coverFileRef.current?.click() }}>
3109
3200
  📤 上传封面
@@ -3194,9 +3285,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3194
3285
  </div>
3195
3286
  <span className={css.meta}>{tt('sensitive.hint')}</span>
3196
3287
  {sensHits.length > 0 && (
3197
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 300, overflowY: 'auto', fontSize: 12 }}>
3288
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', maxHeight: 300, overflowY: 'auto', fontSize: 'var(--nf-fs-12)' }}>
3198
3289
  {sensHits.map((hit, i) => (
3199
- <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', border: '1px solid var(--nf-border)', borderRadius: 6, padding: '4px 8px' }}>
3290
+ <div key={i} style={{ display: 'flex', gap: 'var(--nf-space-8)', alignItems: 'flex-start', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-6)', padding: 'var(--nf-space-4) var(--nf-space-8)' }}>
3200
3291
  <span className={css.badge} style={{ borderColor: 'var(--nf-warn)', color: 'var(--nf-warn)', flex: 'none' }}>
3201
3292
  {hit.chapterNo > 0 ? `第${hit.chapterNo}章` : '文本'}
3202
3293
  </span>
@@ -3223,7 +3314,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3223
3314
  {chapters.length > 0 && (
3224
3315
  <div className={css.card}>
3225
3316
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3226
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
3317
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
3227
3318
  {(volumes !== undefined && volumes.length > 0) && (
3228
3319
  <>
3229
3320
  <button
@@ -3280,7 +3371,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3280
3371
  <span className={css.chapterNum}>{chapter.no}</span>
3281
3372
  <div className={css.chapterMain}>
3282
3373
  <div className={css.chapterTitle}>
3283
- <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ padding: '1px 6px' }} onClick={() => { void handleToggleChapter(chapter.no) }}>
3374
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ padding: 'var(--nf-space-2) var(--nf-space-6)' }} onClick={() => { void handleToggleChapter(chapter.no) }}>
3284
3375
  {expanded ? '−' : '+'}
3285
3376
  </button>
3286
3377
  <span>{chapter.title}</span>
@@ -3291,7 +3382,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3291
3382
  </div>
3292
3383
  {!expanded && <div className={css.chapterBeats} title={chapter.beats}>{chapter.beats}</div>}
3293
3384
  {expanded && (
3294
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
3385
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
3295
3386
  <div className={css.meta}><b>{tt('plan.beats')}:</b></div>
3296
3387
  <div className={css.meta}>{renderBeats(chapter.beats)}</div>
3297
3388
  {chapter.summary !== undefined && chapter.summary !== '' && (
@@ -3308,7 +3399,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3308
3399
  </div>
3309
3400
  <div className={css.meta}><b>{tt('plan.reviewVerdict')}:</b> {review.verdict}</div>
3310
3401
  {review.issues.length > 0 && (
3311
- <ul style={{ margin: 0, paddingLeft: 18, fontSize: 12 }}>
3402
+ <ul style={{ margin: 0, paddingLeft: 'var(--nf-space-18)', fontSize: 'var(--nf-fs-12)' }}>
3312
3403
  {review.issues.map((issue, i) => (
3313
3404
  <li key={i} style={{ color: severityColor(issue.severity) }}>
3314
3405
  [{issue.severity}] {issue.item} → {issue.suggestion}
@@ -3354,7 +3445,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3354
3445
  </div>
3355
3446
  )}
3356
3447
  {(chapter.status === 'rejected' || chapter.status === 'written' || chapter.status === 'approved') && (
3357
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
3448
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
3358
3449
  <div className={css.meta} style={{ fontWeight: 600 }}>
3359
3450
  润色 / 修订 — 在右上角打开工作区:左栏原文可直接选中文字做局部修订,右栏输入指令后预览,确认后再应用(未应用不改动原稿)
3360
3451
  </div>
@@ -3435,16 +3526,16 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3435
3526
  )}
3436
3527
 
3437
3528
  {activeTab === 'book' && (
3438
- <div className={css.card} style={{ gap: 12 }}>
3529
+ <div className={css.card} style={{ gap: 'var(--nf-space-12)' }}>
3439
3530
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3440
- <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📚 本书设定</span>
3531
+ <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-16)', fontWeight: 700 }}>📚 本书设定</span>
3441
3532
  <span className={css.meta}>当前书的知识与资料(参考 AI-Novel-Writing-Assistant:世界观/角色准备收进书内)</span>
3442
3533
  </div>
3443
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
3534
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
3444
3535
  <button
3445
3536
  type="button"
3446
3537
  className={`${css.button} ${bookTab === 'bible' ? css.buttonPrimary : ''}`}
3447
- style={{ fontSize: 14, flex: 1 }}
3538
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3448
3539
  onClick={() => { changeBookTab('bible') }}
3449
3540
  title="设定库:题材 / 世界观规则 / 人物摘要 / 红线 / 文风"
3450
3541
  >
@@ -3453,7 +3544,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3453
3544
  <button
3454
3545
  type="button"
3455
3546
  className={`${css.button} ${bookTab === 'world' ? css.buttonPrimary : ''}`}
3456
- style={{ fontSize: 14, flex: 1 }}
3547
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3457
3548
  onClick={() => { changeBookTab('world') }}
3458
3549
  title="大世界:境界体系 / 地理区域 / 势力分布(注入生成与审稿提示词)"
3459
3550
  >
@@ -3462,7 +3553,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3462
3553
  <button
3463
3554
  type="button"
3464
3555
  className={`${css.button} ${bookTab === 'roles' ? css.buttonPrimary : ''}`}
3465
- style={{ fontSize: 14, flex: 1 }}
3556
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3466
3557
  onClick={() => { changeBookTab('roles') }}
3467
3558
  title="角色库:全书角色主表(定位 / 关系网 / 成长线 / 知情度)"
3468
3559
  >
@@ -3471,7 +3562,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3471
3562
  <button
3472
3563
  type="button"
3473
3564
  className={`${css.button} ${bookTab === 'facts' ? css.buttonPrimary : ''}`}
3474
- style={{ fontSize: 14, flex: 1 }}
3565
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3475
3566
  onClick={() => { changeBookTab('facts') }}
3476
3567
  title="编年录与复盘记录"
3477
3568
  >
@@ -3492,13 +3583,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3492
3583
  {bible === undefined ? (
3493
3584
  <span className={css.meta}>{tt('bible.none')}</span>
3494
3585
  ) : (
3495
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
3586
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
3496
3587
  {bible.genre !== '' && (
3497
3588
  <div><b>{tt('bible.genre')}:</b> <span className={css.meta}>{bible.genre}</span></div>
3498
3589
  )}
3499
3590
  {/* 世界观规则独立卡片(可编辑,每行一条) */}
3500
3591
  {bible.worldRules.length > 0 && (
3501
- <div style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px' }}>
3592
+ <div style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)' }}>
3502
3593
  <div className={css.row} style={{ justifyContent: 'space-between' }}>
3503
3594
  <b>{tt('bible.worldRules')}({bible.worldRules.length})</b>
3504
3595
  <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWorldRulesDraft(bible.worldRules.join('\n')) }}>
@@ -3506,7 +3597,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3506
3597
  </button>
3507
3598
  </div>
3508
3599
  {worldRulesDraft !== '' && (
3509
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
3600
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', marginTop: 'var(--nf-space-6)' }}>
3510
3601
  <textarea
3511
3602
  className={css.textarea}
3512
3603
  style={{ minHeight: 120 }}
@@ -3525,7 +3616,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3525
3616
  </div>
3526
3617
  )}
3527
3618
  {worldRulesDraft === '' && (
3528
- <ul style={{ margin: '4px 0 0', paddingLeft: 18, fontSize: 12 }}>{bible.worldRules.map((r, i) => <li key={i}>{r}</li>)}</ul>
3619
+ <ul style={{ margin: '4px 0 0', paddingLeft: 'var(--nf-space-18)', fontSize: 'var(--nf-fs-12)' }}>{bible.worldRules.map((r, i) => <li key={i}>{r}</li>)}</ul>
3529
3620
  )}
3530
3621
  </div>
3531
3622
  )}
@@ -3533,7 +3624,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3533
3624
  <div>
3534
3625
  <b>{tt('bible.characters')}({bible.characters.length})</b>
3535
3626
  {bible.characters.map(card => (
3536
- <div key={card.name} style={{ marginTop: 4, fontSize: 12 }}>
3627
+ <div key={card.name} style={{ marginTop: 'var(--nf-space-4)', fontSize: 'var(--nf-fs-12)' }}>
3537
3628
  <b>{card.name}</b> <span className={css.meta}>[{card.role}] {card.traits.join('、')}</span>
3538
3629
  {card.goals !== '' && <div className={css.meta}>目标:{card.goals}</div>}
3539
3630
  {card.relations !== '' && <div className={css.meta}>关系:{card.relations}</div>}
@@ -3544,13 +3635,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3544
3635
  {bible.redLines.length > 0 && (
3545
3636
  <div>
3546
3637
  <b>{tt('bible.redLines')}({bible.redLines.length})</b>
3547
- <ul style={{ margin: 0, paddingLeft: 18, fontSize: 12, color: 'var(--nf-error)' }}>{bible.redLines.map((r, i) => <li key={i}>{r}</li>)}</ul>
3638
+ <ul style={{ margin: 0, paddingLeft: 'var(--nf-space-18)', fontSize: 'var(--nf-fs-12)', color: 'var(--nf-error)' }}>{bible.redLines.map((r, i) => <li key={i}>{r}</li>)}</ul>
3548
3639
  </div>
3549
3640
  )}
3550
3641
  {bible.style.length > 0 && (
3551
3642
  <div>
3552
3643
  <b>{tt('bible.style')}({bible.style.length})</b>
3553
- <ul style={{ margin: 0, paddingLeft: 18, fontSize: 12 }}>{bible.style.map((r, i) => <li key={i}>{r}</li>)}</ul>
3644
+ <ul style={{ margin: 0, paddingLeft: 'var(--nf-space-18)', fontSize: 'var(--nf-fs-12)' }}>{bible.style.map((r, i) => <li key={i}>{r}</li>)}</ul>
3554
3645
  </div>
3555
3646
  )}
3556
3647
  </div>
@@ -3600,7 +3691,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3600
3691
  <span className={css.meta}>角色库是全书角色主表:定位(女主/女配/配角/反派)、身份、关系网、成长线、知情度——生成与审稿都会按定位规格刻画互动。点「从编年录刷新状态」可在每张卡上显示角色当前状态。</span>
3601
3692
 
3602
3693
  {roleCandidates !== null && (
3603
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, border: '1px solid var(--nf-info)', borderRadius: 12, padding: 10 }}>
3694
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', border: '1px solid var(--nf-info)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-10)' }}>
3604
3695
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3605
3696
  <b>✨ AI 提炼候选({roleCandidates.length})</b>
3606
3697
  <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setRoleCandidates(null) }}>收起</button>
@@ -3619,7 +3710,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3619
3710
  )}
3620
3711
 
3621
3712
  {roleDraft !== null && (
3622
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8, border: '1px solid var(--nf-accent)', borderRadius: 12, padding: 10 }}>
3713
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-10)' }}>
3623
3714
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
3624
3715
  <div className={css.field} style={{ flex: 1, minWidth: 140 }}>
3625
3716
  <label className={css.fieldLabel}>角色名</label>
@@ -3669,7 +3760,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3669
3760
  {(project?.roles ?? []).length === 0 ? (
3670
3761
  <span className={css.meta}>角色库为空——点「✨ 从全书提炼角色」自动建立,或手动新增。</span>
3671
3762
  ) : (
3672
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
3763
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
3673
3764
  {(project?.roles ?? []).map(r => (
3674
3765
  <RoleCard
3675
3766
  key={r.name}
@@ -3690,246 +3781,313 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3690
3781
 
3691
3782
  {activeTab === 'settings' && configDraft !== null && (
3692
3783
  <>
3693
- {/* ① 路径与输出 */}
3784
+ {/* 设置页内子导航(大容器) */}
3694
3785
  <div className={`${css.card} ${css.settingsCard}`}>
3695
- <span className={css.cardTitle}>📁 路径与输出</span>
3696
- <div className={css.field}>
3697
- <label className={css.fieldLabel}>{tt('settings.outlinePath')}</label>
3698
- <input className={css.input} value={configDraft.outlinePath} onChange={e => { setConfigDraft({ ...configDraft, outlinePath: e.target.value }) }} />
3699
- </div>
3700
- <div className={css.field}>
3701
- <label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
3702
- <input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
3786
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--nf-space-8)' }}>
3787
+ <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-20)', fontWeight: 700 }}>⚙️ 设置</span>
3788
+ <span className={css.meta}>当前:{config?.provider} / {config?.model} · {config?.outputDir}</span>
3703
3789
  </div>
3704
- <div className={css.row}>
3705
- <button type="button" className={css.button} onClick={() => { void api.openFolder() }}>{tt('settings.openFolder')}</button>
3790
+ <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
3791
+ {SETTINGS_SECTIONS.map(s => (
3792
+ <button
3793
+ key={s.id}
3794
+ type="button"
3795
+ className={`${css.button} ${settingsTab === s.id ? css.buttonPrimary : ''}`}
3796
+ style={{ fontSize: 'var(--nf-fs-14)', padding: 'var(--nf-space-8) var(--nf-space-12)', flex: 1, minWidth: 104, justifyContent: 'center' }}
3797
+ onClick={() => { changeSettingsTab(s.id) }}
3798
+ >
3799
+ {s.icon} {s.label}
3800
+ </button>
3801
+ ))}
3706
3802
  </div>
3707
3803
  </div>
3708
3804
 
3709
- {/* ② 模型与推理 */}
3710
- <div className={`${css.card} ${css.settingsCard}`}>
3711
- <span className={css.cardTitle}>🤖 模型与推理</span>
3712
- <div className={css.row} style={{ alignItems: 'flex-start' }}>
3713
- <div className={css.field} style={{ flex: 1 }}>
3714
- <label className={css.fieldLabel}>{tt('settings.provider')}</label>
3715
- <input className={css.input} value={configDraft.provider} onChange={e => { setConfigDraft({ ...configDraft, provider: e.target.value }) }} />
3716
- </div>
3717
- <div className={css.field} style={{ flex: 1.4 }}>
3718
- <label className={css.fieldLabel}>{tt('settings.model')}</label>
3719
- <select
3720
- className={css.input}
3721
- value={modelCustomMode ? '__custom__' : configDraft.model}
3722
- onChange={e => {
3723
- const v = e.target.value
3724
- if (v === '__custom__') {
3725
- setModelCustomMode(true)
3726
- } else {
3727
- setModelCustomMode(false)
3728
- setConfigDraft({ ...configDraft, model: v })
3729
- }
3730
- }}
3731
- >
3732
- {MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}
3733
- <option value="__custom__">{tt('settings.modelCustom')}</option>
3734
- </select>
3735
- {modelCustomMode && (
3736
- <input
3805
+ {settingsTab === 'model' && (
3806
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
3807
+ <span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
3808
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
3809
+ <div className={css.field}>
3810
+ <label className={css.fieldLabel}>{tt('settings.provider')}</label>
3811
+ <input className={css.input} value={configDraft.provider} onChange={e => { setConfigDraft({ ...configDraft, provider: e.target.value }) }} />
3812
+ </div>
3813
+ <div className={css.field}>
3814
+ <label className={css.fieldLabel}>{tt('settings.model')}</label>
3815
+ <select
3737
3816
  className={css.input}
3738
- style={{ marginTop: 6 }}
3739
- value={configDraft.model}
3740
- placeholder={tt('settings.modelCustomPlaceholder')}
3741
- onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }}
3742
- />
3743
- )}
3817
+ value={modelCustomMode ? '__custom__' : configDraft.model}
3818
+ onChange={e => {
3819
+ const v = e.target.value
3820
+ if (v === '__custom__') {
3821
+ setModelCustomMode(true)
3822
+ } else {
3823
+ setModelCustomMode(false)
3824
+ setConfigDraft({ ...configDraft, model: v })
3825
+ }
3826
+ }}
3827
+ >
3828
+ {MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}
3829
+ <option value="__custom__">{tt('settings.modelCustom')}</option>
3830
+ </select>
3831
+ {modelCustomMode && (
3832
+ <input
3833
+ className={css.input}
3834
+ style={{ marginTop: 'var(--nf-space-6)' }}
3835
+ value={configDraft.model}
3836
+ placeholder={tt('settings.modelCustomPlaceholder')}
3837
+ onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }}
3838
+ />
3839
+ )}
3840
+ </div>
3841
+ <div className={css.field}>
3842
+ <label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label>
3843
+ <select
3844
+ className={css.input}
3845
+ value={configDraft.reasoningEffort ?? 'off'}
3846
+ onChange={e => { setConfigDraft({ ...configDraft, reasoningEffort: e.target.value as NovelConfig['reasoningEffort'] }) }}
3847
+ >
3848
+ {REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
3849
+ </select>
3850
+ <span className={css.meta}>{tt('settings.reasoningHint')}</span>
3851
+ </div>
3852
+ <div className={css.field}>
3853
+ <label className={css.fieldLabel}>{tt('settings.analysisReasoning')}</label>
3854
+ <select
3855
+ className={css.input}
3856
+ value={configDraft.analysisReasoning ?? 'low'}
3857
+ onChange={e => { setConfigDraft({ ...configDraft, analysisReasoning: e.target.value as NovelConfig['analysisReasoning'] }) }}
3858
+ >
3859
+ {REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
3860
+ </select>
3861
+ <span className={css.meta}>{tt('settings.analysisReasoningHint')}</span>
3862
+ </div>
3744
3863
  </div>
3745
3864
  </div>
3746
- <div className={css.row} style={{ alignItems: 'flex-start' }}>
3747
- <div className={css.field} style={{ flex: 1 }}>
3748
- <label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label>
3749
- <select
3750
- className={css.input}
3751
- value={configDraft.reasoningEffort ?? 'off'}
3752
- onChange={e => { setConfigDraft({ ...configDraft, reasoningEffort: e.target.value as NovelConfig['reasoningEffort'] }) }}
3753
- >
3754
- {REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
3755
- </select>
3756
- <span className={css.meta}>{tt('settings.reasoningHint')}</span>
3757
- </div>
3758
- <div className={css.field} style={{ flex: 1 }}>
3759
- <label className={css.fieldLabel}>{tt('settings.analysisReasoning')}</label>
3760
- <select
3761
- className={css.input}
3762
- value={configDraft.analysisReasoning ?? 'low'}
3763
- onChange={e => { setConfigDraft({ ...configDraft, analysisReasoning: e.target.value as NovelConfig['analysisReasoning'] }) }}
3764
- >
3765
- {REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
3766
- </select>
3767
- <span className={css.meta}>{tt('settings.analysisReasoningHint')}</span>
3865
+ )}
3866
+
3867
+ {settingsTab === 'writing' && (
3868
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
3869
+ <span className={css.cardTitle}><PenLine size={18} style={{ verticalAlign: -3 }} /> 写作与审稿</span>
3870
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
3871
+ <div className={css.field}>
3872
+ <label className={css.fieldLabel}>{tt('settings.chapterChars')}</label>
3873
+ <input className={css.input} type="number" min={1000} max={20000} value={configDraft.chapterChars} onChange={e => { setConfigDraft({ ...configDraft, chapterChars: Number(e.target.value) }) }} />
3874
+ </div>
3875
+ <div className={css.field}>
3876
+ <label className={css.fieldLabel}>{tt('settings.maxTokens')}</label>
3877
+ <input className={css.input} type="number" min={2000} max={64000} value={configDraft.maxTokens} onChange={e => { setConfigDraft({ ...configDraft, maxTokens: Number(e.target.value) }) }} />
3878
+ </div>
3879
+ <div className={css.field}>
3880
+ <label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label>
3881
+ <input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => { setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) }) }} />
3882
+ <span className={css.meta}>通过判定:综合评分 ≥ 此分数(默认 70),或无 high 级问题且评分 ≥ 60;建议设 60-80。</span>
3883
+ </div>
3884
+ <div className={css.field}>
3885
+ <label className={css.fieldLabel}>{tt('settings.autoReview')}</label>
3886
+ <select
3887
+ className={css.input}
3888
+ value={configDraft.autoReview ? '1' : '0'}
3889
+ onChange={e => { setConfigDraft({ ...configDraft, autoReview: e.target.value === '1' }) }}
3890
+ >
3891
+ <option value="1">✓ 是</option>
3892
+ <option value="0">✗ 否</option>
3893
+ </select>
3894
+ </div>
3895
+ <div className={css.field}>
3896
+ <label className={css.fieldLabel}>{tt('settings.autoAuthorReview')}</label>
3897
+ <select
3898
+ className={css.input}
3899
+ value={configDraft.autoAuthorReview ? '1' : '0'}
3900
+ onChange={e => { setConfigDraft({ ...configDraft, autoAuthorReview: e.target.value === '1' }) }}
3901
+ >
3902
+ <option value="1">✓ 是</option>
3903
+ <option value="0">✗ 否</option>
3904
+ </select>
3905
+ <span className={css.meta}>{tt('settings.autoAuthorReviewHint')}</span>
3906
+ </div>
3907
+ <div className={css.field}>
3908
+ <label className={css.fieldLabel}>{tt('settings.autoReviewAfterRevise')}</label>
3909
+ <select
3910
+ className={css.input}
3911
+ value={configDraft.autoReviewAfterRevise ? '1' : '0'}
3912
+ onChange={e => { setConfigDraft({ ...configDraft, autoReviewAfterRevise: e.target.value === '1' }) }}
3913
+ >
3914
+ <option value="1">✓ 是</option>
3915
+ <option value="0">✗ 否</option>
3916
+ </select>
3917
+ <span className={css.meta}>{tt('settings.autoReviewAfterReviseHint')}</span>
3918
+ </div>
3919
+ <div className={css.field}>
3920
+ <label className={css.fieldLabel}>编辑器字号(正文编辑 / 工作区)</label>
3921
+ <select
3922
+ className={css.input}
3923
+ value={editorFontSize}
3924
+ onChange={e => { changeEditorFontSize(Number(e.target.value)) }}
3925
+ >
3926
+ {[12, 13, 14, 15, 16, 18, 20, 22, 24].map(v => (
3927
+ <option key={v} value={v}>{v}px</option>
3928
+ ))}
3929
+ </select>
3930
+ <span className={css.meta}>更改的是章节正文编辑区与润色/修订工作区(原稿 / 草稿 / diff)的显示字号,不影响面板主题。</span>
3931
+ </div>
3768
3932
  </div>
3769
3933
  </div>
3770
- </div>
3934
+ )}
3771
3935
 
3772
- {/* ③ 生图(豆包) */}
3773
- <div className={`${css.card} ${css.settingsCard}`}>
3774
- <span className={css.cardTitle}>🎨 生图(豆包)</span>
3775
- <div className={css.row}>
3776
- <div className={css.field} style={{ flex: 1 }}>
3777
- <label className={css.fieldLabel}>豆包 API Key(生图)</label>
3778
- <input className={css.input} type="password" placeholder="ark-..." value={configDraft.imageApiKey ?? ''} onChange={e => { setConfigDraft({ ...configDraft, imageApiKey: e.target.value }) }} />
3936
+ {settingsTab === 'image' && (
3937
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
3938
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center' }}>
3939
+ <span className={css.cardTitle}><Palette size={18} style={{ verticalAlign: -3 }} /> 生图模型</span>
3940
+ <span className={css.row} style={{ gap: 'var(--nf-space-8)', alignItems: 'center' }}>
3941
+ <span className={css.meta}>启用生图功能</span>
3942
+ <button
3943
+ type="button"
3944
+ role="switch"
3945
+ aria-checked={configDraft.imageApiEnabled === true}
3946
+ className={`${css.switch} ${configDraft.imageApiEnabled === true ? css.switchOn : ''}`}
3947
+ onClick={() => { setConfigDraft(prev => prev === null ? prev : { ...prev, imageApiEnabled: prev.imageApiEnabled !== true }) }}
3948
+ >
3949
+ <span className={css.switchKnob} />
3950
+ </button>
3951
+ </span>
3779
3952
  </div>
3780
- <div className={css.field} style={{ flex: 1 }}>
3781
- <label className={css.fieldLabel}>豆包生图模型</label>
3782
- <input className={css.input} placeholder="doubao-seedream-5-0-pro-260628" value={configDraft.imageApiModel ?? ''} onChange={e => { setConfigDraft({ ...configDraft, imageApiModel: e.target.value }) }} />
3953
+ <span className={css.meta}>支持多套 OpenAI 兼容生图接口(豆包 / 即梦 / 其他平台);启用一条作为生成模型,保存后角色详情显示「AI 生图」按钮。</span>
3954
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-20)' }}>
3955
+ {(configDraft.imageModels ?? []).map(m => (
3956
+ <div key={m.id} style={{ border: m.enabled ? '1px solid var(--nf-accent)' : '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-14)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-12)', background: m.enabled ? 'var(--nf-accent-soft)' : 'transparent' }}>
3957
+ <div className={css.row} style={{ alignItems: 'flex-end', flexWrap: 'wrap' }}>
3958
+ <div className={css.field} style={{ flex: 1, minWidth: 180 }}>
3959
+ <label className={css.fieldLabel}>名称</label>
3960
+ <input className={css.input} placeholder="如:豆包 Seedream" value={m.name} onChange={e => { updateImageModel(m.id, { name: e.target.value }) }} />
3961
+ </div>
3962
+ <div className={css.field} style={{ flex: 1, minWidth: 180 }}>
3963
+ <label className={css.fieldLabel}>模型 id</label>
3964
+ <input className={css.input} placeholder="doubao-seedream-5-0-pro-260628" value={m.model} onChange={e => { updateImageModel(m.id, { model: e.target.value }) }} />
3965
+ </div>
3966
+ <div className={css.field} style={{ flex: 1, minWidth: 200 }}>
3967
+ <label className={css.fieldLabel}>接口地址</label>
3968
+ <input className={css.input} placeholder="https://ark.cn-beijing.volces.com/api/v3" value={m.baseURL} onChange={e => { updateImageModel(m.id, { baseURL: e.target.value }) }} />
3969
+ </div>
3970
+ </div>
3971
+ <div className={css.field}>
3972
+ <label className={css.fieldLabel}>API Key</label>
3973
+ <input className={css.input} type="password" placeholder="ark-..." value={m.apiKey} onChange={e => { updateImageModel(m.id, { apiKey: e.target.value }) }} />
3974
+ </div>
3975
+ <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap', alignItems: 'center' }}>
3976
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${m.enabled ? css.buttonPrimary : ''}`} onClick={() => { enableImageModel(m.id) }}>
3977
+ {m.enabled ? '✓ 已启用' : '设为启用'}
3978
+ </button>
3979
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={imageTestState[m.id]?.testing === true} onClick={() => { void testImageModel(m) }}>
3980
+ <PlugZap size={13} style={{ verticalAlign: -2 }} /> {imageTestState[m.id]?.testing === true ? '测试中…' : '测试'}
3981
+ </button>
3982
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { removeImageModel(m.id) }}>🗑 删除</button>
3983
+ <span className={css.meta}>{m.enabled ? '当前生效' : '未启用'}</span>
3984
+ {(() => {
3985
+ const st = imageTestState[m.id]
3986
+ if (st === undefined || st.testing) return null
3987
+ return st.ok === true
3988
+ ? <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-fs-12)', fontWeight: 600 }}>✓ 连通 · {st.ms}ms{st.modelFound === true ? '' : st.modelFound === false ? '(模型未在列表)' : ''}</span>
3989
+ : <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-12)' }}>✗ {st.message ?? '失败'}</span>
3990
+ })()}
3991
+ </div>
3992
+ </div>
3993
+ ))}
3994
+ {(configDraft.imageModels ?? []).length === 0 && (
3995
+ <div className={css.meta}>还没有生图模型——点下方「+ 添加生图模型」添加第一条(默认启用)。</div>
3996
+ )}
3783
3997
  </div>
3784
- <div className={css.field} style={{ flex: 1 }}>
3785
- <label className={css.fieldLabel}>启用豆包生图</label>
3786
- <select
3787
- className={css.input}
3788
- value={configDraft.imageApiEnabled ? '1' : '0'}
3789
- onChange={e => { setConfigDraft({ ...configDraft, imageApiEnabled: e.target.value === '1' }) }}
3790
- >
3791
- <option value="0">关(默认)</option>
3792
- <option value="1">开</option>
3793
- </select>
3794
- <span className={css.meta}>开启后角色详情显示「豆包生成」参考图按钮</span>
3998
+ <div className={css.row}>
3999
+ <button type="button" className={css.button} onClick={addImageModel}>+ 添加生图模型</button>
3795
4000
  </div>
3796
4001
  </div>
3797
- </div>
4002
+ )}
3798
4003
 
3799
- {/* ④ 写作与审稿参数 */}
3800
- <div className={`${css.card} ${css.settingsCard}`}>
3801
- <span className={css.cardTitle}>✍️ 写作与审稿参数</span>
3802
- <div className={css.row}>
3803
- <div className={css.field} style={{ flex: 1 }}>
3804
- <label className={css.fieldLabel}>{tt('settings.chapterChars')}</label>
3805
- <input className={css.input} type="number" min={1000} max={20000} value={configDraft.chapterChars} onChange={e => { setConfigDraft({ ...configDraft, chapterChars: Number(e.target.value) }) }} />
3806
- </div>
3807
- <div className={css.field} style={{ flex: 1 }}>
3808
- <label className={css.fieldLabel}>{tt('settings.maxTokens')}</label>
3809
- <input className={css.input} type="number" min={2000} max={64000} value={configDraft.maxTokens} onChange={e => { setConfigDraft({ ...configDraft, maxTokens: Number(e.target.value) }) }} />
3810
- </div>
3811
- <div className={css.field} style={{ flex: 1 }}>
3812
- <label className={css.fieldLabel}>{tt('settings.editorFontSize')}</label>
3813
- <select
3814
- className={css.input}
3815
- value={editorFontSize}
3816
- onChange={e => { changeEditorFontSize(Number(e.target.value)) }}
3817
- >
3818
- {[12, 13, 14, 15, 16, 18, 20, 22, 24].map(v => (
3819
- <option key={v} value={v}>{v}px</option>
3820
- ))}
3821
- </select>
3822
- <span className={css.meta}>{tt('settings.editorFontSizeHint')}</span>
4004
+ {settingsTab === 'files' && (
4005
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
4006
+ <span className={css.cardTitle}><Folder size={18} style={{ verticalAlign: -3 }} /> 路径与文件</span>
4007
+ <div className={css.field}>
4008
+ <label className={css.fieldLabel}>{tt('settings.outlinePath')}</label>
4009
+ <input className={css.input} value={configDraft.outlinePath} onChange={e => { setConfigDraft({ ...configDraft, outlinePath: e.target.value }) }} />
3823
4010
  </div>
3824
- </div>
3825
- <div className={css.row}>
3826
- <div className={css.field} style={{ flex: 1 }}>
3827
- <label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label>
3828
- <input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => { setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) }) }} />
3829
- <span className={css.meta}>通过判定:综合评分 ≥ 此分数(默认 70),或无 high 级问题且评分 ≥ 60;建议设 60-80。</span>
4011
+ <div className={css.field}>
4012
+ <label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
4013
+ <input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
3830
4014
  </div>
3831
- <div className={css.field} style={{ flex: 1 }}>
3832
- <label className={css.fieldLabel}>{tt('settings.autoReview')}</label>
3833
- <select
3834
- className={css.input}
3835
- value={configDraft.autoReview ? '1' : '0'}
3836
- onChange={e => { setConfigDraft({ ...configDraft, autoReview: e.target.value === '1' }) }}
3837
- >
3838
- <option value="1">✓ 是</option>
3839
- <option value="0">✗ 否</option>
3840
- </select>
4015
+ <div className={css.row}>
4016
+ <button type="button" className={css.button} onClick={() => { void api.openFolder() }}>{tt('settings.openFolder')}</button>
3841
4017
  </div>
3842
- <div className={css.field} style={{ flex: 1 }}>
3843
- <label className={css.fieldLabel}>{tt('settings.autoAuthorReview')}</label>
3844
- <select
3845
- className={css.input}
3846
- value={configDraft.autoAuthorReview ? '1' : '0'}
3847
- onChange={e => { setConfigDraft({ ...configDraft, autoAuthorReview: e.target.value === '1' }) }}
3848
- >
3849
- <option value="1">✓ 是</option>
3850
- <option value="0">✗ 否</option>
3851
- </select>
3852
- <span className={css.meta}>{tt('settings.autoAuthorReviewHint')}</span>
4018
+
4019
+ <div style={{ borderTop: '1px solid var(--nf-border)', margin: '14px 0 12px' }} />
4020
+
4021
+ <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-14)' }}>💾 保存与导出</span>
4022
+ <div className={css.row}>
4023
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSaveConfig() }}>
4024
+ {tt('settings.save')}
4025
+ </button>
3853
4026
  </div>
3854
- <div className={css.field} style={{ flex: 1 }}>
3855
- <label className={css.fieldLabel}>{tt('settings.autoReviewAfterRevise')}</label>
3856
- <select
3857
- className={css.input}
3858
- value={configDraft.autoReviewAfterRevise ? '1' : '0'}
3859
- onChange={e => { setConfigDraft({ ...configDraft, autoReviewAfterRevise: e.target.value === '1' }) }}
3860
- >
3861
- <option value="1">✓ 是</option>
3862
- <option value="0">✗ 否</option>
3863
- </select>
3864
- <span className={css.meta}>{tt('settings.autoReviewAfterReviseHint')}</span>
4027
+ <div className={css.row}>
4028
+ <button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('txt') }}>
4029
+ {tt('settings.exportTxt')}
4030
+ </button>
4031
+ <button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('md') }}>
4032
+ {tt('settings.exportMd')}
4033
+ </button>
3865
4034
  </div>
3866
4035
  </div>
3867
- </div>
4036
+ )}
3868
4037
 
3869
- {/* ⑤ 保存与导出 */}
3870
- <div className={`${css.card} ${css.settingsCard}`}>
3871
- <div className={css.row}>
3872
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSaveConfig() }}>
3873
- {tt('settings.save')}
3874
- </button>
3875
- <span className={css.meta}>当前:{config?.provider} / {config?.model} · {config?.outputDir}</span>
3876
- </div>
3877
- <div className={css.row}>
3878
- <span className={css.cardTitle}>{tt('settings.export')}</span>
3879
- <button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('txt') }}>
3880
- {tt('settings.exportTxt')}
3881
- </button>
3882
- <button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('md') }}>
3883
- {tt('settings.exportMd')}
3884
- </button>
4038
+ {settingsTab === 'appearance' && (
4039
+ <div className={`${css.card} ${css.settingsCard}`}>
4040
+ <span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
4041
+ <span className={css.meta}>选择适合长时间创作的界面颜色和显示密度。主题只保存在当前设备,不影响小说内容和任务状态。</span>
4042
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
4043
+ <div className={css.field}>
4044
+ <label className={css.fieldLabel} style={{ fontSize: 'var(--nf-fs-14)' }}>显示模式</label>
4045
+ <select className={css.input} style={{ padding: 'var(--nf-space-10) var(--nf-space-12)', fontSize: 'var(--nf-fs-14)' }} value={themeMode} onChange={e => { changeThemeMode(e.target.value as 'system' | 'light' | 'dark') }}>
4046
+ <option value="system">跟随系统</option>
4047
+ <option value="light">浅色</option>
4048
+ <option value="dark">深色</option>
4049
+ </select>
4050
+ <span className={css.meta}>强制浅色/深色只作用于小说工坊面板</span>
4051
+ </div>
4052
+ <div className={css.field}>
4053
+ <label className={css.fieldLabel} style={{ fontSize: 'var(--nf-fs-14)' }}>主题风格</label>
4054
+ <select className={css.input} style={{ padding: 'var(--nf-space-10) var(--nf-space-12)', fontSize: 'var(--nf-fs-14)' }} value={panelTheme} onChange={e => { changePanelTheme(e.target.value as 'liquid' | 'neumorph' | 'macos' | 'clay') }}>
4055
+ <option value="liquid">液态玻璃 · 清新绿(默认)</option>
4056
+ <option value="neumorph">新拟物 · 柔和浅色</option>
4057
+ <option value="macos">macOS · 玻璃(蓝,随外观浅/深)</option>
4058
+ <option value="clay">粘土拟态 · 柔和黏土</option>
4059
+ </select>
4060
+ <span className={css.meta}>{tt('settings.themeHint')}</span>
4061
+ </div>
4062
+ <div className={css.field}>
4063
+ <label className={css.fieldLabel} style={{ fontSize: 'var(--nf-fs-14)' }}>界面密度</label>
4064
+ <select className={css.input} style={{ padding: 'var(--nf-space-10) var(--nf-space-12)', fontSize: 'var(--nf-fs-14)' }} value={themeDensity} onChange={e => { changeThemeDensity(e.target.value as 'comfort' | 'compact' | 'spacious') }}>
4065
+ <option value="comfort">舒适(默认)</option>
4066
+ <option value="compact">紧凑</option>
4067
+ <option value="spacious">宽松</option>
4068
+ </select>
4069
+ <span className={css.meta}>卡片 / 导航 / 内容的间距密度</span>
4070
+ </div>
4071
+ </div>
4072
+ <div className={css.row} style={{ justifyContent: 'flex-end' }}>
4073
+ <button type="button" className={css.button} style={{ padding: 'var(--nf-space-10) var(--nf-space-18)', fontSize: 'var(--nf-fs-14)' }} onClick={resetTheme}>
4074
+ <RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题
4075
+ </button>
4076
+ </div>
3885
4077
  </div>
3886
- </div>
4078
+ )}
3887
4079
  </>
3888
4080
  )}
3889
-
3890
- {activeTab === 'settings' && (
3891
- <div className={`${css.card} ${css.settingsCard}`}>
3892
- <span className={css.cardTitle}>{tt('settings.theme')}</span>
3893
- <div className={css.row} style={{ gap: 8, flexWrap: 'wrap' }}>
3894
- <button
3895
- type="button"
3896
- className={`${css.button} ${css.buttonSmall} ${panelTheme === 'liquid' ? css.buttonPrimary : ''}`}
3897
- onClick={() => { changePanelTheme('liquid') }}
3898
- title="iOS 液态玻璃质感 · 绿色强调(当前默认)"
3899
- >
3900
- 🧊 {tt('settings.themeLiquid')}
3901
- </button>
3902
- <button
3903
- type="button"
3904
- className={`${css.button} ${css.buttonSmall} ${panelTheme === 'classic' ? css.buttonPrimary : ''}`}
3905
- onClick={() => { changePanelTheme('classic') }}
3906
- title="经典 iOS 毛玻璃 · 蓝色强调"
3907
- >
3908
- 💠 {tt('settings.themeClassic')}
3909
- </button>
3910
- <button
3911
- type="button"
3912
- className={`${css.button} ${css.buttonSmall} ${panelTheme === 'neumorph' ? css.buttonPrimary : ''}`}
3913
- onClick={() => { changePanelTheme('neumorph') }}
3914
- title="新拟物派 · 双阴影立体(仅浅色;深色下自动回退液态)"
3915
- >
3916
- 🔘 {tt('settings.themeNeumorph')}
3917
- </button>
3918
- <span className={css.meta}>{tt('settings.themeHint')}</span>
3919
- </div>
3920
- </div>
3921
- )}
3922
-
3923
4081
  {activeTab === 'book' && bookTab === 'facts' && (
3924
4082
  <div className={css.card}>
3925
4083
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3926
- <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📚 编年 / 复盘</span>
4084
+ <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-16)', fontWeight: 700 }}>📚 编年 / 复盘</span>
3927
4085
  </div>
3928
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
4086
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
3929
4087
  <button
3930
4088
  type="button"
3931
4089
  className={`${css.button} ${archiveTab === 'facts' ? css.buttonPrimary : ''}`}
3932
- style={{ fontSize: 14, flex: 1 }}
4090
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3933
4091
  onClick={() => { changeArchiveTab('facts') }}
3934
4092
  title="编年录:客观事实流水(第 N 章 · 事件),生成/审稿查证用"
3935
4093
  >
@@ -3938,7 +4096,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3938
4096
  <button
3939
4097
  type="button"
3940
4098
  className={`${css.button} ${archiveTab === 'reviews' ? css.buttonPrimary : ''}`}
3941
- style={{ fontSize: 14, flex: 1 }}
4099
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3942
4100
  onClick={() => { changeArchiveTab('reviews') }}
3943
4101
  title="复盘记录:每章作者复盘(钩子 / 推进 / 衔接 / 趋势)"
3944
4102
  >
@@ -3980,12 +4138,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3980
4138
  </span>
3981
4139
  )
3982
4140
  })()}
3983
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', flex: 1, minHeight: 0 }}>
4141
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)', overflowY: 'auto', flex: 1, minHeight: 0 }}>
3984
4142
  {(() => {
3985
4143
  const groupsWithReviews = chapterGroups.filter(group => group.chapters.some(c => c.authorReview !== undefined))
3986
4144
  if (groupsWithReviews.length === 0) return null
3987
4145
  return (
3988
- <div className={css.row} style={{ justifyContent: 'flex-end', gap: 6 }}>
4146
+ <div className={css.row} style={{ justifyContent: 'flex-end', gap: 'var(--nf-space-6)' }}>
3989
4147
  <button
3990
4148
  type="button"
3991
4149
  className={`${css.button} ${css.buttonSmall}`}
@@ -4008,10 +4166,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4008
4166
  if (groupReviewed.length === 0) return null
4009
4167
  const volumeExpanded = expandedVolumes[group.no] === true
4010
4168
  return (
4011
- <div key={group.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden' }}>
4169
+ <div key={group.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', overflow: 'hidden' }}>
4012
4170
  <button
4013
4171
  type="button"
4014
- style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '8px 12px', fontSize: 13, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}
4172
+ style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: 'var(--nf-space-8) var(--nf-space-12)', fontSize: 'var(--nf-fs-14)', display: 'flex', alignItems: 'center', gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}
4015
4173
  onClick={() => { setExpandedVolumes(prev => ({ ...prev, [group.no]: !volumeExpanded })) }}
4016
4174
  >
4017
4175
  <span style={{ color: 'var(--nf-text-3)' }}>{volumeExpanded ? '▾' : '▸'}</span>
@@ -4019,25 +4177,25 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4019
4177
  <span className={css.meta}>已复盘 {groupReviewed.length}/{group.chapters.length} 章</span>
4020
4178
  </button>
4021
4179
  {volumeExpanded && (
4022
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '4px 8px 8px', borderTop: '1px solid var(--nf-border)' }}>
4180
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', padding: 'var(--nf-space-4) var(--nf-space-8) var(--nf-space-8)', borderTop: '1px solid var(--nf-border)' }}>
4023
4181
  {[...groupReviewed].reverse().map(c => {
4024
4182
  const ar = c.authorReview!
4025
4183
  const expanded = expandedReviewChapter === c.no
4026
4184
  return (
4027
- <div key={c.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, overflow: 'hidden' }}>
4185
+ <div key={c.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-8)', overflow: 'hidden' }}>
4028
4186
  <button
4029
4187
  type="button"
4030
- style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '6px 10px', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}
4188
+ style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: 'var(--nf-space-6) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)', display: 'flex', alignItems: 'center', gap: 'var(--nf-space-6)', flexWrap: 'wrap' }}
4031
4189
  onClick={() => { setExpandedReviewChapter(expanded ? null : c.no) }}
4032
4190
  >
4033
4191
  <span style={{ color: 'var(--nf-text-3)' }}>{expanded ? '▾' : '▸'}</span>
4034
4192
  <b>第{c.no}章《{c.title}》</b>
4035
4193
  <span style={{ color: ar.hookHonored ? 'var(--nf-success)' : 'var(--nf-warn)' }}>钩子{ar.hookHonored ? '✓' : '✗'}</span>
4036
4194
  <span style={{ color: ar.endingHook >= 6 ? 'var(--nf-success)' : 'var(--nf-error)' }}>结尾钩子 {ar.endingHook}/10</span>
4037
- {ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 4 }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
4195
+ {ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 'var(--nf-space-4)' }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
4038
4196
  </button>
4039
4197
  {expanded && (
4040
- <div style={{ padding: '4px 10px 8px', fontSize: 12, display: 'flex', flexDirection: 'column', gap: 3, borderTop: '1px solid var(--nf-border)' }}>
4198
+ <div style={{ padding: 'var(--nf-space-4) var(--nf-space-10) var(--nf-space-8)', fontSize: 'var(--nf-fs-12)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', borderTop: '1px solid var(--nf-border)' }}>
4041
4199
  {ar.hookNote !== '' && <span className={css.meta}><b>钩子:</b>{ar.hookNote}</span>}
4042
4200
  {ar.plotlineProgress !== '' && <span className={css.meta}><b>推进:</b>{ar.plotlineProgress}</span>}
4043
4201
  {ar.continuity !== '' && <span className={css.meta}><b>衔接:</b>{ar.continuity}</span>}
@@ -4070,10 +4228,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4070
4228
  </div>
4071
4229
  <span className={css.meta}>{tt('facts.hint')}</span>
4072
4230
  {(project?.facts ?? []).length > 0 ? (
4073
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: '60vh', overflowY: 'auto', fontSize: 12 }}>
4231
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', maxHeight: '60vh', overflowY: 'auto', fontSize: 'var(--nf-fs-12)' }}>
4074
4232
  {[...(project?.facts ?? [])].reverse().map((fact, i) => (
4075
- <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
4076
- <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)', flex: 'none', marginTop: 1 }}>
4233
+ <div key={i} style={{ display: 'flex', gap: 'var(--nf-space-8)', alignItems: 'flex-start' }}>
4234
+ <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)', flex: 'none', marginTop: 'var(--nf-space-2)' }}>
4077
4235
  第 {fact.chapterNo} 章
4078
4236
  </span>
4079
4237
  <span className={css.meta}>{fact.text}</span>
@@ -4091,13 +4249,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4091
4249
  {activeTab === 'plotlines' && (
4092
4250
  <div className={css.card}>
4093
4251
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4094
- <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📜 长线管理</span>
4252
+ <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-16)', fontWeight: 700 }}>📜 长线管理</span>
4095
4253
  </div>
4096
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
4254
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
4097
4255
  <button
4098
4256
  type="button"
4099
4257
  className={`${css.button} ${longlineTab === 'plotlines' ? css.buttonPrimary : ''}`}
4100
- style={{ fontSize: 14, flex: 1 }}
4258
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
4101
4259
  onClick={() => { changeLonglineTab('plotlines') }}
4102
4260
  title="剧情线:故事明线(主线 / 支线 / 人物 / 悬念)"
4103
4261
  >
@@ -4106,7 +4264,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4106
4264
  <button
4107
4265
  type="button"
4108
4266
  className={`${css.button} ${longlineTab === 'foreshadow' ? css.buttonPrimary : ''}`}
4109
- style={{ fontSize: 14, flex: 1 }}
4267
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
4110
4268
  onClick={() => { changeLonglineTab('foreshadow') }}
4111
4269
  title="伏笔:道具 / 事件级暗线(埋设 → 回收)"
4112
4270
  >
@@ -4142,7 +4300,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4142
4300
  </div>
4143
4301
  </div>
4144
4302
  <span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
4145
- <div className={css.row} style={{ gap: 4 }}>
4303
+ <div className={css.row} style={{ gap: 'var(--nf-space-4)' }}>
4146
4304
  {f.status === 'planned' && (
4147
4305
  <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
4148
4306
  void api.foreshadow({ id: f.id, status: 'planted', plantedChapter: doneCount + 1 }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
@@ -4237,7 +4395,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4237
4395
  )}
4238
4396
 
4239
4397
  {plotlineDraft !== null && (
4240
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8, border: '1px solid var(--nf-accent)', borderRadius: 12, padding: 10 }}>
4398
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-10)' }}>
4241
4399
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
4242
4400
  <div className={css.field} style={{ flex: 2, minWidth: 160 }}>
4243
4401
  <label className={css.fieldLabel}>{tt('plotlines.name')}</label>
@@ -4284,7 +4442,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4284
4442
  {(project?.plotlines ?? []).length === 0 ? (
4285
4443
  <span className={css.meta}>{tt('plotlines.empty')}</span>
4286
4444
  ) : (
4287
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
4445
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', overflowY: 'auto', flex: 1, minHeight: 0 }}>
4288
4446
  {(project?.plotlines ?? []).map(line => (
4289
4447
  <PlotlineCard
4290
4448
  key={line.id}
@@ -4307,7 +4465,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4307
4465
  )}
4308
4466
 
4309
4467
  {activeTab === 'manhua' && (
4310
- <MangaWorkspace api={api} project={project} chapters={chapters} onProjectChanged={() => refresh(false)} imageApiEnabled={config?.imageApiEnabled === true} onProgress={(text, kind) => pushProgress(text, kind ?? 'info')} />
4468
+ <MangaWorkspace api={api} project={project} chapters={chapters} onProjectChanged={() => refresh(false)} imageApiEnabled={config?.imageApiEnabled === true} imageModels={config?.imageModels} onProgress={(text, kind) => pushProgress(text, kind ?? 'info')} />
4311
4469
  )}
4312
4470
 
4313
4471
  {activeTab === 'breakdown' && (
@@ -4352,7 +4510,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4352
4510
  <span className={css.meta}>选择范围与档位,点「开始拆书」对已写章节做整卷体检</span>
4353
4511
  </div>
4354
4512
  ) : (
4355
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
4513
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
4356
4514
  <span className={css.meta}>
4357
4515
  分析 {breakdownResult.chaptersScanned} 章 · {breakdownResult.sections.length} 个小节 · 约 {breakdownResult.usedTokens} token
4358
4516
  </span>
@@ -4362,14 +4520,14 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4362
4520
  <b>{sec.title}</b>
4363
4521
  </div>
4364
4522
  {Object.keys(sec.structured).length > 0 && (
4365
- <div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 12, marginBottom: 6 }}>
4523
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-2)', fontSize: 'var(--nf-fs-12)', marginBottom: 'var(--nf-space-6)' }}>
4366
4524
  {Object.entries(sec.structured).map(([k, v]) => {
4367
4525
  const label = (typeof v === 'string') ? v : Array.isArray(v) ? (v as string[]).join('、') : JSON.stringify(v)
4368
4526
  return <div key={k} className={css.meta}><b>{k}:</b>{label}</div>
4369
4527
  })}
4370
4528
  </div>
4371
4529
  )}
4372
- <div className={css.meta} style={{ whiteSpace: 'pre-wrap', fontSize: 12 }}>{sec.markdown}</div>
4530
+ <div className={css.meta} style={{ whiteSpace: 'pre-wrap', fontSize: 'var(--nf-fs-12)' }}>{sec.markdown}</div>
4373
4531
  </div>
4374
4532
  ))}
4375
4533
  </div>
@@ -4413,7 +4571,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4413
4571
  </div>
4414
4572
  )}
4415
4573
 
4416
- {/* 工作进度悬浮窗:可拖动、可拉大小(同 AI 助手),承接活动输出 + 当前任务进度 */}
4574
+ {/* AI进度悬浮窗:可拖动、可拉大小(同 AI 助手),承接活动输出 + 当前任务进度 */}
4417
4575
  {progressOpen && (
4418
4576
  <div
4419
4577
  className={css.assistantFloat}
@@ -4427,24 +4585,24 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4427
4585
  }}
4428
4586
  >
4429
4587
  <span>
4430
- 📊 工作进度
4588
+ 📊 AI 进度
4431
4589
  {busy && <span style={{ color: 'var(--nf-accent)' }}> 🟢 任务进行中</span>}
4432
4590
  <span className={css.meta}>(拖动标题栏移动 · 右下角拉大小)</span>
4433
4591
  </span>
4434
- <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
4592
+ <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-4)' }}>
4435
4593
  {progress.length > 0 && (
4436
4594
  <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setProgress([]) }} title="清空活动记录">
4437
4595
  清空
4438
4596
  </button>
4439
4597
  )}
4440
- <button type="button" className={css.iconButton} title="关闭" aria-label="关闭工作进度" onClick={() => { setProgressOpen(false) }}>×</button>
4598
+ <button type="button" className={css.iconButton} title="关闭" aria-label="关闭AI进度" onClick={() => { setProgressOpen(false) }}>×</button>
4441
4599
  </span>
4442
4600
  </div>
4443
- <div className={css.assistantFloatBody} style={{ padding: 10, display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>
4601
+ <div className={css.assistantFloatBody} style={{ padding: 'var(--nf-space-10)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', overflow: 'hidden' }}>
4444
4602
  {/* 当前任务大进度条:进行中显示 */}
4445
4603
  {(busy && (busyLabel !== '' || liveBar !== null)) && (
4446
- <div style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 5, background: 'color-mix(in srgb, var(--nf-accent) 6%, transparent)' }}>
4447
- <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--nf-accent)' }}>
4604
+ <div style={{ border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-12)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', background: 'color-mix(in srgb, var(--nf-accent) 6%, transparent)' }}>
4605
+ <span style={{ fontSize: 'var(--nf-fs-12)', fontWeight: 600, color: 'var(--nf-accent)' }}>
4448
4606
  ✍ {busyLabel !== '' ? busyLabel : (liveBar?.text ?? '任务进行中')}…
4449
4607
  </span>
4450
4608
  {liveBar?.ratio !== undefined && (
@@ -4456,10 +4614,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4456
4614
  </div>
4457
4615
  )}
4458
4616
  {/* 活动记录列表(标题行已并入窗口标题栏,清空按钮在右上角) */}
4459
- <div className={css.progress} style={{ flex: 1, minHeight: 0, overflowY: 'auto', border: '1px solid var(--nf-border)', borderRadius: 10, background: 'var(--nf-bg-inset)', padding: 8, display: 'flex', flexDirection: 'column' }}>
4617
+ <div className={css.progress} style={{ flex: 1, minHeight: 0, overflowY: 'auto', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', background: 'var(--nf-bg-inset)', padding: 'var(--nf-space-8)', display: 'flex', flexDirection: 'column' }}>
4460
4618
  {progress.length === 0 ? (
4461
- <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4, color: 'var(--nf-text-3)', fontSize: 12, minHeight: 120 }}>
4462
- <span style={{ fontSize: 22 }}>📭</span>
4619
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 'var(--nf-space-4)', color: 'var(--nf-text-3)', fontSize: 'var(--nf-fs-12)', minHeight: 120 }}>
4620
+ <span style={{ fontSize: 'var(--nf-fs-22)' }}>📭</span>
4463
4621
  <span>暂无活动记录</span>
4464
4622
  <span>生成、审稿等操作会显示在这里</span>
4465
4623
  </div>