@waterwx/dsh-novel-forge 1.0.0 → 1.1.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.
@@ -11,7 +11,9 @@ import type { PanelController } from './controller.ts'
11
11
  import { tt } from './helpers.ts'
12
12
  import { AssistantTab } from './AssistantTab.tsx'
13
13
  import { AssetsTab } from './AssetsTab.tsx'
14
- import { BookshelfBar } from './BookshelfBar.tsx'
14
+ import { ShelfView } from './ShelfView.tsx'
15
+ import { CreateBookView } from './CreateBookView.tsx'
16
+ import { WorldTab } from './WorldTab.tsx'
15
17
  import { extractDocxTextFromBuffer } from '../docx.ts'
16
18
  import type {
17
19
  BookshelfSnapshot,
@@ -28,7 +30,7 @@ import css from './panel.module.css'
28
30
 
29
31
  /** The panel's tab identifiers. */
30
32
  export type NovelTab =
31
- | 'workflow' | 'overview' | 'plan' | 'bible' | 'foreshadow' | 'assistant' | 'settings'
33
+ | 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings'
32
34
  | 'characters'
33
35
  | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle'
34
36
 
@@ -58,8 +60,9 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
58
60
  label: '创作',
59
61
  items: [
60
62
  { id: 'workflow', label: tt('tab.workflow'), icon: '🛠️' },
61
- { id: 'plan', label: tt('tab.plan'), icon: '📚' },
62
63
  { id: 'overview', label: tt('tab.overview'), icon: '📄' },
64
+ { id: 'blurb', label: '卷首语', icon: '📖' },
65
+ { id: 'plan', label: tt('tab.plan'), icon: '📚' },
63
66
  ],
64
67
  },
65
68
  {
@@ -74,13 +77,14 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
74
77
  label: '数据库',
75
78
  items: [
76
79
  { id: 'bible', label: tt('tab.bible'), icon: '📖' },
77
- { id: 'characters', label: '角色卡', icon: '👥' },
80
+ { id: 'world', label: '大世界', icon: '🌍' },
81
+ { id: 'characters', label: '人物志', icon: '👥' },
78
82
  { id: 'foreshadow', label: tt('tab.foreshadow'), icon: '🔮' },
79
83
  { id: 'assetsGenre', label: '题材基底', icon: '🏷️' },
80
84
  { id: 'assetsProgression', label: '推进模式', icon: '📈' },
81
- { id: 'assetsTemplates', label: '预置写法', icon: '🖋️' },
82
- { id: 'assetsRules', label: '反 AI 规则', icon: '🚫' },
83
- { id: 'assetsStyle', label: '自定义写法', icon: '🎨' },
85
+ { id: 'assetsTemplates', label: '笔法帖', icon: '🖋️' },
86
+ { id: 'assetsRules', label: '文戒', icon: '🚫' },
87
+ { id: 'assetsStyle', label: '心法', icon: '🎨' },
84
88
  ],
85
89
  },
86
90
  ]
@@ -321,6 +325,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
321
325
  const [wsShowDiff, setWsShowDiff] = useState(false)
322
326
  /** 工作区原文 textarea 引用(用于捕获选中文字)。 */
323
327
  const wsEditorRef = useRef<HTMLTextAreaElement | null>(null)
328
+ /** 工作区:手动编辑后的 AI 审查结果(不落盘)。 */
329
+ const [wsCheckReport, setWsCheckReport] = useState<ReviewReport | null>(null)
324
330
  /** 有未采纳草稿的章节号(refresh 后检测到遗留草稿时提示)。 */
325
331
  const [draftNo, setDraftNo] = useState<number | null>(null)
326
332
  /** 大纲页「更新大纲」编辑区是否展开。 */
@@ -331,6 +337,16 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
331
337
  const [charCards, setCharCards] = useState<import('../../protocol.ts').RoleStatusCard[] | null>(null)
332
338
  /** 世界观规则编辑草稿(bible tab,每行一条)。 */
333
339
  const [worldRulesDraft, setWorldRulesDraft] = useState('')
340
+ /** 小说简介编辑草稿。 */
341
+ const [blurbDraft, setBlurbDraft] = useState('')
342
+ /** 书名编辑草稿(简介页改名用)。 */
343
+ const [bookNameDraft, setBookNameDraft] = useState('')
344
+ /** 封面 dataUrl(无封面为 null)。 */
345
+ const [coverDataUrl, setCoverDataUrl] = useState<string | null>(null)
346
+ /** 封面文件选择。 */
347
+ const coverFileRef = useRef<HTMLInputElement | null>(null)
348
+ /** 章节列表按卷折叠(存已折叠的卷号)。 */
349
+ const [collapsedVolumes, setCollapsedVolumes] = useState<number[]>([])
334
350
  /** AI 助手悬浮窗:是否打开。 */
335
351
  const [assistantOpen, setAssistantOpen] = useState(false)
336
352
  /** 悬浮窗位置(相对面板,localStorage 记忆)。 */
@@ -395,6 +411,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
395
411
  const [navCollapsed, setNavCollapsed] = useState(() => {
396
412
  try { return window.localStorage.getItem('dsh-novel-forge.nav.collapsed') === 'true' } catch { return false }
397
413
  })
