@waterwx/dsh-novel-forge 1.1.9 → 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.
@@ -19,6 +19,7 @@ import { WorldTab } from './WorldTab.tsx'
19
19
  import { AuditIssueRow, PlotlineCard, PlotlineHealthPanel, PlotlinePlanPanel, PlotlineSuggestionPanel, RoleCandidateRow, RoleCard, StatCell, TodoRow } from './views.tsx'
20
20
  import { extractDocxTextFromBuffer } from '../docx.ts'
21
21
  import type {
22
+ AuditStatus,
22
23
  BookshelfSnapshot,
23
24
  ChapterPlan,
24
25
  Foreshadow,
@@ -41,6 +42,7 @@ export type NovelTab =
41
42
  | 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings'
42
43
  | 'characters' | 'roles' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'breakdown' | 'storyboard'
43
44
  | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle' | 'run'
45
+ | 'book' | 'assets'
44
46
 
45
47
  /** Panel shell props. */
46
48
  export interface NovelPanelProps {
@@ -69,10 +71,10 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
69
71
  items: [
70
72
  { id: 'workflow', label: tt('tab.workflow'), icon: '🛠️' },
71
73
  { id: 'overview', label: tt('tab.overview'), icon: '📄' },
72
- { id: 'blurb', label: '卷首语', icon: '📖' },
74
+ { id: 'blurb', label: '简介 / 封面', icon: '📖' },
73
75
  { id: 'plan', label: tt('tab.plan'), icon: '📚' },
74
- { id: 'plotlines', label: tt('tab.plotlines'), icon: '🧵' },
75
- { id: 'run', label: '生产单', icon: '🏭' },
76
+ { id: 'plotlines', label: '长线管理', icon: '📜' },
77
+ { id: 'book', label: '本书设定', icon: '📚' },
76
78
  ],
77
79
  },
78
80
  {
@@ -83,23 +85,14 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
83
85
  { id: 'progress', label: '工作进度', icon: '📊' },
84
86
  { id: 'breakdown', label: '拆书分析', icon: '🔍' },
85
87
  { id: 'storyboard', label: '漫剧工坊', icon: '🎬' },
88
+ { id: 'run', label: '生产单', icon: '🏭' },
86
89
  ],
87
90
  },
88
91
  {
89
- id: 'db',
90
- label: '数据库',
92
+ id: 'assets',
93
+ label: '资产',
91
94
  items: [
92
- { id: 'bible', label: tt('tab.bible'), icon: '📖' },
93
- { id: 'world', label: '大世界', icon: '🌍' },
94
- { id: 'roles', label: '角色库', icon: '👥' },
95
- { id: 'foreshadow', label: tt('tab.foreshadow'), icon: '🔮' },
96
- { id: 'facts', label: tt('tab.facts'), icon: '📜' },
97
- { id: 'reviews', label: '复盘记录', icon: '📋' },
98
- { id: 'assetsGenre', label: '题材基底', icon: '🏷️' },
99
- { id: 'assetsProgression', label: '推进模式', icon: '📈' },
100
- { id: 'assetsTemplates', label: '笔法帖', icon: '🖋️' },
101
- { id: 'assetsRules', label: '文戒', icon: '🚫' },
102
- { id: 'assetsStyle', label: '心法', icon: '🎨' },
95
+ { id: 'assets', label: '创作资产', icon: '🧰' },
103
96
  ],
104
97
  },
105
98
  ]
@@ -107,6 +100,10 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
107
100
  /** Settings tab — pinned to the bottom of the nav rail. */
108
101
  const SETTINGS_TAB: { id: NovelTab; label: string; icon: string } = { id: 'settings', label: tt('tab.settings'), icon: '⚙️' }
109
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
+
110
107
  /** 构建时注入的插件版本(tsdown define 替换为字符串字面量)。 */
111
108
  declare const __NOVEL_FORGE_VERSION__: string | undefined
