@waterwx/dsh-novel-forge 1.1.2 → 1.1.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.
@@ -21,8 +21,10 @@ import type {
21
21
  Foreshadow,
22
22
  JobFrame,
23
23
  NovelConfig,
24
+ Plotline,
24
25
  ProjectState,
25
26
  ReviewReport,
27
+ SensitiveHit,
26
28
  StoryBible,
27
29
  Volume,
28
30
  } from '../../protocol.ts'
@@ -31,7 +33,7 @@ import css from './panel.module.css'
31
33
  /** The panel's tab identifiers. */
32
34
  export type NovelTab =
33
35
  | 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings'
34
- | 'characters'
36
+ | 'characters' | 'facts' | 'plotlines'
35
37
  | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle'
36
38
 
37
39
  /** Panel shell props. */
@@ -63,6 +65,7 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
63
65
  { id: 'overview', label: tt('tab.overview'), icon: '📄' },
64
66
  { id: 'blurb', label: '卷首语', icon: '📖' },
65
67
  { id: 'plan', label: tt('tab.plan'), icon: '📚' },
68
+ { id: 'plotlines', label: tt('tab.plotlines'), icon: '🧵' },
66
69
  ],
67
70
  },
68
71
  {
@@ -80,6 +83,7 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
80
83
  { id: 'world', label: '大世界', icon: '🌍' },
81
84
  { id: 'characters', label: '人物志', icon: '👥' },
82
85
  { id: 'foreshadow', label: tt('tab.foreshadow'), icon: '🔮' },
86
+ { id: 'facts', label: tt('tab.facts'), icon: '📜' },
83
87
  { id: 'assetsGenre', label: '题材基底', icon: '🏷️' },
84
88
  { id: 'assetsProgression', label: '推进模式', icon: '📈' },
85
89
  { id: 'assetsTemplates', label: '笔法帖', icon: '🖋️' },
@@ -347,6 +351,17 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
347
351
  setEditorFontSize(v)
348
352
  try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
349
353
  }
354
+ /** 面板主题(localStorage 记忆):'liquid'=iOS 液态玻璃(绿) / 'classic'=经典毛玻璃(蓝) / 'neumorph'=新拟物(浅色)。 */
355
+ const [panelTheme, setPanelTheme] = useState<'liquid' | 'classic' | 'neumorph'>(() => {
356
+ try {
357
+ const v = window.localStorage.getItem('dsh-novel-forge.theme')
358
+ return v === 'classic' || v === 'neumorph' ? v : 'liquid'
359
+ } catch { return 'liquid' }
360
+ })
361
+ const changePanelTheme = (next: 'liquid' | 'classic' | 'neumorph'): void => {
362
+ setPanelTheme(next)
363
+ try { window.localStorage.setItem('dsh-novel-forge.theme', next) } catch { /* ignore */ }
364
+ }
350
365
  /** 有未采纳草稿的章节号(refresh 后检测到遗留草稿时提示)。 */
351
366
  const [draftNo, setDraftNo] = useState<number | null>(null)
352
367
  /** 大纲页「更新大纲」编辑区是否展开。 */
@@ -367,8 +382,29 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
367
382
  const coverFileRef = useRef<HTMLInputElement | null>(null)
368
383
  /** 章节列表按卷折叠(存已折叠的卷号)。 */
369
384
  const [collapsedVolumes, setCollapsedVolumes] = useState<number[]>([])
385
+ /** 章节页当前选中的卷('all' = 全部卷显示在一起)。 */
386
+ const [selectedVolume, setSelectedVolume] = useState<number | 'all'>('all')
387
+ /** 剧情线编辑草稿(null = 未在编辑)。 */
388
+ const [plotlineDraft, setPlotlineDraft] = useState<{
389
+ id: string
390
+ name: string
391
+ kind: Plotline['kind']
392
+ goal: string
393
+ progress: string
394
+ status: Plotline['status']
395
+ } | null>(null)
396
+ /** 角色知情度编辑草稿(角色名 → 文本,每行一条)。 */
397
+ const [knowledgeDraft, setKnowledgeDraft] = useState<Record<string, string>>({})
398
+ /** 全书敏感词检查结果(null = 未运行)。 */
399
+ const [sensHits, setSensHits] = useState<SensitiveHit[] | null>(null)
400
+ const [sensScanned, setSensScanned] = useState(0)
370
401
  /** npm 最新版本(更新检测;null = 未检测/检测失败)。 */
371
402
  const [npmLatest, setNpmLatest] = useState<string | null>(null)
403
+ /** 活动输出容器:自动滚动锚点(有新活动时跟随到底部)。 */
404
+ const progressEndRef = useRef<HTMLDivElement | null>(null)
405
+ useEffect(() => {
406
+ progressEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
407
+ }, [progress])
372
408
 
