@waterwx/dsh-novel-forge 1.7.0 → 1.7.3

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 (47) hide show
  1. package/README.md +1 -1
  2. package/lib/client.js +3197 -1887
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +1026 -84
  5. package/lib/index.js.map +1 -1
  6. package/lib/types/client/api.d.ts +2 -0
  7. package/lib/types/client/locales.d.ts +0 -1
  8. package/lib/types/client/panel/FlowGuide.d.ts +21 -0
  9. package/lib/types/client/panel/MangaRoleLibrary.d.ts +18 -0
  10. package/lib/types/client/panel/MangaWorkspace.d.ts +2 -2
  11. package/lib/types/client/panel/StoryboardTab.d.ts +5 -1
  12. package/lib/types/engine.d.ts +15 -8
  13. package/lib/types/protocol.d.ts +138 -10
  14. package/lib/types/shot-language.d.ts +46 -0
  15. package/lib/types/story-beat-language.d.ts +35 -0
  16. package/package.json +5 -2
  17. package/skills/chapter-batch/SKILL.md +81 -0
  18. package/src/client/api.ts +5 -0
  19. package/src/client/locales.ts +0 -1
  20. package/src/client/panel/AssetsTab.tsx +22 -22
  21. package/src/client/panel/AssistantTab.tsx +3 -3
  22. package/src/client/panel/BookshelfBar.tsx +1 -1
  23. package/src/client/panel/CreateBookView.tsx +14 -10
  24. package/src/client/panel/FlowGuide.tsx +83 -0
  25. package/src/client/panel/ImportModal.tsx +11 -2
  26. package/src/client/panel/MangaRoleLibrary.tsx +810 -0
  27. package/src/client/panel/MangaWorkspace.tsx +359 -297
  28. package/src/client/panel/NovelPanel.tsx +108 -107
  29. package/src/client/panel/ReaderView.tsx +4 -4
  30. package/src/client/panel/RunPanel.tsx +9 -9
  31. package/src/client/panel/SceneLibrary.tsx +68 -67
  32. package/src/client/panel/ShelfView.tsx +2 -2
  33. package/src/client/panel/StoryboardTab.tsx +145 -49
  34. package/src/client/panel/StyleCard.tsx +13 -13
  35. package/src/client/panel/StyleGallery.tsx +2 -2
  36. package/src/client/panel/WorldTab.tsx +5 -5
  37. package/src/client/panel/panel.module.css +861 -510
  38. package/src/client/panel/views.tsx +24 -24
  39. package/src/engine.ts +514 -62
  40. package/src/index.ts +1 -0
  41. package/src/protocol.ts +147 -10
  42. package/src/routes.ts +216 -7
  43. package/src/run.ts +5 -4
  44. package/src/shot-language.ts +181 -0
  45. package/src/story-beat-language.ts +130 -0
  46. package/lib/types/client/panel/RoleVisualPanel.d.ts +0 -15
  47. package/src/client/panel/RoleVisualPanel.tsx +0 -485
@@ -18,7 +18,6 @@ import { RunPanel } from './RunPanel.tsx'
18
18
  import { CreateBookView } from './CreateBookView.tsx'
19
19
  import { ImportModal } from './ImportModal.tsx'
20
20
  import { WorldTab } from './WorldTab.tsx'
21
- import { StoryboardTab } from './StoryboardTab.tsx'
22
21
  import { MangaWorkspace } from './MangaWorkspace.tsx'
23
22
  import { AuditIssueRow, PlotlineCard, PlotlineHealthPanel, PlotlinePlanPanel, PlotlineSuggestionPanel, RoleCandidateRow, RoleCard, StatCell, TodoRow } from './views.tsx'
24
23
  import { extractDocxTextFromBuffer } from '../docx.ts'
@@ -87,7 +86,7 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
87
86
  label: '工具',
88
87
  items: [
89
88
  { id: 'assistant', label: tt('tab.assistant'), icon: <MessageSquare size={16} /> },
90
- { id: 'progress', label: 'AI进度', icon: <BarChart3 size={16} /> },
89
+ { id: 'progress', label: 'AI 进度', icon: <BarChart3 size={16} /> },
91
90
  { id: 'breakdown', label: '拆书分析', icon: <Search size={16} /> },
92
91
  { id: 'manhua', label: '漫剧工作台', icon: <Clapperboard size={16} /> },
93
92
  { id: 'run', label: '生产单', icon: <Factory size={16} /> },
@@ -139,6 +138,7 @@ function statusBadge(chapter: ChapterPlan): { cls: string; label: string } {
139
138
  case 'approved': return { cls: css.badgeDone, label: tt('plan.approved') }
140
139
  case 'rejected': return { cls: css.badgeRejected, label: tt('plan.rejected') }
141
140
  case 'error': return { cls: css.badgeError, label: tt('plan.error') }
141
+ default: return { cls: css.badgeError, label: tt('plan.error') }
142
142
  }
143
143
  }
144
144
 
