@waterwx/dsh-novel-forge 1.1.1 → 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: '🖋️' },
@@ -219,7 +223,7 @@ function renderBeats(beats: string): ReactElement {
219
223
  }
220
224
 
221
225
  /** The diff list of a draft-vs-original comparison (scrollable). */
222
- function DiffList({ original, draft }: { original: string; draft: string }): ReactElement { const rows = useMemo(() => paragraphDiff(original, draft), [original, draft])
226
+ function DiffList({ original, draft, fontSize }: { original: string; draft: string; fontSize?: number }): ReactElement { const rows = useMemo(() => paragraphDiff(original, draft), [original, draft])
223
227
  const changed = rows.filter(r => r.kind === 'change').length
224
228
  const added = rows.filter(r => r.kind === 'add').length
225
229
  const removed = rows.filter(r => r.kind === 'del').length
@@ -242,7 +246,7 @@ function DiffList({ original, draft }: { original: string; draft: string }): Rea
242
246
  只看改动
243
247
  </label>
244
248
  </div>
245
- <div className={css.diffList}>
249
+ <div className={css.diffList} style={fontSize !== undefined ? { fontSize } : undefined}>
246
250
  {shown.map((row, idx) => {
247
251
  if (row.kind === 'same') {
248
252
  return (
@@ -333,6 +337,31 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
333
337
  const wsEditorRef = useRef<HTMLTextAreaElement | null>(null)
334
338
  /** 工作区:手动编辑后的 AI 审查结果(不落盘)。 */
335
339
  const [wsCheckReport, setWsCheckReport] = useState<ReviewReport | null>(null)
340
+ /** 手动审查结果中作者勾选要修复的问题(issue 下标)。 */
341
+ const [wsChecked, setWsChecked] = useState<number[]>([])
342
+ /** 编辑页字号(localStorage 记忆,仅影响显示)。 */
343
+ const [editorFontSize, setEditorFontSize] = useState<number>(() => {
344
+ try {
345
+ const v = Number(window.localStorage.getItem('dsh-novel-forge.editor.fontSize'))
346
+ return v >= 12 && v <= 24 ? v : 14
347
+ } catch { return 14 }
348
+ })
349
+ const changeEditorFontSize = (next: number): void => {
350
+ const v = Math.min(24, Math.max(12, next))
351
+ setEditorFontSize(v)
352
+ try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
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
+ }
336
365
  /** 有未采纳草稿的章节号(refresh 后检测到遗留草稿时提示)。 */
337
366
  const [draftNo, setDraftNo] = useState<number | null>(null)
338
367
  /** 大纲页「更新大纲」编辑区是否展开。 */
@@ -353,8 +382,29 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
353
382
  const coverFileRef = useRef<HTMLInputElement | null>(null)
354
383
  /** 章节列表按卷折叠(存已折叠的卷号)。 */
355
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)
356
401
  /** npm 最新版本(更新检测;null = 未检测/检测失败)。 */
357
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])
358
408
 
359
409
  /** 后台检测 npm 最新版本(失败静默,不打扰)。 */
360
410
  useEffect(() => {
@@ -654,6 +704,116 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
654
704
  }
655
705
  }
656
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
+
657
817
  /** 保存世界观规则编辑(bible tab,每行一条)。 */
658
818
  const handleSaveWorldRules = async (): Promise<void> => {
659
819
  if (bible === undefined) return
@@ -776,6 +936,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
776
936
  try {
777
937
  const result = await api.chapterCheck(workspace.no, workspace.original)
778
938
  setWsCheckReport(result.report)
939
+ // 默认勾选 high 问题(无 high 则勾选全部 medium),作者可自行增删。
940
+ const highIdx = result.report.issues.map((it, i) => ({ it, i })).filter(x => x.it.severity === 'high').map(x => x.i)
941
+ const mediumIdx = result.report.issues.map((it, i) => ({ it, i })).filter(x => x.it.severity === 'medium').map(x => x.i)
942
+ setWsChecked(highIdx.length > 0 ? highIdx : mediumIdx)
779
943
  pushProgress(`审查完成:${result.report.score} 分 — ${result.report.verdict}`, result.report.passed ? 'done' : 'error')
780
944
  } catch (err) {
781
945
  setError((err as Error).message)
@@ -801,6 +965,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
801
965
  setWorkspace(null)
802
966
  setDraftNo(null)
803
967
  setWsCheckReport(null)
968
+ setWsChecked([])
804
969
  if (report !== undefined) {
805
970
  pushProgress(`已保存并审稿:${report.score} 分 — ${report.verdict}(${report.passed ? '通过' : '未通过'})`, report.passed ? 'done' : 'error')
806
971
  } else {
@@ -947,7 +1112,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
947
1112
  draft: chapter.pendingDraft !== undefined && chapter.pendingDraft !== '' ? chapter.pendingDraft : null,
948
1113
  })
949
1114
  setWsSelected('')
950
- setWsShowDiff(false)
1115
+ setWsShowDiff(true)
1116
+ setWsCheckReport(null)
1117
+ setWsChecked([])
951
1118
  } catch { /* best-effort */ }
952
1119
  }, [api])
953
1120
 
@@ -980,14 +1147,15 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
980
1147
  }
981
1148
 
982
1149
  /** 工作区:按指令修订(whole=true 整章,false 仅修订选中片段)。 */
983
- const handleWsRewrite = async (whole: boolean): Promise<void> => {
1150
+ const handleWsRewrite = async (whole: boolean, overrideInstruction?: string): Promise<void> => {
984
1151
  if (workspace === null) return
985
1152
  const target = whole ? '' : wsSelected
1153
+ const instruction = overrideInstruction ?? workspace.instruction
986
1154
  setBusy(true)
987
1155
  setBusyLabel(`${tt('plan.rewrite')} 第${workspace.no}章`)
988
1156
  setError('')
989
1157
  try {
990
- await api.rewrite(workspace.no, workspace.instruction, target, frame => { applyJobFrame(frame, n => tt('progress.rewriting', { no: n })) })
1158
+ await api.rewrite(workspace.no, instruction, target, frame => { applyJobFrame(frame, n => tt('progress.rewriting', { no: n })) })
991
1159
  } catch (err) {
992
1160
  setError((err as Error).message)
993
1161
  pushProgress(`第 ${workspace.no} 章修订失败:${(err as Error).message}`, 'error')
@@ -998,6 +1166,19 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
998
1166
  }
999
1167
  }
1000
1168
 
1169
+ /** 工作区:按审查报告中勾选的问题一键修订(预填指令 + 立即整章修订到草稿)。 */
1170
+ const handleWsReviseByReport = async (): Promise<void> => {
1171
+ if (workspace === null || wsCheckReport === null) return
1172
+ const picked = wsChecked
1173
+ .map(i => wsCheckReport.issues[i])
1174
+ .filter((it): it is ReviewReport['issues'][number] => it !== undefined)
1175
+ .slice(0, 5)
1176
+ if (picked.length === 0) return
1177
+ const instruction = '按审稿意见修订(优先处理):\n' + picked.map(i => `[${i.severity}] ${i.item} → ${i.suggestion}`).join('\n')
1178
+ setWorkspace({ ...workspace, instruction })
1179
+ await handleWsRewrite(true, instruction)
1180
+ }
1181
+
1001
1182
  /** 采纳草稿:覆盖正文文件并刷新。 */
1002
1183
  const handleDraftApply = async (no: number): Promise<void> => {
1003
1184
  setBusy(true)
@@ -1454,7 +1635,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1454
1635
  })()
1455
1636
 
1456
1637
  return (
1457
- <div className={css.panel}>
1638
+ <div className={css.panel} data-nf-theme={panelTheme}>
1458
1639
  {viewMode === 'shelf' ? (
1459
1640
  /* 书架首页:默认视图,选择一本书进入工作台 */
1460
1641
  <ShelfView
@@ -1513,22 +1694,26 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1513
1694
  ) : (
1514
1695
  <>
1515
1696
  <div className={css.panelHeader}>
1516
- <h2 className={css.panelTitle}>
1517
- <button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={() => { setViewMode('shelf') }}>
1518
- ←
1519
- </button>
1520
- {tt('panel.title')}
1521
- {project?.bookName !== '' && project?.bookName !== undefined && (
1522
- <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)', fontSize: 11 }}>{project.bookName}</span>
1523
- )}
1524
- {chapters.length > 0 && (
1525
- <span className={css.headerProgress}>
1526
- 已完成 <b>{doneCount}</b>/{chapters.length} 章
1527
- <span className={css.headerProgressDot} />
1528
- 通过 <b>{chapters.filter(c => c.status === 'approved').length}</b>
1529
- </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>
1530
1715
  )}
1531
- </h2>
1716
+ </div>
1532
1717
  <div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
1533
1718
  <button
1534
1719
  type="button"
@@ -1665,12 +1850,21 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1665
1850
  </div>
1666
1851
  )}
1667
1852
 
1668
- {/* 润色/修订工作区:左栏原文(可选中局部目标)+ 右栏指令/预览/应用 */}
1853
+ {/* 章节编辑页(独占整页:点「编辑」进入,返回后回到原页面;无卡片盒子,撑满内容区) */}
1669
1854
  {workspace !== null && (
1670
- <div className={css.card} style={{ borderColor: 'var(--nf-accent)' }}>
1671
- <div className={css.busyRow}>
1672
- <span className={css.cardTitle}>第 {workspace.no} 章《{workspace.title}》润色/修订</span>
1673
- <button type="button" className={css.iconButton} title="关闭工作区" aria-label="关闭工作区" onClick={() => { setWorkspace(null) }}>×</button>
1855
+ <div className={css.wsPage}>
1856
+ <div className={css.wsPageHeader}>
1857
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWorkspace(null) }} title="返回章节列表(草稿不丢失)">
1858
+ ← 返回
1859
+ </button>
1860
+ <span className={css.cardTitle}>第 {workspace.no} 章《{workspace.title}》</span>
1861
+ <span className={css.meta}>{workspace.original.length} 字</span>
1862
+ <span style={{ display: 'flex', gap: 4, alignItems: 'center', marginLeft: 'auto' }}>
1863
+ <button type="button" className={css.iconButton} title="减小字号" aria-label="减小字号" onClick={() => { changeEditorFontSize(editorFontSize - 1) }}>A−</button>
1864
+ <span className={css.meta}>{editorFontSize}px</span>
1865
+ <button type="button" className={css.iconButton} title="增大字号" aria-label="增大字号" onClick={() => { changeEditorFontSize(editorFontSize + 1) }}>A+</button>
1866
+ <button type="button" className={css.iconButton} title="关闭工作区" aria-label="关闭工作区" onClick={() => { setWorkspace(null) }}>×</button>
1867
+ </span>
1674
1868
  </div>
1675
1869
  <div className={css.wsColumns}>
1676
1870
  <div className={css.wsColumn}>
@@ -1680,6 +1874,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1680
1874
  <textarea
1681
1875
  ref={wsEditorRef}
1682
1876
  className={`${css.textarea} ${css.wsEditor}`}
1877
+ style={{ fontSize: editorFontSize }}
1683
1878
  value={workspace.original}
1684
1879
  onChange={e => { setWorkspace({ ...workspace, original: e.target.value }) }}
1685
1880
  onMouseUp={captureWsSelection}
@@ -1706,7 +1901,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1706
1901
  <div className={css.meta}>未选中内容时仅支持整章润色/修订。</div>
1707
1902
  )}