373
409
  /** 后台检测 npm 最新版本(失败静默,不打扰)。 */
374
410
  useEffect(() => {
@@ -668,6 +704,116 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
668
704
  }
669
705
  }
670
706
 
707
+ /** 剧情线:保存草稿(新增或更新)。 */
708
+ const handlePlotlineSave = async (): Promise<void> => {
709
+ if (plotlineDraft === null) return
710
+ const line: Plotline = {
711
+ id: plotlineDraft.id,
712
+ name: plotlineDraft.name.trim(),
713
+ kind: plotlineDraft.kind,
714
+ goal: plotlineDraft.goal.trim(),
715
+ progress: plotlineDraft.progress.trim(),
716
+ status: plotlineDraft.status,
717
+ chapters: plotlineDraft.id !== ''
718
+ ? (project?.plotlines?.find(l => l.id === plotlineDraft.id)?.chapters ?? [])
719
+ : [],
720
+ createdAt: plotlineDraft.id !== ''
721
+ ? (project?.plotlines?.find(l => l.id === plotlineDraft.id)?.createdAt ?? new Date().toISOString())
722
+ : new Date().toISOString(),
723
+ }
724
+ if (line.name === '') {
725
+ setError('剧情线名称不能为空')
726
+ return
727
+ }
728
+ setBusy(true)
729
+ setError('')
730
+ try {
731
+ const result = await api.plotlines({ op: plotlineDraft.id !== '' ? 'update' : 'add', line })
732
+ setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
733
+ setPlotlineDraft(null)
734
+ pushProgress(plotlineDraft.id !== '' ? `剧情线已更新:${line.name}` : `剧情线已创建:${line.name}`, 'done')
735
+ } catch (err) {
736
+ setError((err as Error).message)
737
+ pushProgress(`保存剧情线失败:${(err as Error).message}`, 'error')
738
+ } finally {
739
+ setBusy(false)
740
+ }
741
+ }
742
+
743
+ /** 剧情线:删除。 */
744
+ const handlePlotlineRemove = async (id: string): Promise<void> => {
745
+ if (!window.confirm('确定删除这条剧情线?关联章节记录会一并移除。')) return
746
+ setBusy(true)
747
+ setError('')
748
+ try {
749
+ const result = await api.plotlines({ op: 'remove', id })
750
+ setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
751
+ pushProgress('剧情线已删除', 'done')
752
+ } catch (err) {
753
+ setError((err as Error).message)
754
+ } finally {
755
+ setBusy(false)
756
+ }
757
+ }
758
+
759
+ /** 剧情线:把本章关联到某条线(推进节点)。 */
760
+ const handlePlotlineLink = async (id: string, chapterNo: number): Promise<void> => {
761
+ setBusy(true)
762
+ setError('')
763
+ try {
764
+ const result = await api.plotlines({ op: 'link', id, chapterNo })
765
+ setProject(prev => prev === null ? prev : { ...prev, plotlines: result.plotlines, updatedAt: new Date().toISOString() })
766
+ pushProgress(`已把第 ${chapterNo} 章关联到剧情线`, 'done')
767
+ } catch (err) {
768
+ setError((err as Error).message)
769
+ } finally {
770
+ setBusy(false)
771
+ }
772
+ }
773
+
774
+ /** 全书敏感词检查(硬匹配内置词库)。 */
775
+ const handleSensitiveScan = async (): Promise<void> => {
776
+ setBusy(true)
777
+ setBusyLabel('敏感词检查中…')
778
+ setError('')
779
+ try {
780
+ const result = await api.sensitiveCheck({ all: true })
781
+ setSensHits(result.hits)
782
+ setSensScanned(result.scannedChapters)
783
+ pushProgress(result.hits.length > 0
784
+ ? `敏感词检查:${result.hits.length} 处命中(${new Set(result.hits.map(h => h.chapterNo)).size} 章受影响)`
785
+ : `敏感词检查完成:扫描 ${result.scannedChapters} 章,未命中违禁词`, result.hits.length > 0 ? 'error' : 'done')
786
+ } catch (err) {
787
+ setError((err as Error).message)
788
+ pushProgress(`敏感词检查失败:${(err as Error).message}`, 'error')
789
+ } finally {
790
+ setBusy(false)
791
+ setBusyLabel('')
792
+ }
793
+ }
794
+
795
+ /** 保存角色知情度(bible.characters 整体更新)。 */
796
+ const handleKnowledgeSave = async (): Promise<void> => {
797
+ if (bible === undefined) return
798
+ const characters = bible.characters.map(card => ({
799
+ ...card,
800
+ knowledge: (knowledgeDraft[card.name] ?? (card.knowledge ?? []).join('\n'))
801
+ .split('\n').map(l => l.trim()).filter(l => l !== ''),
802
+ }))
803
+ setBusy(true)
804
+ setError('')
805
+ try {
806
+ const result = await api.biblePatch({ characters })
807
+ setProject(prev => prev === null || prev.bible === undefined ? prev : { ...prev, bible: result.bible, updatedAt: new Date().toISOString() })
808
+ pushProgress('角色知情度已保存(生成/审稿都会严格遵守信息差)', 'done')
809
+ } catch (err) {
810
+ setError((err as Error).message)
811
+ pushProgress(`保存知情度失败:${(err as Error).message}`, 'error')
812
+ } finally {
813
+ setBusy(false)
814
+ }
815
+ }
816
+
671
817
  /** 保存世界观规则编辑(bible tab,每行一条)。 */
