@waterwx/dsh-novel-forge 1.1.9 → 1.3.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,
@@ -39,8 +40,10 @@ import css from './panel.module.css'
39
40
  /** The panel's tab identifiers. */
40
41
  export type NovelTab =
41
42
  | 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings'
42
- | 'characters' | 'roles' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'breakdown' | 'storyboard'
43
+ | 'characters' | 'roles' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'breakdown'
44
+ | 'roleImage' | 'scenes'
43
45
  | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle' | 'run'
46
+ | 'book' | 'assets'
44
47
 
45
48
  /** Panel shell props. */
46
49
  export interface NovelPanelProps {
@@ -69,10 +72,10 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
69
72
  items: [
70
73
  { id: 'workflow', label: tt('tab.workflow'), icon: '🛠️' },
71
74
  { id: 'overview', label: tt('tab.overview'), icon: '📄' },
72
- { id: 'blurb', label: '卷首语', icon: '📖' },
75
+ { id: 'blurb', label: '简介 / 封面', icon: '📖' },
73
76
  { id: 'plan', label: tt('tab.plan'), icon: '📚' },
74
- { id: 'plotlines', label: tt('tab.plotlines'), icon: '🧵' },
75
- { id: 'run', label: '生产单', icon: '🏭' },
77
+ { id: 'plotlines', label: '长线管理', icon: '📜' },
78
+ { id: 'book', label: '本书设定', icon: '📚' },
76
79
  ],
77
80
  },
78
81
  {
@@ -82,24 +85,16 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
82
85
  { id: 'assistant', label: tt('tab.assistant'), icon: '💬' },
83
86
  { id: 'progress', label: '工作进度', icon: '📊' },
84
87
  { id: 'breakdown', label: '拆书分析', icon: '🔍' },
85
- { id: 'storyboard', label: '漫剧工坊', icon: '🎬' },
88
+ { id: 'roleImage', label: '角色形象', icon: '🖼️' },
89
+ { id: 'scenes', label: '场景库', icon: '🏞️' },
90
+ { id: 'run', label: '生产单', icon: '🏭' },
86
91
  ],
87
92
  },
88
93
  {
89
- id: 'db',
90
- label: '数据库',
94
+ id: 'assets',
95
+ label: '资产',
91
96
  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: '🎨' },
97
+ { id: 'assets', label: '创作资产', icon: '🧰' },
103
98
  ],
104
99
  },
105
100
  ]
@@ -107,6 +102,10 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
107
102
  /** Settings tab — pinned to the bottom of the nav rail. */
108
103
  const SETTINGS_TAB: { id: NovelTab; label: string; icon: string } = { id: 'settings', label: tt('tab.settings'), icon: '⚙️' }
109
104
 
105
+ /** Common DeepSeek model presets shown in settings; users can also type any model id. */
106
+ const MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
107
+ const REASONING_OPTIONS = ['off', 'low', 'high', 'max'] as const
108
+
110
109
  /** 构建时注入的插件版本(tsdown define 替换为字符串字面量)。 */
111
110
  declare const __NOVEL_FORGE_VERSION__: string | undefined
112
111
  const PLUGIN_VERSION: string = typeof __NOVEL_FORGE_VERSION__ !== 'undefined' ? __NOVEL_FORGE_VERSION__ : '0.0.0'
@@ -319,9 +318,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
319
318
  const [notice, setNotice] = useState('')
320
319
  const [progress, setProgress] = useState<ProgressLine[]>([])
321
320
  const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null)
321
+ const [modelCustomMode, setModelCustomMode] = useState(false)
322
322
  const [expandedChapter, setExpandedChapter] = useState<number | null>(null)
323
323
  /** 复盘记录页:当前展开的章节号。 */
324
324
  const [expandedReviewChapter, setExpandedReviewChapter] = useState<number | null>(null)
325
+ /** 复盘记录页:按卷折叠,卷号 → 是否展开(默认全部收起)。 */
326
+ const [expandedVolumes, setExpandedVolumes] = useState<Record<number, boolean>>({})
325
327
  const [chapterText, setChapterText] = useState('')
326
328
  const progressId = useRef(0)
327
329
  /** id of the single live progress row (generation counter), if any. */
@@ -385,6 +387,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
385
387
  const [updatingOutline, setUpdatingOutline] = useState(false)
386
388
  /** 全书质检结果(null = 未运行)。 */
387
389
  const [auditIssues, setAuditIssues] = useState<import('../../protocol.ts').AuditIssue[] | null>(null)
390
+ /** 全书质检实时状态(来自 /status,用于显示进度)。 */
391
+ const [auditStatus, setAuditStatus] = useState<AuditStatus | null>(null)
388
392
  /** 角色卡(从事实库聚合)。 */
389
393
  const [charCards, setCharCards] = useState<import('../../protocol.ts').RoleStatusCard[] | null>(null)
390
394
  /** 世界观规则编辑草稿(bible tab,每行一条)。 */
@@ -412,6 +416,39 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
412
416
  progress: string
413
417
  status: Plotline['status']
414
418
  } | null>(null)
419
+ /** 长线管理页:子页签(剧情线 / 伏笔),localStorage 记忆。 */
420
+ const [longlineTab, setLonglineTab] = useState<'plotlines' | 'foreshadow'>(() => {
421
+ try {
422
+ const v = window.localStorage.getItem('dsh-novel-forge.longline.tab')
423
+ return v === 'foreshadow' ? 'foreshadow' : 'plotlines'
424
+ } catch { return 'plotlines' }
425
+ })
426
+ const changeLonglineTab = (next: 'plotlines' | 'foreshadow'): void => {
427
+ setLonglineTab(next)
428
+ try { window.localStorage.setItem('dsh-novel-forge.longline.tab', next) } catch { /* ignore */ }
429
+ }
430
+ /** 编年 / 复盘页:子页签(编年录 / 复盘记录),localStorage 记忆。 */
431
+ const [archiveTab, setArchiveTab] = useState<'facts' | 'reviews'>(() => {
432
+ try {
433
+ const v = window.localStorage.getItem('dsh-novel-forge.archive.tab')
434
+ return v === 'reviews' ? 'reviews' : 'facts'
435
+ } catch { return 'facts' }
436
+ })
437
+ const changeArchiveTab = (next: 'facts' | 'reviews'): void => {
438
+ setArchiveTab(next)
439
+ try { window.localStorage.setItem('dsh-novel-forge.archive.tab', next) } catch { /* ignore */ }
440
+ }
441
+ /** 本书设定页:子页签(设定库 / 大世界 / 角色库 / 编年·复盘),localStorage 记忆。 */
442
+ const [bookTab, setBookTab] = useState<'bible' | 'world' | 'roles' | 'facts'>(() => {
443
+ try {
444
+ const v = window.localStorage.getItem('dsh-novel-forge.book.tab')
445
+ return v === 'world' || v === 'roles' || v === 'facts' ? v : 'bible'
446
+ } catch { return 'bible' }
447
+ })
448
+ const changeBookTab = (next: 'bible' | 'world' | 'roles' | 'facts'): void => {
449
+ setBookTab(next)
450
+ try { window.localStorage.setItem('dsh-novel-forge.book.tab', next) } catch { /* ignore */ }
451
+ }
415
452
  /** 角色知情度编辑草稿(角色名 → 文本,每行一条)。 */
416
453
  const [knowledgeDraft, setKnowledgeDraft] = useState<Record<string, string>>({})
417
454
  /** 角色库:AI 提炼候选(null = 未运行;localStorage 持久化,刷新不丢)。 */
@@ -444,16 +481,23 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
444
481
  const [breakdownResult, setBreakdownResult] = useState<import('../../protocol.ts').BreakdownResponse | null>(null)
445
482
  const [breakdownScope, setBreakdownScope] = useState<'recent' | 'volume:2' | 'volume:3' | 'all'>('recent')
446
483
  const [breakdownPreset, setBreakdownPreset] = useState<'quick' | 'standard'>('quick')
447
- /** 漫剧分镜结果(null = 未生成)。 */
448
- const [storyboardResult, setStoryboardResult] = useState<import('../../protocol.ts').StoryboardResponse | null>(null)
449
- const [storyboardTool, setStoryboardTool] = useState<'doubao' | 'seedance' | 'sd' | 'mj'>('doubao')
450
- /** 漫剧工坊当前选中的章节号。 */
451
- const [storyboardChapterNo, setStoryboardChapterNo] = useState<number>(0)
452
- /** 漫剧分集计划结果(null = 未生成)。 */
453
- const [storyboardPlanResult, setStoryboardPlanResult] = useState<import('../../protocol.ts').StoryboardPlanResponse | null>(null)
454
- const [storyboardPlanVolume, setStoryboardPlanVolume] = useState<number>(1)
455
- /** 漫剧工坊子视图:single=单集分镜 / plan=按卷规划。 */
456
- const [storyboardView, setStoryboardView] = useState<'single' | 'plan'>('single')
484
+ /** 角色图集上传:当前等待上传的角色名。 */
485
+ const [roleImageTarget, setRoleImageTarget] = useState<string | null>(null)
486
+ /** 角色图集上传:当前用途标签(立绘/四视图/表情-x/场景/细节)。 */
487
+ const [roleImageLabel, setRoleImageLabel] = useState('立绘')
488
+ /** 角色形象详情:当前打开的角色名(null = 关闭)。 */
489
+ const [detailRoleName, setDetailRoleName] = useState<string | null>(null)
490
+ /** 详情面板:上传标签输入。 */
491
+ const [detailUploadLabel, setDetailUploadLabel] = useState('立绘')
492
+ /** 场景库:AI 提炼候选(null = 未运行)。 */
493
+ const [sceneCandidates, setSceneCandidates] = useState<import('../../protocol.ts').SceneCard[] | null>(null)
494
+ /** 场景库:当前打开的场景名。 */
495
+ const [sceneDetailName, setSceneDetailName] = useState<string | null>(null)
496
+ /** 场景库:上传标签输入。 */
497
+ const [sceneUploadLabel, setSceneUploadLabel] = useState('全景')
498
+ /** 场景库:编辑草稿(null = 不在编辑)。 */
499
+ const [sceneDraft, setSceneDraft] = useState<import('../../protocol.ts').SceneCard | null>(null)
500
+ const roleImageInputRef = useRef<HTMLInputElement | null>(null)
457
501
  /** AI 建议的剧情线候选(null = 未运行)。 */
458
502
  const [plotlineSuggestions, setPlotlineSuggestions] = useState<Plotline[] | null>(null)
459
503
  /** 剧情健康检查报告(null = 未运行)。 */
@@ -615,6 +659,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
615
659
  const status = await api.status()
616
660
  setConfig(status.config)
617
661
  setConfigDraft(status.config)
662
+ setModelCustomMode(!(MODEL_PRESETS as readonly string[]).includes(status.config.model))
663
+ setAuditStatus(status.audit ?? null)
618
664
  setProject(status.project ?? null)
619
665
  // 人物志存档同步:有 roleStatus 存档直接显示,无需重新刷新计算。
620
666
  if (status.project?.roleStatus !== undefined) {
@@ -774,18 +820,32 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
774
820
  setBusy(true)
775
821
  setBusyLabel('全书一致性质检中…')
776
822
  setError('')
823
+ setAuditStatus({ status: 'running', totalBatches: 0, completedBatches: 0 })
824
+ let stopped = false
825
+ const poll = window.setInterval(async () => {
826
+ if (stopped) return
827
+ try {
828
+ const s = await api.status()
829
+ if (s.audit !== undefined) setAuditStatus(s.audit)
830
+ } catch { /* 轮询失败忽略,主请求仍会给出最终结果 */ }
831
+ }, 1000)
777
832
  try {
778
833
  const result = await api.audit()
834
+ if (stopped) return
779
835
  setAuditIssues(result.issues)
780
836
  pushProgress(result.issues.length === 0
781
837
  ? `全书质检完成:${result.auditedChapters} 章未发现矛盾 🎉`
782
838
  : `全书质检完成:发现 ${result.issues.length} 处疑似矛盾`, result.issues.length === 0 ? 'done' : 'error')
783
839
  } catch (err) {
840
+ if (stopped) return
784
841
  setError((err as Error).message)
785
842
  pushProgress(`全书质检失败:${(err as Error).message}`, 'error')
786
843
  } finally {
844
+ stopped = true
845
+ window.clearInterval(poll)
787
846
  setBusy(false)
788
847
  setBusyLabel('')
848
+ await refresh(false)
789
849
  }
790
850
  }