112
109
  const PLUGIN_VERSION: string = typeof __NOVEL_FORGE_VERSION__ !== 'undefined' ? __NOVEL_FORGE_VERSION__ : '0.0.0'
@@ -319,9 +316,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
319
316
  const [notice, setNotice] = useState('')
320
317
  const [progress, setProgress] = useState<ProgressLine[]>([])
321
318
  const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null)
319
+ const [modelCustomMode, setModelCustomMode] = useState(false)
322
320
  const [expandedChapter, setExpandedChapter] = useState<number | null>(null)
323
321
  /** 复盘记录页:当前展开的章节号。 */
324
322
  const [expandedReviewChapter, setExpandedReviewChapter] = useState<number | null>(null)
323
+ /** 复盘记录页:按卷折叠,卷号 → 是否展开(默认全部收起)。 */
324
+ const [expandedVolumes, setExpandedVolumes] = useState<Record<number, boolean>>({})
325
325
  const [chapterText, setChapterText] = useState('')
326
326
  const progressId = useRef(0)
327
327
  /** id of the single live progress row (generation counter), if any. */
@@ -385,6 +385,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
385
385
  const [updatingOutline, setUpdatingOutline] = useState(false)
386
386
  /** 全书质检结果(null = 未运行)。 */
387
387
  const [auditIssues, setAuditIssues] = useState<import('../../protocol.ts').AuditIssue[] | null>(null)
388
+ /** 全书质检实时状态(来自 /status,用于显示进度)。 */
389
+ const [auditStatus, setAuditStatus] = useState<AuditStatus | null>(null)
388
390
  /** 角色卡(从事实库聚合)。 */
389
391
  const [charCards, setCharCards] = useState<import('../../protocol.ts').RoleStatusCard[] | null>(null)
390
392
  /** 世界观规则编辑草稿(bible tab,每行一条)。 */
@@ -412,6 +414,39 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
412
414
  progress: string
413
415
  status: Plotline['status']
414
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
+ }
415
450
  /** 角色知情度编辑草稿(角色名 → 文本,每行一条)。 */
416
451
  const [knowledgeDraft, setKnowledgeDraft] = useState<Record<string, string>>({})
417
452
  /** 角色库:AI 提炼候选(null = 未运行;localStorage 持久化,刷新不丢)。 */
@@ -615,6 +650,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
615
650
  const status = await api.status()
616
651
  setConfig(status.config)
617
652
  setConfigDraft(status.config)
653
+ setModelCustomMode(!(MODEL_PRESETS as readonly string[]).includes(status.config.model))
654
+ setAuditStatus(status.audit ?? null)
618
655
  setProject(status.project ?? null)
619
656
  // 人物志存档同步:有 roleStatus 存档直接显示,无需重新刷新计算。
620
657
  if (status.project?.roleStatus !== undefined) {
@@ -774,18 +811,32 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
774
811
  setBusy(true)
775
812
  setBusyLabel('全书一致性质检中…')
776
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)
777
823
  try {
778
824
  const result = await api.audit()
825
+ if (stopped) return
779
826
  setAuditIssues(result.issues)
780
827
  pushProgress(result.issues.length === 0
781
828
  ? `全书质检完成:${result.auditedChapters} 章未发现矛盾 🎉`
782
829
  : `全书质检完成:发现 ${result.issues.length} 处疑似矛盾`, result.issues.length === 0 ? 'done' : 'error')
783
830
  } catch (err) {
831
+ if (stopped) return
784
832
  setError((err as Error).message)
785
833
  pushProgress(`全书质检失败:${(err as Error).message}`, 'error')
786
834
  } finally {
835
+ stopped = true
836
+ window.clearInterval(poll)
787
837
  setBusy(false)
788
838
  setBusyLabel('')
839
+ await refresh(false)
789
840
  }
790
841
  }
791
842
 
@@ -1120,7 +1171,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1120
1171
 
1121
1172
  /** 作者复盘补跑:全书缺失章节(流式)。 */