672
818
  const handleSaveWorldRules = async (): Promise<void> => {
673
819
  if (bible === undefined) return
@@ -1489,7 +1635,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1489
1635
  })()
1490
1636
 
1491
1637
  return (
1492
- <div className={css.panel}>
1638
+ <div className={css.panel} data-nf-theme={panelTheme}>
1493
1639
  {viewMode === 'shelf' ? (
1494
1640
  /* 书架首页:默认视图,选择一本书进入工作台 */
1495
1641
  <ShelfView
@@ -1548,22 +1694,26 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1548
1694
  ) : (
1549
1695
  <>
1550
1696
  <div className={css.panelHeader}>
1551
- <h2 className={css.panelTitle}>
1552
- <button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={() => { setViewMode('shelf') }}>
1553
- ←
1554
- </button>
1555
- {tt('panel.title')}
1556
- {project?.bookName !== '' && project?.bookName !== undefined && (
1557
- <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)', fontSize: 11 }}>{project.bookName}</span>
1558
- )}
1559
- {chapters.length > 0 && (
1560
- <span className={css.headerProgress}>
1561
- 已完成 <b>{doneCount}</b>/{chapters.length} 章
1562
- <span className={css.headerProgressDot} />
1563
- 通过 <b>{chapters.filter(c => c.status === 'approved').length}</b>
1564
- </span>
1697
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
1698
+ <h2 className={css.panelTitle}>
1699
+ <button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={() => { setViewMode('shelf') }}>
1700
+ ←
1701
+ </button>
1702
+ {tt('panel.title')}
1703
+ </h2>
1704
+ {(project?.bookName !== '' && project?.bookName !== undefined) && (
1705
+ <div className={css.panelSubtitle}>
1706
+ <span>📖 {project.bookName}</span>
1707
+ {chapters.length > 0 && (
1708
+ <span className={css.headerProgress}>
1709
+ 已完成 <b>{doneCount}</b>/{chapters.length} 章
1710
+ <span className={css.headerProgressDot} />
1711
+ 通过 <b>{chapters.filter(c => c.status === 'approved').length}</b>
1712
+ </span>
1713
+ )}
1714
+ </div>
1565
1715
  )}
1566
- </h2>
1716
+ </div>
1567
1717
  <div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
1568
1718
  <button
1569
1719
  type="button"
@@ -1700,10 +1850,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1700
1850
  </div>
1701
1851
  )}
1702
1852
 
1703
- {/* 章节编辑页(独占整页:点「编辑」进入,返回后回到原页面) */}
1853
+ {/* 章节编辑页(独占整页:点「编辑」进入,返回后回到原页面;无卡片盒子,撑满内容区) */}
1704
1854
  {workspace !== null && (
1705
- <div className={css.card} style={{ borderColor: 'var(--nf-accent)' }}>
1706
- <div className={css.busyRow} style={{ flexWrap: 'wrap', gap: 8 }}>
1855
+ <div className={css.wsPage}>
1856
+ <div className={css.wsPageHeader}>
1707
1857
  <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWorkspace(null) }} title="返回章节列表(草稿不丢失)">
1708
1858
  ← 返回
1709
1859
  </button>
@@ -1820,7 +1970,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1820
1970
  </div>
1821
1971
  )}