791
851
 
@@ -1082,36 +1142,80 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1082
1142
  }
1083
1143
  }
1084
1144
 
1085
- /** 漫剧分镜:对某章生成角色锚点 + 分镜表(约 1-2 分钟)。 */
1086
- const handleStoryboard = async (no: number): Promise<void> => {
1145
+ /** 生成单个角色的形象锚点(复用角色库 visual 能力)。 */
1146
+ const handleRoleVisual = async (name: string): Promise<void> => {
1087
1147
  setBusy(true)
1088
- setBusyLabel(`生成第${no}章漫剧分镜…`)
1148
+ setBusyLabel(`生成「${name}」形象锚点…`)
1089
1149
  setError('')
1090
1150
  try {
1091
- const result = await api.storyboard(no, undefined, '抖音', storyboardTool)
1092
- setStoryboardResult(result)
1093
- pushProgress(`第${no}章分镜完成:${result.panels.length} 格 · 角色卡 ${result.characters.length} 张`, 'done')
1151
+ await api.roles({ op: 'visual', name })
1152
+ await refresh(false)
1153
+ pushProgress(`已生成「${name}」形象锚点`, 'done')
1094
1154
  } catch (err) {
1095
1155
  setError((err as Error).message)
1096
- pushProgress(`分镜生成失败:${(err as Error).message}`, 'error')
1156
+ pushProgress(`生成「${name}」形象锚点失败:${(err as Error).message}`, 'error')
1097
1157
  } finally {
1098
1158
  setBusy(false)
1099
1159
  setBusyLabel('')
1100
1160
  }
1101
1161
  }
1102
1162
 
1103
- /** 漫剧分集计划:AI 读一卷 → 按故事弧线分集。 */
1104
- const handleStoryboardPlan = async (): Promise<void> => {
1163
+ /** 上传角色图:读取本地图片为 dataURL 并写入角色卡(带用途标签 → 图集)。 */
1164
+ const handleRoleImageUpload = async (name: string, file: File, label = '立绘'): Promise<void> => {
1165
+ const dataUrl = await new Promise<string>((resolve, reject) => {
1166
+ const reader = new FileReader()
1167
+ reader.onload = () => resolve(String(reader.result))
1168
+ reader.onerror = () => reject(reader.error ?? new Error('读取图片失败'))
1169
+ reader.readAsDataURL(file)
1170
+ })
1105
1171
  setBusy(true)
1106
- setBusyLabel(`规划第${storyboardPlanVolume}卷漫剧分集…`)
1172
+ setBusyLabel(`上传「${name}」${label}…`)
1107
1173
  setError('')
1108
1174
  try {
1109
- const result = await api.storyboardPlan(storyboardPlanVolume, '抖音', 25)
1110
- setStoryboardPlanResult(result)
1111
- pushProgress(`第${storyboardPlanVolume}卷分集计划完成:${result.episodes.length} 集(覆盖 ${result.chaptersScanned} 章)`, 'done')
1175
+ await api.roles({ op: 'image', name, dataUrl, label })
1176
+ await refresh(false)
1177
+ pushProgress(`已上传「${name}」${label}`, 'done')
1112
1178
  } catch (err) {
1113
1179
  setError((err as Error).message)
1114
- pushProgress(`分集计划失败:${(err as Error).message}`, 'error')
1180
+ pushProgress(`上传「${name}」${label}失败:${(err as Error).message}`, 'error')
1181
+ } finally {
1182
+ setBusy(false)
1183
+ setBusyLabel('')
1184
+ setRoleImageTarget(null)
1185
+ if (roleImageInputRef.current !== null) roleImageInputRef.current.value = ''
1186
+ }
1187
+ }
1188
+
1189
+ /** 用豆包/Seedream 为角色生成参考图。 */
1190
+ const handleRoleImageGenerate = async (name: string): Promise<void> => {
1191
+ setBusy(true)
1192
+ setBusyLabel(`用豆包生成「${name}」参考图…`)
1193
+ setError('')
1194
+ try {
1195
+ await api.roles({ op: 'imageGenerate', name })
1196
+ await refresh(false)
1197
+ pushProgress(`已生成「${name}」参考图`, 'done')
1198
+ } catch (err) {
1199
+ setError((err as Error).message)
1200
+ pushProgress(`生成「${name}」参考图失败:${(err as Error).message}`, 'error')
1201
+ } finally {
1202
+ setBusy(false)
1203
+ setBusyLabel('')
1204
+ }
1205
+ }
1206
+
1207
+ /** 更新角色漫画重要性(main/support/extra)。 */
1208
+ const handleRoleImportance = async (role: RoleRecord, importance: RoleRecord['importance']): Promise<void> => {
1209
+ setBusy(true)
1210
+ setBusyLabel(`更新「${role.name}」重要性…`)
1211
+ setError('')
1212
+ try {
1213
+ await api.roles({ op: 'update', role: { ...role, importance } })
1214
+ await refresh(false)
1215
+ pushProgress(`已更新「${role.name}」重要性`, 'done')
1216
+ } catch (err) {
1217
+ setError((err as Error).message)
1218
+ pushProgress(`更新「${role.name}」重要性失败:${(err as Error).message}`, 'error')
1115
1219
  } finally {
1116
1220
  setBusy(false)
1117
1221
  setBusyLabel('')
@@ -1120,7 +1224,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1120
1224
 
1121
1225
  /** 作者复盘补跑:全书缺失章节(流式)。 */
1122
1226
  const handleAuthorBackfillAll = async (): Promise<void> => {
1123
- const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined).length
1227
+ const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
1124
1228
  setBusy(true)
1125
1229
  setBusyLabel(`补齐历史章节作者复盘(${missing} 章)…`)
1126
1230
  setError('')
@@ -1966,12 +2070,16 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1966
2070
  outputDir: configDraft.outputDir,
1967
2071
  provider: configDraft.provider,
1968
2072
  model: configDraft.model,
2073
+ reasoningEffort: configDraft.reasoningEffort ?? 'off',
1969
2074
  chapterChars: configDraft.chapterChars,
1970
2075
  maxTokens: configDraft.maxTokens,
1971
2076
  reviewPassScore: configDraft.reviewPassScore,
1972
2077
  autoReview: configDraft.autoReview,
1973
2078
  autoAuthorReview: configDraft.autoAuthorReview,
1974
2079
  autoReviewAfterRevise: configDraft.autoReviewAfterRevise,
2080
+ imageApiKey: configDraft.imageApiKey,
2081
+ imageApiModel: configDraft.imageApiModel,
2082
+ imageApiEnabled: configDraft.imageApiEnabled ?? false,
1975
2083
  })
1976
2084
  setConfig(result.config)
1977
2085
  setConfigDraft(result.config)
@@ -2057,6 +2165,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2057
2165
  // --------------------------------------------------- dashboard (workflow)
2058
2166
  const approvedCount = chapters.filter(c => c.status === 'approved').length
2059
2167
  const reviewPendingCount = chapters.filter(c => c.status === 'written' || c.status === 'rejected').length
2168
+ const writingNow = chapters.find(c => c.status === 'generating' || c.status === 'reviewing')
2060
2169
  const totalChars = chapters.reduce((sum, c) => sum + (c.chars ?? 0), 0)
2061
2170
  const firstChapter = chapters[0]
2062
2171
  const currentVolumeName = (() => {
@@ -2081,6 +2190,24 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2081
2190
  const journeyPercent = Math.round((journeyDoneCount / journeyStages.length) * 100)
2082
2191
  const currentStageId = journeyStages.find(s => !s.done)?.id
2083
2192
 
2193
+ /** 创作时间线阶段点击 → 跳转对应 tab。 */
2194
+ const jumpToStage = (id: string): void => {
2195
+ switch (id) {
2196
+ case 'outline': setActiveTab('overview'); break
2197
+ case 'bible': setActiveTab('book'); changeBookTab('bible'); break
2198
+ case 'volumes':
2199
+ case 'plan':
2200
+ case 'write': setActiveTab('plan'); break
2201
+ case 'review': setActiveTab('book'); changeBookTab('facts'); changeArchiveTab('reviews'); break
2202
+ }
2203
+ }
2204
+
2205
+ /** 侧栏当前书卡(demo 风格):书架激活书 → 封面首字 / 书名 / 进度。 */
2206
+ const activeBook = shelf?.books.find(b => b.id === shelf?.activeBookId) ?? null
2207
+ const activeBookName = activeBook?.bookName ?? project?.bookName ?? '未选书'
2208
+ const activeBookMeta = activeBook !== null ? `${activeBook.done}/${activeBook.total} 章 · 点按打开书架` : '点按打开书架'
2209
+ const activeBookLetter = activeBookName.trim().charAt(0) || '书'
2210
+
2084
2211
  /** 主行动卡片:推荐下一步(AI-Novel-Writing-Assistant 首页主卡模式)。 */
2085
2212
  const nextAction = useMemo((): { eyebrow: string; title: string; reason: string; actionLabel: string; onClick: () => void } | null => {
2086
2213
  if (project === null) {
@@ -2267,48 +2394,20 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2267
2394
  />
2268
2395
  ) : (
2269
2396
  <>
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
2397
  <div className={css.panelBody}>
2311
2398
  <nav className={`${css.panelNav} ${navCollapsed ? css.panelNavCollapsed : ''}`} role="tablist" aria-label="工作台导航">
2399
+ {/* 标题区:小说工坊 + 当前书名(顶部标题栏取消后移入侧栏) */}
2400
+ <div className={css.navTitle}>
2401
+ <div className={css.navTitleLogo}>书</div>
2402
+ {!navCollapsed && (
2403
+ <div style={{ minWidth: 0 }}>
2404
+ <div className={css.navTitleName}>小说工坊</div>
2405
+ <div className={css.navTitleBook} title={project?.bookName ?? ''}>
2406
+ {project?.bookName !== undefined && project.bookName !== '' ? `📖 ${project.bookName}` : '未选书'}
2407
+ </div>
2408
+ </div>
2409
+ )}
2410
+ </div>
2312
2411
  {/* 分组导航(创作 / 工具 / 数据库) */}
2313
2412
  {NAV_GROUPS.map(group => (
2314
2413
  <div key={group.id} className={css.navGroup}>
@@ -2340,7 +2439,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2340
2439
  {chapters.some(c => c.status === 'error') ? `!${pendingCount + reviewPendingCount}` : pendingCount + reviewPendingCount}
2341
2440
  </span>
2342
2441
  )}
2343
- {tab.id === 'foreshadow' && foreshadows.some(f => f.status === 'planned') && (
2442
+ {tab.id === 'plotlines' && foreshadows.some(f => f.status === 'planned') && (
2344
2443
  <span className={css.navTabBadge}>{foreshadows.filter(f => f.status === 'planned').length}</span>
2345
2444
  )}
2346
2445
  {tab.id === 'workflow' && chapters.length > 0 && (
@@ -2374,6 +2473,22 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2374
2473
  <span className={css.navTabIcon}>{SETTINGS_TAB.icon}</span>
2375
2474
  {!navCollapsed && <span className={css.navTabLabel}>{SETTINGS_TAB.label}</span>}
2376
2475
  </button>
2476
+ {/* 当前书切换卡(demo 风格底部内容卡:封面首字 + 书名 + 进度,点击回书架) */}
2477
+ {!navCollapsed && shelf !== null && (
2478
+ <button
2479
+ type="button"
2480
+ className={css.bookSwitch}
2481
+ title={activeBook !== null ? `当前书:${activeBook.bookName}(${activeBook.outputDir})· 点击打开书架` : '打开书架'}
2482
+ onClick={() => { setViewMode('shelf') }}
2483
+ >
2484
+ <span className={css.bookSwitchCover}>{activeBookLetter}</span>
2485
+ <span style={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
2486
+ <span className={css.bookSwitchName}>{activeBookName}</span>
2487
+ <span className={css.bookSwitchMeta}>{activeBookMeta}</span>
2488
+ </span>
2489
+ <span className={css.bookSwitchArrow}>⌄</span>
2490
+ </button>
2491
+ )}
2377
2492
  {/* 关于:版本 + GitHub + 更新检测 */}
2378
2493
  <div className={css.navAbout}>
2379
2494
  <button
@@ -2400,6 +2515,33 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2400
2515
  </button>
2401
2516
  )}
2402
2517
  </div>
2518
+ {/* 操作行:收起导航 / 关闭面板(原顶部按钮下放,收起态保留图标) */}
2519
+ <div className={css.navActions}>
2520
+ <button
2521
+ type="button"
2522
+ className={css.navActionBtn}
2523
+ title={navCollapsed ? '展开导航栏' : '收起导航栏'}
2524
+ aria-label={navCollapsed ? '展开导航栏' : '收起导航栏'}
2525
+ onClick={() => {
2526
+ setNavCollapsed(prev => {
2527
+ const next = !prev
2528
+ try { window.localStorage.setItem('dsh-novel-forge.nav.collapsed', String(next)) } catch { /* ignore */ }
2529
+ return next
2530
+ })
2531
+ }}
2532
+ >
2533
+ {navCollapsed ? '▸' : '◂'}
2534
+ </button>
2535
+ <button
2536
+ type="button"
2537
+ className={css.navActionBtn}
2538
+ title={tt('common.close')}
2539
+ aria-label={tt('common.close')}
2540
+ onClick={() => { controller.close() }}
2541
+ >
2542
+ ×
2543
+ </button>
2544
+ </div>
2403
2545
  </nav>
2404
2546
  <div className={css.panelContent}>
2405
2547
  {error !== '' && <div className={css.card} style={{ borderColor: 'var(--nf-error)' }}><span style={{ color: 'var(--nf-error)' }}>{tt('common.error')}: {error}</span></div>}
@@ -2634,29 +2776,64 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2634
2776
  <span className={css.meta} style={{ fontWeight: 600 }}>创作旅程</span>
2635
2777
  <span className={css.meta}>{journeyPercent}% · 已完成 {journeyDoneCount}/{journeyStages.length} 步</span>
2636
2778
  </div>
2637
- <div className={css.dashJourneyBar}>
2638
- <div className={css.dashJourneyFill} style={{ width: `${journeyPercent}%` }} />
2639
- </div>
2640
- <div className={css.dashJourneyStages}>
2779
+ <div className={css.tlBar}>
2641
2780
  {journeyStages.map(stage => (
2642
- <span
2781
+ <button
2643
2782
  key={stage.id}
2644
- className={`${css.dashStage} ${stage.done ? css.dashStageDone : stage.id === currentStageId ? css.dashStageCurrent : ''}`}
2783
+ type="button"
2784
+ className={`${css.tlSeg} ${stage.done ? css.tlSegDone : stage.id === currentStageId ? css.tlSegCurrent : css.tlSegTodo}`}
2785
+ title={`${stage.label}${stage.done ? ' · 已完成' : stage.id === currentStageId ? ' · 进行中' : ' · 未开始'}(点击跳转)`}
2786
+ onClick={() => { jumpToStage(stage.id) }}
2645
2787
  >
2646
- <span className={css.dashStageDot}>{stage.done ? '✓' : stage.id === currentStageId ? '●' : '○'}</span>
2647
- {stage.label}
2648
- </span>
2788
+ <span className={css.tlSegTrack}><span className={css.tlSegFill} /></span>
2789
+ <span className={css.tlSegLabel}>{stage.done ? '✓ ' : ''}{stage.label}</span>
2790
+ </button>
2649
2791
  ))}
2650
2792
  </div>
2651
2793
  </div>
2652
2794
  </div>
2653
2795
 
2654
- {/* 状态摘要条:全书进度 + 六个统计格,同样式并排一行 */}
2796
+ {/* 统计卡(demo 风格:今日待写 / 写作中 / 已通过 / 待审稿) */}
2797
+ <div className={css.statRowD}>
2798
+ <div className={css.statCardD}>
2799
+ <div style={{ minWidth: 0 }}>
2800
+ <div className={css.statCardDLabel}>今日待写</div>
2801
+ <div className={css.statCardDValue}>{pendingCount} <span className={css.statCardDUnit}>章</span></div>
2802
+ <div className={css.statCardDDetail}>
2803
+ {chapters.some(c => c.status === 'error') ? `含 error ${chapters.filter(c => c.status === 'error').length} 章` : '待生成队列'}
2804
+ </div>
2805
+ </div>
2806
+ <div className={css.statCardDIcon}>📝</div>
2807
+ </div>
2808
+ <div className={css.statCardD}>
2809
+ <div style={{ minWidth: 0 }}>
2810
+ <div className={css.statCardDLabel}>写作中</div>
2811
+ <div className={css.statCardDValue}>{chapters.filter(c => c.status === 'generating' || c.status === 'reviewing').length} <span className={css.statCardDUnit}>章</span></div>
2812
+ <div className={css.statCardDDetail}>{writingNow !== undefined ? `第 ${writingNow.no} 章 · ${writingNow.title}` : '无进行中任务'}</div>
2813
+ </div>
2814
+ <div className={css.statCardDIcon}>✍️</div>
2815
+ </div>
2816
+ <div className={css.statCardD}>
2817
+ <div style={{ minWidth: 0 }}>
2818
+ <div className={css.statCardDLabel}>已通过</div>
2819
+ <div className={css.statCardDValue}>{approvedCount} <span className={css.statCardDUnit}>章</span></div>
2820
+ <div className={css.statCardDDetail}>{chapters.length > 0 ? `占全书 ${Math.round((approvedCount / chapters.length) * 100)}%` : '尚无已通过章节'}</div>
2821
+ </div>
2822
+ <div className={css.statCardDIcon}>✅</div>
2823
+ </div>
2824
+ <div className={css.statCardD}>
2825
+ <div style={{ minWidth: 0 }}>
2826
+ <div className={css.statCardDLabel}>待审稿</div>
2827
+ <div className={css.statCardDValue}>{reviewPendingCount} <span className={css.statCardDUnit}>章</span></div>
2828
+ <div className={`${css.statCardDDetail} ${reviewPendingCount > 0 ? css.statCardDDown : ''}`}>
2829
+ {`written ${chapters.filter(c => c.status === 'written').length} · rejected ${chapters.filter(c => c.status === 'rejected').length}`}
2830
+ </div>
2831
+ </div>
2832
+ <div className={`${css.statCardDIcon} ${css.statCardDIconRed}`}>⚠️</div>
2833
+ </div>
2834
+ </div>
2835
+ {/* 补充统计:总字数 / 当前卷 / 最近创作(原有数据保留) */}
2655
2836
  <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
2837
  <StatCell label="总字数" value={String(totalChars)} detail="已生成正文累计" />
2661
2838
  <StatCell label="当前卷" value={currentVolumeName} valueFontSize={13} detail="正在推进的卷" />
2662
2839
  <StatCell label="最近创作" value={lastUpdated} valueFontSize={13} detail="最近生成/编辑时间" />
@@ -2670,6 +2847,19 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2670
2847
  🔍 全书质检
2671
2848
  </button>
2672
2849
  </div>
2850
+ {auditStatus?.status === 'running' && (
2851
+ <div className={css.meta} style={{ marginTop: 8 }}>
2852
+ 🔍 全书质检中:{auditStatus.completedBatches}/{auditStatus.totalBatches > 0 ? auditStatus.totalBatches : '…'} 批
2853
+ {auditStatus.totalBatches > 0 && (
2854
+ <div className={css.dashJourneyBar} style={{ marginTop: 4 }}>
2855
+ <div className={css.dashJourneyFill} style={{ width: `${Math.round((auditStatus.completedBatches / auditStatus.totalBatches) * 100)}%` }} />
2856
+ </div>
2857
+ )}
2858
+ </div>
2859
+ )}
2860
+ {auditStatus?.status === 'error' && auditStatus.error !== undefined && (
2861
+ <div className={css.meta} style={{ color: 'var(--nf-error)', marginTop: 8 }}>全书质检失败:{auditStatus.error}</div>
2862
+ )}
2673
2863
  <div className={css.assetGrid}>
2674
2864
  <StatCell
2675
2865
  label="道藏"
@@ -2732,7 +2922,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2732
2922
  <span className={css.cardTitle}>📋 作者复盘(最近 {Math.min(6, chapters.filter(c => c.authorReview !== undefined).length)} 章)</span>
2733
2923
  <div className={css.row}>
2734
2924
  {(() => {
2735
- const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.authorReview === undefined).length
2925
+ const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
2736
2926
  return missing > 0 && (
2737
2927
  <button
2738
2928
  type="button"
@@ -2745,7 +2935,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2745
2935
  </button>
2746
2936
  )
2747
2937
  })()}
2748
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('reviews') }} title="查看按卷分组的全部复盘记录">
2938
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('book'); changeBookTab('facts'); changeArchiveTab('reviews') }} title="查看按卷分组的全部复盘记录">
2749
2939
  查看全部 →
2750
2940
  </button>
2751
2941
  </div>
@@ -2918,7 +3108,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2918
3108
  {activeTab === 'blurb' && (
2919
3109
  <div className={css.card}>
2920
3110
  <div className={css.row} style={{ justifyContent: 'space-between' }}>
2921
- <span className={css.cardTitle}>卷首语</span>
3111
+ <span className={css.cardTitle}>📖 简介 / 封面</span>
2922
3112
  <div className={css.row}>
2923
3113
  <button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy || project === null} onClick={() => { void handleBlurbGenerate() }}>
2924
3114
  ✨ AI 生成
@@ -3183,7 +3373,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3183
3373
  )}
3184
3374
  </div>
3185
3375
  )}
3186
- {chapter.authorReview === undefined && chapter.status !== 'pending' && chapter.status !== 'generating' && (
3376
+ {chapter.authorReview === undefined && chapter.status !== 'pending' && chapter.status !== 'generating' && chapter.status !== 'error' && (
3187
3377
  <div className={css.row}>
3188
3378
  <button
3189
3379
  type="button"
@@ -3299,85 +3489,53 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3299
3489
  </>
3300
3490
  )}
3301
3491
 
3302
- {activeTab === 'reviews' && (
3303
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
3492
+ {activeTab === 'book' && (
3493
+ <div className={css.card} style={{ gap: 12 }}>
3304
3494
  <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>
3495
+ <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📚 本书设定</span>
3496
+ <span className={css.meta}>当前书的知识与资料(参考 AI-Novel-Writing-Assistant:世界观/角色准备收进书内)</span>
3322
3497
  </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
- })}
3498
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
3499
+ <button
3500
+ type="button"
3501
+ className={`${css.button} ${bookTab === 'bible' ? css.buttonPrimary : ''}`}
3502
+ style={{ fontSize: 14, flex: 1 }}
3503
+ onClick={() => { changeBookTab('bible') }}
3504
+ title="设定库:题材 / 世界观规则 / 人物摘要 / 红线 / 文风"
3505
+ >
3506
+ 📖 设定库
3507
+ </button>
3508
+ <button
3509
+ type="button"
3510
+ className={`${css.button} ${bookTab === 'world' ? css.buttonPrimary : ''}`}
3511
+ style={{ fontSize: 14, flex: 1 }}
3512
+ onClick={() => { changeBookTab('world') }}
3513
+ title="大世界:境界体系 / 地理区域 / 势力分布(注入生成与审稿提示词)"
3514
+ >
3515
+ 🌍 大世界
3516
+ </button>
3517
+ <button
3518
+ type="button"
3519
+ className={`${css.button} ${bookTab === 'roles' ? css.buttonPrimary : ''}`}
3520
+ style={{ fontSize: 14, flex: 1 }}
3521
+ onClick={() => { changeBookTab('roles') }}
3522
+ title="角色库:全书角色主表(定位 / 关系网 / 成长线 / 知情度)"
3523
+ >
3524
+ 👥 角色库({project?.roles?.length ?? 0})
3525
+ </button>
3526
+ <button
3527
+ type="button"
3528
+ className={`${css.button} ${bookTab === 'facts' ? css.buttonPrimary : ''}`}
3529
+ style={{ fontSize: 14, flex: 1 }}
3530
+ onClick={() => { changeBookTab('facts') }}
3531
+ title="编年录与复盘记录"
3532
+ >
3533
+ 📚 编年 / 复盘
3534
+ </button>
3376
3535
  </div>
3377
3536
  </div>
3378
3537
  )}
3379
-
3380
- {activeTab === 'bible' && (
3538
+ {activeTab === 'book' && bookTab === 'bible' && (
3381
3539
  <>
3382
3540
  <div className={css.card}>
3383
3541
  <div className={css.row} style={{ justifyContent: 'space-between' }}>
@@ -3457,7 +3615,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3457
3615
  )}
3458
3616
 
3459
3617
  {/* 大世界:境界体系 / 区域 / 势力 */}
3460
- {activeTab === 'world' && (
3618
+ {activeTab === 'book' && bookTab === 'world' && (
3461
3619
  <WorldTab
3462
3620
  api={api}
3463
3621
  world={project?.world}
@@ -3469,8 +3627,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3469
3627
  )}
3470
3628
 
3471
3629
  {/* 角色库:全书角色主表(独立导航页) */}
3472
- {activeTab === 'roles' && (
3473
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
3630
+ {activeTab === 'book' && bookTab === 'roles' && (
3631
+ <div className={css.card}>
3474
3632
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3475
3633
  <span className={css.cardTitle}>👥 角色库({(project?.roles ?? []).length} 个)</span>
3476
3634
  <div className={css.row}>
@@ -3583,63 +3741,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3583
3741
  )}
3584
3742
 
3585
3743
  {/* 写作资产 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
- )}
3744
+ {activeTab === 'assets' && <AssetsTab api={api} />}
3643
3745
 
3644
3746
  {activeTab === 'settings' && configDraft !== null && (
3645
3747
  <div className={css.card}>
@@ -3652,14 +3754,71 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3652
3754
  <label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
3653
3755
  <input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
3654
3756
  </div>
3655
- <div className={css.row}>
3757
+ <div className={css.row} style={{ alignItems: 'flex-start' }}>
3656
3758
  <div className={css.field} style={{ flex: 1 }}>
3657
3759
  <label className={css.fieldLabel}>{tt('settings.provider')}</label>
3658
3760
  <input className={css.input} value={configDraft.provider} onChange={e => { setConfigDraft({ ...configDraft, provider: e.target.value }) }} />
3659
3761
  </div>
3660
- <div className={css.field} style={{ flex: 1 }}>
3762
+ <div className={css.field} style={{ flex: 1.4 }}>
3661
3763
  <label className={css.fieldLabel}>{tt('settings.model')}</label>
3662
- <input className={css.input} value={configDraft.model} onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }} />
3764
+ <select
3765
+ className={css.input}
3766
+ value={modelCustomMode ? '__custom__' : configDraft.model}
3767
+ onChange={e => {
3768
+ const v = e.target.value
3769
+ if (v === '__custom__') {
3770
+ setModelCustomMode(true)
3771
+ } else {
3772
+ setModelCustomMode(false)
3773
+ setConfigDraft({ ...configDraft, model: v })
3774
+ }
3775
+ }}
3776
+ >
3777
+ {MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}
3778
+ <option value="__custom__">{tt('settings.modelCustom')}</option>
3779
+ </select>
3780
+ {modelCustomMode && (
3781
+ <input
3782
+ className={css.input}
3783
+ style={{ marginTop: 6 }}
3784
+ value={configDraft.model}
3785
+ placeholder={tt('settings.modelCustomPlaceholder')}
3786
+ onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }}
3787
+ />
3788
+ )}
3789
+ </div>
3790
+ <div className={css.field} style={{ flex: 1 }}>
3791
+ <label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label>
3792
+ <select
3793
+ className={css.input}
3794
+ value={configDraft.reasoningEffort ?? 'off'}
3795
+ onChange={e => { setConfigDraft({ ...configDraft, reasoningEffort: e.target.value as NovelConfig['reasoningEffort'] }) }}
3796
+ >
3797
+ {REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
3798
+ </select>
3799
+ <span className={css.meta}>{tt('settings.reasoningHint')}</span>
3800
+ </div>
3801
+ </div>
3802
+ <div className={css.row}>
3803
+ <div className={css.field} style={{ flex: 1 }}>
3804
+ <label className={css.fieldLabel}>豆包 API Key(生图)</label>
3805
+ <input className={css.input} type="password" placeholder="ark-..." value={configDraft.imageApiKey ?? ''} onChange={e => { setConfigDraft({ ...configDraft, imageApiKey: e.target.value }) }} />
3806
+ </div>
3807
+ <div className={css.field} style={{ flex: 1 }}>
3808
+ <label className={css.fieldLabel}>豆包生图模型</label>
3809
+ <input className={css.input} placeholder="doubao-seedream-5-0-pro-260628" value={configDraft.imageApiModel ?? ''} onChange={e => { setConfigDraft({ ...configDraft, imageApiModel: e.target.value }) }} />
3810
+ </div>
3811
+ <div className={css.field} style={{ flex: 1 }}>
3812
+ <label className={css.fieldLabel}>启用豆包生图</label>
3813
+ <select
3814
+ className={css.input}
3815
+ value={configDraft.imageApiEnabled ? '1' : '0'}
3816
+ onChange={e => { setConfigDraft({ ...configDraft, imageApiEnabled: e.target.value === '1' }) }}
3817
+ >
3818
+ <option value="0">关(默认)</option>
3819
+ <option value="1">开</option>
3820
+ </select>
3821
+ <span className={css.meta}>开启后漫画工坊才显示「豆包生成」按钮</span>
3663
3822
  </div>
3664
3823
  </div>
3665
3824
  <div className={css.row}>
@@ -3780,8 +3939,142 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3780
3939
  </div>
3781
3940
  )}
3782
3941
 
3783
- {activeTab === 'facts' && (
3942
+ {activeTab === 'book' && bookTab === 'facts' && (
3784
3943
  <div className={css.card}>
3944
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3945
+ <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📚 编年 / 复盘</span>
3946
+ </div>
3947
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
3948
+ <button
3949
+ type="button"
3950
+ className={`${css.button} ${archiveTab === 'facts' ? css.buttonPrimary : ''}`}
3951
+ style={{ fontSize: 14, flex: 1 }}
3952
+ onClick={() => { changeArchiveTab('facts') }}
3953
+ title="编年录:客观事实流水(第 N 章 · 事件),生成/审稿查证用"
3954
+ >
3955
+ 📜 编年录({(project?.facts ?? []).length})
3956
+ </button>
3957
+ <button
3958
+ type="button"
3959
+ className={`${css.button} ${archiveTab === 'reviews' ? css.buttonPrimary : ''}`}
3960
+ style={{ fontSize: 14, flex: 1 }}
3961
+ onClick={() => { changeArchiveTab('reviews') }}
3962
+ title="复盘记录:每章作者复盘(钩子 / 推进 / 衔接 / 趋势)"
3963
+ >
3964
+ 📋 复盘记录({chapters.filter(c => c.authorReview !== undefined).length} 章)
3965
+ </button>
3966
+ </div>
3967
+
3968
+ {archiveTab === 'reviews' ? (
3969
+ <>
3970
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
3971
+ <span className={css.cardTitle}>📋 复盘记录({chapters.filter(c => c.authorReview !== undefined).length} 章已复盘)</span>
3972
+ <div className={css.row}>
3973
+ {(() => {
3974
+ const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
3975
+ return missing > 0 && (
3976
+ <button
3977
+ type="button"
3978
+ className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`}
3979
+ disabled={busy}
3980
+ onClick={() => { void handleAuthorBackfillAll() }}
3981
+ title="对历史已写章节逐章补跑作者复盘"
3982
+ >
3983
+ ↻ 补齐缺失复盘({missing})
3984
+ </button>
3985
+ )
3986
+ })()}
3987
+ </div>
3988
+ </div>
3989
+ {(() => {
3990
+ const reviewed = chapters.filter(c => c.authorReview !== undefined)
3991
+ if (reviewed.length === 0) {
3992
+ return <span className={css.meta}>尚无作者复盘——生成/审稿后自动生成,或点「补齐缺失复盘」为已写章节补跑。</span>
3993
+ }
3994
+ const honored = reviewed.filter(c => c.authorReview!.hookHonored).length
3995
+ const avg = Math.round(reviewed.reduce((s, c) => s + c.authorReview!.endingHook, 0) / reviewed.length * 10) / 10
3996
+ return (
3997
+ <span className={css.meta} style={{ fontWeight: 600 }}>
3998
+ 钩子兑现 {honored}/{reviewed.length} · 结尾钩子均分 {avg}/10
3999
+ </span>
4000
+ )
4001
+ })()}
4002
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', flex: 1, minHeight: 0 }}>
4003
+ {(() => {
4004
+ const groupsWithReviews = chapterGroups.filter(group => group.chapters.some(c => c.authorReview !== undefined))
4005
+ if (groupsWithReviews.length === 0) return null
4006
+ return (
4007
+ <div className={css.row} style={{ justifyContent: 'flex-end', gap: 6 }}>
4008
+ <button
4009
+ type="button"
4010
+ className={`${css.button} ${css.buttonSmall}`}
4011
+ onClick={() => { setExpandedVolumes(Object.fromEntries(groupsWithReviews.map(g => [g.no, true]))) }}
4012
+ >
4013
+ 全部展开
4014
+ </button>
4015
+ <button
4016
+ type="button"
4017
+ className={`${css.button} ${css.buttonSmall}`}
4018
+ onClick={() => { setExpandedVolumes({}) }}
4019
+ >
4020
+ 全部折叠
4021
+ </button>
4022
+ </div>
4023
+ )
4024
+ })()}
4025
+ {chapterGroups.map(group => {
4026
+ const groupReviewed = group.chapters.filter(c => c.authorReview !== undefined)
4027
+ if (groupReviewed.length === 0) return null
4028
+ const volumeExpanded = expandedVolumes[group.no] === true
4029
+ return (
4030
+ <div key={group.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden' }}>
4031
+ <button
4032
+ type="button"
4033
+ style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '8px 12px', fontSize: 13, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}
4034
+ onClick={() => { setExpandedVolumes(prev => ({ ...prev, [group.no]: !volumeExpanded })) }}
4035
+ >
4036
+ <span style={{ color: 'var(--nf-text-3)' }}>{volumeExpanded ? '▾' : '▸'}</span>
4037
+ <b>{group.title}</b>
4038
+ <span className={css.meta}>已复盘 {groupReviewed.length}/{group.chapters.length} 章</span>
4039
+ </button>
4040
+ {volumeExpanded && (
4041
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '4px 8px 8px', borderTop: '1px solid var(--nf-border)' }}>
4042
+ {[...groupReviewed].reverse().map(c => {
4043
+ const ar = c.authorReview!
4044
+ const expanded = expandedReviewChapter === c.no
4045
+ return (
4046
+ <div key={c.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, overflow: 'hidden' }}>
4047
+ <button
4048
+ type="button"
4049
+ style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '6px 10px', fontSize: 12, display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}
4050
+ onClick={() => { setExpandedReviewChapter(expanded ? null : c.no) }}
4051
+ >
4052
+ <span style={{ color: 'var(--nf-text-3)' }}>{expanded ? '▾' : '▸'}</span>
4053
+ <b>第{c.no}章《{c.title}》</b>
4054
+ <span style={{ color: ar.hookHonored ? 'var(--nf-success)' : 'var(--nf-warn)' }}>钩子{ar.hookHonored ? '✓' : '✗'}</span>
4055
+ <span style={{ color: ar.endingHook >= 6 ? 'var(--nf-success)' : 'var(--nf-error)' }}>结尾钩子 {ar.endingHook}/10</span>
4056
+ {ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 4 }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
4057
+ </button>
4058
+ {expanded && (
4059
+ <div style={{ padding: '4px 10px 8px', fontSize: 12, display: 'flex', flexDirection: 'column', gap: 3, borderTop: '1px solid var(--nf-border)' }}>
4060
+ {ar.hookNote !== '' && <span className={css.meta}><b>钩子:</b>{ar.hookNote}</span>}
4061
+ {ar.plotlineProgress !== '' && <span className={css.meta}><b>推进:</b>{ar.plotlineProgress}</span>}
4062
+ {ar.continuity !== '' && <span className={css.meta}><b>衔接:</b>{ar.continuity}</span>}
4063
+ {ar.trend !== '' && <span className={css.meta}><b>趋势:</b>{ar.trend}</span>}
4064
+ </div>
4065
+ )}
4066
+ </div>
4067
+ )
4068
+ })}
4069
+ </div>
4070
+ )}
4071
+ </div>
4072
+ )
4073
+ })}
4074
+ </div>
4075
+ </>
4076
+ ) : (
4077
+ <>
3785
4078
  <div className={css.busyRow} style={{ flexWrap: 'wrap' }}>
3786
4079
  <span className={css.cardTitle}>{tt('facts.title', { n: (project?.facts ?? []).length })}</span>
3787
4080
  <button
@@ -3809,14 +4102,92 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3809
4102
  ) : (
3810
4103
  <span className={css.meta}>暂无事实条目——写一章后会自动生成,或点击上方「回填」。</span>
3811
4104
  )}
4105
+ </>
4106
+ )}
3812
4107
  </div>
3813
4108
  )}
3814
4109
 
3815
4110
  {activeTab === 'plotlines' && (
3816
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
4111
+ <div className={css.card}>
3817
4112
  <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' }}>
4113
+ <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>📜 长线管理</span>
4114
+ </div>
4115
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
4116
+ <button
4117
+ type="button"
4118
+ className={`${css.button} ${longlineTab === 'plotlines' ? css.buttonPrimary : ''}`}
4119
+ style={{ fontSize: 14, flex: 1 }}
4120
+ onClick={() => { changeLonglineTab('plotlines') }}
4121
+ title="剧情线:故事明线(主线 / 支线 / 人物 / 悬念)"
4122
+ >
4123
+ 🧵 剧情线({project?.plotlines?.length ?? 0})
4124
+ </button>
4125
+ <button
4126
+ type="button"
4127
+ className={`${css.button} ${longlineTab === 'foreshadow' ? css.buttonPrimary : ''}`}
4128
+ style={{ fontSize: 14, flex: 1 }}
4129
+ onClick={() => { changeLonglineTab('foreshadow') }}
4130
+ title="伏笔:道具 / 事件级暗线(埋设 → 回收)"
4131
+ >
4132
+ 🔮 伏笔({foreshadows.length})
4133
+ </button>
4134
+ </div>
4135
+
4136
+ {longlineTab === 'foreshadow' ? (
4137
+ <>
4138
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4139
+ <span className={css.cardTitle}>🔮 {tt('foreshadow.title')}({foreshadows.length})</span>
4140
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSuggestForeshadows() }}>
4141
+ {tt('foreshadow.suggest')}
4142
+ </button>
4143
+ </div>
4144
+ {foreshadows.length === 0 ? (
4145
+ <span className={css.meta}>{tt('foreshadow.none')}</span>
4146
+ ) : (
4147
+ <div className={css.chapterList}>
4148
+ {foreshadows.map(f => {
4149
+ const statusLabel = { planned: tt('foreshadow.planned'), planted: tt('foreshadow.planted'), progressing: tt('foreshadow.progressing'), resolved: tt('foreshadow.resolved'), abandoned: tt('foreshadow.abandoned') }[f.status]
4150
+ 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)'
4151
+ return (
4152
+ <div key={f.id} className={css.chapter}>
4153
+ <div className={css.chapterMain}>
4154
+ <div className={css.chapterTitle}>
4155
+ <span>{f.description}</span>
4156
+ </div>
4157
+ <div className={css.meta}>
4158
+ {f.plantedChapter !== undefined && <span>{tt('foreshadow.plantedAt')} 第{f.plantedChapter}章 · </span>}
4159
+ {f.targetChapter !== undefined && <span>{tt('foreshadow.target')} 第{f.targetChapter}章 · </span>}
4160
+ {f.resolvedNote !== undefined && f.resolvedNote !== '' && <span>回收:{f.resolvedNote} · </span>}
4161
+ </div>
4162
+ </div>
4163
+ <span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
4164
+ <div className={css.row} style={{ gap: 4 }}>
4165
+ {f.status === 'planned' && (
4166
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
4167
+ void api.foreshadow({ id: f.id, status: 'planted', plantedChapter: doneCount + 1 }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
4168
+ }}>
4169
+ {tt('foreshadow.setPlanted')}
4170
+ </button>
4171
+ )}
4172
+ {(f.status === 'planted' || f.status === 'progressing') && (
4173
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
4174
+ void api.foreshadow({ id: f.id, status: 'resolved', resolvedNote: `第${doneCount}章回收` }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
4175
+ }}>
4176
+ {tt('foreshadow.setResolved')}
4177
+ </button>
4178
+ )}
4179
+ </div>
4180
+ </div>
4181
+ )
4182
+ })}
4183
+ </div>
4184
+ )}
4185
+ </>
4186
+ ) : (
4187
+ <>
4188
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4189
+ <span className={css.cardTitle}>🧵 {tt('tab.plotlines')}({project?.plotlines?.length ?? 0})</span>
4190
+ <div className={css.row} style={{ flexWrap: 'wrap' }}>
3820
4191
  <button
3821
4192
  type="button"
3822
4193
  className={`${css.button} ${css.buttonSmall}`}
@@ -3945,6 +4316,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3945
4316
  ))}
3946
4317
  </div>
3947
4318
  )}
4319
+ </>
4320
+ )}
3948
4321
  </div>
3949
4322
  )}
3950
4323
 
@@ -3959,7 +4332,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3959
4332
  <div className={css.row} style={{ flexWrap: 'wrap' }}>
3960
4333
  <select
3961
4334
  className={css.input}
3962
- style={{ width: 130 }}
4335
+ style={{ width: 160 }}
3963
4336
  value={breakdownScope}
3964
4337
  onChange={e => { setBreakdownScope(e.target.value as typeof breakdownScope) }}
3965
4338
  title="分析范围"
@@ -3972,13 +4345,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3972
4345
  </select>
3973
4346
  <select
3974
4347
  className={css.input}
3975
- style={{ width: 100 }}
4348
+ style={{ width: 150 }}
3976
4349
  value={breakdownPreset}
3977
4350
  onChange={e => { setBreakdownPreset(e.target.value as 'quick' | 'standard') }}
3978
4351
  title="分析档位"
3979
4352
  >
3980
4353
  <option value="quick">快速(4 维)</option>
3981
- <option value="standard">标准(+卖点)</option>
4354
+ <option value="standard">标准+卖点</option>
3982
4355
  </select>
3983
4356
  <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleBreakdown() }} title="对已写章节做结构/人物/文风/卖点体检(约 1-3 分钟,消耗 LLM 额度)">
3984
4357
  {busy ? '⏳ 分析中…' : '✨ 开始拆书'}
@@ -4019,171 +4392,573 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4019
4392
  </div>
4020
4393
  )}
4021
4394
 
4022
- {activeTab === 'storyboard' && (
4023
- <div className={css.card}>
4395
+ {activeTab === 'roleImage' && (
4396
+ <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
4024
4397
  <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 }}>
4027
- <button
4028
- type="button"
4029
- className={`${css.button} ${css.buttonSmall} ${storyboardView === 'single' ? css.buttonPrimary : ''}`}
4030
- onClick={() => { setStoryboardView('single') }}
4031
- >
4032
- 🎞️ 单集分镜
4033
- </button>
4034
- <button
4035
- type="button"
4036
- className={`${css.button} ${css.buttonSmall} ${storyboardView === 'plan' ? css.buttonPrimary : ''}`}
4037
- onClick={() => { setStoryboardView('plan') }}
4038
- >
4039
- 📺 按卷规划
4040
- </button>
4041
- </div>
4398
+ <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🖼️ 角色形象</span>
4399
+ <span className={css.meta}>角色形象锚点 / 参考图(漫画功能已移除,形象保留用于立绘与一致性)</span>
4042
4400
  </div>
4043
4401
 
4044
- {storyboardView === 'plan' && (
4045
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
4046
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
4047
- <select
4048
- className={css.input}
4049
- style={{ width: 170 }}
4050
- value={storyboardPlanVolume}
4051
- onChange={e => { setStoryboardPlanVolume(Number(e.target.value)) }}
4052
- title="选择要规划的卷"
4053
- >
4054
- {volumes !== undefined && volumes.map(v => (
4055
- <option key={v.no} value={v.no}>第{v.no}卷《{v.title}》</option>
4056
- ))}
4057
- </select>
4058
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleStoryboardPlan() }} title="AI 通读整卷章节摘要,按故事弧线把章节分组成漫剧集(高潮章单独成集、过渡章合并),约 1-3 分钟">
4059
- {busy ? '⏳ 规划中…' : '✨ 生成分集计划'}
4060
- </button>
4061
- <span className={css.meta}>AI 读卷 → 按弧线分集(不再是固定一章一集)</span>
4402
+ <input
4403
+ ref={roleImageInputRef}
4404
+ type="file"
4405
+ accept="image/*"
4406
+ style={{ display: 'none' }}
4407
+ onChange={e => {
4408
+ const file = e.target.files?.[0]
4409
+ const target = roleImageTarget
4410
+ if (file === undefined || target === null) return
4411
+ const readDataUrl = (): Promise<string> => new Promise((resolve, reject) => {
4412
+ const reader = new FileReader()
4413
+ reader.onload = () => resolve(String(reader.result))
4414
+ reader.onerror = () => reject(reader.error ?? new Error('读取图片失败'))
4415
+ reader.readAsDataURL(file!)
4416
+ })
4417
+ void (async () => {
4418
+ const dataUrl = await readDataUrl()
4419
+ if (target.endsWith('||scene')) {
4420
+ const sceneName = target.slice(0, -'||scene'.length)
4421
+ setBusy(true)
4422
+ setBusyLabel(`上传场景图「${sceneName}」${roleImageLabel}…`)
4423
+ setError('')
4424
+ try {
4425
+ await api.scenes({ op: 'image', name: sceneName, dataUrl, label: roleImageLabel })
4426
+ await refresh(false)
4427
+ pushProgress(`已上传场景「${sceneName}」${roleImageLabel}`, 'done')
4428
+ } catch (err) {
4429
+ setError((err as Error).message)
4430
+ pushProgress(`场景图上传失败:${(err as Error).message}`, 'error')
4431
+ } finally {
4432
+ setBusy(false)
4433
+ setBusyLabel('')
4434
+ setRoleImageTarget(null)
4435
+ if (roleImageInputRef.current !== null) roleImageInputRef.current.value = ''
4436
+ }
4437
+ } else {
4438
+ await handleRoleImageUpload(target, file, roleImageLabel)
4439
+ }
4440
+ })()
4441
+ }}
4442
+ />
4443
+
4444
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 10, flex: 1, minHeight: 0, overflowY: 'auto' }}>
4445
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 6, justifyContent: 'space-between' }}>
4446
+ <span className={css.row} style={{ gap: 6 }}>
4447
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void (async () => {
4448
+ setBusy(true)
4449
+ setBusyLabel('⚠️ 提炼本书视觉规则…')
4450
+ setError('')
4451
+ try {
4452
+ const result = await api.visualRules({ op: 'extract' })
4453
+ await refresh(false)
4454
+ pushProgress(`已提炼 ${result.rules.length} 条视觉规则(已注入所有提示词)`, 'done')
4455
+ } catch (err) {
4456
+ setError((err as Error).message)
4457
+ pushProgress(`视觉规则提炼失败:${(err as Error).message}`, 'error')
4458
+ } finally {
4459
+ setBusy(false)
4460
+ setBusyLabel('')
4461
+ }
4462
+ })() }}>⚠️ 提炼视觉规则</button>
4463
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void refresh(false); pushProgress('角色库已刷新', 'done') }}>🔄 刷新角色库</button>
4464
+ </span>
4062
4465
  </div>
4063
- <span className={css.meta}>分集原则:高潮章单独成集或两章一集;过渡章 2-3 章合并;断点选在钩子最强处;每集 60-120 秒。生成后逐集确认,满意再用「单集分镜」为某集生成分镜。</span>
4064
4466
 
4065
- {storyboardPlanResult === null ? (
4066
- <div className={css.shelfEmpty} style={{ minHeight: 140 }}>
4067
- <span className={css.shelfEmptyIcon}>📺</span>
4068
- <span className={css.shelfEmptyTitle}>尚未生成分集计划</span>
4069
- <span className={css.meta}>选卷后点「生成分集计划」,AI 把整卷按故事弧线分成漫剧集</span>
4467
+ {(project?.visualRules ?? []).length > 0 && (
4468
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center', marginTop: 6 }}>
4469
+ <span className={css.meta} style={{ fontSize: 11 }}>⚠️ 本书视觉规则:</span>
4470
+ {(project?.visualRules ?? []).map((r, i) => <span key={i} style={{ border: '1px solid var(--nf-warn, #b8860b)', borderRadius: 999, padding: '1px 8px', fontSize: 10, color: 'var(--nf-warn, #b8860b)' }}>{r}</span>)}
4070
4471
  </div>
4071
- ) : (
4072
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
4073
- <div className={css.meta}><b>策略:</b>{storyboardPlanResult.strategy}</div>
4074
- <div className={css.meta}>共 {storyboardPlanResult.episodes.length} 集 · 覆盖 {storyboardPlanResult.chaptersScanned} 章</div>
4075
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 380, overflowY: 'auto' }}>
4076
- {storyboardPlanResult.episodes.map(ep => (
4077
- <div key={ep.index} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 10px', fontSize: 12 }}>
4078
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
4079
- <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)', flex: 'none' }}>第{ep.index}集</span>
4080
- <b>{ep.title}</b>
4081
- <span className={css.meta}>涵盖:第{ep.chapters[0]}-{ep.chapters[ep.chapters.length - 1]}章({ep.chapters.length}章)</span>
4472
+ )}
4473
+
4474
+ {(() => {
4475
+ const roles = project?.roles ?? []
4476
+ if (roles.length === 0) {
4477
+ return (
4478
+ <div className={css.shelfEmpty} style={{ minHeight: 140, flex: 1 }}>
4479
+ <span className={css.shelfEmptyIcon}>🎭</span>
4480
+ <span className={css.shelfEmptyTitle}>角色库为空</span>
4481
+ <span className={css.meta}>请先到「角色库」提炼角色并生成形象锚点</span>
4482
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('roles') }}>去角色库</button>
4483
+ </div>
4484
+ )
4485
+ }
4486
+ const detailRole = detailRoleName !== null ? roles.find(r => r.name === detailRoleName) : undefined
4487
+ const group = (role: RoleRecord) => {
4488
+ const g = role.gallery ?? []
4489
+ const byLabel = (key: string) => g.filter(x => x.label === key)
4490
+ const byPrefix = (pre: string) => g.filter(x => x.label.startsWith(pre))
4491
+ return {
4492
+ portrait: byLabel('立绘')[0],
4493
+ sheet: byLabel('四视图')[0],
4494
+ expressions: byPrefix('表情-'),
4495
+ others: g.filter(x => x.label !== '立绘' && x.label !== '四视图' && !x.label.startsWith('表情-')),
4496
+ }
4497
+ }
4498
+ const uploadInDetail = (role: RoleRecord) => {
4499
+ setRoleImageLabel(detailUploadLabel.trim() !== '' ? detailUploadLabel.trim() : '立绘')
4500
+ setRoleImageTarget(role.name)
4501
+ roleImageInputRef.current?.click()
4502
+ }
4503
+ return (
4504
+ <>
4505
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 10 }}>
4506
+ {roles.map(r => (
4507
+ <div key={r.name} onClick={() => { setDetailRoleName(r.name); setDetailUploadLabel('立绘') }} style={{ cursor: 'pointer', border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden', background: 'var(--nf-bg-inset)', display: 'flex', flexDirection: 'column' }}>
4508
+ <div style={{ aspectRatio: '3/4', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--nf-bg-inset)', borderBottom: '1px solid var(--nf-border)', position: 'relative', overflow: 'hidden' }}>
4509
+ {r.imageUrl !== undefined
4510
+ ? <img src={r.imageUrl} alt={r.name} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 20%' }} />
4511
+ : <span className={css.meta}>暂无形象</span>}
4512
+ <span style={{ position: 'absolute', bottom: 4, right: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 10, borderRadius: 6, padding: '1px 6px' }}>🖼 {(r.gallery ?? []).length}</span>
4513
+ </div>
4514
+ <div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
4515
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
4516
+ <b style={{ fontSize: 13 }}>{r.name}</b>
4517
+ <span className={css.meta} style={{ fontSize: 10 }}>{r.roleLabel}</span>
4518
+ </div>
4519
+ <div className={css.meta} style={{ fontSize: 10, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
4520
+ {r.imagePrompt !== undefined ? '✓ 锚点' : '⚠ 无锚点'} · {(r.identity ?? '').slice(0, 16)}
4521
+ </div>
4522
+ </div>
4523
+ </div>
4524
+ ))}
4525
+ </div>
4526
+
4527
+ {detailRole !== undefined && (
4528
+ <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setDetailRoleName(null) }}>
4529
+ <div style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 16, width: 'min(960px, 100%)', maxHeight: '92vh', overflowY: 'auto', padding: 20, position: 'relative' }} onClick={e => e.stopPropagation()}>
4530
+ <button type="button" className={css.iconButton} style={{ position: 'absolute', top: 12, right: 12, fontSize: 18 }} title="关闭" aria-label="关闭" onClick={() => { setDetailRoleName(null) }}>✕</button>
4531
+ <div style={{ marginBottom: 12 }}>
4532
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
4533
+ <b style={{ fontSize: 22 }}>{detailRole.name}</b>
4534
+ <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{detailRole.roleLabel}</span>
4535
+ </div>
4536
+ <div className={css.meta} style={{ marginTop: 4 }}>{detailRole.identity}</div>
4537
+ </div>
4538
+
4539
+ {(project?.visualRules ?? []).length > 0 && (
4540
+ <div style={{ border: '1px solid var(--nf-warn, #b8860b)', borderRadius: 10, padding: '6px 10px', marginBottom: 10, fontSize: 11 }}>
4541
+ <b>⚠️ 本书视觉规则(已内嵌到下方所有提示词)</b>
4542
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
4543
+ {(project?.visualRules ?? []).map((r, i) => <span key={i} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '1px 8px', fontSize: 10 }}>{r}</span>)}
4544
+ </div>
4545
+ </div>
4546
+ )}
4547
+
4548
+ <details style={{ marginBottom: 10, fontSize: 12 }}>
4549
+ <summary style={{ cursor: 'pointer', color: 'var(--nf-text-2)' }}>🖼 图集({(detailRole.gallery ?? []).length} 张 · 点击展开/收起)</summary>
4550
+ <div style={{ marginTop: 8 }}>
4551
+ {(() => {
4552
+ const g = group(detailRole)
4553
+ const hasMain = g.portrait !== undefined || g.sheet !== undefined
4554
+ return (
4555
+ <>
4556
+ {hasMain && (
4557
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 10, marginBottom: 12 }}>
4558
+ {g.portrait !== undefined && (
4559
+ <div style={{ border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden' }}>
4560
+ <img src={g.portrait.dataUrl} alt="立绘" style={{ width: '100%', maxHeight: 380, objectFit: 'contain', display: 'block', background: '#111' }} />
4561
+ <div className={css.meta} style={{ padding: '2px 8px', fontSize: 11 }}>立绘</div>
4562
+ </div>
4563
+ )}
4564
+ {g.sheet !== undefined && (
4565
+ <div style={{ border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden' }}>
4566
+ <img src={g.sheet.dataUrl} alt="四视图" style={{ width: '100%', maxHeight: 380, objectFit: 'contain', display: 'block', background: '#111' }} />
4567
+ <div className={css.meta} style={{ padding: '2px 8px', fontSize: 11 }}>四视图</div>
4568
+ </div>
4569
+ )}
4570
+ </div>
4571
+ )}
4572
+ {g.expressions.length > 0 && (
4573
+ <div style={{ marginBottom: 12 }}>
4574
+ <b style={{ fontSize: 13 }}>表情设定</b>
4575
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: 8, marginTop: 6 }}>
4576
+ {g.expressions.map(img => (
4577
+ <div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
4578
+ <img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 110, objectFit: 'cover', display: 'block' }} />
4579
+ <div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label.replace('表情-', '')}</div>
4580
+ <button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
4581
+ try {
4582
+ await api.roles({ op: 'removeImage', name: detailRole.name, label: img.label })
4583
+ await refresh(false)
4584
+ pushProgress(`已删除「${detailRole.name}」${img.label}`, 'done')
4585
+ } catch (err) { setError((err as Error).message) }
4586
+ })() }}>×</button>
4587
+ </div>
4588
+ ))}
4589
+ </div>
4590
+ </div>
4591
+ )}
4592
+ {g.others.length > 0 && (
4593
+ <div style={{ marginBottom: 12 }}>
4594
+ <b style={{ fontSize: 13 }}>场景 / 细节</b>
4595
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8, marginTop: 6 }}>
4596
+ {g.others.map(img => (
4597
+ <div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
4598
+ <img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }} />
4599
+ <div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label}</div>
4600
+ <button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
4601
+ try {
4602
+ await api.roles({ op: 'removeImage', name: detailRole.name, label: img.label })
4603
+ await refresh(false)
4604
+ pushProgress(`已删除「${detailRole.name}」${img.label}`, 'done')
4605
+ } catch (err) { setError((err as Error).message) }
4606
+ })() }}>×</button>
4607
+ </div>
4608
+ ))}
4609
+ </div>
4610
+ </div>
4611
+ )}
4612
+ </>
4613
+ )
4614
+ })()}
4615
+ </div>
4616
+ </details>
4617
+
4618
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 14, marginBottom: 12 }}>
4619
+ <div>
4620
+ <b style={{ fontSize: 13 }}>基本信息</b>
4621
+ <div style={{ fontSize: 12, lineHeight: 1.8, marginTop: 4 }}>
4622
+ <div><span className={css.meta}>性格:</span>{(detailRole.traits ?? []).join('、') || '—'}</div>
4623
+ <div><span className={css.meta}>目标:</span>{detailRole.goals || '—'}</div>
4624
+ <div><span className={css.meta}>关系:</span>{(detailRole.relations ?? []).join(';') || '—'}</div>
4625
+ <div><span className={css.meta}>成长线:</span>{(detailRole.arc ?? []).join(';') || '—'}</div>
4626
+ </div>
4627
+ </div>
4628
+ <div>
4629
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4630
+ <b style={{ fontSize: 13 }}>📄 提示词包</b>
4631
+ {detailRole.imagePrompt !== undefined && detailRole.promptKit === undefined && (
4632
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void (async () => {
4633
+ setBusy(true)
4634
+ setBusyLabel(`精修「${detailRole.name}」提示词…`)
4635
+ setError('')
4636
+ try {
4637
+ await api.roles({ op: 'promptKit', name: detailRole.name })
4638
+ await refresh(false)
4639
+ pushProgress(`已生成「${detailRole.name}」四类精修提示词`, 'done')
4640
+ } catch (err) {
4641
+ setError((err as Error).message)
4642
+ pushProgress(`提示词精修失败:${(err as Error).message}`, 'error')
4643
+ } finally {
4644
+ setBusy(false)
4645
+ setBusyLabel('')
4646
+ }
4647
+ })() }}>✨ 生成精修版</button>
4648
+ )}
4649
+ </div>
4650
+ {detailRole.imagePrompt === undefined && <span className={css.meta}>未生成锚点,先点「✨ 生成锚点」</span>}
4651
+ {(() => {
4652
+ const anchor = detailRole.imagePrompt
4653
+ if (anchor === undefined) return null
4654
+ const rules = project?.visualRules ?? []
4655
+ const rulesZh = rules.length > 0 ? '\n【本书视觉规则】' + rules.join(';') : ''
4656
+ const expressions = detailRole.expressions ?? ['平静']
4657
+ const expName = (n: string) => n.replace(/^表情-/, '')
4658
+ const kit = detailRole.promptKit
4659
+ const blocks: Array<{ key: string; title: string; zh: string; en: string }> = []
4660
+ if (kit !== undefined) {
4661
+ blocks.push({ key: 'portrait', title: '立绘', zh: kit.portrait.zh, en: kit.portrait.en })
4662
+ blocks.push({ key: 'sheet', title: '四视图', zh: kit.sheet.zh, en: kit.sheet.en })
4663
+ for (const e of kit.expressions) blocks.push({ key: 'exp-' + e.name, title: `表情·${e.name}`, zh: e.zh, en: e.en })
4664
+ blocks.push({ key: 'details', title: '细节', zh: kit.details.zh, en: kit.details.en })
4665
+ } else {
4666
+ blocks.push({ key: 'portrait', title: '立绘', zh: anchor.zh + '。全身/半身正视图,写实电影感。' + rulesZh, en: anchor.en + ', full body, front view, plain background' })
4667
+ blocks.push({ key: 'sheet', title: '四视图', zh: anchor.zh + '。同一角色的正面/左侧面/右侧面/背面四视图设定表,纯白背景,四个视角分别描述。' + rulesZh, en: anchor.en + ', character sheet, front view, left side view, right side view, back view, full body, plain white background' })
4668
+ for (const n of expressions) blocks.push({ key: 'exp-' + n, title: `表情·${expName(n)}`, zh: anchor.zh + `。表情:${expName(n)},脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,皮肤纹理细节完整,无多余杂物。` + rulesZh, en: anchor.en + `, facial close-up, head to collarbone, expression: ${expName(n)}, plain white background, consistent with character design, detailed skin texture, no extra objects` })
4669
+ blocks.push({ key: 'details', title: '细节', zh: `多组局部细节集合参考图,纯白背景:${anchor.tags.map(t => t + '特写').join(';')}。细节清晰锐利,角色细节参考稿,无多余杂物。` + rulesZh, en: anchor.tags.join(', ') + ', multi-panel detail reference sheet, plain white background, macro close-up, sharp details, character detail sheet, no extra objects' })
4670
+ }
4671
+ return (
4672
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
4673
+ {blocks.map(b => (
4674
+ <div key={b.key} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 8px' }}>
4675
+ <div className={css.row} style={{ justifyContent: 'space-between' }}>
4676
+ <b style={{ fontSize: 11 }}>{b.title}</b>
4677
+ <span className={css.row} style={{ gap: 4 }}>
4678
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ fontSize: 10 }} onClick={() => {
4679
+ void navigator.clipboard?.writeText(b.zh).then(() => { pushProgress(`已复制「${detailRole.name}」${b.title}中文提示词`, 'done') }).catch(() => { /* ignore */ })
4680
+ }}>复制</button>
4681
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ fontSize: 10 }} onClick={() => {
4682
+ void navigator.clipboard?.writeText(b.en).then(() => { pushProgress(`已复制「${detailRole.name}」${b.title}英文标签`, 'done') }).catch(() => { /* ignore */ })
4683
+ }}>EN</button>
4684
+ </span>
4685
+ </div>
4686
+ <div className={css.meta} style={{ fontSize: 11, lineHeight: 1.5, marginTop: 3 }}>{b.zh}</div>
4687
+ <details style={{ marginTop: 3 }}>
4688
+ <summary style={{ cursor: 'pointer', fontSize: 10, color: 'var(--nf-text-2)' }}>🌐 英文标签(点击展开)</summary>
4689
+ <div className={css.meta} style={{ fontSize: 9, fontFamily: 'monospace', marginTop: 2, wordBreak: 'break-all', color: 'var(--nf-text-2)' }}>{b.en}</div>
4690
+ </details>
4691
+ </div>
4692
+ ))}
4693
+ <div className={css.meta} style={{ fontSize: 10 }}>关键词:{(anchor.tags ?? []).join('、')}</div>
4694
+ </div>
4695
+ )
4696
+ })()}
4697
+ </div>
4698
+ </div>
4699
+
4700
+ <div style={{ borderTop: '1px solid var(--nf-border)', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
4701
+ {detailRole.imagePrompt === undefined && (
4702
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRoleVisual(detailRole.name) }}>✨ 生成锚点</button>
4703
+ )}
4704
+ {detailRole.imagePrompt !== undefined && config?.imageApiEnabled === true && (
4705
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRoleImageGenerate(detailRole.name) }}>豆包生成</button>
4706
+ )}
4707
+ {detailRole.imagePrompt !== undefined && (
4708
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => {
4709
+ const text = [detailRole.imagePrompt!.en, detailRole.imagePrompt!.tags.join(', ')].filter(Boolean).join('\n')
4710
+ void navigator.clipboard?.writeText(text).then(() => { pushProgress(`已复制「${detailRole.name}」英文生图提示词`, 'done') }).catch(() => { /* ignore */ })
4711
+ }}>复制提示词</button>
4712
+ )}
4713
+ <input
4714
+ className={css.input}
4715
+ style={{ width: 140, fontSize: 12, padding: '3px 6px' }}
4716
+ placeholder="图集标签"
4717
+ value={detailUploadLabel}
4718
+ onChange={e => { setDetailUploadLabel(e.target.value) }}
4719
+ />
4720
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { uploadInDetail(detailRole) }}>📤 上传图</button>
4721
+ <select
4722
+ className={css.input}
4723
+ style={{ fontSize: 12, padding: '3px 4px', width: 90 }}
4724
+ value={detailRole.importance ?? 'support'}
4725
+ onChange={e => { void handleRoleImportance(detailRole, e.target.value as RoleRecord['importance']) }}
4726
+ title="漫剧重要性"
4727
+ >
4728
+ <option value="main">重要</option>
4729
+ <option value="support">配角</option>
4730
+ <option value="extra">路人</option>
4731
+ </select>
4732
+ </div>
4082
4733
  </div>