1122
1173
  const handleAuthorBackfillAll = async (): Promise<void> => {
1123
- 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
1124
1175
  setBusy(true)
1125
1176
  setBusyLabel(`补齐历史章节作者复盘(${missing} 章)…`)
1126
1177
  setError('')
@@ -1966,6 +2017,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1966
2017
  outputDir: configDraft.outputDir,
1967
2018
  provider: configDraft.provider,
1968
2019
  model: configDraft.model,
2020
+ reasoningEffort: configDraft.reasoningEffort ?? 'off',
1969
2021
  chapterChars: configDraft.chapterChars,
1970
2022
  maxTokens: configDraft.maxTokens,
1971
2023
  reviewPassScore: configDraft.reviewPassScore,
@@ -2057,6 +2109,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2057
2109
  // --------------------------------------------------- dashboard (workflow)
2058
2110
  const approvedCount = chapters.filter(c => c.status === 'approved').length
2059
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')
2060
2113
  const totalChars = chapters.reduce((sum, c) => sum + (c.chars ?? 0), 0)
2061
2114
  const firstChapter = chapters[0]
2062
2115
  const currentVolumeName = (() => {
@@ -2081,6 +2134,24 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2081
2134
  const journeyPercent = Math.round((journeyDoneCount / journeyStages.length) * 100)
2082
2135
  const currentStageId = journeyStages.find(s => !s.done)?.id
2083
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
+
2084
2155
  /** 主行动卡片:推荐下一步(AI-Novel-Writing-Assistant 首页主卡模式)。 */
2085
2156
  const nextAction = useMemo((): { eyebrow: string; title: string; reason: string; actionLabel: string; onClick: () => void } | null => {
2086
2157
  if (project === null) {
@@ -2267,48 +2338,20 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2267
2338
  />
2268
2339
  ) : (
2269
2340
  <>
2270
- <div className={css.panelHeader}>
2271
- <div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
2272
- <h2 className={css.panelTitle}>
2273
- <button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={() => { setViewMode('shelf') }}>
2274
- ←
2275
- </button>
2276
- {tt('panel.title')}
2277
- </h2>
2278
- {(project?.bookName !== '' && project?.bookName !== undefined) && (
2279
- <div className={css.panelSubtitle}>
2280
- <span>📖 {project.bookName}</span>
2281
- {chapters.length > 0 && (
2282
- <span className={css.headerProgress}>
2283
- 已完成 <b>{doneCount}</b>/{chapters.length} 章
2284
- <span className={css.headerProgressDot} />
2285
- 通过 <b>{chapters.filter(c => c.status === 'approved').length}</b>
2286
- </span>
2287
- )}
2288
- </div>
2289
- )}
2290
- </div>
2291
- <div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
2292
- <button
2293
- type="button"
2294
- className={css.iconButton}
2295
- title={navCollapsed ? '展开导航栏' : '收起导航栏'}
2296
- aria-label={navCollapsed ? '展开导航栏' : '收起导航栏'}
2297
- onClick={() => {
2298
- setNavCollapsed(prev => {
2299
- const next = !prev
2300
- try { window.localStorage.setItem('dsh-novel-forge.nav.collapsed', String(next)) } catch { /* ignore */ }
2301
- return next
2302
- })
2303
- }}
2304
- >
2305
- {navCollapsed ? '▸' : '◂'}
2306
- </button>
2307
- <button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>×</button>
2308
- </div>
2309
- </div>
2310
2341
  <div className={css.panelBody}>
2311
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>
2312
2355
  {/* 分组导航(创作 / 工具 / 数据库) */}
2313
2356
  {NAV_GROUPS.map(group => (
2314
2357
  <div key={group.id} className={css.navGroup}>
@@ -2340,7 +2383,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2340
2383
  {chapters.some(c => c.status === 'error') ? `!${pendingCount + reviewPendingCount}` : pendingCount + reviewPendingCount}
2341
2384
  </span>
2342
2385
  )}
2343
- {tab.id === 'foreshadow' && foreshadows.some(f => f.status === 'planned') && (
2386
+ {tab.id === 'plotlines' && foreshadows.some(f => f.status === 'planned') && (
2344
2387
  <span className={css.navTabBadge}>{foreshadows.filter(f => f.status === 'planned').length}</span>
2345
2388
  )}
2346
2389
  {tab.id === 'workflow' && chapters.length > 0 && (
@@ -2374,6 +2417,22 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2374
2417
  <span className={css.navTabIcon}>{SETTINGS_TAB.icon}</span>
2375
2418
  {!navCollapsed && <span className={css.navTabLabel}>{SETTINGS_TAB.label}</span>}
2376
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
+ )}
2377
2436
  {/* 关于:版本 + GitHub + 更新检测 */}
2378
2437
  <div className={css.navAbout}>
2379
2438
  <button
@@ -2400,6 +2459,33 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2400
2459
  </button>
2401
2460
  )}
2402
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>
2403
2489
  </nav>
2404
2490
  <div className={css.panelContent}>
2405
2491
  {error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
@@ -2634,29 +2720,64 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2634
2720
  <span className={css.meta} style={{ fontWeight: 600 }}>创作旅程</span>
2635
2721
  <span className={css.meta}>{journeyPercent}% · 已完成 {journeyDoneCount}/{journeyStages.length} 步</span>
2636
2722
  </div>
2637
- <div className={css.dashJourneyBar}>
2638
- <div className={css.dashJourneyFill} style={{ width: `${journeyPercent}%` }} />
2639
- </div>
2640
- <div className={css.dashJourneyStages}>
2723
+ <div className={css.tlBar}>
2641
2724
  {journeyStages.map(stage => (
2642
- <span
2725
+ <button
2643
2726
  key={stage.id}
2644
- 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) }}
2645
2731
  >
2646
- <span className={css.dashStageDot}>{stage.done ? '✓' : stage.id === currentStageId ? '●' : '○'}</span>
2647
- {stage.label}
2648
- </span>
2732
+ <span className={css.tlSegTrack}><span className={css.tlSegFill} /></span>
2733
+ <span className={css.tlSegLabel}>{stage.done ? '✓ ' : ''}{stage.label}</span>
2734
+ </button>
2649
2735
  ))}
2650
2736
  </div>
2651
2737
  </div>
2652
2738
  </div>
2653
2739
 
2654
- {/* 状态摘要条:全书进度 + 六个统计格,同样式并排一行 */}
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
+ {/* 补充统计:总字数 / 当前卷 / 最近创作(原有数据保留) */}
2655
2780
  <div className={css.assetGrid}>
2656
- <StatCell label="全书进度" value={`${doneCount}/${chapters.length} 章`} valueColor="var(--nf-accent)" detail={`${chapters.length > 0 ? Math.round((doneCount / chapters.length) * 100) : 0}%`} />
2657
- <StatCell label="已通过审稿" value={String(approvedCount)} valueColor={approvedCount > 0 ? 'var(--nf-success)' : undefined} detail="章节已 approved" />
2658
- <StatCell label="待生成" value={String(pendingCount)} valueColor={pendingCount > 0 ? 'var(--nf-warn)' : undefined} detail="pending + error" />
2659
- <StatCell label="待审稿" value={String(reviewPendingCount)} valueColor={reviewPendingCount > 0 ? 'var(--nf-info)' : undefined} detail="written + rejected" />
2660
2781
  <StatCell label="总字数" value={String(totalChars)} detail="已生成正文累计" />
2661
2782
  <StatCell label="当前卷" value={currentVolumeName} valueFontSize={13} detail="正在推进的卷" />
2662
2783
  <StatCell label="最近创作" value={lastUpdated} valueFontSize={13} detail="最近生成/编辑时间" />
@@ -2670,6 +2791,19 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2670
2791
  🔍 全书质检
2671
2792
  </button>
2672
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
+ )}
2673
2807
  <div className={css.assetGrid}>
2674
2808
  <StatCell
2675
2809
  label="道藏"
@@ -2732,7 +2866,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2732
2866
  <span className={css.cardTitle}>📋 作者复盘(最近 {Math.min(6, chapters.filter(c => c.authorReview !== undefined).length)} 章)</span>
2733
2867
  <div className={css.row}>
2734
2868
  {(() => {
2735
- 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
2736
2870
  return missing > 0 && (
2737
2871
  <button
2738
2872
  type="button"
@@ -2745,7 +2879,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2745
2879
  </button>
2746
2880
  )
2747
2881
  })()}
2748
- <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="查看按卷分组的全部复盘记录">
2749
2883
  查看全部 →
2750
2884
  </button>
2751
2885
  </div>
@@ -2918,7 +3052,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2918
3052
  {activeTab === 'blurb' && (
2919
3053
  <div className={css.card}>
2920
3054
  <div className={css.row} style={{ justifyContent: 'space-between' }}>
2921
- <span className={css.cardTitle}>卷首语</span>
3055
+ <span className={css.cardTitle}>📖 简介 / 封面</span>
2922
3056
  <div className={css.row}>
2923
3057
  <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || project === null} onClick={() => { void handleBlurbGenerate() }}>
2924
3058
  ✨ AI 生成
@@ -3183,7 +3317,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3183
3317
  )}
3184
3318
  </div>
3185
3319
  )}
