@waterwx/dsh-novel-forge 1.1.8 → 1.2.0

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.
@@ -13,11 +13,13 @@ import { AssistantTab } from './AssistantTab.tsx'
13
13
  import { AssetsTab } from './AssetsTab.tsx'
14
14
  import { ShelfView } from './ShelfView.tsx'
15
15
  import { ReaderView } from './ReaderView.tsx'
16
+ import { RunPanel } from './RunPanel.tsx'
16
17
  import { CreateBookView } from './CreateBookView.tsx'
17
18
  import { WorldTab } from './WorldTab.tsx'
18
19
  import { AuditIssueRow, PlotlineCard, PlotlineHealthPanel, PlotlinePlanPanel, PlotlineSuggestionPanel, RoleCandidateRow, RoleCard, StatCell, TodoRow } from './views.tsx'
19
20
  import { extractDocxTextFromBuffer } from '../docx.ts'
20
21
  import type {
22
+ AuditStatus,
21
23
  BookshelfSnapshot,
22
24
  ChapterPlan,
23
25
  Foreshadow,
@@ -39,7 +41,8 @@ import css from './panel.module.css'
39
41
  export type NovelTab =
40
42
  | 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings'
41
43
  | 'characters' | 'roles' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'breakdown' | 'storyboard'
42
- | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle'
44
+ | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle' | 'run'
45
+ | 'book' | 'assets'
43
46
 
44
47
  /** Panel shell props. */
45
48
  export interface NovelPanelProps {
@@ -68,9 +71,10 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
68
71
  items: [
69
72
  { id: 'workflow', label: tt('tab.workflow'), icon: '🛠️' },
70
73
  { id: 'overview', label: tt('tab.overview'), icon: '📄' },
71
- { id: 'blurb', label: '卷首语', icon: '📖' },
74
+ { id: 'blurb', label: '简介 / 封面', icon: '📖' },
72
75
  { id: 'plan', label: tt('tab.plan'), icon: '📚' },
73
- { id: 'plotlines', label: tt('tab.plotlines'), icon: '🧵' },
76
+ { id: 'plotlines', label: '长线管理', icon: '📜' },
77
+ { id: 'book', label: '本书设定', icon: '📚' },
74
78
  ],
75
79
  },
76
80
  {
@@ -81,23 +85,14 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
81
85
  { id: 'progress', label: '工作进度', icon: '📊' },
82
86
  { id: 'breakdown', label: '拆书分析', icon: '🔍' },
83
87
  { id: 'storyboard', label: '漫剧工坊', icon: '🎬' },
88
+ { id: 'run', label: '生产单', icon: '🏭' },
84
89
  ],
85
90
  },
86
91
  {
87
- id: 'db',
88
- label: '数据库',
92
+ id: 'assets',
93
+ label: '资产',
89
94
  items: [
90
- { id: 'bible', label: tt('tab.bible'), icon: '📖' },
91
- { id: 'world', label: '大世界', icon: '🌍' },
92
- { id: 'roles', label: '角色库', icon: '👥' },
93
- { id: 'foreshadow', label: tt('tab.foreshadow'), icon: '🔮' },
94
- { id: 'facts', label: tt('tab.facts'), icon: '📜' },
95
- { id: 'reviews', label: '复盘记录', icon: '📋' },
96
- { id: 'assetsGenre', label: '题材基底', icon: '🏷️' },
97
- { id: 'assetsProgression', label: '推进模式', icon: '📈' },
98
- { id: 'assetsTemplates', label: '笔法帖', icon: '🖋️' },
99
- { id: 'assetsRules', label: '文戒', icon: '🚫' },
100
- { id: 'assetsStyle', label: '心法', icon: '🎨' },
95
+ { id: 'assets', label: '创作资产', icon: '🧰' },
101
96
  ],
102
97
  },
103
98
  ]
@@ -105,6 +100,10 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
105
100
  /** Settings tab — pinned to the bottom of the nav rail. */
106
101
  const SETTINGS_TAB: { id: NovelTab; label: string; icon: string } = { id: 'settings', label: tt('tab.settings'), icon: '⚙️' }
107
102
 
103
+ /** Common DeepSeek model presets shown in settings; users can also type any model id. */
104
+ const MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
105
+ const REASONING_OPTIONS = ['off', 'low', 'high', 'max'] as const
106
+
108
107
  /** 构建时注入的插件版本(tsdown define 替换为字符串字面量)。 */
109
108
  declare const __NOVEL_FORGE_VERSION__: string | undefined
110
109
  const PLUGIN_VERSION: string = typeof __NOVEL_FORGE_VERSION__ !== 'undefined' ? __NOVEL_FORGE_VERSION__ : '0.0.0'
@@ -317,9 +316,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
317
316
  const [notice, setNotice] = useState('')
318
317
  const [progress, setProgress] = useState<ProgressLine[]>([])
319
318
  const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null)
319
+ const [modelCustomMode, setModelCustomMode] = useState(false)
320
320
  const [expandedChapter, setExpandedChapter] = useState<number | null>(null)
321
321
  /** 复盘记录页:当前展开的章节号。 */
322
322
  const [expandedReviewChapter, setExpandedReviewChapter] = useState<number | null>(null)
323
+ /** 复盘记录页:按卷折叠,卷号 → 是否展开(默认全部收起)。 */
324
+ const [expandedVolumes, setExpandedVolumes] = useState<Record<number, boolean>>({})
323
325
  const [chapterText, setChapterText] = useState('')
324
326
  const progressId = useRef(0)
325
327
  /** id of the single live progress row (generation counter), if any. */
@@ -383,6 +385,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
383
385
  const [updatingOutline, setUpdatingOutline] = useState(false)
384
386
  /** 全书质检结果(null = 未运行)。 */
385
387
  const [auditIssues, setAuditIssues] = useState<import('../../protocol.ts').AuditIssue[] | null>(null)
388
+ /** 全书质检实时状态(来自 /status,用于显示进度)。 */
389
+ const [auditStatus, setAuditStatus] = useState<AuditStatus | null>(null)
386
390
  /** 角色卡(从事实库聚合)。 */
387
391
  const [charCards, setCharCards] = useState<import('../../protocol.ts').RoleStatusCard[] | null>(null)
388
392
  /** 世界观规则编辑草稿(bible tab,每行一条)。 */
@@ -410,6 +414,39 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
410
414
  progress: string
411
415
  status: Plotline['status']
412
416
  } | null>(null)