1822
1972
  {workspace.draft !== null && (
1823
- <div className={css.wsPreview}>
1973
+ <div className={css.wsPreview} style={{ flex: 1, minHeight: 0 }}>
1824
1974
  <div className={css.busyRow}>
1825
1975
  <span className={css.meta}>优化预览({workspace.draft.length} 字)</span>
1826
1976
  <span style={{ display: 'flex', gap: 8 }}>
@@ -1903,6 +2053,43 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1903
2053
  </div>
1904
2054
  </div>
1905
2055
 
2056
+ {/* 全书进度条(原章节页进度移入工作流) */}
2057
+ <div className={css.card} style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
2058
+ <span className={css.meta} style={{ fontWeight: 600 }}>
2059
+ 📊 全书进度:已完成 <b style={{ color: 'var(--nf-accent)' }}>{doneCount}</b>/{chapters.length} 章({chapters.length > 0 ? Math.round((doneCount / chapters.length) * 100) : 0}%)
2060
+ </span>
2061
+ <span className={css.meta}>
2062
+ 待生成 {pendingCount} · 待审稿 {reviewPendingCount} · 通过 {approvedCount}
2063
+ </span>
2064
+ </div>
2065
+
2066
+ {/* 剧情线进度(工作流实时视图) */}
2067
+ <div className={css.card}>
2068
+ <span className={css.cardTitle}>🧵 {tt('plotlines.workflowTitle')}</span>
2069
+ {(project?.plotlines ?? []).filter(l => l.status === 'active' || l.status === 'paused').length === 0 ? (
2070
+ <span className={css.meta}>{tt('plotlines.workflowEmpty')}</span>
2071
+ ) : (
2072
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
2073
+ {(project?.plotlines ?? []).filter(l => l.status === 'active' || l.status === 'paused').map(line => {
2074
+ const kindLabel = { main: tt('plotlines.kindMain'), branch: tt('plotlines.kindBranch'), character: tt('plotlines.kindCharacter'), mystery: tt('plotlines.kindMystery') }[line.kind]
2075
+ return (
2076
+ <div key={line.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 10px', fontSize: 12 }}>
2077
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
2078
+ <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
2079
+ <b>{line.name}</b>
2080
+ <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{kindLabel}</span>
2081
+ {line.status === 'paused' && <span className={css.badge} style={{ borderColor: 'var(--nf-warn)', color: 'var(--nf-warn)' }}>{tt('plotlines.statusPaused')}</span>}
2082
+ </span>
2083
+ <span className={css.meta}>{tt('plotlines.chapters')} {line.chapters.length} 章</span>
2084
+ </div>
2085
+ {line.progress !== '' && <div className={css.meta}>{line.progress}</div>}
2086
+ </div>
2087
+ )
2088
+ })}
2089
+ </div>
2090
+ )}
2091
+ </div>
2092
+
1906
2093
  {/* 状态摘要条 */}
1907
2094
  <div className={css.assetGrid}>
1908
2095
  <div className={css.assetStat}>
@@ -2028,6 +2215,35 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2028
2215
  )}
2029
2216
  </div>
2030
2217
  )}
2218
+
2219
+ {/* 📟 活动输出:无论生成 / 审稿 / 润色 / 质检 / 助手操作,全部活动实时显示于此 */}
2220
+ <div className={css.card}>
2221
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
2222
+ <span className={css.cardTitle}>📟 活动输出({progress.length})</span>
2223
+ <div className={css.row}>
2224
+ <span className={css.meta}>生成/审稿/润色/质检等所有操作都会记录在这里</span>
2225
+ {progress.length > 0 && (
2226
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setProgress([]) }}>
2227
+ 清空
2228
+ </button>
2229
+ )}
2230
+ </div>
2231
+ </div>
2232
+ <div className={css.progress} style={{ maxHeight: 260, overflowY: 'auto' }}>
2233
+ {progress.length === 0 && <span className={css.meta}>{tt('progress.empty')}</span>}
2234
+ {progress.map(line => (
2235
+ <div key={line.id} className={line.kind === 'done' ? css.progressLineDone : line.kind === 'error' ? css.progressLineError : line.live === true ? css.progressLineLive : css.progressLine}>
2236
+ {line.live === true && (
2237
+ <span className={css.progressBar}>
2238
+ <span className={css.progressBarFill} style={{ width: `${Math.round((line.ratio ?? 0) * 100)}%` }} />
2239
+ </span>
2240
+ )}
2241
+ {line.text}
2242
+ </div>
2243
+ ))}
2244
+ <div ref={progressEndRef} />
2245
+ </div>
2246
+ </div>
2031
2247
  </>
2032
2248
  )}
2033
2249
 
@@ -2239,9 +2455,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2239
2455
  {activeTab === 'plan' && (
2240
2456
  <>
2241
2457
  <div className={css.card}>
2242
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
2458
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
2243
2459
  <span className={css.cardTitle}>{tt('tab.plan')}</span>
2244
- <div className={css.row}>
2460
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
2245
2461
  <span className={css.meta}>{tt('plan.generateHint')}</span>
2246
2462
  <input
2247
2463
  className={css.input}
@@ -2256,23 +2472,78 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2256
2472
  <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || outlineText.length < 50} onClick={() => { void handlePlan() }}>
2257
2473
  {tt('plan.generate')}
2258
2474
  </button>
2475
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || doneCount === 0} onClick={() => { void handleSensitiveScan() }} title={tt('sensitive.hint')}>
2476
+ 🔞 {tt('sensitive.scanAll')}
2477
+ </button>
2259
2478
  </div>