3186
- {chapter.authorReview === undefined && chapter.status !== 'pending' && chapter.status !== 'generating' && (
3320
+ {chapter.authorReview === undefined && chapter.status !== 'pending' && chapter.status !== 'generating' && chapter.status !== 'error' && (
3187
3321
  <div className={css.row}>
3188
3322
  <button
3189
3323
  type="button"
@@ -3299,85 +3433,53 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3299
3433
  </>
3300
3434
  )}
3301
3435
 
3302
- {activeTab === 'reviews' && (
3303
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
3436
+ {activeTab === 'book' && (
3437
+ <div className={css.card} style={{ gap: 12 }}>
3304
3438
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3305
- <span className={css.cardTitle}>📋 复盘记录({chapters.filter(c => c.authorReview !== undefined).length} 章已复盘)</span>
3306
- <div className={css.row}>
3307
- {(() => {
3308
- const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined).length
3309
- return missing > 0 && (
3310
- <button
3311
- type="button"
3312
- className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
3313
- disabled={busy}
3314
- onClick={() => { void handleAuthorBackfillAll() }}
3315
- title="对历史已写章节逐章补跑作者复盘"
3316
- >
3317
- ↻ 补齐缺失复盘({missing})
3318
- </button>
3319
- )
3320
- })()}
3321
- </div>
3439
+ <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📚 本书设定</span>
3440
+ <span className={css.meta}>当前书的知识与资料(参考 AI-Novel-Writing-Assistant:世界观/角色准备收进书内)</span>
3322
3441
  </div>
3323
- {(() => {
3324
- const reviewed = chapters.filter(c => c.authorReview !== undefined)
3325
- if (reviewed.length === 0) {
3326
- return <span className={css.meta}>尚无作者复盘——生成/审稿后自动生成,或点「补齐缺失复盘」为已写章节补跑。</span>
3327
- }
3328
- const honored = reviewed.filter(c => c.authorReview!.hookHonored).length
3329
- const avg = Math.round(reviewed.reduce((s, c) => s + c.authorReview!.endingHook, 0) / reviewed.length * 10) / 10
3330
- return (
3331
- <span className={css.meta} style={{ fontWeight: 600 }}>
3332
- 钩子兑现 {honored}/{reviewed.length} · 结尾钩子均分 {avg}/10
3333
- </span>
3334
- )
3335
- })()}
3336
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', flex: 1, minHeight: 0 }}>
3337
- {chapterGroups.map(group => {
3338
- const groupReviewed = group.chapters.filter(c => c.authorReview !== undefined)
3339
- if (groupReviewed.length === 0) return null
3340
- return (
3341
- <div key={group.no} style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
3342
- <div className={css.row} style={{ alignItems: 'center', gap: 8 }}>
3343
- <b style={{ fontSize: 13 }}>{group.title}</b>
3344
- <span className={css.meta}>已复盘 {groupReviewed.length}/{group.chapters.length} 章</span>
3345
- </div>
3346
- {[...groupReviewed].reverse().map(c => {
3347
- const ar = c.authorReview!
3348
- const expanded = expandedReviewChapter === c.no
3349
- return (
3350
- <div key={c.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, overflow: 'hidden' }}>
3351
- <button
3352
- type="button"
3353
- style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '6px 10px', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}
3354
- onClick={() => { setExpandedReviewChapter(expanded ? null : c.no) }}
3355
- >
3356
- <span style={{ color: 'var(--nf-text-3)' }}>{expanded ? '▾' : '▸'}</span>
3357
- <b>第{c.no}章《{c.title}》</b>
3358
- <span style={{ color: ar.hookHonored ? 'var(--nf-success)' : 'var(--nf-warn)' }}>钩子{ar.hookHonored ? '✓' : '✗'}</span>
3359
- <span style={{ color: ar.endingHook >= 6 ? 'var(--nf-success)' : 'var(--nf-error)' }}>结尾钩子 {ar.endingHook}/10</span>
3360
- {ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 4 }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
3361
- </button>
3362
- {expanded && (
3363
- <div style={{ padding: '4px 10px 8px', fontSize: 12, display: 'flex', flexDirection: 'column', gap: 3, borderTop: '1px solid var(--nf-border)' }}>
3364
- {ar.hookNote !== '' && <span className={css.meta}><b>钩子:</b>{ar.hookNote}</span>}
3365
- {ar.plotlineProgress !== '' && <span className={css.meta}><b>推进:</b>{ar.plotlineProgress}</span>}
3366
- {ar.continuity !== '' && <span className={css.meta}><b>衔接:</b>{ar.continuity}</span>}
3367
- {ar.trend !== '' && <span className={css.meta}><b>趋势:</b>{ar.trend}</span>}
3368
- </div>
3369
- )}
3370
- </div>
3371
- )
3372
- })}
3373
- </div>
3374
- )
3375
- })}
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>
3376
3479
  </div>
3377
3480
  </div>
3378
3481
  )}
3379
-
3380
- {activeTab === 'bible' && (
3482
+ {activeTab === 'book' && bookTab === 'bible' && (
3381
3483
  <>
3382
3484
  <div className={css.card}>
3383
3485
  <div className={css.row} style={{ justifyContent: 'space-between' }}>
@@ -3457,7 +3559,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3457
3559
  )}
3458
3560
 
3459
3561
  {/* 大世界:境界体系 / 区域 / 势力 */}
3460
- {activeTab === 'world' && (
3562
+ {activeTab === 'book' && bookTab === 'world' && (
3461
3563
  <WorldTab
3462
3564
  api={api}
3463
3565
  world={project?.world}
@@ -3469,8 +3571,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3469
3571
  )}
3470
3572
 
3471
3573
  {/* 角色库:全书角色主表(独立导航页) */}
3472
- {activeTab === 'roles' && (
3473
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
3574
+ {activeTab === 'book' && bookTab === 'roles' && (
3575
+ <div className={css.card}>
3474
3576
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3475
3577
  <span className={css.cardTitle}>👥 角色库({(project?.roles ?? []).length} 个)</span>
3476
3578
  <div className={css.row}>
@@ -3583,63 +3685,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3583
3685
  )}
3584
3686
 
3585
3687
  {/* 写作资产 5 个子分类(左侧导航直达) */}
3586
- {activeTab === 'assetsGenre' && <AssetsTab api={api} initialTab="genre" />}
3587
- {activeTab === 'assetsProgression' && <AssetsTab api={api} initialTab="progression" />}
3588
- {activeTab === 'assetsTemplates' && <AssetsTab api={api} initialTab="templates" />}
3589
- {activeTab === 'assetsRules' && <AssetsTab api={api} initialTab="rules" />}
3590
- {activeTab === 'assetsStyle' && <AssetsTab api={api} initialTab="style" />}
3591
-
3592
- {activeTab === 'foreshadow' && (
3593
- <div className={css.card}>
3594
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
3595
- <span className={css.cardTitle}>{tt('foreshadow.title')}({foreshadows.length})</span>
3596
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSuggestForeshadows() }}>
3597
- {tt('foreshadow.suggest')}
3598
- </button>
3599
- </div>
3600
- {foreshadows.length === 0 ? (
3601
- <span className={css.meta}>{tt('foreshadow.none')}</span>
3602
- ) : (
3603
- <div className={css.chapterList}>
3604
- {foreshadows.map(f => {
3605
- const statusLabel = { planned: tt('foreshadow.planned'), planted: tt('foreshadow.planted'), progressing: tt('foreshadow.progressing'), resolved: tt('foreshadow.resolved'), abandoned: tt('foreshadow.abandoned') }[f.status]
3606
- 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)'
3607
- return (
3608
- <div key={f.id} className={css.chapter}>
3609
- <div className={css.chapterMain}>
3610
- <div className={css.chapterTitle}>
3611
- <span>{f.description}</span>
3612
- </div>
3613
- <div className={css.meta}>
3614
- {f.plantedChapter !== undefined && <span>{tt('foreshadow.plantedAt')} 第{f.plantedChapter}章 · </span>}
3615
- {f.targetChapter !== undefined && <span>{tt('foreshadow.target')} 第{f.targetChapter}章 · </span>}
3616
- {f.resolvedNote !== undefined && f.resolvedNote !== '' && <span>回收:{f.resolvedNote} · </span>}
3617
- </div>
3618
- </div>
3619
- <span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
3620
- <div className={css.row} style={{ gap: 4 }}>
3621
- {f.status === 'planned' && (
3622
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
3623
- void api.foreshadow({ id: f.id, status: 'planted', plantedChapter: doneCount + 1 }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
3624
- }}>
3625
- {tt('foreshadow.setPlanted')}
3626
- </button>
3627
- )}
3628
- {(f.status === 'planted' || f.status === 'progressing') && (
3629
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
3630
- void api.foreshadow({ id: f.id, status: 'resolved', resolvedNote: `第${doneCount}章回收` }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
3631
- }}>
3632
- {tt('foreshadow.setResolved')}
3633
- </button>
3634
- )}
3635
- </div>
3636
- </div>
3637
- )
3638
- })}
3639
- </div>
3640
- )}
3641
- </div>
3642
- )}
3688
+ {activeTab === 'assets' && <AssetsTab api={api} />}
3643
3689
 
3644
3690
  {activeTab === 'settings' && configDraft !== null && (
3645
3691
  <div className={css.card}>
@@ -3652,14 +3698,49 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3652
3698
  <label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
3653
3699
  <input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
3654
3700
  </div>
3655
- <div className={css.row}>
3701
+ <div className={css.row} style={{ alignItems: 'flex-start' }}>
3656
3702
  <div className={css.field} style={{ flex: 1 }}>
3657
3703
  <label className={css.fieldLabel}>{tt('settings.provider')}</label>
3658
3704
  <input className={css.input} value={configDraft.provider} onChange={e => { setConfigDraft({ ...configDraft, provider: e.target.value }) }} />
3659
3705
  </div>
3660
- <div className={css.field} style={{ flex: 1 }}>
3706
+ <div className={css.field} style={{ flex: 1.4 }}>
3661
3707
  <label className={css.fieldLabel}>{tt('settings.model')}</label>
3662
- <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>
3663
3744
  </div>
3664
3745
  </div>
3665
3746
  <div className={css.row}>
@@ -3780,8 +3861,142 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3780
3861
  </div>
3781
3862
  )}
3782
3863
 
3783
- {activeTab === 'facts' && (
3864
+ {activeTab === 'book' && bookTab === 'facts' && (
3784
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
+ <>
3785
4000
  <div className={css.busyRow} style={{ flexWrap: 'wrap' }}>
3786
4001
  <span className={css.cardTitle}>{tt('facts.title', { n: (project?.facts ?? []).length })}</span>
3787
4002
  <button
@@ -3809,14 +4024,92 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3809
4024
  ) : (
3810
4025
  <span className={css.meta}>暂无事实条目——写一章后会自动生成,或点击上方「回填」。</span>
3811
4026
  )}
4027
+ </>
4028
+ )}
3812
4029
  </div>
3813
4030
  )}