4083
- <div className={css.meta}>{ep.narrativeJob}</div>
4084
- <div className={css.meta} style={{ color: 'var(--nf-info)' }}>开头钩子:{ep.openingHook}</div>
4085
- <div className={css.meta} style={{ color: 'var(--nf-warn)' }}>结尾钩子:{ep.endingHook}</div>
4086
4734
  </div>
4087
- ))}
4735
+ )}
4736
+ </>
4737
+ )
4738
+ })()}
4739
+ </div>
4740
+ </div>
4741
+ )}
4742
+
4743
+ {activeTab === 'scenes' && (
4744
+ <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
4745
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4746
+ <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🏞️ 场景库</span>
4747
+ <span className={css.meta}>场景视觉锚点:提炼自正文,供漫剧分镜/生图锁定"在哪、什么氛围"</span>
4748
+ </div>
4749
+
4750
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 6, margin: '8px 0' }}>
4751
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void (async () => {
4752
+ setBusy(true)
4753
+ setBusyLabel('✨ 从全书提炼场景…')
4754
+ setError('')
4755
+ try {
4756
+ const result = await api.scenes({ op: 'extract' })
4757
+ setSceneCandidates(result.candidates ?? [])
4758
+ pushProgress(`提炼出 ${(result.candidates ?? []).length} 个候选场景,可采纳或修改后采纳`, 'done')
4759
+ } catch (err) {
4760
+ setError((err as Error).message)
4761
+ pushProgress(`场景提炼失败:${(err as Error).message}`, 'error')
4762
+ } finally {
4763
+ setBusy(false)
4764
+ setBusyLabel('')
4765
+ }
4766
+ })() }}>{busy ? '⏳ 提炼中…' : '✨ 从全书提炼场景'}</button>
4767
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void refresh(false); pushProgress('场景库已刷新', 'done') }}>🔄 刷新</button>
4768
+ {sceneCandidates !== null && (
4769
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneCandidates(null) }}>收起候选</button>
4770
+ )}
4771
+ </div>
4772
+
4773
+ {sceneDraft !== null && (
4774
+ <div style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: 10, marginBottom: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
4775
+ <b style={{ fontSize: 13 }}>编辑场景</b>
4776
+ <input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="场景名" value={sceneDraft.name} onChange={e => { setSceneDraft({ ...sceneDraft, name: e.target.value }) }} />
4777
+ <input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="一句话定位" value={sceneDraft.summary} onChange={e => { setSceneDraft({ ...sceneDraft, summary: e.target.value }) }} />
4778
+ <div className={css.row} style={{ gap: 6 }}>
4779
+ <input className={css.input} style={{ fontSize: 12, padding: '4px 8px', flex: 1 }} placeholder="幕归属(第一幕后场…)" value={sceneDraft.act ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, act: e.target.value }) }} />
4780
+ <input className={css.input} style={{ fontSize: 12, padding: '4px 8px', flex: 1 }} placeholder="时间光态(雨夜/闭店后…)" value={sceneDraft.moment ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, moment: e.target.value }) }} />
4781
+ </div>
4782
+ <textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 48 }} placeholder="关键镜头(每行一条:人物动作+情绪+镜头)" value={(sceneDraft.beats ?? []).join('\n')} onChange={e => { setSceneDraft({ ...sceneDraft, beats: e.target.value.split(/\n+/).map(x => x.trim()).filter(Boolean) }) }} />
4783
+ <input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="人物在场状态(含标志物细节)" value={sceneDraft.characterState ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, characterState: e.target.value }) }} />
4784
+ <textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="环境构成(每行一项)" value={(sceneDraft.elements ?? []).join('\n')} onChange={e => { setSceneDraft({ ...sceneDraft, elements: e.target.value.split(/\n+/).map(x => x.trim()).filter(Boolean) }) }} />
4785
+ <textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="中文生图提示词" value={sceneDraft.zh} onChange={e => { setSceneDraft({ ...sceneDraft, zh: e.target.value }) }} />
4786
+ <textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="英文生图提示词" value={sceneDraft.en} onChange={e => { setSceneDraft({ ...sceneDraft, en: e.target.value }) }} />
4787
+ <div className={css.row} style={{ gap: 6 }}>
4788
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || sceneDraft.name.trim() === ''} onClick={() => { void (async () => {
4789
+ setBusy(true)
4790
+ try {
4791
+ await api.scenes({ op: 'update', scene: { ...sceneDraft, name: sceneDraft.name.trim() } })
4792
+ await refresh(false)
4793
+ setSceneDraft(null)
4794
+ pushProgress(`已保存场景「${sceneDraft.name.trim()}」`, 'done')
4795
+ } catch (err) {
4796
+ setError((err as Error).message)
4797
+ } finally {
4798
+ setBusy(false)
4799
+ }
4800
+ })() }}>保存</button>
4801
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(null) }}>取消</button>
4802
+ </div>
4803
+ </div>
4804
+ )}
4805
+
4806
+ {sceneCandidates !== null && sceneCandidates.length > 0 && (
4807
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
4808
+ {sceneCandidates.map(s => (
4809
+ <div key={s.name} style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: '8px 10px', fontSize: 12 }}>
4810
+ <div className={css.row} style={{ flexWrap: 'wrap', gap: 6, justifyContent: 'space-between' }}>
4811
+ <span><b>{s.name}</b> <span className={css.meta}>· {(s.moods ?? []).join('、')}</span></span>
4812
+ <span className={css.row} style={{ gap: 4 }}>
4813
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void (async () => {
4814
+ try {
4815
+ await api.scenes({ op: 'adopt', scene: s })
4816
+ setSceneCandidates((sceneCandidates ?? []).filter(x => x.name !== s.name))
4817
+ await refresh(false)
4818
+ pushProgress(`已采纳场景「${s.name}」`, 'done')
4819
+ } catch (err) { setError((err as Error).message) }
4820
+ })() }}>采纳</button>
4821
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(s); setSceneCandidates(null) }}>修改后采纳</button>
4822
+ </span>
4088
4823
  </div>