1708
1903
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
1709
- {workspace.instruction.startsWith('按审稿意见修订') && (
1904
+ {workspace.instruction.includes('按审稿意见修订') && (
1710
1905
  <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleWsRewrite(true) }} title="AI 按已预填的审稿意见自动修订整章(无需自己找问题)">
1711
1906
  🔧 按意见修订
1712
1907
  </button>
@@ -1738,20 +1933,44 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1738
1933
  </div>
1739
1934
  <div className={css.meta}><b>总评:</b>{wsCheckReport.verdict}</div>
1740
1935
  {wsCheckReport.issues.length > 0 && (
1741
- <ul style={{ margin: 0, paddingLeft: 16, fontSize: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
1936
+ <ul style={{ margin: 0, paddingLeft: 16, display: 'flex', flexDirection: 'column', gap: 3, fontSize: editorFontSize - 1, maxHeight: '45vh', overflowY: 'auto' }}>
1742
1937
  {wsCheckReport.issues.map((issue, i) => (
1743
- <li key={i} style={{ color: severityColor(issue.severity) }}>
1744
- [{issue.severity}] {issue.item}
1745
- {issue.suggestion !== '' && <span style={{ color: 'var(--nf-text-2)' }}> → {issue.suggestion}</span>}
1938
+ <li key={i} style={{ color: severityColor(issue.severity), display: 'flex', gap: 6, alignItems: 'flex-start' }}>
1939
+ <input
1940
+ type="checkbox"
1941
+ style={{ marginTop: 1 }}
1942
+ checked={wsChecked.includes(i)}
1943
+ onChange={e => {
1944
+ setWsChecked(prev => e.target.checked ? [...prev, i] : prev.filter(x => x !== i))
1945
+ }}
1946
+ title="勾选后可由「修复所选问题」一起修订"
1947
+ />
1948
+ <span>
1949
+ [{issue.severity}] {issue.item}
1950
+ {issue.suggestion !== '' && <span style={{ color: 'var(--nf-text-2)' }}> → {issue.suggestion}</span>}
1951
+ </span>
1746
1952
  </li>
1747
1953
  ))}
1748
1954
  </ul>
1749
1955
  )}
1750
- <span className={css.meta}>审查只读不落盘;改完点「💾 保存为正文」才写入文件(原稿自动备份 .bak)。</span>
1956
+ <span className={css.meta}>审查只读不落盘;勾选要修的问题,点下方按钮一键修订;改完点「💾 保存为正文」才写入文件(原稿自动备份 .bak)。</span>
1957
+ {wsCheckReport.issues.length > 0 && wsChecked.length > 0 && (
1958
+ <div style={{ marginTop: 8 }}>
1959
+ <button
1960
+ type="button"
1961
+ className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
1962
+ disabled={busy}
1963
+ onClick={() => { void handleWsReviseByReport() }}
1964
+ title="自动按勾选的问题修订整章,产出草稿预览(不落盘),对比后应用或放弃"
1965
+ >
1966
+ 🔧 修复所选问题({wsChecked.length})
1967
+ </button>
1968
+ </div>
1969
+ )}
1751
1970
  </div>
1752
1971
  )}
1753
1972
  {workspace.draft !== null && (
1754
- <div className={css.wsPreview}>
1973
+ <div className={css.wsPreview} style={{ flex: 1, minHeight: 0 }}>
1755
1974
  <div className={css.busyRow}>
1756
1975
  <span className={css.meta}>优化预览({workspace.draft.length} 字)</span>
1757
1976
  <span style={{ display: 'flex', gap: 8 }}>
@@ -1767,8 +1986,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1767
1986
  </span>
1768
1987
  </div>
1769
1988
  {wsShowDiff
1770
- ? <DiffList original={workspace.original} draft={workspace.draft} />
1771
- : <pre className={css.wsPreviewText}>{workspace.draft}</pre>}
1989
+ ? <DiffList original={workspace.original} draft={workspace.draft} fontSize={editorFontSize} />
1990
+ : <pre className={css.wsPreviewText} style={{ fontSize: editorFontSize }}>{workspace.draft}</pre>}
1772
1991
  </div>
1773
1992
  )}
1774
1993
  </div>
@@ -1776,6 +1995,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1776
1995
  </div>
1777
1996
  )}