417
+ /** 长线管理页:子页签(剧情线 / 伏笔),localStorage 记忆。 */
418
+ const [longlineTab, setLonglineTab] = useState<'plotlines' | 'foreshadow'>(() => {
419
+ try {
420
+ const v = window.localStorage.getItem('dsh-novel-forge.longline.tab')
421
+ return v === 'foreshadow' ? 'foreshadow' : 'plotlines'
422
+ } catch { return 'plotlines' }
423
+ })
424
+ const changeLonglineTab = (next: 'plotlines' | 'foreshadow'): void => {
425
+ setLonglineTab(next)
426
+ try { window.localStorage.setItem('dsh-novel-forge.longline.tab', next) } catch { /* ignore */ }
427
+ }
428
+ /** 编年 / 复盘页:子页签(编年录 / 复盘记录),localStorage 记忆。 */
429
+ const [archiveTab, setArchiveTab] = useState<'facts' | 'reviews'>(() => {
430
+ try {
431
+ const v = window.localStorage.getItem('dsh-novel-forge.archive.tab')
432
+ return v === 'reviews' ? 'reviews' : 'facts'
433
+ } catch { return 'facts' }
434
+ })
435
+ const changeArchiveTab = (next: 'facts' | 'reviews'): void => {
436
+ setArchiveTab(next)
437
+ try { window.localStorage.setItem('dsh-novel-forge.archive.tab', next) } catch { /* ignore */ }
438
+ }
439
+ /** 本书设定页:子页签(设定库 / 大世界 / 角色库 / 编年·复盘),localStorage 记忆。 */
440
+ const [bookTab, setBookTab] = useState<'bible' | 'world' | 'roles' | 'facts'>(() => {
441
+ try {
442
+ const v = window.localStorage.getItem('dsh-novel-forge.book.tab')
443
+ return v === 'world' || v === 'roles' || v === 'facts' ? v : 'bible'
444
+ } catch { return 'bible' }
445
+ })
446
+ const changeBookTab = (next: 'bible' | 'world' | 'roles' | 'facts'): void => {
447
+ setBookTab(next)
448
+ try { window.localStorage.setItem('dsh-novel-forge.book.tab', next) } catch { /* ignore */ }
449
+ }
413
450
  /** 角色知情度编辑草稿(角色名 → 文本,每行一条)。 */
414
451
  const [knowledgeDraft, setKnowledgeDraft] = useState<Record<string, string>>({})
415
452
  /** 角色库:AI 提炼候选(null = 未运行;localStorage 持久化,刷新不丢)。 */
@@ -613,6 +650,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
613
650
  const status = await api.status()
614
651
  setConfig(status.config)
615
652
  setConfigDraft(status.config)
653
+ setModelCustomMode(!(MODEL_PRESETS as readonly string[]).includes(status.config.model))
654
+ setAuditStatus(status.audit ?? null)
616
655
  setProject(status.project ?? null)
617
656
  // 人物志存档同步:有 roleStatus 存档直接显示,无需重新刷新计算。
618
657
  if (status.project?.roleStatus !== undefined) {
@@ -772,18 +811,32 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
772
811
  setBusy(true)
773
812
  setBusyLabel('全书一致性质检中…')
774
813
  setError('')
814
+ setAuditStatus({ status: 'running', totalBatches: 0, completedBatches: 0 })
815
+ let stopped = false
816
+ const poll = window.setInterval(async () => {
817
+ if (stopped) return
818
+ try {
819
+ const s = await api.status()
820
+ if (s.audit !== undefined) setAuditStatus(s.audit)
821
+ } catch { /* 轮询失败忽略,主请求仍会给出最终结果 */ }
822
+ }, 1000)
775
823
  try {
776
824
  const result = await api.audit()
825
+ if (stopped) return
777
826
  setAuditIssues(result.issues)
778
827
  pushProgress(result.issues.length === 0
779
828
  ? `全书质检完成:${result.auditedChapters} 章未发现矛盾 🎉`
780
829
  : `全书质检完成:发现 ${result.issues.length} 处疑似矛盾`, result.issues.length === 0 ? 'done' : 'error')
781
830
  } catch (err) {
831
+ if (stopped) return
782
832
  setError((err as Error).message)
783
833
  pushProgress(`全书质检失败:${(err as Error).message}`, 'error')
784
834
  } finally {
835
+ stopped = true
836
+ window.clearInterval(poll)
785
837
  setBusy(false)
786
838
  setBusyLabel('')
839
+ await refresh(false)
787
840
  }
788
841
  }
789
842
 
@@ -1118,7 +1171,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1118
1171
 
1119
1172
  /** 作者复盘补跑:全书缺失章节(流式)。 */
1120
1173
  const handleAuthorBackfillAll = async (): Promise<void> => {
1121
- const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined).length
1174
+ const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
1122
1175
  setBusy(true)
1123
1176
  setBusyLabel(`补齐历史章节作者复盘(${missing} 章)…`)
1124
1177
  setError('')
@@ -1964,6 +2017,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1964
2017
  outputDir: configDraft.outputDir,
1965
2018
  provider: configDraft.provider,
1966
2019
  model: configDraft.model,
2020
+ reasoningEffort: configDraft.reasoningEffort ?? 'off',
1967
2021
  chapterChars: configDraft.chapterChars,
1968
2022
  maxTokens: configDraft.maxTokens,
1969
2023
  reviewPassScore: configDraft.reviewPassScore,
@@ -2055,6 +2109,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2055
2109
  // --------------------------------------------------- dashboard (workflow)
2056
2110
  const approvedCount = chapters.filter(c => c.status === 'approved').length
2057
2111
  const reviewPendingCount = chapters.filter(c => c.status === 'written' || c.status === 'rejected').length
2112
+ const writingNow = chapters.find(c => c.status === 'generating' || c.status === 'reviewing')
2058
2113
  const totalChars = chapters.reduce((sum, c) => sum + (c.chars ?? 0), 0)
2059
2114
  const firstChapter = chapters[0]