4824
+ <div className={css.meta} style={{ marginTop: 2 }}>{s.summary}</div>
4825
+ <div className={css.meta} style={{ marginTop: 2, fontSize: 11 }}>要素:{(s.elements ?? []).join(';')}</div>
4089
4826
  </div>
4090
- )}
4827
+ ))}
4091
4828
  </div>
4092
4829
  )}
4093
4830
 
4094
- {storyboardView === 'single' && (
4095
- <>
4096
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4097
- <span className={css.meta}>把一章网文改编为高完播率的漫剧分镜:角色视觉锚点(复用角色库形象词)+ 8-12 格分镜表(画面/台词/转场/AI 提示词),可直接用于豆包/即梦/SD 出图或视频。黄金开局 3 秒 + 每 10 秒小高潮 + 结尾钩子。</span>
4098
- <div className={css.row} style={{ flexWrap: 'wrap' }}>
4099
- <select
4100
- className={css.input}
4101
- style={{ width: 150 }}
4102
- value={storyboardChapterNo}
4103
- onChange={e => { setStoryboardChapterNo(Number(e.target.value)) }}
4104
- title="选择要改编的章节"
4105
- >
4106
- {chapters.filter(c => c.status !== 'pending' && c.status !== 'generating').map(c => (
4107
- <option key={c.no} value={c.no}>第{c.no}章 {c.title}</option>
4108
- ))}
4109
- </select>
4110
- <select
4111
- className={css.input}
4112
- style={{ width: 100 }}
4113
- value={storyboardTool}
4114
- onChange={e => { setStoryboardTool(e.target.value as typeof storyboardTool) }}
4115
- title="AI 工具(影响提示词格式)"
4116
- >
4117
- <option value="doubao">豆包</option>
4118
- <option value="seedance">Seedance</option>
4119
- <option value="sd">SD/ComfyUI</option>
4120
- <option value="mj">Midjourney</option>
4121
- </select>
4122
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleStoryboard(storyboardChapterNo) }} title="把所选章节改编为抖音漫剧分镜(约 1-2 分钟,消耗 LLM 额度)">
4123
- {busy ? '⏳ 生成中…' : '✨ 生成分镜'}
4124
- </button>
4831
+ {(() => {
4832
+ const scenes = project?.scenes ?? []
4833
+ const detail = sceneDetailName !== null ? scenes.find(s => s.name === sceneDetailName) : undefined
4834
+ if (scenes.length === 0 && sceneCandidates === null) {
4835
+ return (
4836
+ <div className={css.shelfEmpty} style={{ minHeight: 140, flex: 1 }}>
4837
+ <span className={css.shelfEmptyIcon}>🏞️</span>
4838
+ <span className={css.shelfEmptyTitle}>场景库为空</span>
4839
+ <span className={css.meta}>点「✨ 从全书提炼场景」自动建立,或手动新增</span>
4840
+ </div>
4841
+ )
4842
+ }
4843
+ return (
4844
+ <>
4845
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 10 }}>
4846
+ {scenes.map(s => {
4847
+ const cover = (s.gallery ?? [])[0]
4848
+ return (
4849
+ <div key={s.name} onClick={() => { setSceneDetailName(s.name); setSceneUploadLabel('全景') }} style={{ cursor: 'pointer', border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden', background: 'var(--nf-bg-inset)', display: 'flex', flexDirection: 'column' }}>
4850
+ <div style={{ height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--nf-bg-inset)', borderBottom: '1px solid var(--nf-border)', position: 'relative' }}>
4851
+ {cover !== undefined
4852
+ ? <img src={cover.dataUrl} alt={s.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
4853
+ : <span className={css.meta}>暂无场景图</span>}
4854
+ <span style={{ position: 'absolute', bottom: 4, right: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 10, borderRadius: 6, padding: '1px 6px' }}>🖼 {(s.gallery ?? []).length}</span>
4855
+ </div>
4856
+ <div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
4857
+ <b style={{ fontSize: 13 }}>{s.name}</b>
4858
+ <div className={css.meta} style={{ fontSize: 10 }}>{(s.moods ?? []).join('、') || s.summary.slice(0, 16)}</div>
4859
+ </div>
4860
+ </div>
4861
+ )
4862
+ })}
4125
4863
  </div>
4126
- </div>
4127
4864
 
4128
- {storyboardResult === null ? (
4129
- <div className={css.shelfEmpty} style={{ minHeight: 140 }}>
4130
- <span className={css.shelfEmptyIcon}>🎬</span>
4131
- <span className={css.shelfEmptyTitle}>尚未生成分镜</span>
4132
- <span className={css.meta}>选择章节与 AI 工具,点「生成分镜」开始</span>
4133
- </div>
4134
- ) : (
4135
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
4136
- <div className={css.meta}><b>本集:</b>{storyboardResult.title}</div>
4137
- <div className={css.meta}><b>赛道节奏:</b>{storyboardResult.pacingNote}</div>
4138
- <div className={css.meta}><b>本集钩子:</b>{storyboardResult.hook}</div>
4865
+ {detail !== undefined && (
4866
+ <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={() => { setSceneDetailName(null) }}>
4867
+ <div style={{ background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 16, width: 'min(860px, 100%)', maxHeight: '92vh', overflowY: 'auto', padding: 20, position: 'relative' }} onClick={e => e.stopPropagation()}>
4868
+ <button type="button" className={css.iconButton} style={{ position: 'absolute', top: 12, right: 12, fontSize: 18 }} title="关闭" aria-label="关闭" onClick={() => { setSceneDetailName(null) }}>✕</button>
4869
+ <div style={{ marginBottom: 12 }}>
4870
+ <b style={{ fontSize: 20 }}>{detail.name}</b>
4871
+ <div className={css.meta} style={{ marginTop: 4 }}>{detail.summary}</div>
4872
+ </div>
4139
4873
 
4140
- {storyboardResult.characters.length > 0 && (
4141
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
4142
- <b>角色视觉锚点(全分镜强制复用)</b>
4143
- {storyboardResult.characters.map(c => (
4144
- <div key={c.name} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 10px', fontSize: 12 }}>
4145
- <div><b>{c.name}</b></div>
4146
- <div className={css.meta}>{c.visualAnchor}</div>
4147
- <div className={css.meta} style={{ fontFamily: 'monospace', fontSize: 11 }}>{c.tags}</div>
4148
- </div>
4149
- ))}
4150
- </div>
4151
- )}
4874
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 8, marginBottom: 12 }}>
4875
+ {(detail.gallery ?? []).map(img => (
4876
+ <div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
4877
+ <img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 100, objectFit: 'cover', display: 'block' }} />
4878
+ <div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label}</div>
4879
+ <button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
4880
+ try {
4881
+ await api.scenes({ op: 'removeImage', name: detail.name, label: img.label })
4882
+ await refresh(false)
4883
+ pushProgress(`已删除「${detail.name}」${img.label}`, 'done')
4884
+ } catch (err) { setError((err as Error).message) }
4885
+ })() }}>×</button>
4886
+ </div>
4887
+ ))}
4888
+ {(detail.gallery ?? []).length === 0 && <span className={css.meta}>暂无场景图</span>}
4889
+ </div>
4152
4890
 
4153
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
4154
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
4155
- <b>分镜表({storyboardResult.panels.length} 格)</b>
4156
- <button
4157
- type="button"
4158
- className={`${css.button} ${css.buttonSmall}`}
4159
- onClick={() => {
4160
- const text = storyboardResult.panels.map(p => `[${p.timecode}] ${p.shot}\n画面:${p.visual}\n台词:${p.dialogue}\n转场:${p.transition}\n提示词:${p.prompt}`).join('\n\n')
4161
- void navigator.clipboard?.writeText(text).then(() => { pushProgress('分镜表已复制到剪贴板', 'done') }).catch(() => { /* ignore */ })
4162
- }}
4163
- title="复制全部分镜提示词"
4164
- >
4165
- 📋 复制全部
4166
- </button>
4167
- </div>
4168
- {storyboardResult.panels.map(p => (
4169
- <div key={p.index} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 10px', fontSize: 12 }}>
4170
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
4171
- <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)', flex: 'none' }}>{p.timecode}</span>
4172
- <b>{p.shot}</b>
4173
- <span className={css.meta}>转场:{p.transition}</span>
4891
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 14, marginBottom: 12 }}>
4892
+ <div style={{ fontSize: 12, lineHeight: 1.8 }}>
4893
+ {(detail.act !== undefined || detail.moment !== undefined) && (
4894
+ <div style={{ marginBottom: 6 }}>
4895
+ {(detail.act !== undefined && detail.act !== '') && <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)', marginRight: 6 }}>{detail.act}</span>}
4896
+ {(detail.moment !== undefined && detail.moment !== '') && <span className={css.meta}>{detail.moment}</span>}
4897
+ </div>
4898
+ )}
4899
+ {(detail.beats ?? []).length > 0 && (
4900
+ <div style={{ marginBottom: 8 }}>
4901
+ <b style={{ fontSize: 13 }}>关键镜头</b>
4902
+ <div style={{ marginTop: 4 }}>{(detail.beats ?? []).map((b, i) => <div key={i} style={{ fontSize: 11, color: 'var(--nf-text-2)', marginTop: 2 }}>🎬 {b}</div>)}</div>
4903
+ </div>
4904
+ )}
4905
+ {detail.characterState !== undefined && detail.characterState !== '' && (
4906
+ <div style={{ marginBottom: 8 }}>
4907
+ <b style={{ fontSize: 13 }}>人物状态</b>
4908
+ <div className={css.meta} style={{ marginTop: 2 }}>{detail.characterState}</div>
4909
+ </div>
4910
+ )}
4911
+ <b style={{ fontSize: 13 }}>环境构成</b>
4912
+ <div style={{ marginTop: 4 }}>{(detail.elements ?? []).map(e => <span key={e} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '1px 8px', fontSize: 11, margin: '0 4px 4px 0', display: 'inline-block' }}>{e}</span>)}</div>
4913
+ <div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>色调光影</b></div>
4914
+ <div style={{ marginTop: 4 }}>{(detail.palette ?? []).join(';') || '—'}</div>
4915
+ <div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>氛围</b></div>
4916
+ <div style={{ marginTop: 4 }}>{(detail.moods ?? []).join('、') || '—'}</div>
4917
+ <div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>依据</b></div>
4918
+ <div className={css.meta} style={{ marginTop: 4 }}>{detail.source || '—'}</div>
4919
+ </div>
4920
+ <div>
4921
+ <b style={{ fontSize: 13 }}>生图提示词</b>
4922
+ <div className={css.meta} style={{ fontSize: 11, lineHeight: 1.6, marginTop: 4 }}>{detail.zh}</div>
4923
+ <div className={css.meta} style={{ fontSize: 10, fontFamily: 'monospace', marginTop: 4, wordBreak: 'break-all' }}>{detail.en}</div>
4924
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 8 }}>
4925
+ {(detail.tags ?? []).map(t => <span key={t} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '2px 8px', fontSize: 11 }}>{t}</span>)}
4926
+ </div>
4927
+ </div>
4928
+ </div>
4929
+
4930
+ <div style={{ borderTop: '1px solid var(--nf-border)', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
4931
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => {
4932
+ const text = [detail.en, detail.tags.join(', ')].filter(Boolean).join('\n')
4933
+ void navigator.clipboard?.writeText(text).then(() => { pushProgress(`已复制「${detail.name}」英文生图提示词`, 'done') }).catch(() => { /* ignore */ })
4934
+ }}>复制提示词</button>
4935
+ <input className={css.input} style={{ width: 130, fontSize: 12, padding: '3px 6px' }} placeholder="图集标签" value={sceneUploadLabel} onChange={e => { setSceneUploadLabel(e.target.value) }} />
4936
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
4937
+ setRoleImageLabel(sceneUploadLabel.trim() !== '' ? sceneUploadLabel.trim() : '全景')
4938
+ setSceneUploadLabel(sceneUploadLabel.trim() !== '' ? sceneUploadLabel.trim() : '全景')
4939
+ setRoleImageTarget(detail.name + '||scene')
4940
+ roleImageInputRef.current?.click()
4941
+ }}>📤 上传场景图</button>
4942
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(detail); setSceneDetailName(null) }}>✏️ 编辑</button>
4943
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ color: 'var(--nf-danger, #e05)' }} onClick={() => { void (async () => {
4944
+ if (!window.confirm(`确定删除场景「${detail.name}」?`)) return
4945
+ try {
4946
+ await api.scenes({ op: 'remove', name: detail.name })
4947
+ await refresh(false)
4948
+ setSceneDetailName(null)
4949
+ pushProgress(`已删除场景「${detail.name}」`, 'done')
4950
+ } catch (err) { setError((err as Error).message) }
4951
+ })() }}>🗑 删除</button>
4952
+ </div>
4174
4953
  </div>
4175
- <div className={css.meta}>{p.visual}</div>
4176
- {p.dialogue !== '' && <div className={css.meta} style={{ color: 'var(--nf-info)' }}>💬 {p.dialogue}</div>}
4177
- <div className={css.meta} style={{ fontFamily: 'monospace', fontSize: 11, color: 'var(--nf-text-2)' }}>{p.prompt}</div>
4178
4954
  </div>
4179
- ))}
4180
- </div>
4181
- </div>
4182
- )}
4183
- </>
4184
- )}
4955
+ )}
4956
+ </>
4957
+ )
4958
+ })()}
4185
4959
  </div>
4186
4960
  )}
4961
+
4187
4962
  </>)}
4188
4963
  </div>
4189
4964
  </div>