2260
2479
  </div>
2261
- {volumes !== undefined && volumes.length > 0 && (
2262
- <div className={css.row}>
2263
- {volumes.map(v => (
2264
- <span key={v.no} className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>
2265
- {v.no}. {v.title}({v.chapterStart}-{v.chapterEnd})
2266
- </span>
2267
- ))}
2268
- </div>
2269
- )}
2270
2480
  </div>
2271
2481
 
2482
+ {sensHits !== null && (
2483
+ <div className={css.card} style={{ borderColor: sensHits.length > 0 ? 'var(--nf-warn)' : 'var(--nf-success)' }}>
2484
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
2485
+ <span className={css.cardTitle}>
2486
+ {sensHits.length === 0
2487
+ ? tt('sensitive.clean', { n: sensScanned })
2488
+ : tt('sensitive.hits', { n: sensHits.length, chapters: new Set(sensHits.map(h => h.chapterNo)).size })}
2489
+ </span>
2490
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSensHits(null) }}>收起</button>
2491
+ </div>
2492
+ <span className={css.meta}>{tt('sensitive.hint')}</span>
2493
+ {sensHits.length > 0 && (
2494
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 300, overflowY: 'auto', fontSize: 12 }}>
2495
+ {sensHits.map((hit, i) => (
2496
+ <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', border: '1px solid var(--nf-border)', borderRadius: 6, padding: '4px 8px' }}>
2497
+ <span className={css.badge} style={{ borderColor: 'var(--nf-warn)', color: 'var(--nf-warn)', flex: 'none' }}>
2498
+ {hit.chapterNo > 0 ? `第${hit.chapterNo}章` : '文本'}
2499
+ </span>
2500
+ <span className={css.meta} style={{ flex: 1 }}>
2501
+ <b style={{ color: 'var(--nf-error)' }}>{hit.word}</b> ×{hit.count} · [{hit.category}]
2502
+ </span>
2503
+ {hit.chapterNo > 0 && (
2504
+ <button
2505
+ type="button"
2506
+ className={`${css.button} ${css.buttonSmall}`}
2507
+ disabled={busy}
2508
+ onClick={() => { void openWorkspace(hit.chapterNo, tt('sensitive.fixPrefill', { word: hit.word, category: hit.category })) }}
2509
+ >
2510
+ {tt('sensitive.goFix')}
2511
+ </button>
2512
+ )}
2513
+ </div>
2514
+ ))}
2515
+ </div>
2516
+ )}
2517
+ </div>
2518
+ )}
2519
+
2272
2520
  {chapters.length > 0 && (
2273
2521
  <div className={css.card}>
2274
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
2275
- <span className={css.meta}>共 {chapters.length} 章 · 已完成 {doneCount} · 待生成 {pendingCount}</span>
2522
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
2523
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
2524
+ {(volumes !== undefined && volumes.length > 0) && (
2525
+ <>
2526
+ <button
2527
+ type="button"
2528
+ className={`${css.button} ${css.buttonSmall} ${selectedVolume === 'all' ? css.buttonPrimary : ''}`}
2529
+ onClick={() => { setSelectedVolume('all') }}
2530
+ >
2531
+ 全部卷
2532
+ </button>
2533
+ {volumes.map(v => (
2534
+ <button
2535
+ key={v.no}
2536
+ type="button"
2537
+ className={`${css.button} ${css.buttonSmall} ${selectedVolume === v.no ? css.buttonPrimary : ''}`}
2538
+ onClick={() => { setSelectedVolume(v.no) }}
2539
+ title={`第${v.no}卷 · ${v.chapterStart}-${v.chapterEnd} 章`}
2540
+ >
2541
+ {v.no}. {v.title}({v.chapterStart}-{v.chapterEnd})
2542
+ </button>
2543
+ ))}
2544
+ </>
2545
+ )}
2546
+ </div>
2276
2547
  {pendingCount > 0 && (
2277
2548
  <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleWriteAll() }}>
2278
2549
  {tt('plan.writeAllPending')}({pendingCount})
@@ -2280,7 +2551,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2280
2551
  )}
2281
2552
  </div>
2282
2553
  <div className={css.chapterList}>