3814
4031
 
3815
4032
  {activeTab === 'plotlines' && (
3816
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
4033
+ <div className={css.card}>
3817
4034
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3818
- <span className={css.cardTitle}>{tt('tab.plotlines')}({project?.plotlines?.length ?? 0})</span>
3819
- <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' }}>
3820
4113
  <button
3821
4114
  type="button"
3822
4115
  className={`${css.button} ${css.buttonSmall}`}
@@ -3945,6 +4238,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3945
4238
  ))}
3946
4239
  </div>
3947
4240
  )}
4241
+ </>
4242
+ )}
3948
4243
  </div>
3949
4244
  )}
3950
4245
 
@@ -3959,7 +4254,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3959
4254
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
3960
4255
  <select
3961
4256
  className={css.input}
3962
- style={{ width: 130 }}
4257
+ style={{ width: 160 }}
3963
4258
  value={breakdownScope}
3964
4259
  onChange={e => { setBreakdownScope(e.target.value as typeof breakdownScope) }}
3965
4260
  title="分析范围"
@@ -3972,13 +4267,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3972
4267
  </select>
3973
4268
  <select
3974
4269
  className={css.input}
3975
- style={{ width: 100 }}
4270
+ style={{ width: 150 }}
3976
4271
  value={breakdownPreset}