1778
1997
 
1998
+ {/* 编辑页打开时独占整页:隐藏其余所有页面内容 */}
1999
+ {workspace === null && (<>
1779
2000
  {activeTab === 'workflow' && (
1780
2001
  <>
1781
2002
  {/* ⭐ 主行动大卡片 */}
@@ -1832,6 +2053,43 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1832
2053
  </div>
1833
2054
  </div>
1834
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
+
1835
2093
  {/* 状态摘要条 */}
1836
2094
  <div className={css.assetGrid}>
1837
2095
  <div className={css.assetStat}>
@@ -1957,6 +2215,35 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1957
2215
  )}
1958
2216
  </div>
1959
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>
1960
2247
  </>
1961
2248
  )}
1962
2249
 
@@ -2168,9 +2455,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2168
2455
  {activeTab === 'plan' && (
2169
2456
  <>
2170
2457
  <div className={css.card}>
2171
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
2458
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
2172
2459
  <span className={css.cardTitle}>{tt('tab.plan')}</span>
2173
- <div className={css.row}>
2460
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
2174
2461
  <span className={css.meta}>{tt('plan.generateHint')}</span>
2175
2462
  <input
2176
2463
  className={css.input}
@@ -2185,23 +2472,78 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2185
2472
  <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || outlineText.length < 50} onClick={() => { void handlePlan() }}>
2186
2473
  {tt('plan.generate')}
2187
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>
2188
2478
  </div>
2189
2479
  </div>
2190
- {volumes !== undefined && volumes.length > 0 && (
2191
- <div className={css.row}>
2192
- {volumes.map(v => (
2193
- <span key={v.no} className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>
2194
- {v.no}. {v.title}({v.chapterStart}-{v.chapterEnd})
2195
- </span>
2196
- ))}
2197
- </div>
2198
- )}
2199
2480
  </div>
2200
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
+
2201
2520
  {chapters.length > 0 && (
2202
2521
  <div className={css.card}>
2203
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
2204
- <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>
2205
2547
  {pendingCount > 0 && (
2206
2548
  <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleWriteAll() }}>
2207
2549
  {tt('plan.writeAllPending')}({pendingCount})
@@ -2209,7 +2551,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2209
2551
  )}
2210
2552
  </div>
2211
2553
  <div className={css.chapterList}>
2212
- {chapterGroups.map(group => {
2554
+ {chapterGroups.filter(g => selectedVolume === 'all' || g.no === selectedVolume).map(group => {
2213
2555
  const collapsed = group.no !== 0 && collapsedVolumes.includes(group.no)
2214
2556
  const groupDone = group.chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
2215
2557
  return (
@@ -2340,23 +2682,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2340
2682
  </div>
2341
2683
  </div>
2342
2684
  )}
2343
-
2344
- <div className={css.card}>
2345
- <span className={css.cardTitle}>{tt('plan.progress')}</span>
2346
- <div className={css.progress}>
2347
- {progress.length === 0 && <span className={css.meta}>{tt('progress.empty')}</span>}
2348
- {progress.map(line => (
2349
- <div key={line.id} className={line.kind === 'done' ? css.progressLineDone : line.kind === 'error' ? css.progressLineError : line.live === true ? css.progressLineLive : css.progressLine}>
2350
- {line.live === true && (
2351
- <span className={css.progressBar}>
2352
- <span className={css.progressBarFill} style={{ width: `${Math.round((line.ratio ?? 0) * 100)}%` }} />
2353
- </span>
2354
- )}
2355
- {line.text}
2356
- </div>
2357
- ))}
2358
- </div>
2359
- </div>
2360
2685
  </>
2361
2686
  )}
2362
2687
 
@@ -2493,6 +2818,44 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2493
2818
  </div>
2494
2819
  )}
2495
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
+
2496
2859
  {/* 写作资产 5 个子分类(左侧导航直达) */}
2497
2860
  {activeTab === 'assetsGenre' && <AssetsTab api={api} initialTab="genre" />}
2498
2861
  {activeTab === 'assetsProgression' && <AssetsTab api={api} initialTab="progression" />}
@@ -2588,6 +2951,19 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2588
2951
  <label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label>
2589
2952
  <input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => { setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) }) }} />