2060
2115
  const currentVolumeName = (() => {
@@ -2079,6 +2134,24 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2079
2134
  const journeyPercent = Math.round((journeyDoneCount / journeyStages.length) * 100)
2080
2135
  const currentStageId = journeyStages.find(s => !s.done)?.id
2081
2136
 
2137
+ /** 创作时间线阶段点击 → 跳转对应 tab。 */
2138
+ const jumpToStage = (id: string): void => {
2139
+ switch (id) {
2140
+ case 'outline': setActiveTab('overview'); break
2141
+ case 'bible': setActiveTab('book'); changeBookTab('bible'); break
2142
+ case 'volumes':
2143
+ case 'plan':
2144
+ case 'write': setActiveTab('plan'); break
2145
+ case 'review': setActiveTab('book'); changeBookTab('facts'); changeArchiveTab('reviews'); break
2146
+ }
2147
+ }
2148
+
2149
+ /** 侧栏当前书卡(demo 风格):书架激活书 → 封面首字 / 书名 / 进度。 */
2150
+ const activeBook = shelf?.books.find(b => b.id === shelf?.activeBookId) ?? null
2151
+ const activeBookName = activeBook?.bookName ?? project?.bookName ?? '未选书'
2152
+ const activeBookMeta = activeBook !== null ? `${activeBook.done}/${activeBook.total} 章 · 点按打开书架` : '点按打开书架'
2153
+ const activeBookLetter = activeBookName.trim().charAt(0) || '书'
2154
+
2082
2155
  /** 主行动卡片:推荐下一步(AI-Novel-Writing-Assistant 首页主卡模式)。 */
2083
2156
  const nextAction = useMemo((): { eyebrow: string; title: string; reason: string; actionLabel: string; onClick: () => void } | null => {
2084
2157
  if (project === null) {
@@ -2265,48 +2338,20 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2265
2338
  />
2266
2339
  ) : (
2267
2340
  <>
2268
- <div className={css.panelHeader}>
2269
- <div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
2270
- <h2 className={css.panelTitle}>
2271
- <button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={() => { setViewMode('shelf') }}>
2272
- ←
2273
- </button>
2274
- {tt('panel.title')}
2275
- </h2>
2276
- {(project?.bookName !== '' && project?.bookName !== undefined) && (
2277
- <div className={css.panelSubtitle}>
2278
- <span>📖 {project.bookName}</span>
2279
- {chapters.length > 0 && (
2280
- <span className={css.headerProgress}>
2281
- 已完成 <b>{doneCount}</b>/{chapters.length} 章
2282
- <span className={css.headerProgressDot} />
2283
- 通过 <b>{chapters.filter(c => c.status === 'approved').length}</b>
2284
- </span>
2285
- )}
2286
- </div>
2287
- )}
2288
- </div>
2289
- <div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
2290
- <button
2291
- type="button"
2292
- className={css.iconButton}
2293
- title={navCollapsed ? '展开导航栏' : '收起导航栏'}
2294
- aria-label={navCollapsed ? '展开导航栏' : '收起导航栏'}
2295
- onClick={() => {
2296
- setNavCollapsed(prev => {
2297
- const next = !prev
2298
- try { window.localStorage.setItem('dsh-novel-forge.nav.collapsed', String(next)) } catch { /* ignore */ }
2299
- return next
2300
- })
2301
- }}
2302
- >
2303
- {navCollapsed ? '▸' : '◂'}
2304
- </button>
2305
- <button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>×</button>
2306
- </div>
2307
- </div>
2308
2341
  <div className={css.panelBody}>
2309
2342
  <nav className={`${css.panelNav} ${navCollapsed ? css.panelNavCollapsed : ''}`} role="tablist" aria-label="工作台导航">
2343
+ {/* 标题区:小说工坊 + 当前书名(顶部标题栏取消后移入侧栏) */}
2344
+ <div className={css.navTitle}>
2345
+ <div className={css.navTitleLogo}>书</div>
2346
+ {!navCollapsed && (
2347
+ <div style={{ minWidth: 0 }}>
2348
+ <div className={css.navTitleName}>小说工坊</div>
2349
+ <div className={css.navTitleBook} title={project?.bookName ?? ''}>
2350
+ {project?.bookName !== undefined && project.bookName !== '' ? `📖 ${project.bookName}` : '未选书'}
2351
+ </div>
2352
+ </div>
2353
+ )}
2354
+ </div>
2310
2355
  {/* 分组导航(创作 / 工具 / 数据库) */}
2311
2356
  {NAV_GROUPS.map(group => (
2312
2357
  <div key={group.id} className={css.navGroup}>
@@ -2338,7 +2383,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2338
2383
  {chapters.some(c => c.status === 'error') ? `!${pendingCount + reviewPendingCount}` : pendingCount + reviewPendingCount}
2339
2384
  </span>
2340
2385
  )}
2341
- {tab.id === 'foreshadow' && foreshadows.some(f => f.status === 'planned') && (
2386
+ {tab.id === 'plotlines' && foreshadows.some(f => f.status === 'planned') && (
2342
2387
  <span className={css.navTabBadge}>{foreshadows.filter(f => f.status === 'planned').length}</span>
2343
2388
  )}
2344
2389
  {tab.id === 'workflow' && chapters.length > 0 && (
@@ -2372,6 +2417,22 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2372
2417
  <span className={css.navTabIcon}>{SETTINGS_TAB.icon}</span>
2373
2418
  {!navCollapsed && <span className={css.navTabLabel}>{SETTINGS_TAB.label}</span>}
2374
2419
  </button>
2420
+ {/* 当前书切换卡(demo 风格底部内容卡:封面首字 + 书名 + 进度,点击回书架) */}
2421
+ {!navCollapsed && shelf !== null && (
2422
+ <button
2423
+ type="button"
2424
+ className={css.bookSwitch}
2425
+ title={activeBook !== null ? `当前书:${activeBook.bookName}(${activeBook.outputDir})· 点击打开书架` : '打开书架'}
2426
+ onClick={() => { setViewMode('shelf') }}
2427
+ >
2428
+ <span className={css.bookSwitchCover}>{activeBookLetter}</span>
2429
+ <span style={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
2430
+ <span className={css.bookSwitchName}>{activeBookName}</span>
2431
+ <span className={css.bookSwitchMeta}>{activeBookMeta}</span>
2432
+ </span>
2433
+ <span className={css.bookSwitchArrow}>⌄</span>
2434
+ </button>
2435
+ )}
2375
2436
  {/* 关于:版本 + GitHub + 更新检测 */}
2376
2437
  <div className={css.navAbout}>
2377
2438
  <button
@@ -2398,6 +2459,33 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2398
2459
  </button>
2399
2460
  )}
2400
2461
  </div>
2462
+ {/* 操作行:收起导航 / 关闭面板(原顶部按钮下放,收起态保留图标) */}
2463
+ <div className={css.navActions}>
2464
+ <button
2465
+ type="button"
2466
+ className={css.navActionBtn}
2467
+ title={navCollapsed ? '展开导航栏' : '收起导航栏'}
2468
+ aria-label={navCollapsed ? '展开导航栏' : '收起导航栏'}
2469
+ onClick={() => {
2470
+ setNavCollapsed(prev => {
2471
+ const next = !prev
2472
+ try { window.localStorage.setItem('dsh-novel-forge.nav.collapsed', String(next)) } catch { /* ignore */ }
2473
+ return next
2474
+ })
2475
+ }}
2476
+ >
2477
+ {navCollapsed ? '▸' : '◂'}
2478
+ </button>
2479
+ <button
2480
+ type="button"
2481
+ className={css.navActionBtn}
2482
+ title={tt('common.close')}
2483
+ aria-label={tt('common.close')}
2484
+ onClick={() => { controller.close() }}
2485
+ >
2486
+ ×
2487
+ </button>
2488
+ </div>
2401
2489
  </nav>
2402
2490
  <div className={css.panelContent}>
2403
2491
  {error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
@@ -2632,29 +2720,64 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2632
2720
  <span className={css.meta} style={{ fontWeight: 600 }}>创作旅程</span>
2633
2721
  <span className={css.meta}>{journeyPercent}% · 已完成 {journeyDoneCount}/{journeyStages.length} 步</span>
2634
2722
  </div>
2635
- <div className={css.dashJourneyBar}>
2636
- <div className={css.dashJourneyFill} style={{ width: `${journeyPercent}%` }} />
2637
- </div>
2638
- <div className={css.dashJourneyStages}>
2723
+ <div className={css.tlBar}>
2639
2724
  {journeyStages.map(stage => (
2640
- <span
2725
+ <button
2641
2726
  key={stage.id}
2642
- className={`${css.dashStage} ${stage.done ? css.dashStageDone : stage.id === currentStageId ? css.dashStageCurrent : ''}`}
2727
+ type="button"
2728
+ className={`${css.tlSeg} ${stage.done ? css.tlSegDone : stage.id === currentStageId ? css.tlSegCurrent : css.tlSegTodo}`}
2729
+ title={`${stage.label}${stage.done ? ' · 已完成' : stage.id === currentStageId ? ' · 进行中' : ' · 未开始'}(点击跳转)`}
2730
+ onClick={() => { jumpToStage(stage.id) }}
2643
2731
  >
2644
- <span className={css.dashStageDot}>{stage.done ? '✓' : stage.id === currentStageId ? '●' : '○'}</span>
2645
- {stage.label}
2646
- </span>
2732
+ <span className={css.tlSegTrack}><span className={css.tlSegFill} /></span>
2733
+ <span className={css.tlSegLabel}>{stage.done ? '✓ ' : ''}{stage.label}</span>
2734
+ </button>
2647
2735
  ))}
2648
2736
  </div>
2649
2737
  </div>
2650
2738
  </div>
2651
2739
 
2652
- {/* 状态摘要条:全书进度 + 六个统计格,同样式并排一行 */}
2740
+ {/* 统计卡(demo 风格:今日待写 / 写作中 / 已通过 / 待审稿) */}
2741
+ <div className={css.statRowD}>
2742
+ <div className={css.statCardD}>
2743
+ <div style={{ minWidth: 0 }}>
2744
+ <div className={css.statCardDLabel}>今日待写</div>
2745
+ <div className={css.statCardDValue}>{pendingCount} <span className={css.statCardDUnit}>章</span></div>
2746
+ <div className={css.statCardDDetail}>
2747
+ {chapters.some(c => c.status === 'error') ? `含 error ${chapters.filter(c => c.status === 'error').length} 章` : '待生成队列'}
2748
+ </div>
2749
+ </div>
2750
+ <div className={css.statCardDIcon}>📝</div>
2751
+ </div>
2752
+ <div className={css.statCardD}>
2753
+ <div style={{ minWidth: 0 }}>
2754
+ <div className={css.statCardDLabel}>写作中</div>
2755
+ <div className={css.statCardDValue}>{chapters.filter(c => c.status === 'generating' || c.status === 'reviewing').length} <span className={css.statCardDUnit}>章</span></div>
2756
+ <div className={css.statCardDDetail}>{writingNow !== undefined ? `第 ${writingNow.no} 章 · ${writingNow.title}` : '无进行中任务'}</div>
2757
+ </div>
2758
+ <div className={css.statCardDIcon}>✍️</div>
2759
+ </div>
2760
+ <div className={css.statCardD}>
2761
+ <div style={{ minWidth: 0 }}>
2762
+ <div className={css.statCardDLabel}>已通过</div>
2763
+ <div className={css.statCardDValue}>{approvedCount} <span className={css.statCardDUnit}>章</span></div>
2764
+ <div className={css.statCardDDetail}>{chapters.length > 0 ? `占全书 ${Math.round((approvedCount / chapters.length) * 100)}%` : '尚无已通过章节'}</div>
2765
+ </div>
2766
+ <div className={css.statCardDIcon}>✅</div>
2767
+ </div>
2768
+ <div className={css.statCardD}>
2769
+ <div style={{ minWidth: 0 }}>
2770
+ <div className={css.statCardDLabel}>待审稿</div>
2771
+ <div className={css.statCardDValue}>{reviewPendingCount} <span className={css.statCardDUnit}>章</span></div>
2772
+ <div className={`${css.statCardDDetail} ${reviewPendingCount > 0 ? css.statCardDDown : ''}`}>
2773
+ {`written ${chapters.filter(c => c.status === 'written').length} · rejected ${chapters.filter(c => c.status === 'rejected').length}`}
2774
+ </div>
2775
+ </div>
2776
+ <div className={`${css.statCardDIcon} ${css.statCardDIconRed}`}>⚠️</div>
2777
+ </div>
2778
+ </div>
2779
+ {/* 补充统计:总字数 / 当前卷 / 最近创作(原有数据保留) */}
2653
2780
  <div className={css.assetGrid}>
2654
- <StatCell label="全书进度" value={`${doneCount}/${chapters.length} 章`} valueColor="var(--nf-accent)" detail={`${chapters.length > 0 ? Math.round((doneCount / chapters.length) * 100) : 0}%`} />
2655
- <StatCell label="已通过审稿" value={String(approvedCount)} valueColor={approvedCount > 0 ? 'var(--nf-success)' : undefined} detail="章节已 approved" />
2656
- <StatCell label="待生成" value={String(pendingCount)} valueColor={pendingCount > 0 ? 'var(--nf-warn)' : undefined} detail="pending + error" />
2657
- <StatCell label="待审稿" value={String(reviewPendingCount)} valueColor={reviewPendingCount > 0 ? 'var(--nf-info)' : undefined} detail="written + rejected" />
2658
2781
  <StatCell label="总字数" value={String(totalChars)} detail="已生成正文累计" />
2659
2782
  <StatCell label="当前卷" value={currentVolumeName} valueFontSize={13} detail="正在推进的卷" />
2660
2783
  <StatCell label="最近创作" value={lastUpdated} valueFontSize={13} detail="最近生成/编辑时间" />
@@ -2668,6 +2791,19 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2668
2791
  🔍 全书质检
2669
2792
  </button>
2670
2793
  </div>
2794
+ {auditStatus?.status === 'running' && (
2795
+ <div className={css.meta} style={{ marginTop: 8 }}>
2796
+ 🔍 全书质检中:{auditStatus.completedBatches}/{auditStatus.totalBatches > 0 ? auditStatus.totalBatches : '…'} 批
2797
+ {auditStatus.totalBatches > 0 && (
2798
+ <div className={css.dashJourneyBar} style={{ marginTop: 4 }}>
2799
+ <div className={css.dashJourneyFill} style={{ width: `${Math.round((auditStatus.completedBatches / auditStatus.totalBatches) * 100)}%` }} />
2800
+ </div>
2801
+ )}
2802
+ </div>
2803
+ )}
2804
+ {auditStatus?.status === 'error' && auditStatus.error !== undefined && (
2805
+ <div className={css.meta} style={{ color: 'var(--nf-error)', marginTop: 8 }}>全书质检失败:{auditStatus.error}</div>
2806
+ )}
2671
2807
  <div className={css.assetGrid}>
2672
2808
  <StatCell
2673
2809
  label="道藏"
@@ -2730,7 +2866,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2730
2866
  <span className={css.cardTitle}>📋 作者复盘(最近 {Math.min(6, chapters.filter(c => c.authorReview !== undefined).length)} 章)</span>
2731
2867
  <div className={css.row}>
2732
2868
  {(() => {
2733
- const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined).length
2869
+ const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
2734
2870
  return missing > 0 && (
2735
2871
  <button
2736
2872
  type="button"
@@ -2743,7 +2879,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2743
2879
  </button>
2744
2880
  )
2745
2881
  })()}
2746
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('reviews') }} title="查看按卷分组的全部复盘记录">
2882
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('book'); changeBookTab('facts'); changeArchiveTab('reviews') }} title="查看按卷分组的全部复盘记录">
2747
2883
  查看全部 →
2748
2884
  </button>
2749
2885
  </div>
@@ -2916,7 +3052,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2916
3052
  {activeTab === 'blurb' && (
2917
3053
  <div className={css.card}>
2918
3054
  <div className={css.row} style={{ justifyContent: 'space-between' }}>
2919
- <span className={css.cardTitle}>卷首语</span>
3055
+ <span className={css.cardTitle}>📖 简介 / 封面</span>
2920
3056
  <div className={css.row}>
2921
3057
  <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || project === null} onClick={() => { void handleBlurbGenerate() }}>
2922
3058
  ✨ AI 生成
@@ -3181,7 +3317,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3181
3317
  )}
3182
3318
  </div>
3183
3319
  )}
3184
- {chapter.authorReview === undefined && chapter.status !== 'pending' && chapter.status !== 'generating' && (
3320
+ {chapter.authorReview === undefined && chapter.status !== 'pending' && chapter.status !== 'generating' && chapter.status !== 'error' && (
3185
3321
  <div className={css.row}>
3186
3322
  <button
3187
3323
  type="button"
@@ -3297,85 +3433,53 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3297
3433
  </>
3298
3434
  )}
3299
3435
 
3300
- {activeTab === 'reviews' && (
3301
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
3436
+ {activeTab === 'book' && (
3437
+ <div className={css.card} style={{ gap: 12 }}>
3302
3438
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3303
- <span className={css.cardTitle}>📋 复盘记录({chapters.filter(c => c.authorReview !== undefined).length} 章已复盘)</span>
3304
- <div className={css.row}>
3305
- {(() => {
3306
- const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined).length
3307
- return missing > 0 && (
3308
- <button
3309
- type="button"
3310
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
3311
- disabled={busy}
3312
- onClick={() => { void handleAuthorBackfillAll() }}
3313
- title="对历史已写章节逐章补跑作者复盘"
3314
- >
3315
- ↻ 补齐缺失复盘({missing})
3316
- </button>
3317
- )
3318
- })()}
3319
- </div>
3439
+ <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📚 本书设定</span>
3440
+ <span className={css.meta}>当前书的知识与资料(参考 AI-Novel-Writing-Assistant:世界观/角色准备收进书内)</span>
3320
3441
  </div>
3321
- {(() => {
3322
- const reviewed = chapters.filter(c => c.authorReview !== undefined)
3323
- if (reviewed.length === 0) {
3324
- return <span className={css.meta}>尚无作者复盘——生成/审稿后自动生成,或点「补齐缺失复盘」为已写章节补跑。</span>
3325
- }
3326
- const honored = reviewed.filter(c => c.authorReview!.hookHonored).length
3327
- const avg = Math.round(reviewed.reduce((s, c) => s + c.authorReview!.endingHook, 0) / reviewed.length * 10) / 10
3328
- return (
3329
- <span className={css.meta} style={{ fontWeight: 600 }}>
3330
- 钩子兑现 {honored}/{reviewed.length} · 结尾钩子均分 {avg}/10
3331
- </span>
3332
- )
3333
- })()}
3334
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', flex: 1, minHeight: 0 }}>
3335
- {chapterGroups.map(group => {
3336
- const groupReviewed = group.chapters.filter(c => c.authorReview !== undefined)
3337
- if (groupReviewed.length === 0) return null
3338
- return (
3339
- <div key={group.no} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
3340
- <div className={css.row} style={{ alignItems: 'center', gap: 8 }}>
3341
- <b style={{ fontSize: 13 }}>{group.title}</b>
3342
- <span className={css.meta}>已复盘 {groupReviewed.length}/{group.chapters.length} 章</span>
3343
- </div>
3344
- {[...groupReviewed].reverse().map(c => {
3345
- const ar = c.authorReview!
3346
- const expanded = expandedReviewChapter === c.no
3347
- return (
3348
- <div key={c.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, overflow: 'hidden' }}>
3349
- <button
3350
- type="button"
3351
- style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '6px 10px', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}
3352
- onClick={() => { setExpandedReviewChapter(expanded ? null : c.no) }}
3353
- >
3354
- <span style={{ color: 'var(--nf-text-3)' }}>{expanded ? '▾' : '▸'}</span>
3355
- <b>第{c.no}章《{c.title}》</b>
3356
- <span style={{ color: ar.hookHonored ? 'var(--nf-success)' : 'var(--nf-warn)' }}>钩子{ar.hookHonored ? '✓' : '✗'}</span>
3357
- <span style={{ color: ar.endingHook >= 6 ? 'var(--nf-success)' : 'var(--nf-error)' }}>结尾钩子 {ar.endingHook}/10</span>
3358
- {ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 4 }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
3359
- </button>
3360
- {expanded && (
3361
- <div style={{ padding: '4px 10px 8px', fontSize: 12, display: 'flex', flexDirection: 'column', gap: 3, borderTop: '1px solid var(--nf-border)' }}>
3362
- {ar.hookNote !== '' && <span className={css.meta}><b>钩子:</b>{ar.hookNote}</span>}
3363
- {ar.plotlineProgress !== '' && <span className={css.meta}><b>推进:</b>{ar.plotlineProgress}</span>}
3364
- {ar.continuity !== '' && <span className={css.meta}><b>衔接:</b>{ar.continuity}</span>}
3365
- {ar.trend !== '' && <span className={css.meta}><b>趋势:</b>{ar.trend}</span>}
3366
- </div>
3367
- )}
3368
- </div>
3369
- )
3370
- })}
3371
- </div>
3372
- )
3373
- })}
3442
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
3443
+ <button
3444
+ type="button"
3445
+ className={`${css.button} ${bookTab === 'bible' ? css.buttonPrimary : ''}`}
3446
+ style={{ fontSize: 14, flex: 1 }}
3447
+ onClick={() => { changeBookTab('bible') }}
3448
+ title="设定库:题材 / 世界观规则 / 人物摘要 / 红线 / 文风"
3449
+ >
3450
+ 📖 设定库
3451
+ </button>
3452
+ <button
3453
+ type="button"
3454
+ className={`${css.button} ${bookTab === 'world' ? css.buttonPrimary : ''}`}
3455
+ style={{ fontSize: 14, flex: 1 }}
3456
+ onClick={() => { changeBookTab('world') }}
3457
+ title="大世界:境界体系 / 地理区域 / 势力分布(注入生成与审稿提示词)"
3458
+ >
3459
+ 🌍 大世界
3460
+ </button>
3461
+ <button
3462
+ type="button"
3463
+ className={`${css.button} ${bookTab === 'roles' ? css.buttonPrimary : ''}`}
3464
+ style={{ fontSize: 14, flex: 1 }}
3465
+ onClick={() => { changeBookTab('roles') }}
3466
+ title="角色库:全书角色主表(定位 / 关系网 / 成长线 / 知情度)"
3467
+ >
3468
+ 👥 角色库({project?.roles?.length ?? 0})
3469
+ </button>
3470
+ <button
3471
+ type="button"
3472
+ className={`${css.button} ${bookTab === 'facts' ? css.buttonPrimary : ''}`}
3473
+ style={{ fontSize: 14, flex: 1 }}
3474
+ onClick={() => { changeBookTab('facts') }}
3475
+ title="编年录与复盘记录"
3476
+ >
3477
+ 📚 编年 / 复盘
3478
+ </button>
3374
3479
  </div>
3375
3480
  </div>
3376
3481
  )}
3377
-
3378
- {activeTab === 'bible' && (
3482
+ {activeTab === 'book' && bookTab === 'bible' && (
3379
3483
  <>
3380
3484
  <div className={css.card}>
3381
3485
  <div className={css.row} style={{ justifyContent: 'space-between' }}>
@@ -3455,7 +3559,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3455
3559
  )}
3456
3560
 
3457
3561
  {/* 大世界:境界体系 / 区域 / 势力 */}
3458
- {activeTab === 'world' && (
3562
+ {activeTab === 'book' && bookTab === 'world' && (
3459
3563
  <WorldTab
3460
3564
  api={api}
3461
3565
  world={project?.world}
@@ -3467,8 +3571,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3467
3571
  )}
3468
3572
 
3469
3573
  {/* 角色库:全书角色主表(独立导航页) */}
3470
- {activeTab === 'roles' && (
3471
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
3574
+ {activeTab === 'book' && bookTab === 'roles' && (
3575
+ <div className={css.card}>
3472
3576
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3473
3577
  <span className={css.cardTitle}>👥 角色库({(project?.roles ?? []).length} 个)</span>
3474
3578
  <div className={css.row}>
@@ -3581,63 +3685,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3581
3685
  )}
3582
3686
 
3583
3687
  {/* 写作资产 5 个子分类(左侧导航直达) */}
3584
- {activeTab === 'assetsGenre' && <AssetsTab api={api} initialTab="genre" />}
3585
- {activeTab === 'assetsProgression' && <AssetsTab api={api} initialTab="progression" />}
3586
- {activeTab === 'assetsTemplates' && <AssetsTab api={api} initialTab="templates" />}
3587
- {activeTab === 'assetsRules' && <AssetsTab api={api} initialTab="rules" />}
3588
- {activeTab === 'assetsStyle' && <AssetsTab api={api} initialTab="style" />}
3589
-
3590
- {activeTab === 'foreshadow' && (
3591
- <div className={css.card}>
3592
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
3593
- <span className={css.cardTitle}>{tt('foreshadow.title')}({foreshadows.length})</span>
3594
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSuggestForeshadows() }}>
3595
- {tt('foreshadow.suggest')}
3596
- </button>
3597
- </div>
3598
- {foreshadows.length === 0 ? (
3599
- <span className={css.meta}>{tt('foreshadow.none')}</span>
3600
- ) : (
3601
- <div className={css.chapterList}>
3602
- {foreshadows.map(f => {
3603
- const statusLabel = { planned: tt('foreshadow.planned'), planted: tt('foreshadow.planted'), progressing: tt('foreshadow.progressing'), resolved: tt('foreshadow.resolved'), abandoned: tt('foreshadow.abandoned') }[f.status]
3604
- const statusColor = f.status === 'resolved' ? 'var(--nf-success)' : f.status === 'planted' || f.status === 'progressing' ? 'var(--nf-accent)' : f.status === 'abandoned' ? 'var(--nf-text-3)' : 'var(--nf-info)'
3605
- return (
3606
- <div key={f.id} className={css.chapter}>
3607
- <div className={css.chapterMain}>
3608
- <div className={css.chapterTitle}>
3609
- <span>{f.description}</span>
3610
- </div>
3611
- <div className={css.meta}>
3612
- {f.plantedChapter !== undefined && <span>{tt('foreshadow.plantedAt')} 第{f.plantedChapter}章 · </span>}
3613
- {f.targetChapter !== undefined && <span>{tt('foreshadow.target')} 第{f.targetChapter}章 · </span>}
3614
- {f.resolvedNote !== undefined && f.resolvedNote !== '' && <span>回收:{f.resolvedNote} · </span>}
3615
- </div>
3616
- </div>
3617
- <span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
3618
- <div className={css.row} style={{ gap: 4 }}>
3619
- {f.status === 'planned' && (
3620
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
3621
- void api.foreshadow({ id: f.id, status: 'planted', plantedChapter: doneCount + 1 }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
3622
- }}>
3623
- {tt('foreshadow.setPlanted')}
3624
- </button>
3625
- )}
3626
- {(f.status === 'planted' || f.status === 'progressing') && (
3627
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
3628
- void api.foreshadow({ id: f.id, status: 'resolved', resolvedNote: `第${doneCount}章回收` }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
3629
- }}>
3630
- {tt('foreshadow.setResolved')}
3631
- </button>
3632
- )}
3633
- </div>
3634
- </div>
3635
- )
3636
- })}
3637
- </div>
3638
- )}
3639
- </div>
3640
- )}
3688
+ {activeTab === 'assets' && <AssetsTab api={api} />}
3641
3689
 
3642
3690
  {activeTab === 'settings' && configDraft !== null && (
3643
3691
  <div className={css.card}>
@@ -3650,14 +3698,49 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3650
3698
  <label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
3651
3699
  <input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
3652
3700
  </div>
3653
- <div className={css.row}>
3701
+ <div className={css.row} style={{ alignItems: 'flex-start' }}>
3654
3702
  <div className={css.field} style={{ flex: 1 }}>
3655
3703
  <label className={css.fieldLabel}>{tt('settings.provider')}</label>
3656
3704
  <input className={css.input} value={configDraft.provider} onChange={e => { setConfigDraft({ ...configDraft, provider: e.target.value }) }} />
3657
3705
  </div>
3658
- <div className={css.field} style={{ flex: 1 }}>
3706
+ <div className={css.field} style={{ flex: 1.4 }}>
3659
3707
  <label className={css.fieldLabel}>{tt('settings.model')}</label>
3660
- <input className={css.input} value={configDraft.model} onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }} />
3708
+ <select
3709
+ className={css.input}
3710
+ value={modelCustomMode ? '__custom__' : configDraft.model}
3711
+ onChange={e => {
3712
+ const v = e.target.value
3713
+ if (v === '__custom__') {
3714
+ setModelCustomMode(true)
3715
+ } else {
3716
+ setModelCustomMode(false)
3717
+ setConfigDraft({ ...configDraft, model: v })
3718
+ }
3719
+ }}
3720
+ >
3721
+ {MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}
3722
+ <option value="__custom__">{tt('settings.modelCustom')}</option>
3723
+ </select>
3724
+ {modelCustomMode && (
3725
+ <input
3726
+ className={css.input}
3727
+ style={{ marginTop: 6 }}
3728
+ value={configDraft.model}
3729
+ placeholder={tt('settings.modelCustomPlaceholder')}
3730
+ onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }}
3731
+ />
3732
+ )}
3733
+ </div>
3734
+ <div className={css.field} style={{ flex: 1 }}>
3735
+ <label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label>
3736
+ <select
3737
+ className={css.input}
3738
+ value={configDraft.reasoningEffort ?? 'off'}
3739
+ onChange={e => { setConfigDraft({ ...configDraft, reasoningEffort: e.target.value as NovelConfig['reasoningEffort'] }) }}
3740
+ >
3741
+ {REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
3742
+ </select>
3743
+ <span className={css.meta}>{tt('settings.reasoningHint')}</span>
3661
3744
  </div>
3662
3745
  </div>
3663
3746
  <div className={css.row}>
@@ -3778,8 +3861,142 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3778
3861
  </div>
3779
3862
  )}
3780
3863
 
3781
- {activeTab === 'facts' && (
3864
+ {activeTab === 'book' && bookTab === 'facts' && (
3782
3865
  <div className={css.card}>
3866
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3867
+ <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📚 编年 / 复盘</span>
3868
+ </div>
3869
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
3870
+ <button
3871
+ type="button"
3872
+ className={`${css.button} ${archiveTab === 'facts' ? css.buttonPrimary : ''}`}
3873
+ style={{ fontSize: 14, flex: 1 }}
3874
+ onClick={() => { changeArchiveTab('facts') }}
3875
+ title="编年录:客观事实流水(第 N 章 · 事件),生成/审稿查证用"
3876
+ >
3877
+ 📜 编年录({(project?.facts ?? []).length})
3878
+ </button>
3879
+ <button
3880
+ type="button"
3881
+ className={`${css.button} ${archiveTab === 'reviews' ? css.buttonPrimary : ''}`}
3882
+ style={{ fontSize: 14, flex: 1 }}
3883
+ onClick={() => { changeArchiveTab('reviews') }}
3884
+ title="复盘记录:每章作者复盘(钩子 / 推进 / 衔接 / 趋势)"
3885
+ >
3886
+ 📋 复盘记录({chapters.filter(c => c.authorReview !== undefined).length} 章)
3887
+ </button>
3888
+ </div>
3889
+
3890
+ {archiveTab === 'reviews' ? (
3891
+ <>
3892
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3893
+ <span className={css.cardTitle}>📋 复盘记录({chapters.filter(c => c.authorReview !== undefined).length} 章已复盘)</span>
3894
+ <div className={css.row}>
3895
+ {(() => {
3896
+ const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
3897
+ return missing > 0 && (
3898
+ <button
3899
+ type="button"
3900
+ className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
3901
+ disabled={busy}
3902
+ onClick={() => { void handleAuthorBackfillAll() }}
3903
+ title="对历史已写章节逐章补跑作者复盘"
3904
+ >
3905
+ ↻ 补齐缺失复盘({missing})
3906
+ </button>
3907
+ )
3908
+ })()}
3909
+ </div>
3910
+ </div>
3911
+ {(() => {
3912
+ const reviewed = chapters.filter(c => c.authorReview !== undefined)
3913
+ if (reviewed.length === 0) {
3914
+ return <span className={css.meta}>尚无作者复盘——生成/审稿后自动生成,或点「补齐缺失复盘」为已写章节补跑。</span>
3915
+ }
3916
+ const honored = reviewed.filter(c => c.authorReview!.hookHonored).length
3917
+ const avg = Math.round(reviewed.reduce((s, c) => s + c.authorReview!.endingHook, 0) / reviewed.length * 10) / 10
3918
+ return (
3919
+ <span className={css.meta} style={{ fontWeight: 600 }}>
3920
+ 钩子兑现 {honored}/{reviewed.length} · 结尾钩子均分 {avg}/10
3921
+ </span>
3922
+ )
3923
+ })()}
3924
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', flex: 1, minHeight: 0 }}>
3925
+ {(() => {
3926
+ const groupsWithReviews = chapterGroups.filter(group => group.chapters.some(c => c.authorReview !== undefined))
3927
+ if (groupsWithReviews.length === 0) return null
3928
+ return (
3929
+ <div className={css.row} style={{ justifyContent: 'flex-end', gap: 6 }}>
3930
+ <button
3931
+ type="button"
3932
+ className={`${css.button} ${css.buttonSmall}`}
3933
+ onClick={() => { setExpandedVolumes(Object.fromEntries(groupsWithReviews.map(g => [g.no, true]))) }}
3934
+ >
3935
+ 全部展开
3936
+ </button>
3937
+ <button
3938
+ type="button"
3939
+ className={`${css.button} ${css.buttonSmall}`}
3940
+ onClick={() => { setExpandedVolumes({}) }}
3941
+ >
3942
+ 全部折叠
3943
+ </button>
3944
+ </div>
3945
+ )
3946
+ })()}
3947
+ {chapterGroups.map(group => {
3948
+ const groupReviewed = group.chapters.filter(c => c.authorReview !== undefined)
3949
+ if (groupReviewed.length === 0) return null
3950
+ const volumeExpanded = expandedVolumes[group.no] === true
3951
+ return (
3952
+ <div key={group.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden' }}>
3953
+ <button
3954
+ type="button"
3955
+ style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '8px 12px', fontSize: 13, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}
3956
+ onClick={() => { setExpandedVolumes(prev => ({ ...prev, [group.no]: !volumeExpanded })) }}
3957
+ >
3958
+ <span style={{ color: 'var(--nf-text-3)' }}>{volumeExpanded ? '▾' : '▸'}</span>
3959
+ <b>{group.title}</b>
3960
+ <span className={css.meta}>已复盘 {groupReviewed.length}/{group.chapters.length} 章</span>
3961
+ </button>
3962
+ {volumeExpanded && (
3963
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '4px 8px 8px', borderTop: '1px solid var(--nf-border)' }}>
3964
+ {[...groupReviewed].reverse().map(c => {
3965
+ const ar = c.authorReview!
3966
+ const expanded = expandedReviewChapter === c.no
3967
+ return (
3968
+ <div key={c.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, overflow: 'hidden' }}>
3969
+ <button
3970
+ type="button"
3971
+ style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '6px 10px', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}
3972
+ onClick={() => { setExpandedReviewChapter(expanded ? null : c.no) }}
3973
+ >
3974
+ <span style={{ color: 'var(--nf-text-3)' }}>{expanded ? '▾' : '▸'}</span>
3975
+ <b>第{c.no}章《{c.title}》</b>
3976
+ <span style={{ color: ar.hookHonored ? 'var(--nf-success)' : 'var(--nf-warn)' }}>钩子{ar.hookHonored ? '✓' : '✗'}</span>
3977
+ <span style={{ color: ar.endingHook >= 6 ? 'var(--nf-success)' : 'var(--nf-error)' }}>结尾钩子 {ar.endingHook}/10</span>
3978
+ {ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 4 }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
3979
+ </button>
3980
+ {expanded && (
3981
+ <div style={{ padding: '4px 10px 8px', fontSize: 12, display: 'flex', flexDirection: 'column', gap: 3, borderTop: '1px solid var(--nf-border)' }}>
3982
+ {ar.hookNote !== '' && <span className={css.meta}><b>钩子:</b>{ar.hookNote}</span>}
3983
+ {ar.plotlineProgress !== '' && <span className={css.meta}><b>推进:</b>{ar.plotlineProgress}</span>}
3984
+ {ar.continuity !== '' && <span className={css.meta}><b>衔接:</b>{ar.continuity}</span>}
3985
+ {ar.trend !== '' && <span className={css.meta}><b>趋势:</b>{ar.trend}</span>}
3986
+ </div>
3987
+ )}
3988
+ </div>
3989
+ )
3990
+ })}
3991
+ </div>
3992
+ )}
3993
+ </div>
3994
+ )
3995
+ })}
3996
+ </div>
3997
+ </>
3998
+ ) : (
3999
+ <>
3783
4000
  <div className={css.busyRow} style={{ flexWrap: 'wrap' }}>
3784
4001
  <span className={css.cardTitle}>{tt('facts.title', { n: (project?.facts ?? []).length })}</span>
3785
4002
  <button
@@ -3807,14 +4024,92 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3807
4024
  ) : (
3808
4025
  <span className={css.meta}>暂无事实条目——写一章后会自动生成,或点击上方「回填」。</span>
3809
4026
  )}
4027
+ </>
4028
+ )}
3810
4029
  </div>