3977
4272
  onChange={e => { setBreakdownPreset(e.target.value as 'quick' | 'standard') }}
3978
4273
  title="分析档位"
3979
4274
  >
3980
4275
  <option value="quick">快速(4 维)</option>
3981
- <option value="standard">标准(+卖点)</option>
4276
+ <option value="standard">标准+卖点</option>
3982
4277
  </select>
3983
4278
  <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleBreakdown() }} title="对已写章节做结构/人物/文风/卖点体检(约 1-3 分钟,消耗 LLM 额度)">
3984
4279
  {busy ? '⏳ 分析中…' : '✨ 开始拆书'}
@@ -4022,24 +4317,26 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4022
4317
  {activeTab === 'storyboard' && (
4023
4318
  <div className={css.card}>
4024
4319
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4025
- <span className={css.cardTitle}>🎬 漫剧工坊</span>
4026
- <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 }}>
4027
4323
  <button
4028
4324
  type="button"
4029
- className={`${css.button} ${css.buttonSmall} ${storyboardView === 'single' ? css.buttonPrimary : ''}`}
4325
+ className={`${css.button} ${storyboardView === 'single' ? css.buttonPrimary : ''}`}
4326
+ style={{ fontSize: 14, flex: 1 }}
4030
4327
  onClick={() => { setStoryboardView('single') }}
4031
4328
  >
4032
4329
  🎞️ 单集分镜
4033
4330
  </button>
4034
4331
  <button
4035
4332
  type="button"
4036
- className={`${css.button} ${css.buttonSmall} ${storyboardView === 'plan' ? css.buttonPrimary : ''}`}
4333
+ className={`${css.button} ${storyboardView === 'plan' ? css.buttonPrimary : ''}`}
4334
+ style={{ fontSize: 14, flex: 1 }}
4037
4335
  onClick={() => { setStoryboardView('plan') }}
4038
4336
  >
4039
4337
  📺 按卷规划
4040
4338
  </button>
4041
4339
  </div>
4042
- </div>
4043
4340
 
4044
4341
  {storyboardView === 'plan' && (
4045
4342
  <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>