414
+ /** 视图:shelf = 书架首页;create = 开书向导;workspace = 当前书工作台。 */
415
+ const [viewMode, setViewMode] = useState<'shelf' | 'create' | 'workspace'>('shelf')
398
416
 
399
417
  /** Refresh bookshelf. */
400
418
  const refreshShelf = useCallback(async () => {
@@ -430,7 +448,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
430
448
  }, [])
431
449
 
432
450
  /** Refresh status (config + project + files). */
433
- const refresh = useCallback(async (showError = true) => {
451
+ const refresh = useCallback(async (showError = true, forceOutline = false) => {
434
452
  try {
435
453
  const status = await api.status()
436
454
  setConfig(status.config)
@@ -440,8 +458,11 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
440
458
  const withDraft = status.project?.chapters.find(c => c.pendingDraft !== undefined && c.pendingDraft !== '')
441
459
  setDraftNo(withDraft?.no ?? null)
442
460
  const nextOutline = status.project?.outline
443
- if (nextOutline !== undefined && outlineText === '') {
444
- setOutlineText(nextOutline)
461
+ // forceOutline:切换书/开书后强制同步大纲(refresh 闭包里的
462
+ // outlineText 可能是旧值,导致 `=== ''` 条件失效、大纲不同步、
463
+ // 「生成章节计划」按钮被禁用)。
464
+ if (forceOutline || (nextOutline !== undefined && outlineText === '')) {
465
+ setOutlineText(nextOutline ?? '')
445
466
  }
446
467
  } catch (err) {
447
468
  if (showError) setError((err as Error).message)
@@ -478,6 +499,20 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
478
499
  // eslint-disable-next-line react-hooks/exhaustive-deps
479
500
  }, [])
480
501
 
502
+ /** 进入简介页时预填已保存的简介。 */
503
+ useEffect(() => {
504
+ if (activeTab === 'blurb') {
505
+ if (blurbDraft === '' && project?.blurb !== undefined && project.blurb !== '') {
506
+ setBlurbDraft(project.blurb)
507
+ }
508
+ if (bookNameDraft === '' && project?.bookName !== undefined && project.bookName !== '') {
509
+ setBookNameDraft(project.bookName)
510
+ }
511
+ void loadCover()
512
+ }
513
+ // eslint-disable-next-line react-hooks/exhaustive-deps
514
+ }, [activeTab, project?.blurb, project?.bookName])
515
+
481
516
  /** Load the outline from docx (default path or custom). */
482
517
  const handleLoadDocx = async (useCustom: boolean): Promise<void> => {
483
518
  setBusy(true)
@@ -524,7 +559,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
524
559
  /** 重置项目:清空全部进度,从新大纲重新开始(二次确认)。 */
525
560
  const handleResetProject = async (): Promise<void> => {
526
561
  if (project === null) return
527
- if (!window.confirm('将清空本书全部进度(设定圣经/卷计划/章节计划/已生成章节/伏笔/写作资产/事实库),且不可恢复。确定用新大纲重置?')) return
562
+ if (!window.confirm('将清空本书全部进度(道藏/卷计划/章节计划/已生成章节/暗线/写作资产/编年录),且不可恢复。确定用新总纲重置?')) return
528
563
  setBusy(true)
529
564
  setError('')
530
565
  try {
@@ -580,8 +615,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
580
615
  }
581
616
 
582
617
  /** 事实库回填(历史章节批量抽取)。 */
583
- const handleFactsBackfill = async (): Promise<void> => {
584
- if (doneCount === 0) return
618
+ const handleFactsBackfill = async (): Promise<void> => { if (doneCount === 0) return
585
619
  setBusy(true)
586
620
  setBusyLabel('回填历史章节事实中…')
587
621
  setError('')
@@ -617,6 +651,186 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
617
651
  }
618
652
  }
619
653
 
654
+ /** 简介:AI 全量生成。 */
655
+ const handleBlurbGenerate = async (): Promise<void> => {
656
+ if (project === null) return
657
+ setBusy(true)
658
+ setBusyLabel('AI 生成简介中…')
659
+ setError('')
660
+ try {
661
+ const result = await api.blurb('generate')
662
+ setBlurbDraft(result.blurb)
663
+ pushProgress(`简介已生成(${result.blurb.length} 字)`, 'done')
664
+ await refresh(false)
665
+ } catch (err) {
666
+ setError((err as Error).message)
667
+ pushProgress(`简介生成失败:${(err as Error).message}`, 'error')
668
+ } finally {
669
+ setBusy(false)
670
+ setBusyLabel('')
671
+ }
672
+ }
673
+
674
+ /** 简介:按已写开头 AI 补全。 */
675
+ const handleBlurbComplete = async (): Promise<void> => {
676
+ if (project === null) return
677
+ if (blurbDraft.trim() === '') return
678
+ setBusy(true)
679
+ setBusyLabel('AI 补全简介中…')
680
+ setError('')
681
+ try {
682
+ const result = await api.blurb('generate', undefined, blurbDraft)
683
+ setBlurbDraft(result.blurb)
684
+ pushProgress(`简介已补全(${result.blurb.length} 字)`, 'done')
685
+ await refresh(false)
686
+ } catch (err) {
687
+ setError((err as Error).message)
688
+ pushProgress(`简介补全失败:${(err as Error).message}`, 'error')
689
+ } finally {
690
+ setBusy(false)
691
+ setBusyLabel('')
692
+ }
693
+ }
694
+
695
+ /** 简介:手动保存。 */
696
+ const handleBlurbSave = async (): Promise<void> => {
697
+ if (project === null) return
698
+ setBusy(true)
699
+ setError('')
700
+ try {
701
+ const result = await api.blurb('save', blurbDraft)
702
+ pushProgress(`简介已保存(${result.blurb.length} 字)`, 'done')
703
+ await refresh(false)
704
+ } catch (err) {
705
+ setError((err as Error).message)
706
+ } finally {
707
+ setBusy(false)
708
+ }
709
+ }
710
+
711
+ /** 加载封面(进入简介页时)。 */
712
+ const loadCover = useCallback(async (): Promise<void> => {
713
+ try {
714
+ const result = await api.coverGet()
715
+ setCoverDataUrl(result.dataUrl)
716
+ } catch { /* best-effort */ }
717
+ }, [api])
718
+
719
+ /** 封面上传(本地预览 + 落盘)。 */
720
+ const handleCoverUpload = (file: File | undefined): void => {
721
+ if (file === undefined) return
722
+ const reader = new FileReader()
723
+ reader.onload = () => {
724
+ const dataUrl = typeof reader.result === 'string' ? reader.result : null
725
+ if (dataUrl === null) return
726
+ setCoverDataUrl(dataUrl)
727
+ void (async () => {
728
+ setBusy(true)
729
+ setError('')
730
+ try {
731
+ await api.coverPost('upload', dataUrl)
732
+ pushProgress(`封面已上传:${file.name}`, 'done')
733
+ await refresh(false)
734
+ } catch (err) {
735
+ setError((err as Error).message)
736
+ pushProgress(`封面上传失败:${(err as Error).message}`, 'error')
737
+ await loadCover()
738
+ } finally {
739
+ setBusy(false)
740
+ }
741
+ })()
742
+ }
743
+ reader.readAsDataURL(file)
744
+ }
745
+
746
+ /** 工作区:AI 审查当前编辑的正文(不落盘)。 */
747
+ const handleWsCheck = async (): Promise<void> => {
748
+ if (workspace === null) return
749
+ if (workspace.original.trim().length < 50) {
750
+ setError('正文过短(<50 字),请先编辑内容')
751
+ return
752
+ }
753
+ setBusy(true)
754
+ setBusyLabel(`AI 审查 第${workspace.no}章`)
755
+ setError('')
756
+ try {
757
+ const result = await api.chapterCheck(workspace.no, workspace.original)
758
+ setWsCheckReport(result.report)
759
+ pushProgress(`审查完成:${result.report.score} 分 — ${result.report.verdict}`, result.report.passed ? 'done' : 'error')
760
+ } catch (err) {
761
+ setError((err as Error).message)
762
+ pushProgress(`AI 审查失败:${(err as Error).message}`, 'error')
763
+ } finally {
764
+ setBusy(false)
765
+ setBusyLabel('')
766
+ }
767
+ }
768
+
769
+ /** 工作区:保存手动编辑的正文(备份 .bak;沿用审查报告或自动审稿,保存即出结论)。 */
770
+ const handleWsSave = async (): Promise<void> => {
771
+ if (workspace === null) return
772
+ if (workspace.original.trim().length < 50) {
773
+ setError('正文过短(<50 字),未保存')
774
+ return
775
+ }
776
+ setBusy(true)
777
+ setError('')
778
+ try {
779
+ const result = await api.chapterSave(workspace.no, workspace.original, wsCheckReport ?? undefined)
780
+ const report = result.report
781
+ setWorkspace(null)
782
+ setDraftNo(null)
783
+ setWsCheckReport(null)
784
+ if (report !== undefined) {
785
+ pushProgress(`已保存并审稿:${report.score} 分 — ${report.verdict}(${report.passed ? '通过' : '未通过'})`, report.passed ? 'done' : 'error')
786
+ } else {
787
+ pushProgress(`已保存第 ${workspace.no} 章编辑(${result.chars} 字,原稿已备份 .bak)`, 'done')
788
+ }
789
+ } catch (err) {
790
+ setError((err as Error).message)
791
+ pushProgress(`保存失败:${(err as Error).message}`, 'error')
792
+ } finally {
793
+ setBusy(false)
794
+ setBusyLabel('')
795
+ await refresh(false)
796
+ }
797
+ }
798
+
799
+ /** 移除封面。 */
800
+ const handleCoverRemove = async (): Promise<void> => {
801
+ setBusy(true)
802
+ setError('')
803
+ try {
804
+ await api.coverPost('remove')
805
+ setCoverDataUrl(null)
806
+ pushProgress('封面已移除', 'info')
807
+ await refresh(false)
808
+ } catch (err) {
809
+ setError((err as Error).message)
810
+ } finally {
811
+ setBusy(false)
812
+ }
813
+ }
814
+
815
+ /** 重命名当前书(同步项目与书架)。 */
816
+ const handleRename = async (): Promise<void> => {
817
+ const name = bookNameDraft.trim()
818
+ if (name === '' || project === null || name === project.bookName) return
819
+ setBusy(true)
820
+ setError('')
821
+ try {
822
+ const result = await api.rename(name)
823
+ pushProgress(`书名已改为《${result.bookName}》`, 'done')
824
+ await refresh(false)
825
+ await refreshShelf()
826
+ } catch (err) {
827
+ setError((err as Error).message)
828
+ pushProgress(`改名失败:${(err as Error).message}`, 'error')
829
+ } finally {
830
+ setBusy(false)
831
+ }
832
+ }
833
+
620
834
  /** Extract the story bible. */
621
835
  const handleBible = async (): Promise<void> => {
622
836
  setBusy(true)
@@ -633,7 +847,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
633
847
  }), 'done')
634
848
  } catch (err) {
635
849
  setError((err as Error).message)
636
- pushProgress(`提炼设定圣经失败:${(err as Error).message}`, 'error')
850
+ pushProgress(`提炼道藏失败:${(err as Error).message}`, 'error')
637
851
  } finally {
638
852
  setBusy(false)
639
853
  setBusyLabel('')
@@ -683,16 +897,24 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
683
897
  }
684
898
 
685
899
  /** 打开某章的工作区(读取服务器原文;有遗留草稿时预载草稿;可预填修订指令)。 */
686
- const openWorkspace = useCallback(async (no: number, instruction = ''): Promise<void> => {
900
+ const openWorkspace = useCallback(async (no: number, instruction?: string): Promise<void> => {
687
901
  try {
688
902
  const [chapterRes, statusRes] = await Promise.all([api.chapter(no), api.status()])
689
903
  const chapter = statusRes.project?.chapters.find(c => c.no === no)
690
904
  if (chapter === undefined) return
905
+ // 未显式给指令时:若本章审稿未通过,自动预填「按审稿意见修订」,
906
+ // 把高优先级问题带进修订指令(作者点「修订」即可直接改)。
907
+ let autoInstruction = instruction ?? ''
908
+ if (autoInstruction === '' && chapter.review !== undefined && !chapter.review.passed) {
909
+ const high = chapter.review.issues.filter(i => i.severity === 'high')
910
+ const picked = high.length > 0 ? high.slice(0, 3) : chapter.review.issues.slice(0, 3)
911
+ autoInstruction = '按审稿意见修订(优先处理):\n' + picked.map(i => `[${i.severity}] ${i.item} → ${i.suggestion}`).join('\n')
912
+ }
691
913
  setWorkspace({
692
914
  no,
693
915
  title: chapter.title,
694
916
  original: chapterRes.markdown,
695
- instruction,
917
+ instruction: autoInstruction,
696
918
  draft: chapter.pendingDraft !== undefined && chapter.pendingDraft !== '' ? chapter.pendingDraft : null,
697
919
  })
698
920
  setWsSelected('')
@@ -1026,6 +1248,21 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1026
1248
  const volumes: Volume[] | undefined = project?.volumes
1027
1249
  const foreshadows: Foreshadow[] = project?.foreshadows ?? []
1028
1250
 
1251
+ /** 章节按卷分组(未分卷章节单独一组;章节多时可按卷折叠浏览)。 */
1252
+ const chapterGroups = useMemo(() => {
1253
+ const groups: Array<{ no: number; title: string; chapters: ChapterPlan[] }> = []
1254
+ if (volumes !== undefined) {
1255
+ for (const v of volumes) {
1256
+ const list = chapters.filter(c => c.volume === v.no)
1257
+ if (list.length > 0) groups.push({ no: v.no, title: `第${v.no}卷 · ${v.title}`, chapters: list })
1258
+ }
1259
+ }
1260
+ const unassigned = chapters.filter(c => c.volume === 0)
1261
+ if (unassigned.length > 0) groups.push({ no: 0, title: '未分卷', chapters: unassigned })
1262
+ if (groups.length === 0) groups.push({ no: 0, title: '全部章节', chapters })
1263
+ return groups
1264
+ }, [chapters, volumes])
1265
+
1029
1266
  // --------------------------------------------------- dashboard (workflow)
1030
1267
  const approvedCount = chapters.filter(c => c.status === 'approved').length
1031
1268
  const reviewPendingCount = chapters.filter(c => c.status === 'written' || c.status === 'rejected').length
@@ -1067,9 +1304,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1067
1304
  if (bible === undefined) {
1068
1305
  return {
1069
1306
  eyebrow: '推荐下一步',
1070
- title: '提炼设定圣经',
1307
+ title: '提炼道藏',
1071
1308
  reason: '人设、世界观、金手指规则、写作红线是后续所有生成的地基,越完整质量越高。',
1072
- actionLabel: '生成设定圣经',
1309
+ actionLabel: '生成道藏',
1073
1310
  onClick: () => { void handleBible() },
1074
1311
  }
1075
1312
  }
@@ -1189,8 +1426,68 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1189
1426
 
1190
1427
  return (
1191
1428
  <div className={css.panel}>
1429
+ {viewMode === 'shelf' ? (
1430
+ /* 书架首页:默认视图,选择一本书进入工作台 */
1431
+ <ShelfView
1432
+ api={api}
1433
+ shelf={shelf ?? { books: [], activeBookId: null }}
1434
+ onOpenBook={async (id) => {
1435
+ setBusy(true)
1436
+ try {
1437
+ await api.bookActivate(id)
1438
+ // 切换书后重置本地编辑状态,重新拉取目标书。
1439
+ setOutlineText('')
1440
+ setProject(null)
1441
+ setGeneratedFiles([])
1442
+ setChapterText('')
1443
+ setExpandedChapter(null)
1444
+ setProgress([])
1445
+ setAuditIssues(null)
1446
+ setCharCards(null)
1447
+ await refresh(false, true)
1448
+ await refreshShelf()
1449
+ setViewMode('workspace')
1450
+ } catch (err) {
1451
+ setError((err as Error).message)
1452
+ } finally {
1453
+ setBusy(false)
1454
+ }
1455
+ }}
1456
+ onAddBook={() => { setViewMode('create') }}
1457
+ />
1458
+ ) : viewMode === 'create' ? (
1459
+ /* 开书向导:独立页面 */
1460
+ <CreateBookView
1461
+ api={api}
1462
+ onBack={() => { setViewMode('shelf') }}
1463
+ onCreated={async (id) => {
1464
+ setBusy(true)
1465
+ try {
1466
+ setOutlineText('')
1467
+ setProject(null)
1468
+ setGeneratedFiles([])
1469
+ setChapterText('')
1470
+ setExpandedChapter(null)
1471
+ setProgress([])
1472
+ setAuditIssues(null)
1473
+ setCharCards(null)
1474
+ await refresh(false, true)
1475
+ await refreshShelf()
1476
+ setViewMode('workspace')
1477
+ } catch (err) {
1478
+ setError((err as Error).message)
1479
+ } finally {
1480
+ setBusy(false)
1481
+ }
1482
+ }}
1483
+ />
1484
+ ) : (
1485
+ <>
1192
1486
  <div className={css.panelHeader}>
1193
1487
  <h2 className={css.panelTitle}>
1488
+ <button type="button" className={css.iconButton} title="返回书架" aria-label="返回书架" onClick={() => { setViewMode('shelf') }}>
1489
+ ←
1490
+ </button>
1194
1491
  {tt('panel.title')}
1195
1492
  {project?.bookName !== '' && project?.bookName !== undefined && (
1196
1493
  <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)', fontSize: 11 }}>{project.bookName}</span>
@@ -1222,23 +1519,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1222
1519
  <button type="button" className={css.iconButton} title={tt('common.close')} aria-label={tt('common.close')} onClick={() => { controller.close() }}>×</button>
1223
1520
  </div>
1224
1521
  </div>
1225
- {shelf !== null && (
1226
- <BookshelfBar
1227
- api={api}
1228
- shelf={shelf}
1229
- onSwitch={() => {
1230
- void refreshShelf()
1231
- // 切换书后重置本地编辑状态,重新拉取目标书。
1232
- setOutlineText('')
1233
- setProject(null)
1234
- setGeneratedFiles([])
1235
- setChapterText('')
1236
- setExpandedChapter(null)
1237
- setProgress([])
1238
- void refresh(false)
1239
- }}
1240
- />
1241
- )}
1242
1522
  <div className={css.panelBody}>
1243
1523
  <nav className={`${css.panelNav} ${navCollapsed ? css.panelNavCollapsed : ''}`} role="tablist" aria-label="工作台导航">
1244
1524
  {/* 分组导航(创作 / 工具 / 数据库) */}
@@ -1371,6 +1651,11 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1371
1651
  <div className={css.meta}>未选中内容时仅支持整章润色/修订。</div>
1372
1652
  )}
1373
1653
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
1654
+ {workspace.instruction.startsWith('按审稿意见修订') && (
1655
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleWsRewrite(true) }} title="AI 按已预填的审稿意见自动修订整章(无需自己找问题)">
1656
+ 🔧 按意见修订
1657
+ </button>
1658
+ )}
1374
1659
  <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleWsPolish() }}>
1375
1660
  ✨ 去AI味润色
1376
1661
  </button>
@@ -1380,7 +1665,36 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1380
1665
  <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || wsSelected === ''} onClick={() => { void handleWsRewrite(false) }}>
1381
1666
  修订选中
1382
1667
  </button>
1668
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || workspace.original.trim().length < 50} onClick={() => { void handleWsCheck() }}>
1669
+ 🔍 AI 审查
1670
+ </button>
1671
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || workspace.original.trim().length < 50} onClick={() => { void handleWsSave() }}>
1672
+ 💾 保存并审稿
1673
+ </button>
1383
1674
  </div>
1675
+ {/* 手动编辑后的 AI 审查结果 */}
1676
+ {wsCheckReport !== null && (
1677
+ <div className={css.wsPreview} style={{ borderColor: wsCheckReport.passed ? 'var(--nf-success)' : 'var(--nf-warn)' }}>
1678
+ <div className={css.busyRow}>
1679
+ <span className={css.meta} style={{ fontWeight: 600 }}>AI 审查结果</span>
1680
+ <span style={{ color: wsCheckReport.passed ? 'var(--nf-success)' : 'var(--nf-error)' }}>
1681
+ {wsCheckReport.score} 分 — {wsCheckReport.passed ? '通过' : '未通过'}
1682
+ </span>
1683
+ </div>
1684
+ <div className={css.meta}><b>总评:</b>{wsCheckReport.verdict}</div>
1685
+ {wsCheckReport.issues.length > 0 && (
1686
+ <ul style={{ margin: 0, paddingLeft: 16, fontSize: 12, display: 'flex', flexDirection: 'column', gap: 3 }}>
1687
+ {wsCheckReport.issues.map((issue, i) => (
1688
+ <li key={i} style={{ color: severityColor(issue.severity) }}>
1689
+ [{issue.severity}] {issue.item}
1690
+ {issue.suggestion !== '' && <span style={{ color: 'var(--nf-text-2)' }}> → {issue.suggestion}</span>}
1691
+ </li>
1692
+ ))}
1693
+ </ul>
1694
+ )}
1695
+ <span className={css.meta}>审查只读不落盘;改完点「💾 保存为正文」才写入文件(原稿自动备份 .bak)。</span>
1696
+ </div>
1697
+ )}
1384
1698
  {workspace.draft !== null && (
1385
1699
  <div className={css.wsPreview}>
1386
1700
  <div className={css.busyRow}>
@@ -1516,12 +1830,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1516
1830
  </div>
1517
1831
  <div className={css.assetGrid}>
1518
1832
  <div className={css.assetStat}>
1519
- <span className={css.assetStatLabel}>设定圣经</span>
1833
+ <span className={css.assetStatLabel}>道藏</span>
1520
1834
  <span className={css.assetStatValue} style={{ color: bible !== undefined ? 'var(--nf-success)' : 'var(--nf-text-3)' }}>
1521
1835
  {bible !== undefined ? `✓ ${bible.worldRules.length} 条规则` : '未生成'}
1522
1836
  </span>
1523
1837
  <span className={css.assetStatDetail}>
1524
- {bible !== undefined ? `${bible.characters.length} 角色 · ${bible.redLines.length} 红线` : '提炼人设 / 世界观 / 金手指'}
1838
+ {bible !== undefined ? `${bible.characters.length} 人物 · ${bible.redLines.length} 红线` : '提炼人设 / 世界观 / 金手指'}
1525
1839
  </span>
1526
1840
  </div>
1527
1841
  <div className={css.assetStat}>
@@ -1656,7 +1970,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1656
1970
  <>
1657
1971
  <div className={css.meta}>
1658
1972
  大纲是本书的「出生证明」。更新时请二选一:<b>仅更新文本</b>(保留设定/章节/正文全部进度),或
1659
- <b>重置项目</b>(从新大纲重新开始,清空设定/卷/章节/正文/伏笔/资产/事实库,不可恢复)。
1973
+ <b>重置项目</b>(从新总纲重新开始,清空道藏/卷/章节/正文/暗线/资产/编年录,不可恢复)。
1660
1974
  </div>
1661
1975
  <textarea
1662
1976
  className={css.textarea}
@@ -1690,6 +2004,112 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1690
2004
  </>
1691
2005
  )}
1692
2006
 
2007
+ {/* 卷首语:面向读者的作品门面 */}
2008
+ {activeTab === 'blurb' && (
2009
+ <div className={css.card}>
2010
+ <div className={css.row} style={{ justifyContent: 'space-between' }}>
2011
+ <span className={css.cardTitle}>卷首语</span>
2012
+ <div className={css.row}>
2013
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || project === null} onClick={() => { void handleBlurbGenerate() }}>
2014
+ ✨ AI 生成
2015
+ </button>
2016
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || project === null || blurbDraft.trim() === ''} onClick={() => { void handleBlurbComplete() }}>
2017
+ ✍️ AI 补全
2018
+ </button>
2019
+ {project?.blurb !== undefined && (
2020
+ <button
2021
+ type="button"
2022
+ className={`${css.button} ${css.buttonSmall}`}
2023
+ disabled={busy || project === null}
2024
+ onClick={() => {
2025
+ if (window.confirm('重新生成会覆盖当前简介(可先复制保存),确定?')) void handleBlurbGenerate()
2026
+ }}
2027
+ >
2028
+ 🔄 重新生成
2029
+ </button>
2030
+ )}
2031
+ </div>
2032
+ </div>
2033
+ {/* 书名(可改名,同步书架) */}
2034
+ <div className={css.row}>
2035
+ <input
2036
+ className={css.input}
2037
+ style={{ flex: 1, maxWidth: 320 }}
2038
+ placeholder="书名"
2039
+ value={bookNameDraft}
2040
+ onChange={e => { setBookNameDraft(e.target.value) }}
2041
+ onKeyDown={e => { if (e.key === 'Enter') void handleRename() }}
2042
+ />
2043
+ <button
2044
+ type="button"
2045
+ className={`${css.button} ${css.buttonSmall}`}
2046
+ disabled={busy || bookNameDraft.trim() === '' || bookNameDraft.trim() === project?.bookName}
2047
+ onClick={() => { void handleRename() }}
2048
+ >
2049
+ 💾 改书名
2050
+ </button>
2051
+ </div>
2052
+ {/* 封面 */}
2053
+ <div className={css.row} style={{ alignItems: 'flex-start', gap: 14 }}>
2054
+ <div className={css.coverPreview}>
2055
+ {coverDataUrl !== null ? (
2056
+ <img src={coverDataUrl} alt="封面" />
2057
+ ) : (
2058
+ <span className={css.coverPlaceholder}>暂无封面</span>
2059
+ )}
2060
+ </div>
2061
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
2062
+ <div className={css.row}>
2063
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || project === null} onClick={() => { coverFileRef.current?.click() }}>
2064
+ 📤 上传封面
2065
+ </button>
2066
+ <input
2067
+ ref={coverFileRef}
2068
+ type="file"
2069
+ accept="image/png,image/jpeg,image/webp"
2070
+ style={{ display: 'none' }}
2071
+ onChange={e => {
2072
+ handleCoverUpload(e.target.files?.[0])
2073
+ e.target.value = ''
2074
+ }}
2075
+ />
2076
+ {coverDataUrl !== null && (
2077
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleCoverRemove() }}>
2078
+ 🗑️ 移除
2079
+ </button>
2080
+ )}
2081
+ </div>
2082
+ <span className={css.meta}>支持 PNG / JPG / WebP,建议 3:4 竖版;保存于输出目录 cover.*。</span>
2083
+ </div>
2084
+ </div>
2085
+ <span className={css.meta}>
2086
+ 面向读者的作品门面(120-250 字):突出核心卖点与开局钩子,不剧透。点击 ✨AI 生成全量生成;或先写几句再点 ✍️AI 补全续写完整;不满意可 🔄 重新生成。
2087
+ </span>
2088
+ {project === null ? (
2089
+ <span className={css.meta}>请先在大纲页导入大纲建立项目。</span>
2090
+ ) : (
2091
+ <>
2092
+ <textarea
2093
+ className={css.textarea}
2094
+ style={{ minHeight: 140 }}
2095
+ placeholder="点击 ✨AI 生成,或先写下开头几句,再点 ✍️AI 补全…"
2096
+ value={blurbDraft}
2097
+ onChange={e => { setBlurbDraft(e.target.value) }}
2098
+ spellCheck={false}
2099
+ />
2100
+ <div className={css.row}>
2101
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || blurbDraft.trim() === ''} onClick={() => { void handleBlurbSave() }}>
2102
+ 💾 保存简介
2103
+ </button>
2104
+ <span className={css.meta}>
2105
+ {blurbDraft.length} 字 · 已保存:{project.blurb !== undefined ? `${project.blurb.length} 字` : '无'}
2106
+ </span>
2107
+ </div>
2108
+ </>
2109
+ )}
2110
+ </div>
2111
+ )}
2112
+
1693
2113
  {activeTab === 'plan' && (
1694
2114
  <>
1695
2115
  <div className={css.card}>
@@ -1734,7 +2154,24 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1734
2154
  )}