@@ -226,7 +226,7 @@ function paragraphDiff(oldText: string, newText: string): DiffRow[] {
226
226
  function renderBeats(beats: string): ReactElement {
227
227
  const lines = beats.split('\n')
228
228
  return (
229
- <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
229
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-2)' }}>
230
230
  {lines.map((line, i) => {
231
231
  const trimmed = line.trim()
232
232
  const match = /^([^::]{2,14})[::]/.exec(trimmed)
@@ -382,14 +382,14 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
382
382
  setEditorFontSize(v)
383
383
  try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
384
384
  }
385
- /** 面板主题(localStorage 记忆):'liquid'=iOS 液态玻璃(绿) / 'classic'=经典毛玻璃(蓝) / 'neumorph'=新拟物(浅色)。 */
386
- const [panelTheme, setPanelTheme] = useState<'liquid' | 'classic' | 'neumorph'>(() => {
385
+ /** 面板主题(localStorage 记忆):'liquid'=iOS 液态玻璃(绿) / 'neumorph'=新拟物(浅色) / 'macos'=macOS 玻璃(蓝,随外观自动浅深) / 'clay'=粘土拟态。 */
386
+ const [panelTheme, setPanelTheme] = useState<'liquid' | 'neumorph' | 'macos' | 'clay'>(() => {
387
387
  try {
388
388
  const v = window.localStorage.getItem('dsh-novel-forge.theme')
389
- return v === 'classic' || v === 'neumorph' ? v : 'liquid'
389
+ return v === 'neumorph' || v === 'macos' || v === 'clay' ? v : 'liquid'
390
390
  } catch { return 'liquid' }
391
391
  })
392
- const changePanelTheme = (next: 'liquid' | 'classic' | 'neumorph'): void => {
392
+ const changePanelTheme = (next: 'liquid' | 'neumorph' | 'macos' | 'clay'): void => {
393
393
  setPanelTheme(next)
394
394
  try { window.localStorage.setItem('dsh-novel-forge.theme', next) } catch { /* ignore */ }
395
395
  }
@@ -2575,7 +2575,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2575
2575
  <div className={css.card} style={{ borderColor: 'var(--nf-info)' }}>
2576
2576
  <div className={css.busyRow}>
2577
2577
  <span style={{ color: 'var(--nf-info)' }}>第 {draftNo} 章有未采纳的润色/修订草稿(原稿未被改动)</span>
2578
- <span style={{ display: 'flex', gap: 8 }}>
2578
+ <span style={{ display: 'flex', gap: 'var(--nf-space-8)' }}>
2579
2579
  <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void openWorkspace(draftNo) }}>
2580
2580
  打开工作区
2581
2581
  </button>
@@ -2602,7 +2602,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2602
2602
  )}
2603
2603
  <span className={css.cardTitle}>第 {workspace.no} 章《{workspace.title}》</span>
2604
2604
  <span className={css.meta}>{workspace.original.length} 字</span>
2605
- <span style={{ display: 'flex', gap: 4, alignItems: 'center', marginLeft: 'auto' }}>
2605
+ <span style={{ display: 'flex', gap: 'var(--nf-space-4)', alignItems: 'center', marginLeft: 'auto' }}>
2606
2606
  <button type="button" className={css.iconButton} title="减小字号" aria-label="减小字号" onClick={() => { changeEditorFontSize(editorFontSize - 1) }}>A−</button>
2607
2607
  <span className={css.meta}>{editorFontSize}px</span>
2608
2608
  <button type="button" className={css.iconButton} title="增大字号" aria-label="增大字号" onClick={() => { changeEditorFontSize(editorFontSize + 1) }}>A+</button>
@@ -2717,12 +2717,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2717
2717
  </div>
2718
2718
  <div className={css.meta}><b>总评:</b>{wsCheckReport.verdict}</div>
2719
2719
  {wsCheckReport.issues.length > 0 && (
2720
- <ul style={{ margin: 0, paddingLeft: 16, display: 'flex', flexDirection: 'column', gap: 3, fontSize: editorFontSize - 1, maxHeight: '45vh', overflowY: 'auto' }}>
2720
+ <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' }}>
2721
2721
  {wsCheckReport.issues.map((issue, i) => (
2722
- <li key={i} style={{ color: severityColor(issue.severity), display: 'flex', gap: 6, alignItems: 'flex-start' }}>
2722
+ <li key={i} style={{ color: severityColor(issue.severity), display: 'flex', gap: 'var(--nf-space-6)', alignItems: 'flex-start' }}>
2723
2723
  <input
2724
2724
  type="checkbox"
2725
- style={{ marginTop: 1 }}
2725
+ style={{ marginTop: 'var(--nf-space-2)' }}
2726
2726
  checked={wsChecked.includes(i)}
2727
2727
  onChange={e => {
2728
2728
  setWsChecked(prev => e.target.checked ? [...prev, i] : prev.filter(x => x !== i))
@@ -2748,7 +2748,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2748
2748
  <div className={css.wsPreview} style={{ flex: 1, minHeight: 0 }}>
2749
2749
  <div className={css.busyRow}>
2750
2750
  <span className={css.meta}>优化预览({workspace.draft.length} 字)</span>
2751
- <span style={{ display: 'flex', gap: 8 }}>
2751
+ <span style={{ display: 'flex', gap: 'var(--nf-space-8)' }}>
2752
2752
  <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWsShowDiff(v => !v) }}>
2753
2753
  {wsShowDiff ? '显示文本' : '查看对比'}
2754
2754
  </button>
@@ -2871,17 +2871,17 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2871
2871
  </button>
2872
2872
  </div>
2873
2873
  {auditStatus?.status === 'running' && (
2874
- <div className={css.meta} style={{ marginTop: 8 }}>
2874
+ <div className={css.meta} style={{ marginTop: 'var(--nf-space-8)' }}>
2875
2875
  🔍 全书质检中:{auditStatus.completedBatches}/{auditStatus.totalBatches > 0 ? auditStatus.totalBatches : '…'} 批
2876
2876
  {auditStatus.totalBatches > 0 && (
2877
- <div className={css.dashJourneyBar} style={{ marginTop: 4 }}>
2877
+ <div className={css.dashJourneyBar} style={{ marginTop: 'var(--nf-space-4)' }}>
2878
2878
  <div className={css.dashJourneyFill} style={{ width: `${Math.round((auditStatus.completedBatches / auditStatus.totalBatches) * 100)}%` }} />
2879
2879
  </div>
2880
2880
  )}
2881
2881
  </div>
2882
2882
  )}
2883
2883
  {auditStatus?.status === 'error' && auditStatus.error !== undefined && (
2884
- <div className={css.meta} style={{ color: 'var(--nf-error)', marginTop: 8 }}>全书质检失败:{auditStatus.error}</div>
2884
+ <div className={css.meta} style={{ color: 'var(--nf-error)', marginTop: 'var(--nf-space-8)' }}>全书质检失败:{auditStatus.error}</div>
2885
2885
  )}
2886
2886
  <div className={css.assetGrid}>
2887
2887
  <StatCell
@@ -2913,7 +2913,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2913
2913
  </div>
2914
2914
 
2915
2915
  {/* 剧情线进度 | 作者复盘趋势:精简摘要卡,并排(点击跳转详情页) */}
2916
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 14, alignItems: 'stretch' }}>
2916
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 'var(--nf-space-14)', alignItems: 'stretch' }}>
2917
2917
  <div className={css.card}>
2918
2918
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
2919
2919
  <span className={css.cardTitle}>🧵 {tt('plotlines.workflowTitle')}({(project?.plotlines ?? []).length} 条)</span>
@@ -2992,7 +2992,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2992
2992
  </button>
2993
2993
  </div>
2994
2994
  {auditIssues.length > 0 && (
2995
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
2995
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
2996
2996
  {auditIssues.map((issue, i) => (
2997
2997
  <AuditIssueRow
2998
2998
  key={i}
@@ -3124,7 +3124,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3124
3124
  ) : (
3125
3125
  /* 只读展示:大纲是开书时的出生证明 */
3126
3126
  <>
3127
- <div className={css.meta} style={{ marginBottom: 6 }}>
3127
+ <div className={css.meta} style={{ marginBottom: 'var(--nf-space-6)' }}>
3128
3128
  大纲是本书的「出生证明」;已写章节可点右上角「🔄 反推大纲」从正文反推,或「更新大纲」手动修订。
3129
3129
  </div>
3130
3130
  <pre className={css.outlineReadonly}>{project.outline}</pre>
@@ -3187,7 +3187,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3187
3187
  </button>
3188
3188
  </div>
3189
3189
  {/* 封面 */}
3190
- <div className={css.row} style={{ alignItems: 'flex-start', gap: 14 }}>
3190
+ <div className={css.row} style={{ alignItems: 'flex-start', gap: 'var(--nf-space-14)' }}>
3191
3191
  <div className={css.coverPreview}>
3192
3192
  {coverDataUrl !== null ? (
3193
3193
  <img src={coverDataUrl} alt="封面" />
@@ -3195,7 +3195,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3195
3195
  <span className={css.coverPlaceholder}>暂无封面</span>
3196
3196
  )}
3197
3197
  </div>
3198
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
3198
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)' }}>
3199
3199
  <div className={css.row}>
3200
3200
  <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || project === null} onClick={() => { coverFileRef.current?.click() }}>
3201
3201
  📤 上传封面
@@ -3286,9 +3286,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3286
3286
  </div>
3287
3287
  <span className={css.meta}>{tt('sensitive.hint')}</span>
3288
3288
  {sensHits.length > 0 && (
3289
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 300, overflowY: 'auto', fontSize: 12 }}>
3289
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', maxHeight: 300, overflowY: 'auto', fontSize: 'var(--nf-fs-12)' }}>
3290
3290
  {sensHits.map((hit, i) => (
3291
- <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', border: '1px solid var(--nf-border)', borderRadius: 6, padding: '4px 8px' }}>
3291
+ <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)' }}>
3292
3292
  <span className={css.badge} style={{ borderColor: 'var(--nf-warn)', color: 'var(--nf-warn)', flex: 'none' }}>
3293
3293
  {hit.chapterNo > 0 ? `第${hit.chapterNo}章` : '文本'}
3294
3294
  </span>
@@ -3315,7 +3315,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3315
3315
  {chapters.length > 0 && (
3316
3316
  <div className={css.card}>
3317
3317
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3318
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
3318
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
3319
3319
  {(volumes !== undefined && volumes.length > 0) && (
3320
3320
  <>
3321
3321
  <button
@@ -3372,7 +3372,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3372
3372
  <span className={css.chapterNum}>{chapter.no}</span>
3373
3373
  <div className={css.chapterMain}>
3374
3374
  <div className={css.chapterTitle}>
3375
- <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ padding: '1px 6px' }} onClick={() => { void handleToggleChapter(chapter.no) }}>
3375
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ padding: 'var(--nf-space-2) var(--nf-space-6)' }} onClick={() => { void handleToggleChapter(chapter.no) }}>
3376
3376
  {expanded ? '−' : '+'}
3377
3377
  </button>
3378
3378
  <span>{chapter.title}</span>
@@ -3383,7 +3383,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3383
3383
  </div>
3384
3384
  {!expanded && <div className={css.chapterBeats} title={chapter.beats}>{chapter.beats}</div>}
3385
3385
  {expanded && (
3386
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
3386
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
3387
3387
  <div className={css.meta}><b>{tt('plan.beats')}:</b></div>
3388
3388
  <div className={css.meta}>{renderBeats(chapter.beats)}</div>
3389
3389
  {chapter.summary !== undefined && chapter.summary !== '' && (
@@ -3400,7 +3400,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3400
3400
  </div>
3401
3401
  <div className={css.meta}><b>{tt('plan.reviewVerdict')}:</b> {review.verdict}</div>
3402
3402
  {review.issues.length > 0 && (
3403
- <ul style={{ margin: 0, paddingLeft: 18, fontSize: 12 }}>
3403
+ <ul style={{ margin: 0, paddingLeft: 'var(--nf-space-18)', fontSize: 'var(--nf-fs-12)' }}>
3404
3404
  {review.issues.map((issue, i) => (
3405
3405
  <li key={i} style={{ color: severityColor(issue.severity) }}>
3406
3406
  [{issue.severity}] {issue.item} → {issue.suggestion}
@@ -3446,7 +3446,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3446
3446
  </div>
3447
3447
  )}
3448
3448
  {(chapter.status === 'rejected' || chapter.status === 'written' || chapter.status === 'approved') && (
3449
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
3449
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
3450
3450
  <div className={css.meta} style={{ fontWeight: 600 }}>
3451
3451
  润色 / 修订 — 在右上角打开工作区:左栏原文可直接选中文字做局部修订,右栏输入指令后预览,确认后再应用(未应用不改动原稿)
3452
3452
  </div>
@@ -3527,16 +3527,16 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3527
3527
  )}
3528
3528
 
3529
3529
  {activeTab === 'book' && (
3530
- <div className={css.card} style={{ gap: 12 }}>
3530
+ <div className={css.card} style={{ gap: 'var(--nf-space-12)' }}>
3531
3531
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3532
- <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📚 本书设定</span>
3532
+ <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-16)', fontWeight: 700 }}>📚 本书设定</span>
3533
3533
  <span className={css.meta}>当前书的知识与资料(参考 AI-Novel-Writing-Assistant:世界观/角色准备收进书内)</span>
3534
3534
  </div>
3535
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
3535
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
3536
3536
  <button
3537
3537
  type="button"
3538
3538
  className={`${css.button} ${bookTab === 'bible' ? css.buttonPrimary : ''}`}
3539
- style={{ fontSize: 14, flex: 1 }}
3539
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3540
3540
  onClick={() => { changeBookTab('bible') }}
3541
3541
  title="设定库:题材 / 世界观规则 / 人物摘要 / 红线 / 文风"
3542
3542
  >
@@ -3545,7 +3545,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3545
3545
  <button
3546
3546
  type="button"
3547
3547
  className={`${css.button} ${bookTab === 'world' ? css.buttonPrimary : ''}`}
3548
- style={{ fontSize: 14, flex: 1 }}
3548
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3549
3549
  onClick={() => { changeBookTab('world') }}
3550
3550
  title="大世界:境界体系 / 地理区域 / 势力分布(注入生成与审稿提示词)"
3551
3551
  >
@@ -3554,7 +3554,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3554
3554
  <button
3555
3555
  type="button"
3556
3556
  className={`${css.button} ${bookTab === 'roles' ? css.buttonPrimary : ''}`}
3557
- style={{ fontSize: 14, flex: 1 }}
3557
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3558
3558
  onClick={() => { changeBookTab('roles') }}
3559
3559
  title="角色库:全书角色主表(定位 / 关系网 / 成长线 / 知情度)"
3560
3560
  >
@@ -3563,7 +3563,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3563
3563
  <button
3564
3564
  type="button"
3565
3565
  className={`${css.button} ${bookTab === 'facts' ? css.buttonPrimary : ''}`}
3566
- style={{ fontSize: 14, flex: 1 }}
3566
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
3567
3567
  onClick={() => { changeBookTab('facts') }}
3568
3568
  title="编年录与复盘记录"
3569
3569
  >
@@ -3584,13 +3584,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3584
3584
  {bible === undefined ? (
3585
3585
  <span className={css.meta}>{tt('bible.none')}</span>
3586
3586
  ) : (
3587
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
3587
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
3588
3588
  {bible.genre !== '' && (
3589
3589
  <div><b>{tt('bible.genre')}:</b> <span className={css.meta}>{bible.genre}</span></div>
3590
3590
  )}
3591
3591
  {/* 世界观规则独立卡片(可编辑,每行一条) */}
3592
3592
  {bible.worldRules.length > 0 && (
3593
- <div style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px' }}>
3593
+ <div style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)' }}>
3594
3594
  <div className={css.row} style={{ justifyContent: 'space-between' }}>
3595
3595
  <b>{tt('bible.worldRules')}({bible.worldRules.length})</b>
3596
3596
  <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWorldRulesDraft(bible.worldRules.join('\n')) }}>
@@ -3598,7 +3598,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3598
3598
  </button>
3599
3599
  </div>
3600
3600
  {worldRulesDraft !== '' && (
3601
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
3601
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', marginTop: 'var(--nf-space-6)' }}>
3602
3602
  <textarea
3603
3603
  className={css.textarea}
3604
3604
  style={{ minHeight: 120 }}
@@ -3617,7 +3617,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3617
3617
  </div>
3618
3618
  )}
3619
3619
  {worldRulesDraft === '' && (
3620
- <ul style={{ margin: '4px 0 0', paddingLeft: 18, fontSize: 12 }}>{bible.worldRules.map((r, i) => <li key={i}>{r}</li>)}</ul>
3620
+ <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>
3621
3621
  )}
3622
3622
  </div>
3623
3623
  )}
@@ -3625,7 +3625,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3625
3625
  <div>
3626
3626
  <b>{tt('bible.characters')}({bible.characters.length})</b>
3627
3627
  {bible.characters.map(card => (
3628
- <div key={card.name} style={{ marginTop: 4, fontSize: 12 }}>
3628
+ <div key={card.name} style={{ marginTop: 'var(--nf-space-4)', fontSize: 'var(--nf-fs-12)' }}>
3629
3629
  <b>{card.name}</b> <span className={css.meta}>[{card.role}] {card.traits.join('、')}</span>
3630
3630
  {card.goals !== '' && <div className={css.meta}>目标:{card.goals}</div>}
3631
3631
  {card.relations !== '' && <div className={css.meta}>关系:{card.relations}</div>}
@@ -3636,13 +3636,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3636
3636
  {bible.redLines.length > 0 && (
3637
3637
  <div>
3638
3638
  <b>{tt('bible.redLines')}({bible.redLines.length})</b>
3639
- <ul style={{ margin: 0, paddingLeft: 18, fontSize: 12, color: 'var(--nf-error)' }}>{bible.redLines.map((r, i) => <li key={i}>{r}</li>)}</ul>
3639
+ <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>
3640
3640
  </div>
3641
3641
  )}
3642
3642
  {bible.style.length > 0 && (
3643
3643
  <div>
3644
3644
  <b>{tt('bible.style')}({bible.style.length})</b>
3645
- <ul style={{ margin: 0, paddingLeft: 18, fontSize: 12 }}>{bible.style.map((r, i) => <li key={i}>{r}</li>)}</ul>
3645
+ <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>
3646
3646
  </div>
3647
3647
  )}
3648
3648
  </div>
@@ -3692,7 +3692,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3692
3692
  <span className={css.meta}>角色库是全书角色主表:定位(女主/女配/配角/反派)、身份、关系网、成长线、知情度——生成与审稿都会按定位规格刻画互动。点「从编年录刷新状态」可在每张卡上显示角色当前状态。</span>
3693
3693
 
3694
3694
  {roleCandidates !== null && (
3695
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, border: '1px solid var(--nf-info)', borderRadius: 12, padding: 10 }}>
3695
+ <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)' }}>
3696
3696
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3697
3697
  <b>✨ AI 提炼候选({roleCandidates.length})</b>
3698
3698
  <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setRoleCandidates(null) }}>收起</button>
@@ -3711,7 +3711,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3711
3711
  )}
3712
3712
 
3713
3713
  {roleDraft !== null && (
3714
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8, border: '1px solid var(--nf-accent)', borderRadius: 12, padding: 10 }}>
3714
+ <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)' }}>
3715
3715
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
3716
3716
  <div className={css.field} style={{ flex: 1, minWidth: 140 }}>
3717
3717
  <label className={css.fieldLabel}>角色名</label>
@@ -3761,7 +3761,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3761
3761
  {(project?.roles ?? []).length === 0 ? (
3762
3762
  <span className={css.meta}>角色库为空——点「✨ 从全书提炼角色」自动建立,或手动新增。</span>
3763
3763
  ) : (
3764
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
3764
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
3765
3765
  {(project?.roles ?? []).map(r => (
3766
3766
  <RoleCard
3767
3767
  key={r.name}
@@ -3783,18 +3783,18 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3783
3783
  {activeTab === 'settings' && configDraft !== null && (
3784
3784
  <>
3785
3785
  {/* 设置页内子导航(大容器) */}
3786
- <div className={`${css.card} ${css.settingsCard}`} style={{ padding: '20px 22px', gap: 14 }}>
3787
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
3788
- <span className={css.cardTitle} style={{ fontSize: 20, fontWeight: 700 }}>⚙️ 设置</span>
3786
+ <div className={`${css.card} ${css.settingsCard}`}>
3787
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--nf-space-8)' }}>
3788
+ <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-20)', fontWeight: 700 }}>⚙️ 设置</span>
3789
3789
  <span className={css.meta}>当前:{config?.provider} / {config?.model} · {config?.outputDir}</span>
3790
3790
  </div>
3791
- <div className={css.row} style={{ gap: 8, flexWrap: 'wrap' }}>
3791
+ <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
3792
3792
  {SETTINGS_SECTIONS.map(s => (
3793
3793
  <button
3794
3794
  key={s.id}
3795
3795
  type="button"
3796
3796
  className={`${css.button} ${settingsTab === s.id ? css.buttonPrimary : ''}`}
3797
- style={{ fontSize: 14, padding: '8px 12px', flex: 1, minWidth: 104, justifyContent: 'center' }}
3797
+ style={{ fontSize: 'var(--nf-fs-14)', padding: 'var(--nf-space-8) var(--nf-space-12)', flex: 1, minWidth: 104, justifyContent: 'center' }}
3798
3798
  onClick={() => { changeSettingsTab(s.id) }}
3799
3799
  >
3800
3800
  {s.icon} {s.label}
@@ -3804,9 +3804,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3804
3804
  </div>
3805
3805
 
3806
3806
  {settingsTab === 'model' && (
3807
- <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 26 }}>
3807
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
3808
3808
  <span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
3809
- <div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
3809
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
3810
3810
  <div className={css.field}>
3811
3811
  <label className={css.fieldLabel}>{tt('settings.provider')}</label>
3812
3812
  <input className={css.input} value={configDraft.provider} onChange={e => { setConfigDraft({ ...configDraft, provider: e.target.value }) }} />
@@ -3832,7 +3832,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3832
3832
  {modelCustomMode && (
3833
3833
  <input
3834
3834
  className={css.input}
3835
- style={{ marginTop: 6 }}
3835
+ style={{ marginTop: 'var(--nf-space-6)' }}
3836
3836
  value={configDraft.model}
3837
3837
  placeholder={tt('settings.modelCustomPlaceholder')}
3838
3838
  onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }}
@@ -3866,9 +3866,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3866
3866
  )}
3867
3867
 
3868
3868
  {settingsTab === 'writing' && (
3869
- <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 26 }}>
3869
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
3870
3870
  <span className={css.cardTitle}><PenLine size={18} style={{ verticalAlign: -3 }} /> 写作与审稿</span>
3871
- <div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
3871
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
3872
3872
  <div className={css.field}>
3873
3873
  <label className={css.fieldLabel}>{tt('settings.chapterChars')}</label>
3874
3874
  <input className={css.input} type="number" min={1000} max={20000} value={configDraft.chapterChars} onChange={e => { setConfigDraft({ ...configDraft, chapterChars: Number(e.target.value) }) }} />
@@ -3935,10 +3935,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3935
3935
  )}
3936
3936
 
3937
3937
  {settingsTab === 'image' && (
3938
- <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 24 }}>
3938
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
3939
3939
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center' }}>
3940
3940
  <span className={css.cardTitle}><Palette size={18} style={{ verticalAlign: -3 }} /> 生图模型</span>
3941
- <span className={css.row} style={{ gap: 8, alignItems: 'center' }}>
3941
+ <span className={css.row} style={{ gap: 'var(--nf-space-8)', alignItems: 'center' }}>
3942
3942
  <span className={css.meta}>启用生图功能</span>
3943
3943
  <button
3944
3944
  type="button"
@@ -3952,9 +3952,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3952
3952
  </span>
3953
3953
  </div>
3954
3954
  <span className={css.meta}>支持多套 OpenAI 兼容生图接口(豆包 / 即梦 / 其他平台);启用一条作为生成模型,保存后角色详情显示「AI 生图」按钮。</span>
3955
- <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
3955
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-20)' }}>
3956
3956
  {(configDraft.imageModels ?? []).map(m => (
3957
- <div key={m.id} style={{ border: m.enabled ? '1px solid var(--nf-accent)' : '1px solid var(--nf-border)', borderRadius: 12, padding: 14, display: 'flex', flexDirection: 'column', gap: 12, background: m.enabled ? 'var(--nf-accent-soft)' : 'transparent' }}>
3957
+ <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' }}>
3958
3958
  <div className={css.row} style={{ alignItems: 'flex-end', flexWrap: 'wrap' }}>
3959
3959
  <div className={css.field} style={{ flex: 1, minWidth: 180 }}>
3960
3960
  <label className={css.fieldLabel}>名称</label>
@@ -3973,7 +3973,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3973
3973
  <label className={css.fieldLabel}>API Key</label>
3974
3974
  <input className={css.input} type="password" placeholder="ark-..." value={m.apiKey} onChange={e => { updateImageModel(m.id, { apiKey: e.target.value }) }} />
3975
3975
  </div>
3976
- <div className={css.row} style={{ gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
3976
+ <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap', alignItems: 'center' }}>
3977
3977
  <button type="button" className={`${css.button} ${css.buttonSmall} ${m.enabled ? css.buttonPrimary : ''}`} onClick={() => { enableImageModel(m.id) }}>
3978
3978
  {m.enabled ? '✓ 已启用' : '设为启用'}
3979
3979
  </button>
@@ -3986,8 +3986,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3986
3986
  const st = imageTestState[m.id]
3987
3987
  if (st === undefined || st.testing) return null
3988
3988
  return st.ok === true
3989
- ? <span style={{ color: 'var(--nf-success)', fontSize: 12, fontWeight: 600 }}>✓ 连通 · {st.ms}ms{st.modelFound === true ? '' : st.modelFound === false ? '(模型未在列表)' : ''}</span>
3990
- : <span style={{ color: 'var(--nf-error)', fontSize: 12 }}>✗ {st.message ?? '失败'}</span>
3989
+ ? <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-fs-12)', fontWeight: 600 }}>✓ 连通 · {st.ms}ms{st.modelFound === true ? '' : st.modelFound === false ? '(模型未在列表)' : ''}</span>
3990
+ : <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-12)' }}>✗ {st.message ?? '失败'}</span>
3991
3991
  })()}
3992
3992
  </div>
3993
3993
  </div>
@@ -4003,7 +4003,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4003
4003
  )}
4004
4004
 
4005
4005
  {settingsTab === 'files' && (
4006
- <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 24 }}>
4006
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
4007
4007
  <span className={css.cardTitle}><Folder size={18} style={{ verticalAlign: -3 }} /> 路径与文件</span>
4008
4008
  <div className={css.field}>
4009
4009
  <label className={css.fieldLabel}>{tt('settings.outlinePath')}</label>
@@ -4019,7 +4019,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4019
4019
 
4020
4020
  <div style={{ borderTop: '1px solid var(--nf-border)', margin: '14px 0 12px' }} />
4021
4021
 
4022
- <span className={css.cardTitle} style={{ fontSize: 13 }}>💾 保存与导出</span>
4022
+ <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-14)' }}>💾 保存与导出</span>
4023
4023
  <div className={css.row}>
4024
4024
  <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSaveConfig() }}>
4025
4025
  {tt('settings.save')}
@@ -4040,10 +4040,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4040
4040
  <div className={`${css.card} ${css.settingsCard}`}>
4041
4041
  <span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
4042
4042
  <span className={css.meta}>选择适合长时间创作的界面颜色和显示密度。主题只保存在当前设备,不影响小说内容和任务状态。</span>
4043
- <div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
4043
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
4044
4044
  <div className={css.field}>
4045
- <label className={css.fieldLabel} style={{ fontSize: 13 }}>显示模式</label>
4046
- <select className={css.input} style={{ padding: '10px 12px', fontSize: 14 }} value={themeMode} onChange={e => { changeThemeMode(e.target.value as 'system' | 'light' | 'dark') }}>
4045
+ <label className={css.fieldLabel} style={{ fontSize: 'var(--nf-fs-14)' }}>显示模式</label>
4046
+ <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') }}>
4047
4047
  <option value="system">跟随系统</option>
4048
4048
  <option value="light">浅色</option>
4049
4049
  <option value="dark">深色</option>
@@ -4051,17 +4051,18 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4051
4051
  <span className={css.meta}>强制浅色/深色只作用于小说工坊面板</span>
4052
4052
  </div>
4053
4053
  <div className={css.field}>
4054
- <label className={css.fieldLabel} style={{ fontSize: 13 }}>主题风格</label>
4055
- <select className={css.input} style={{ padding: '10px 12px', fontSize: 14 }} value={panelTheme} onChange={e => { changePanelTheme(e.target.value as 'liquid' | 'classic' | 'neumorph') }}>
4054
+ <label className={css.fieldLabel} style={{ fontSize: 'var(--nf-fs-14)' }}>主题风格</label>
4055
+ <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') }}>
4056
4056
  <option value="liquid">液态玻璃 · 清新绿(默认)</option>
4057
- <option value="classic">经典毛玻璃 · 沉静蓝</option>
4058
4057
  <option value="neumorph">新拟物 · 柔和浅色</option>
4058
+ <option value="macos">macOS · 玻璃(蓝,随外观浅/深)</option>
4059
+ <option value="clay">粘土拟态 · 柔和黏土</option>
4059
4060
  </select>
4060
4061
  <span className={css.meta}>{tt('settings.themeHint')}</span>
4061
4062
  </div>
4062
4063
  <div className={css.field}>
4063
- <label className={css.fieldLabel} style={{ fontSize: 13 }}>界面密度</label>
4064
- <select className={css.input} style={{ padding: '10px 12px', fontSize: 14 }} value={themeDensity} onChange={e => { changeThemeDensity(e.target.value as 'comfort' | 'compact' | 'spacious') }}>
4064
+ <label className={css.fieldLabel} style={{ fontSize: 'var(--nf-fs-14)' }}>界面密度</label>
4065
+ <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
4066
  <option value="comfort">舒适(默认)</option>
4066
4067
  <option value="compact">紧凑</option>
4067
4068
  <option value="spacious">宽松</option>
@@ -4070,7 +4071,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4070
4071
  </div>
4071
4072
  </div>
4072
4073
  <div className={css.row} style={{ justifyContent: 'flex-end' }}>
4073
- <button type="button" className={css.button} style={{ padding: '9px 18px', fontSize: 13.5 }} onClick={resetTheme}>
4074
+ <button type="button" className={css.button} style={{ padding: 'var(--nf-space-10) var(--nf-space-18)', fontSize: 'var(--nf-fs-14)' }} onClick={resetTheme}>
4074
4075
  <RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题
4075
4076
  </button>
4076
4077
  </div>
@@ -4081,13 +4082,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4081
4082
  {activeTab === 'book' && bookTab === 'facts' && (
4082
4083
  <div className={css.card}>
4083
4084
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4084
- <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📚 编年 / 复盘</span>
4085
+ <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-16)', fontWeight: 700 }}>📚 编年 / 复盘</span>
4085
4086
  </div>
4086
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
4087
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
4087
4088
  <button
4088
4089
  type="button"
4089
4090
  className={`${css.button} ${archiveTab === 'facts' ? css.buttonPrimary : ''}`}
4090
- style={{ fontSize: 14, flex: 1 }}
4091
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
4091
4092
  onClick={() => { changeArchiveTab('facts') }}
4092
4093
  title="编年录:客观事实流水(第 N 章 · 事件),生成/审稿查证用"
4093
4094
  >
@@ -4096,7 +4097,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4096
4097
  <button
4097
4098
  type="button"
4098
4099
  className={`${css.button} ${archiveTab === 'reviews' ? css.buttonPrimary : ''}`}
4099
- style={{ fontSize: 14, flex: 1 }}
4100
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
4100
4101
  onClick={() => { changeArchiveTab('reviews') }}
4101
4102
  title="复盘记录:每章作者复盘(钩子 / 推进 / 衔接 / 趋势)"
4102
4103
  >
@@ -4138,12 +4139,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4138
4139
  </span>
4139
4140
  )
4140
4141
  })()}
4141
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', flex: 1, minHeight: 0 }}>
4142
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)', overflowY: 'auto', flex: 1, minHeight: 0 }}>
4142
4143
  {(() => {
4143
4144
  const groupsWithReviews = chapterGroups.filter(group => group.chapters.some(c => c.authorReview !== undefined))
4144
4145
  if (groupsWithReviews.length === 0) return null
4145
4146
  return (
4146
- <div className={css.row} style={{ justifyContent: 'flex-end', gap: 6 }}>
4147
+ <div className={css.row} style={{ justifyContent: 'flex-end', gap: 'var(--nf-space-6)' }}>
4147
4148
  <button
4148
4149
  type="button"
4149
4150
  className={`${css.button} ${css.buttonSmall}`}
@@ -4166,10 +4167,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4166
4167
  if (groupReviewed.length === 0) return null
4167
4168
  const volumeExpanded = expandedVolumes[group.no] === true
4168
4169
  return (
4169
- <div key={group.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden' }}>
4170
+ <div key={group.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', overflow: 'hidden' }}>
4170
4171
  <button
4171
4172
  type="button"
4172
- style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '8px 12px', fontSize: 13, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}
4173
+ 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' }}
4173
4174
  onClick={() => { setExpandedVolumes(prev => ({ ...prev, [group.no]: !volumeExpanded })) }}
4174
4175
  >
4175
4176
  <span style={{ color: 'var(--nf-text-3)' }}>{volumeExpanded ? '▾' : '▸'}</span>
@@ -4177,25 +4178,25 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4177
4178
  <span className={css.meta}>已复盘 {groupReviewed.length}/{group.chapters.length} 章</span>
4178
4179
  </button>
4179
4180
  {volumeExpanded && (
4180
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '4px 8px 8px', borderTop: '1px solid var(--nf-border)' }}>
4181
+ <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)' }}>
4181
4182
  {[...groupReviewed].reverse().map(c => {
4182
4183
  const ar = c.authorReview!
4183
4184
  const expanded = expandedReviewChapter === c.no
4184
4185
  return (
4185
- <div key={c.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, overflow: 'hidden' }}>
4186
+ <div key={c.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-8)', overflow: 'hidden' }}>
4186
4187
  <button
4187
4188
  type="button"
4188
- style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '6px 10px', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}
4189
+ 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' }}
4189
4190
  onClick={() => { setExpandedReviewChapter(expanded ? null : c.no) }}
4190
4191
  >
4191
4192
  <span style={{ color: 'var(--nf-text-3)' }}>{expanded ? '▾' : '▸'}</span>
4192
4193
  <b>第{c.no}章《{c.title}》</b>
4193
4194
  <span style={{ color: ar.hookHonored ? 'var(--nf-success)' : 'var(--nf-warn)' }}>钩子{ar.hookHonored ? '✓' : '✗'}</span>
4194
4195
  <span style={{ color: ar.endingHook >= 6 ? 'var(--nf-success)' : 'var(--nf-error)' }}>结尾钩子 {ar.endingHook}/10</span>
4195
- {ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 4 }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
4196
+ {ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 'var(--nf-space-4)' }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
4196
4197
  </button>
4197
4198
  {expanded && (
4198
- <div style={{ padding: '4px 10px 8px', fontSize: 12, display: 'flex', flexDirection: 'column', gap: 3, borderTop: '1px solid var(--nf-border)' }}>
4199
+ <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)' }}>
4199
4200
  {ar.hookNote !== '' && <span className={css.meta}><b>钩子:</b>{ar.hookNote}</span>}
4200
4201
  {ar.plotlineProgress !== '' && <span className={css.meta}><b>推进:</b>{ar.plotlineProgress}</span>}
4201
4202
  {ar.continuity !== '' && <span className={css.meta}><b>衔接:</b>{ar.continuity}</span>}
@@ -4228,10 +4229,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4228
4229
  </div>
4229
4230
  <span className={css.meta}>{tt('facts.hint')}</span>
4230
4231
  {(project?.facts ?? []).length > 0 ? (
4231
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: '60vh', overflowY: 'auto', fontSize: 12 }}>
4232
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', maxHeight: '60vh', overflowY: 'auto', fontSize: 'var(--nf-fs-12)' }}>
4232
4233
  {[...(project?.facts ?? [])].reverse().map((fact, i) => (
4233
- <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
4234
- <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)', flex: 'none', marginTop: 1 }}>
4234
+ <div key={i} style={{ display: 'flex', gap: 'var(--nf-space-8)', alignItems: 'flex-start' }}>
4235
+ <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)', flex: 'none', marginTop: 'var(--nf-space-2)' }}>
4235
4236
  第 {fact.chapterNo} 章
4236
4237
  </span>
4237
4238
  <span className={css.meta}>{fact.text}</span>
@@ -4249,13 +4250,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4249
4250
  {activeTab === 'plotlines' && (
4250
4251
  <div className={css.card}>
4251
4252
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4252
- <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📜 长线管理</span>
4253
+ <span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-16)', fontWeight: 700 }}>📜 长线管理</span>
4253
4254
  </div>
4254
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
4255
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
4255
4256
  <button
4256
4257
  type="button"
4257
4258
  className={`${css.button} ${longlineTab === 'plotlines' ? css.buttonPrimary : ''}`}
4258
- style={{ fontSize: 14, flex: 1 }}
4259
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
4259
4260
  onClick={() => { changeLonglineTab('plotlines') }}
4260
4261
  title="剧情线:故事明线(主线 / 支线 / 人物 / 悬念)"
4261
4262
  >
@@ -4264,7 +4265,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4264
4265
  <button
4265
4266
  type="button"
4266
4267
  className={`${css.button} ${longlineTab === 'foreshadow' ? css.buttonPrimary : ''}`}
4267
- style={{ fontSize: 14, flex: 1 }}
4268
+ style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
4268
4269
  onClick={() => { changeLonglineTab('foreshadow') }}
4269
4270
  title="伏笔:道具 / 事件级暗线(埋设 → 回收)"
4270
4271
  >
@@ -4300,7 +4301,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4300
4301
  </div>
4301
4302
  </div>
4302
4303
  <span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
4303
- <div className={css.row} style={{ gap: 4 }}>
4304
+ <div className={css.row} style={{ gap: 'var(--nf-space-4)' }}>
4304
4305
  {f.status === 'planned' && (
4305
4306
  <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
4306
4307
  void api.foreshadow({ id: f.id, status: 'planted', plantedChapter: doneCount + 1 }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
@@ -4395,7 +4396,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4395
4396
  )}
4396
4397
 
4397
4398
  {plotlineDraft !== null && (
4398
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8, border: '1px solid var(--nf-accent)', borderRadius: 12, padding: 10 }}>
4399
+ <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)' }}>
4399
4400
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
4400
4401
  <div className={css.field} style={{ flex: 2, minWidth: 160 }}>
4401
4402
  <label className={css.fieldLabel}>{tt('plotlines.name')}</label>
@@ -4442,7 +4443,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4442
4443
  {(project?.plotlines ?? []).length === 0 ? (
4443
4444
  <span className={css.meta}>{tt('plotlines.empty')}</span>
4444
4445
  ) : (
4445
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
4446
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', overflowY: 'auto', flex: 1, minHeight: 0 }}>
4446
4447
  {(project?.plotlines ?? []).map(line => (
4447
4448
  <PlotlineCard
4448
4449
  key={line.id}
@@ -4510,7 +4511,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4510
4511
  <span className={css.meta}>选择范围与档位,点「开始拆书」对已写章节做整卷体检</span>
4511
4512
  </div>
4512
4513
  ) : (
4513
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
4514
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
4514
4515
  <span className={css.meta}>
4515
4516
  分析 {breakdownResult.chaptersScanned} 章 · {breakdownResult.sections.length} 个小节 · 约 {breakdownResult.usedTokens} token
4516
4517
  </span>
@@ -4520,14 +4521,14 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4520
4521
  <b>{sec.title}</b>
4521
4522
  </div>
4522
4523
  {Object.keys(sec.structured).length > 0 && (
4523
- <div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 12, marginBottom: 6 }}>
4524
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-2)', fontSize: 'var(--nf-fs-12)', marginBottom: 'var(--nf-space-6)' }}>
4524
4525
  {Object.entries(sec.structured).map(([k, v]) => {
4525
4526
  const label = (typeof v === 'string') ? v : Array.isArray(v) ? (v as string[]).join('、') : JSON.stringify(v)
4526
4527
  return <div key={k} className={css.meta}><b>{k}:</b>{label}</div>
4527
4528
  })}
4528
4529
  </div>
4529
4530
  )}
4530
- <div className={css.meta} style={{ whiteSpace: 'pre-wrap', fontSize: 12 }}>{sec.markdown}</div>
4531
+ <div className={css.meta} style={{ whiteSpace: 'pre-wrap', fontSize: 'var(--nf-fs-12)' }}>{sec.markdown}</div>
4531
4532
  </div>
4532
4533
  ))}
4533
4534
  </div>
@@ -4585,11 +4586,11 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4585
4586
  }}
4586
4587
  >
4587
4588
  <span>
4588
- 📊 AI进度
4589
+ 📊 AI 进度
4589
4590
  {busy && <span style={{ color: 'var(--nf-accent)' }}> 🟢 任务进行中</span>}
4590
4591
  <span className={css.meta}>(拖动标题栏移动 · 右下角拉大小)</span>
4591
4592
  </span>
4592
- <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
4593
+ <span style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-4)' }}>
4593
4594
  {progress.length > 0 && (
4594
4595
  <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setProgress([]) }} title="清空活动记录">
4595
4596
  清空
@@ -4598,11 +4599,11 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4598
4599
  <button type="button" className={css.iconButton} title="关闭" aria-label="关闭AI进度" onClick={() => { setProgressOpen(false) }}>×</button>
4599
4600
  </span>
4600
4601
  </div>
4601
- <div className={css.assistantFloatBody} style={{ padding: 10, display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>
4602
+ <div className={css.assistantFloatBody} style={{ padding: 'var(--nf-space-10)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', overflow: 'hidden' }}>
4602
4603
  {/* 当前任务大进度条:进行中显示 */}
4603
4604
  {(busy && (busyLabel !== '' || liveBar !== null)) && (
4604
- <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)' }}>
4605
- <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--nf-accent)' }}>
4605
+ <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)' }}>
4606
+ <span style={{ fontSize: 'var(--nf-fs-12)', fontWeight: 600, color: 'var(--nf-accent)' }}>
4606
4607
  ✍ {busyLabel !== '' ? busyLabel : (liveBar?.text ?? '任务进行中')}…
4607
4608
  </span>
4608
4609
  {liveBar?.ratio !== undefined && (
@@ -4614,10 +4615,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4614
4615
  </div>
4615
4616
  )}
4616
4617
  {/* 活动记录列表(标题行已并入窗口标题栏,清空按钮在右上角) */}
4617
- <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' }}>
4618
+ <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' }}>
4618
4619
  {progress.length === 0 ? (
4619
- <div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4, color: 'var(--nf-text-3)', fontSize: 12, minHeight: 120 }}>
4620
- <span style={{ fontSize: 22 }}>📭</span>
4620
+ <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 }}>
4621
+ <span style={{ fontSize: 'var(--nf-fs-22)' }}>📭</span>
4621
4622
  <span>暂无活动记录</span>
4622
4623
  <span>生成、审稿等操作会显示在这里</span>
4623
4624
  </div>