3811
4030
  )}
3812
4031
 
3813
4032
  {activeTab === 'plotlines' && (
3814
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
4033
+ <div className={css.card}>
3815
4034
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3816
- <span className={css.cardTitle}>{tt('tab.plotlines')}({project?.plotlines?.length ?? 0})</span>
3817
- <div className={css.row} style={{ flexWrap: 'wrap' }}>
4035
+ <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📜 长线管理</span>
4036
+ </div>
4037
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
4038
+ <button
4039
+ type="button"
4040
+ className={`${css.button} ${longlineTab === 'plotlines' ? css.buttonPrimary : ''}`}
4041
+ style={{ fontSize: 14, flex: 1 }}
4042
+ onClick={() => { changeLonglineTab('plotlines') }}
4043
+ title="剧情线:故事明线(主线 / 支线 / 人物 / 悬念)"
4044
+ >
4045
+ 🧵 剧情线({project?.plotlines?.length ?? 0})
4046
+ </button>
4047
+ <button
4048
+ type="button"
4049
+ className={`${css.button} ${longlineTab === 'foreshadow' ? css.buttonPrimary : ''}`}
4050
+ style={{ fontSize: 14, flex: 1 }}
4051
+ onClick={() => { changeLonglineTab('foreshadow') }}
4052
+ title="伏笔:道具 / 事件级暗线(埋设 → 回收)"
4053
+ >
4054
+ 🔮 伏笔({foreshadows.length})
4055
+ </button>
4056
+ </div>
4057
+
4058
+ {longlineTab === 'foreshadow' ? (
4059
+ <>
4060
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4061
+ <span className={css.cardTitle}>🔮 {tt('foreshadow.title')}({foreshadows.length})</span>
4062
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSuggestForeshadows() }}>
4063
+ {tt('foreshadow.suggest')}
4064
+ </button>
4065
+ </div>
4066
+ {foreshadows.length === 0 ? (
4067
+ <span className={css.meta}>{tt('foreshadow.none')}</span>
4068
+ ) : (
4069
+ <div className={css.chapterList}>
4070
+ {foreshadows.map(f => {
4071
+ const statusLabel = { planned: tt('foreshadow.planned'), planted: tt('foreshadow.planted'), progressing: tt('foreshadow.progressing'), resolved: tt('foreshadow.resolved'), abandoned: tt('foreshadow.abandoned') }[f.status]
4072
+ const statusColor = f.status === 'resolved' ? 'var(--nf-success)' : f.status === 'planted' || f.status === 'progressing' ? 'var(--nf-accent)' : f.status === 'abandoned' ? 'var(--nf-text-3)' : 'var(--nf-info)'
4073
+ return (
4074
+ <div key={f.id} className={css.chapter}>
4075
+ <div className={css.chapterMain}>
4076
+ <div className={css.chapterTitle}>
4077
+ <span>{f.description}</span>
4078
+ </div>
4079
+ <div className={css.meta}>
4080
+ {f.plantedChapter !== undefined && <span>{tt('foreshadow.plantedAt')} 第{f.plantedChapter}章 · </span>}
4081
+ {f.targetChapter !== undefined && <span>{tt('foreshadow.target')} 第{f.targetChapter}章 · </span>}
4082
+ {f.resolvedNote !== undefined && f.resolvedNote !== '' && <span>回收:{f.resolvedNote} · </span>}
4083
+ </div>
4084
+ </div>
4085
+ <span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
4086
+ <div className={css.row} style={{ gap: 4 }}>
4087
+ {f.status === 'planned' && (
4088
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
4089
+ void api.foreshadow({ id: f.id, status: 'planted', plantedChapter: doneCount + 1 }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
4090
+ }}>
4091
+ {tt('foreshadow.setPlanted')}
4092
+ </button>
4093
+ )}
4094
+ {(f.status === 'planted' || f.status === 'progressing') && (
4095
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
4096
+ void api.foreshadow({ id: f.id, status: 'resolved', resolvedNote: `第${doneCount}章回收` }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
4097
+ }}>
4098
+ {tt('foreshadow.setResolved')}
4099
+ </button>
4100
+ )}
4101
+ </div>
4102
+ </div>
4103
+ )
4104
+ })}
4105
+ </div>
4106
+ )}
4107
+ </>
4108
+ ) : (
4109
+ <>
4110
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4111
+ <span className={css.cardTitle}>🧵 {tt('tab.plotlines')}({project?.plotlines?.length ?? 0})</span>
4112
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
3818
4113
  <button
3819
4114
  type="button"
3820
4115
  className={`${css.button} ${css.buttonSmall}`}
@@ -3943,9 +4238,15 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3943
4238
  ))}