2590
2953
  </div>
2954
+ <div className={css.field} style={{ flex: 1 }}>
2955
+ <label className={css.fieldLabel}>{tt('settings.editorFontSize')}</label>
2956
+ <select
2957
+ className={css.input}
2958
+ value={editorFontSize}
2959
+ onChange={e => { changeEditorFontSize(Number(e.target.value)) }}
2960
+ >
2961
+ {[12, 13, 14, 15, 16, 18, 20, 22, 24].map(v => (
2962
+ <option key={v} value={v}>{v}px</option>
2963
+ ))}
2964
+ </select>
2965
+ <span className={css.meta}>{tt('settings.editorFontSizeHint')}</span>
2966
+ </div>
2591
2967
  <div className={css.field} style={{ flex: 1 }}>
2592
2968
  <label className={css.fieldLabel}>{tt('settings.autoReview')}</label>
2593
2969
  <select
@@ -2618,27 +2994,173 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2618
2994
  {tt('settings.exportMd')}
2619
2995
  </button>
2620
2996
  </div>
2621
- {/* 编年录 / 时间线 */}
2622
- {(project?.facts ?? []).length > 0 && (
2623
- <div className={css.card}>
2624
- <span className={css.cardTitle}>编年录 / 时间线({(project?.facts ?? []).length} 条)</span>
2625
- <span className={css.meta}>
2626
- 每章生成后自动抽取「已确立事实」(人物状态/境界资源/关系变化/伏笔落地),最近 20 条注入后续章节生成,保证长期一致。
2627
- </span>
2628
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 300, overflowY: 'auto', fontSize: 12 }}>
2629
- {[...(project?.facts ?? [])].reverse().slice(0, 60).map((fact, i) => (
2630
- <div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
2631
- <span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)', flex: 'none', marginTop: 1 }}>
2632
- 第 {fact.chapterNo} 章
2633
- </span>
2634
- <span className={css.meta}>{fact.text}</span>
2635
- </div>
2636
- ))}
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 }) }} />
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>
2637
3122
  </div>
2638
3123
  </div>
2639
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
+ })}
3159
+ </div>
3160
+ )}
2640
3161
  </div>
2641
3162
  )}
3163
+ </>)}
2642
3164
  </div>
2643
3165
  </div>
2644
3166
  </>