2283
- {chapterGroups.map(group => {
2554
+ {chapterGroups.filter(g => selectedVolume === 'all' || g.no === selectedVolume).map(group => {
2284
2555
  const collapsed = group.no !== 0 && collapsedVolumes.includes(group.no)
2285
2556
  const groupDone = group.chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
2286
2557
  return (
@@ -2411,23 +2682,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2411
2682
  </div>
2412
2683
  </div>
2413
2684
  )}
2414
-
2415
- <div className={css.card}>
2416
- <span className={css.cardTitle}>{tt('plan.progress')}</span>
2417
- <div className={css.progress}>
2418
- {progress.length === 0 && <span className={css.meta}>{tt('progress.empty')}</span>}
2419
- {progress.map(line => (
2420
- <div key={line.id} className={line.kind === 'done' ? css.progressLineDone : line.kind === 'error' ? css.progressLineError : line.live === true ? css.progressLineLive : css.progressLine}>
2421
- {line.live === true && (
2422
- <span className={css.progressBar}>
2423
- <span className={css.progressBarFill} style={{ width: `${Math.round((line.ratio ?? 0) * 100)}%` }} />
2424
- </span>
2425
- )}
2426
- {line.text}
2427
- </div>
2428
- ))}
2429
- </div>
2430
- </div>
2431
2685
  </>
2432
2686
  )}
2433
2687
 
@@ -2564,6 +2818,44 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2564
2818
  </div>
2565
2819
  )}
2566
2820
 
2821
+ {/* 角色知情度:信息差管理 */}
2822
+ {activeTab === 'characters' && (
2823
+ <div className={css.card}>
2824
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
2825
+ <span className={css.cardTitle}>角色知情度(信息差管理)</span>
2826
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || (bible?.characters ?? []).length === 0} onClick={() => { void handleKnowledgeSave() }}>
2827
+ 💾 保存知情度
2828
+ </button>
2829
+ </div>
2830
+ <span className={css.meta}>
2831
+ 填写每个角色「已经知道」的事实/秘密(每行一条)。生成与审稿会严格遵守:未列出的信息该角色一律不知道——避免"不该知道的人知道了"。
2832
+ </span>
2833
+ {(bible?.characters ?? []).length === 0 ? (
2834
+ <span className={css.meta}>暂无角色卡——先在「道藏」提炼设定。</span>
2835
+ ) : (
2836
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
2837
+ {bible!.characters.map(card => (
2838
+ <div key={card.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px', fontSize: 12 }}>
2839
+ <div className={css.row} style={{ justifyContent: 'space-between' }}>
2840
+ <b>{card.name}</b>
2841
+ <span className={css.meta}>
2842
+ {card.role === 'protagonist' ? '主角' : card.role === 'supporting' ? '配角' : card.role === 'antagonist' ? '反派' : '其他'}
2843
+ </span>
2844
+ </div>
2845
+ <textarea
2846
+ className={css.textarea}
2847
+ style={{ minHeight: 52, fontSize: 12 }}
2848
+ placeholder="每行一条该角色知道的信息,例如:林越的真实身份 / 古玉残片的秘密…"
2849
+ value={knowledgeDraft[card.name] ?? (card.knowledge ?? []).join('\n')}
2850
+ onChange={e => { setKnowledgeDraft(prev => ({ ...prev, [card.name]: e.target.value })) }}
2851
+ />
2852
+ </div>
2853
+ ))}
2854
+ </div>
2855
+ )}
2856
+ </div>
2857
+ )}
2858
+
2567
2859
  {/* 写作资产 5 个子分类(左侧导航直达) */}
2568
2860
  {activeTab === 'assetsGenre' && <AssetsTab api={api} initialTab="genre" />}
2569
2861
  {activeTab === 'assetsProgression' && <AssetsTab api={api} initialTab="progression" />}
@@ -2702,23 +2994,168 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2702
2994
  {tt('settings.exportMd')}
2703
2995
  </button>
2704
2996
  </div>
2705
- {/* 编年录 / 时间线 */}
2706
- {(project?.facts ?? []).length > 0 && (
2707
- <div className={css.card}>
2708
- <span className={css.cardTitle}>编年录 / 时间线({(project?.facts ?? []).length} 条)</span>
2709
- <span className={css.meta}>
2710
- 每章生成后自动抽取「已确立事实」(人物状态/境界资源/关系变化/伏笔落地),最近 20 条注入后续章节生成,保证长期一致。
2711
- </span>
2712
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 300, overflowY: 'auto', fontSize: 12 }}>
2713
- {[...(project?.facts ?? [])].reverse().slice(0, 60).map((fact, i) => (
2714
- <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
2715
- <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)', flex: 'none', marginTop: 1 }}>
2716
- 第 {fact.chapterNo} 章
2717
- </span>
2718
- <span className={css.meta}>{fact.text}</span>
2719
- </div>
2720
- ))}
2997
+ </div>
2998
+ )}
2999
+
3000
+ {activeTab === 'settings' && (
3001
+ <div className={css.card}>
3002
+ <span className={css.cardTitle}>{tt('settings.theme')}</span>
3003
+ <div className={css.row} style={{ gap: 8, flexWrap: 'wrap' }}>
3004
+ <button
3005
+ type="button"
3006
+ className={`${css.button} ${css.buttonSmall} ${panelTheme === 'liquid' ? css.buttonPrimary : ''}`}
3007
+ onClick={() => { changePanelTheme('liquid') }}
3008
+ title="iOS 液态玻璃质感 · 绿色强调(当前默认)"
3009
+ >
3010
+ 🧊 {tt('settings.themeLiquid')}
3011
+ </button>
3012
+ <button
3013
+ type="button"
3014
+ className={`${css.button} ${css.buttonSmall} ${panelTheme === 'classic' ? css.buttonPrimary : ''}`}
3015
+ onClick={() => { changePanelTheme('classic') }}
3016
+ title="经典 iOS 毛玻璃 · 蓝色强调"
3017
+ >
3018
+ 💠 {tt('settings.themeClassic')}
3019
+ </button>
3020
+ <button
3021
+ type="button"
3022
+ className={`${css.button} ${css.buttonSmall} ${panelTheme === 'neumorph' ? css.buttonPrimary : ''}`}
3023
+ onClick={() => { changePanelTheme('neumorph') }}
3024
+ title="新拟物派 · 双阴影立体(仅浅色;深色下自动回退液态)"
3025
+ >
3026
+ 🔘 {tt('settings.themeNeumorph')}
3027
+ </button>
3028
+ <span className={css.meta}>{tt('settings.themeHint')}</span>
3029
+ </div>
3030
+ </div>
3031
+ )}
3032
+
3033
+ {activeTab === 'facts' && (
3034
+ <div className={css.card}>
3035
+ <div className={css.busyRow} style={{ flexWrap: 'wrap' }}>
3036
+ <span className={css.cardTitle}>{tt('facts.title', { n: (project?.facts ?? []).length })}</span>
3037
+ <button
3038
+ type="button"
3039
+ className={`${css.button} ${css.buttonSmall}`}
3040
+ disabled={busy || chapters.length === 0}
3041
+ onClick={() => { void handleFactsBackfill() }}
3042
+ title="用 LLM 从历史章节正文重新抽取事实,补齐缺失的编年录条目"
3043
+ >
3044
+ 📥 {tt('facts.backfill')}
3045
+ </button>
3046
+ </div>
3047
+ <span className={css.meta}>{tt('facts.hint')}</span>
3048
+ {(project?.facts ?? []).length > 0 ? (
3049
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: '60vh', overflowY: 'auto', fontSize: 12 }}>
3050
+ {[...(project?.facts ?? [])].reverse().map((fact, i) => (
3051
+ <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
3052
+ <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)', flex: 'none', marginTop: 1 }}>
3053
+ 第 {fact.chapterNo} 章
3054
+ </span>
3055
+ <span className={css.meta}>{fact.text}</span>
3056
+ </div>
3057
+ ))}
3058
+ </div>
3059
+ ) : (
3060
+ <span className={css.meta}>暂无事实条目——写一章后会自动生成,或点击上方「回填」。</span>
3061
+ )}
3062
+ </div>
3063
+ )}
3064
+
3065
+ {activeTab === 'plotlines' && (
3066
+ <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
3067
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3068
+ <span className={css.cardTitle}>{tt('tab.plotlines')}({project?.plotlines?.length ?? 0})</span>
3069
+ {plotlineDraft === null && (
3070
+ <button
3071
+ type="button"
3072
+ className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
3073
+ onClick={() => { setPlotlineDraft({ id: '', name: '', kind: 'main', goal: '', progress: '', status: 'active' }) }}
3074
+ >
3075
+ {tt('plotlines.new')}
3076
+ </button>
3077
+ )}
3078
+ </div>
3079
+ <span className={css.meta}>{tt('plotlines.hint')}</span>
3080
+
3081
+ {plotlineDraft !== null && (
3082
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, border: '1px solid var(--nf-accent)', borderRadius: 12, padding: 10 }}>
3083
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
3084
+ <div className={css.field} style={{ flex: 2, minWidth: 160 }}>
3085
+ <label className={css.fieldLabel}>{tt('plotlines.name')}</label>
3086
+ <input className={css.input} value={plotlineDraft.name} onChange={e => { setPlotlineDraft({ ...plotlineDraft, name: e.target.value }) }} placeholder="如:集齐古玉残片" />
3087
+ </div>
3088
+ <div className={css.field} style={{ flex: 1 }}>
3089
+ <label className={css.fieldLabel}>{tt('plotlines.kind')}</label>
3090
+ <select className={css.input} value={plotlineDraft.kind} onChange={e => { setPlotlineDraft({ ...plotlineDraft, kind: e.target.value as Plotline['kind'] }) }}>
3091
+ <option value="main">{tt('plotlines.kindMain')}</option>
3092
+ <option value="branch">{tt('plotlines.kindBranch')}</option>
3093
+ <option value="character">{tt('plotlines.kindCharacter')}</option>
3094
+ <option value="mystery">{tt('plotlines.kindMystery')}</option>
3095
+ </select>
3096
+ </div>
3097
+ <div className={css.field} style={{ flex: 1 }}>
3098
+ <label className={css.fieldLabel}>{tt('plotlines.status')}</label>
3099
+ <select className={css.input} value={plotlineDraft.status} onChange={e => { setPlotlineDraft({ ...plotlineDraft, status: e.target.value as Plotline['status'] }) }}>
3100
+ <option value="active">{tt('plotlines.statusActive')}</option>
3101
+ <option value="paused">{tt('plotlines.statusPaused')}</option>
3102
+ <option value="resolved">{tt('plotlines.statusResolved')}</option>
3103
+ <option value="abandoned">{tt('plotlines.statusAbandoned')}</option>
3104
+ </select>
3105
+ </div>
3106
+ </div>
3107
+ <div className={css.field}>
3108
+ <label className={css.fieldLabel}>{tt('plotlines.goal')}</label>
3109
+ <textarea className={css.textarea} style={{ minHeight: 48 }} value={plotlineDraft.goal} onChange={e => { setPlotlineDraft({ ...plotlineDraft, goal: e.target.value }) }} />
2721
3110
  </div>
3111
+ <div className={css.field}>
3112
+ <label className={css.fieldLabel}>{tt('plotlines.progress')}</label>
3113
+ <textarea className={css.textarea} style={{ minHeight: 40 }} value={plotlineDraft.progress} onChange={e => { setPlotlineDraft({ ...plotlineDraft, progress: e.target.value }) }} placeholder="如:已取得第二枚残片,正追踪第三枚线索" />
3114
+ </div>
3115
+ <div className={css.row}>
3116
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handlePlotlineSave() }}>
3117
+ {tt('plotlines.save')}
3118
+ </button>
3119
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setPlotlineDraft(null) }}>
3120
+ {tt('plotlines.cancel')}
3121
+ </button>
3122
+ </div>
3123
+ </div>
3124
+ )}
3125
+
3126
+ {(project?.plotlines ?? []).length === 0 ? (
3127
+ <span className={css.meta}>{tt('plotlines.empty')}</span>
3128
+ ) : (
3129
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
3130
+ {(project?.plotlines ?? []).map(line => {
3131
+ const kindLabel = { main: tt('plotlines.kindMain'), branch: tt('plotlines.kindBranch'), character: tt('plotlines.kindCharacter'), mystery: tt('plotlines.kindMystery') }[line.kind]
3132
+ const statusLabel = { active: tt('plotlines.statusActive'), paused: tt('plotlines.statusPaused'), resolved: tt('plotlines.statusResolved'), abandoned: tt('plotlines.statusAbandoned') }[line.status]
3133
+ const statusColor = line.status === 'resolved' ? 'var(--nf-success)' : line.status === 'abandoned' ? 'var(--nf-text-3)' : line.status === 'paused' ? 'var(--nf-warn)' : 'var(--nf-accent)'
3134
+ return (
3135
+ <div key={line.id} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 12px', fontSize: 12, display: 'flex', flexDirection: 'column', gap: 4 }}>
3136
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3137
+ <span style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
3138
+ <b>{line.name}</b>
3139
+ <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{kindLabel}</span>
3140
+ <span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
3141
+ </span>
3142
+ <span style={{ display: 'flex', gap: 6 }}>
3143
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { setPlotlineDraft({ id: line.id, name: line.name, kind: line.kind, goal: line.goal, progress: line.progress, status: line.status }) }}>
3144
+ {tt('plotlines.edit')}
3145
+ </button>
3146
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handlePlotlineRemove(line.id) }}>
3147
+ {tt('plotlines.remove')}
3148
+ </button>
3149
+ </span>
3150
+ </div>
3151
+ {line.goal !== '' && <div className={css.meta}><b>{tt('plotlines.goal')}:</b>{line.goal}</div>}
3152
+ {line.progress !== '' && <div className={css.meta}><b>{tt('plotlines.progress')}:</b>{line.progress}</div>}
3153
+ <div className={css.meta}>
3154
+ {tt('plotlines.chapters')}:{line.chapters.length > 0 ? line.chapters.map(n => `第${n}章`).join('、') : '—'}
3155
+ </div>
3156
+ </div>
3157
+ )
3158
+ })}
2722
3159
  </div>
2723
3160
  )}
2724
3161
  </div>