3944
4239
  </div>
3945
4240
  )}
4241
+ </>
4242
+ )}
3946
4243
  </div>
3947
4244
  )}
3948
4245
 
4246
+ {activeTab === 'run' && (
4247
+ <RunPanel api={api} totalChapters={chapters.length} />
4248
+ )}
4249
+
3949
4250
  {activeTab === 'breakdown' && (
3950
4251
  <div className={css.card}>
3951
4252
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
@@ -3953,7 +4254,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3953
4254
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
3954
4255
  <select
3955
4256
  className={css.input}
3956
- style={{ width: 130 }}
4257
+ style={{ width: 160 }}
3957
4258
  value={breakdownScope}
3958
4259
  onChange={e => { setBreakdownScope(e.target.value as typeof breakdownScope) }}
3959
4260
  title="分析范围"
@@ -3966,13 +4267,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3966
4267
  </select>
3967
4268
  <select
3968
4269
  className={css.input}
3969
- style={{ width: 100 }}
4270
+ style={{ width: 150 }}
3970
4271
  value={breakdownPreset}
3971
4272
  onChange={e => { setBreakdownPreset(e.target.value as 'quick' | 'standard') }}
3972
4273
  title="分析档位"
3973
4274
  >
3974
4275
  <option value="quick">快速(4 维)</option>
3975
- <option value="standard">标准(+卖点)</option>
4276
+ <option value="standard">标准+卖点</option>
3976
4277
  </select>
3977
4278
  <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleBreakdown() }} title="对已写章节做结构/人物/文风/卖点体检(约 1-3 分钟,消耗 LLM 额度)">