1735
2155
  </div>
1736
2156
  <div className={css.chapterList}>
1737
- {chapters.map(chapter => {
2157
+ {chapterGroups.map(group => {
2158
+ const collapsed = group.no !== 0 && collapsedVolumes.includes(group.no)
2159
+ const groupDone = group.chapters.filter(c => c.status === 'approved' || c.status === 'written' || c.status === 'rejected').length
2160
+ return (
2161
+ <div key={group.no} className={css.volumeGroup}>
2162
+ <div
2163
+ className={css.volumeGroupHeader}
2164
+ onClick={() => {
2165
+ if (group.no !== 0) {
2166
+ setCollapsedVolumes(prev => prev.includes(group.no) ? prev.filter(x => x !== group.no) : [...prev, group.no])
2167
+ }
2168
+ }}
2169
+ >
2170
+ <span className={css.volumeGroupToggle}>{group.no !== 0 ? (collapsed ? '▸' : '▾') : '📖'}</span>
2171
+ <b>{group.title}</b>
2172
+ <span className={css.meta}>({group.chapters.length} 章 · 已完成 {groupDone})</span>
2173
+ </div>
2174
+ {!collapsed && group.chapters.map(chapter => {
1738
2175
  const badge = statusBadge(chapter)
1739
2176
  const expanded = expandedChapter === chapter.no
1740
2177
  const review: ReviewReport | undefined = chapter.review
@@ -1818,18 +2255,31 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1818
2255
  {tt('plan.approve')}
1819
2256
  </button>
1820
2257
  )}
2258
+ {(chapter.status === 'written' || chapter.status === 'rejected' || chapter.status === 'approved') && (
2259
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void openWorkspace(chapter.no) }} title="手动编辑正文 → AI 审查 → 保存">
2260
+ ✏️ 编辑
2261
+ </button>
2262
+ )}
1821
2263
  {(chapter.status === 'written' || chapter.status === 'rejected' || chapter.status === 'approved') && (
1822
2264
  <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void openWorkspace(chapter.no) }}>
1823
2265
  {tt('plan.polish')}
1824
2266
  </button>
1825
2267
  )}
1826
2268
  {chapter.status === 'rejected' && (
1827
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void handleWriteChapter(chapter.no, true) }}>
1828
- {tt('plan.rewrite')}
2269
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void openWorkspace(chapter.no) }} title="按审稿意见修订(自动带入意见)">
2270
+ 按意见修订
2271
+ </button>
2272
+ )}
2273
+ {chapter.status === 'rejected' && (
2274
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || busyAny} onClick={() => { void handleWriteChapter(chapter.no, true) }} title="整章重新生成">
2275
+ 重新生成
1829
2276
  </button>
1830
2277
  )}
1831
2278
  </div>
1832
2279
  </div>
2280
+ )
2281
+ })}
2282
+ </div>
1833
2283
  )
1834
2284
  })}
1835
2285
  </div>
@@ -1934,28 +2384,40 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1934
2384
  </>
1935
2385
  )}
1936
2386
 
1937
- {/* 角色卡:从事实库聚合的当前状态(独立导航页) */}
2387
+ {/* 大世界:境界体系 / 区域 / 势力 */}
2388
+ {activeTab === 'world' && (
2389
+ <WorldTab
2390
+ api={api}
2391
+ world={project?.world}
2392
+ onChanged={w => {
2393
+ setProject(prev => prev === null ? prev : { ...prev, world: w, updatedAt: new Date().toISOString() })
2394
+ pushProgress(`大世界已保存:${w.realms.length} 境界 · ${w.regions.length} 区域 · ${w.factions.length} 势力`, 'done')
2395
+ }}
2396
+ />
2397
+ )}
2398
+
2399
+ {/* 人物志:从编年录聚合的当前状态(独立导航页) */}
1938
2400
  {activeTab === 'characters' && (
1939
2401
  <div className={css.card}>
1940
2402
  <div className={css.row} style={{ justifyContent: 'space-between' }}>
1941
- <span className={css.cardTitle}>角色卡(当前状态)</span>
2403
+ <span className={css.cardTitle}>人物志(当前状态)</span>
1942
2404
  <div className={css.row}>
1943
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || doneCount === 0} onClick={() => { void handleFactsBackfill() }} title="历史章节(事实库功能上线前生成的)批量抽取事实">
1944
- 📥 回填事实库
2405
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || doneCount === 0} onClick={() => { void handleFactsBackfill() }} title="历史章节(编年录功能上线前生成的)批量抽取事实">
2406
+ 📥 回填编年录
1945
2407
  </button>