3978
4279
  {busy ? '⏳ 分析中…' : '✨ 开始拆书'}
@@ -4016,24 +4317,26 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4016
4317
  {activeTab === 'storyboard' && (
4017
4318
  <div className={css.card}>
4018
4319
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4019
- <span className={css.cardTitle}>🎬 漫剧工坊</span>
4020
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
4320
+ <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🎬 漫剧工坊</span>
4321
+ </div>
4322
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
4021
4323
  <button
4022
4324
  type="button"
4023
- className={`${css.button} ${css.buttonSmall} ${storyboardView === 'single' ? css.buttonPrimary : ''}`}
4325
+ className={`${css.button} ${storyboardView === 'single' ? css.buttonPrimary : ''}`}
4326
+ style={{ fontSize: 14, flex: 1 }}
4024
4327
  onClick={() => { setStoryboardView('single') }}
4025
4328
  >
4026
4329
  🎞️ 单集分镜
4027
4330
  </button>
4028
4331
  <button
4029
4332
  type="button"
4030
- className={`${css.button} ${css.buttonSmall} ${storyboardView === 'plan' ? css.buttonPrimary : ''}`}
4333
+ className={`${css.button} ${storyboardView === 'plan' ? css.buttonPrimary : ''}`}
4334
+ style={{ fontSize: 14, flex: 1 }}
4031
4335
  onClick={() => { setStoryboardView('plan') }}
4032
4336
  >
4033
4337
  📺 按卷规划
4034
4338
  </button>
4035
4339
  </div>
4036
- </div>
4037
4340
 
4038
4341
  {storyboardView === 'plan' && (
4039
4342
  <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>