1946
2408
  <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || (project?.facts ?? []).length === 0} onClick={() => { void handleCharactersRefresh() }}>
1947
- ↻ 从事实库刷新
2409
+ ↻ 从编年录刷新
1948
2410
  </button>
1949
2411
  </div>
1950
2412
  </div>
1951
2413
  {charCards === null ? (
1952
2414
  <span className={css.meta}>
1953
2415
  {(project?.facts ?? []).length === 0
1954
- ? '暂无事实库(生成章节后自动积累人物状态/境界/资源/关系等事实),刷新按钮将在有事实后可用。'
1955
- : '点击「从事实库刷新」聚合各角色当前状态(境界/资源/伤势/心境)。'}
2416
+ ? '暂无编年录(生成章节后自动积累人物状态/境界/资源/关系等事实),刷新按钮将在有事实后可用。'
2417
+ : '点击「从编年录刷新」聚合各人物当前状态(境界/资源/伤势/心境)。'}
1956
2418
  </span>
1957
2419
  ) : charCards.length === 0 ? (
1958
- <span className={css.meta}>未识别到角色信息。</span>
2420
+ <span className={css.meta}>未识别到人物信息。</span>
1959
2421
  ) : (
1960
2422
  <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
1961
2423
  {charCards.map(card => (
@@ -1972,7 +2434,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1972
2434
  ))}
1973
2435
  </div>
1974
2436
  )}
1975
- <span className={css.meta}>角色卡由「设定圣经」角色名单 + 「事实库」聚合而来,随章节生成自动保持最新。</span>
2437
+ <span className={css.meta}>人物志由「道藏」人物名单 + 「编年录」聚合而来,随章节生成自动保持最新。</span>
1976
2438
  </div>
1977
2439
  )}
1978
2440
 
@@ -2101,10 +2563,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2101
2563
  {tt('settings.exportMd')}
2102
2564
  </button>
2103
2565
  </div>
2104
- {/* 事实库 / 时间线 */}
2566
+ {/* 编年录 / 时间线 */}
2105
2567
  {(project?.facts ?? []).length > 0 && (
2106
2568
  <div className={css.card}>
2107
- <span className={css.cardTitle}>事实库 / 时间线({(project?.facts ?? []).length} 条)</span>
2569
+ <span className={css.cardTitle}>编年录 / 时间线({(project?.facts ?? []).length} 条)</span>
2108
2570
  <span className={css.meta}>
2109
2571
  每章生成后自动抽取「已确立事实」(人物状态/境界资源/关系变化/伏笔落地),最近 20 条注入后续章节生成,保证长期一致。
2110
2572
  </span>
@@ -2124,6 +2586,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2124
2586
  )}
2125
2587
  </div>
2126
2588
  </div>
2589
+ </>
2590
+ )}
2127
2591
 
2128
2592
  {/* AI 助手悬浮窗:可拖动、可拉大小,不占用工作台 */}
2129
2593
  {assistantOpen && (