@waterwx/dsh-novel-forge 1.5.0 → 1.7.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.
@@ -9,6 +9,7 @@ import type { ReactElement } from 'react'
9
9
  import type { NovelApi } from '../api.ts'
10
10
  import type { PanelController } from './controller.ts'
11
11
  import { tt } from './helpers.ts'
12
+ import { BarChart3, Book, BookMarked, BookOpen, Brain, Clapperboard, Factory, FileText, Folder, GitBranch, Library, MessageSquare, Palette, PenLine, PlugZap, RotateCcw, ScrollText, Search, Settings, Sparkles, Wrench } from 'lucide-react'
12
13
  import { AssistantTab } from './AssistantTab.tsx'
13
14
  import { AssetsTab } from './AssetsTab.tsx'
14
15
  import { ShelfView } from './ShelfView.tsx'
@@ -19,7 +20,6 @@ import { ImportModal } from './ImportModal.tsx'
19
20
  import { WorldTab } from './WorldTab.tsx'
20
21
  import { StoryboardTab } from './StoryboardTab.tsx'
21
22
  import { MangaWorkspace } from './MangaWorkspace.tsx'
22
- import { StyleGallery } from './StyleGallery.tsx'
23
23
  import { AuditIssueRow, PlotlineCard, PlotlineHealthPanel, PlotlinePlanPanel, PlotlineSuggestionPanel, RoleCandidateRow, RoleCard, StatCell, TodoRow } from './views.tsx'
24
24
  import { extractDocxTextFromBuffer } from '../docx.ts'
25
25
  import type {
@@ -27,6 +27,7 @@ import type {
27
27
  BookshelfSnapshot,
28
28
  ChapterPlan,
29
29
  Foreshadow,
30
+ ImageModelConfig,
30
31
  JobFrame,
31
32
  NovelConfig,
32
33
  Plotline,
@@ -45,8 +46,7 @@ import css from './panel.module.css'
45
46
  export type NovelTab =
46
47
  | 'workflow' | 'overview' | 'blurb' | 'plan' | 'bible' | 'world' | 'foreshadow' | 'assistant' | 'settings'
47
48
  | 'characters' | 'roles' | 'facts' | 'plotlines' | 'reviews' | 'progress' | 'breakdown'
48
- | 'roleImage' | 'scenes'
49
- | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle' | 'run' | 'manhua' | 'styleLib'
49
+ | 'assetsGenre' | 'assetsProgression' | 'assetsTemplates' | 'assetsRules' | 'assetsStyle' | 'run' | 'manhua'
50
50
  | 'book' | 'assets'
51
51
 
52
52
  /** Panel shell props. */
@@ -69,49 +69,55 @@ interface ProgressLine {
69
69
  }
70
70
 
71
71
  /** The navigation groups (AI-Novel-Writing-Assistant style grouping). */
72
- const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArray<{ id: NovelTab; label: string; icon: string }> }> = [
72
+ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArray<{ id: NovelTab; label: string; icon: ReactElement }> }> = [
73
73
  {
74
74
  id: 'create',
75
75
  label: '创作',
76
76
  items: [
77
- { id: 'workflow', label: tt('tab.workflow'), icon: '🛠️' },
78
- { id: 'overview', label: tt('tab.overview'), icon: '📄' },
79
- { id: 'blurb', label: '简介 / 封面', icon: '📖' },
80
- { id: 'plan', label: tt('tab.plan'), icon: '📚' },
81
- { id: 'plotlines', label: '长线管理', icon: '📜' },
82
- { id: 'book', label: '本书设定', icon: '📚' },
77
+ { id: 'workflow', label: tt('tab.workflow'), icon: <Wrench size={16} /> },
78
+ { id: 'overview', label: tt('tab.overview'), icon: <FileText size={16} /> },
79
+ { id: 'blurb', label: '简介 / 封面', icon: <BookOpen size={16} /> },
80
+ { id: 'plan', label: tt('tab.plan'), icon: <BookMarked size={16} /> },
81
+ { id: 'plotlines', label: '长线管理', icon: <ScrollText size={16} /> },
82
+ { id: 'book', label: '本书设定', icon: <Library size={16} /> },
83
83
  ],
84
84
  },
85
85
  {
86
86
  id: 'tools',
87
87
  label: '工具',
88
88
  items: [
89
- { id: 'assistant', label: tt('tab.assistant'), icon: '💬' },
90
- { id: 'progress', label: '工作进度', icon: '📊' },
91
- { id: 'breakdown', label: '拆书分析', icon: '🔍' },
92
- { id: 'roleImage', label: '角色形象', icon: '🖼️' },
93
- { id: 'scenes', label: '场景库', icon: '🏞️' },
94
- { id: 'styleLib', label: '风格库', icon: '🎨' },
95
- { id: 'manhua', label: '漫剧', icon: '🎬' },
96
- { id: 'run', label: '生产单', icon: '🏭' },
89
+ { id: 'assistant', label: tt('tab.assistant'), icon: <MessageSquare size={16} /> },
90
+ { id: 'progress', label: 'AI进度', icon: <BarChart3 size={16} /> },
91
+ { id: 'breakdown', label: '拆书分析', icon: <Search size={16} /> },
92
+ { id: 'manhua', label: '漫剧工作台', icon: <Clapperboard size={16} /> },
93
+ { id: 'run', label: '生产单', icon: <Factory size={16} /> },
97
94
  ],
98
95
  },
99
96
  {
100
97
  id: 'assets',
101
98
  label: '资产',
102
99
  items: [
103
- { id: 'assets', label: '创作资产', icon: '🧰' },
100
+ { id: 'assets', label: '创作资产', icon: <Wrench size={16} /> },
104
101
  ],
105
102
  },
106
103
  ]
107
104
 
108
105
  /** Settings tab — pinned to the bottom of the nav rail. */
109
- const SETTINGS_TAB: { id: NovelTab; label: string; icon: string } = { id: 'settings', label: tt('tab.settings'), icon: '⚙️' }
106
+ const SETTINGS_TAB: { id: NovelTab; label: string; icon: ReactElement } = { id: 'settings', label: tt('tab.settings'), icon: <Settings size={16} /> }
110
107
 
111
108
  /** Common DeepSeek model presets shown in settings; users can also type any model id. */
112
109
  const MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
113
110
  const REASONING_OPTIONS = ['off', 'low', 'high', 'max'] as const
114
111
 
112
+ /** 设置页内子导航分组。 */
113
+ const SETTINGS_SECTIONS: ReadonlyArray<{ id: 'model' | 'writing' | 'image' | 'files' | 'appearance'; label: string; icon: ReactElement }> = [
114
+ { id: 'model', label: '模型与推理', icon: <Brain size={16} /> },
115
+ { id: 'writing', label: '写作与审稿', icon: <PenLine size={16} /> },
116
+ { id: 'image', label: '生图模型', icon: <Palette size={16} /> },
117
+ { id: 'files', label: '路径与文件', icon: <Folder size={16} /> },
118
+ { id: 'appearance', label: '外观与主题', icon: <Sparkles size={16} /> },
119
+ ]
120
+
115
121
  /** 构建时注入的插件版本(tsdown define 替换为字符串字面量)。 */
116
122
  declare const __NOVEL_FORGE_VERSION__: string | undefined
117
123
  const PLUGIN_VERSION: string = typeof __NOVEL_FORGE_VERSION__ !== 'undefined' ? __NOVEL_FORGE_VERSION__ : '0.0.0'
@@ -387,6 +393,50 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
387
393
  setPanelTheme(next)
388
394
  try { window.localStorage.setItem('dsh-novel-forge.theme', next) } catch { /* ignore */ }
389
395
  }
396
+ /** 显示模式(跟随系统 / 强制浅色 / 强制深色),localStorage 记忆,只作用于小说工坊面板。 */
397
+ const [themeMode, setThemeMode] = useState<'system' | 'light' | 'dark'>(() => {
398
+ try {
399
+ const v = window.localStorage.getItem('dsh-novel-forge.theme.mode')
400
+ return v === 'light' || v === 'dark' ? v : 'system'
401
+ } catch { return 'system' }
402
+ })
403
+ const changeThemeMode = (next: 'system' | 'light' | 'dark'): void => {
404
+ setThemeMode(next)
405
+ try { window.localStorage.setItem('dsh-novel-forge.theme.mode', next) } catch { /* ignore */ }
406
+ }
407
+ /** 界面密度(舒适 / 紧凑 / 宽松),localStorage 记忆。 */
408
+ const [themeDensity, setThemeDensity] = useState<'comfort' | 'compact' | 'spacious'>(() => {
409
+ try {
410
+ const v = window.localStorage.getItem('dsh-novel-forge.theme.density')
411
+ return v === 'compact' || v === 'spacious' ? v : 'comfort'
412
+ } catch { return 'comfort' }
413
+ })
414
+ const changeThemeDensity = (next: 'comfort' | 'compact' | 'spacious'): void => {
415
+ setThemeDensity(next)
416
+ try { window.localStorage.setItem('dsh-novel-forge.theme.density', next) } catch { /* ignore */ }
417
+ }
418
+ /** 恢复默认主题:清掉主题/模式/密度记忆。 */
419
+ const resetTheme = (): void => {
420
+ try {
421
+ window.localStorage.removeItem('dsh-novel-forge.theme')
422
+ window.localStorage.removeItem('dsh-novel-forge.theme.mode')
423
+ window.localStorage.removeItem('dsh-novel-forge.theme.density')
424
+ } catch { /* ignore */ }
425
+ setPanelTheme('liquid')
426
+ setThemeMode('system')
427
+ setThemeDensity('comfort')
428
+ }
429
+ /** 设置页内子导航:当前分组(localStorage 记忆)。 */
430
+ const [settingsTab, setSettingsTab] = useState<'model' | 'writing' | 'image' | 'files' | 'appearance'>(() => {
431
+ try {
432
+ const v = window.localStorage.getItem('dsh-novel-forge.settings.tab')
433
+ return v === 'writing' || v === 'image' || v === 'files' || v === 'appearance' ? v : 'model'
434
+ } catch { return 'model' }
435
+ })
436
+ const changeSettingsTab = (next: 'model' | 'writing' | 'image' | 'files' | 'appearance'): void => {
437
+ setSettingsTab(next)
438
+ try { window.localStorage.setItem('dsh-novel-forge.settings.tab', next) } catch { /* ignore */ }
439
+ }
390
440
  /** 有未采纳草稿的章节号(refresh 后检测到遗留草稿时提示)。 */
391
441
  const [draftNo, setDraftNo] = useState<number | null>(null)
392
442
  /** 大纲页「更新大纲」编辑区是否展开。 */
@@ -487,23 +537,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
487
537
  const [breakdownResult, setBreakdownResult] = useState<import('../../protocol.ts').BreakdownResponse | null>(null)
488
538
  const [breakdownScope, setBreakdownScope] = useState<'recent' | 'volume:2' | 'volume:3' | 'all'>('recent')
489
539
  const [breakdownPreset, setBreakdownPreset] = useState<'quick' | 'standard'>('quick')
490
- /** 角色图集上传:当前等待上传的角色名。 */
491
- const [roleImageTarget, setRoleImageTarget] = useState<string | null>(null)
492
- /** 角色图集上传:当前用途标签(立绘/四视图/表情-x/场景/细节)。 */
493
- const [roleImageLabel, setRoleImageLabel] = useState('立绘')
494
- /** 角色形象详情:当前打开的角色名(null = 关闭)。 */
495
- const [detailRoleName, setDetailRoleName] = useState<string | null>(null)
496
- /** 详情面板:上传标签输入。 */
497
- const [detailUploadLabel, setDetailUploadLabel] = useState('立绘')
498
- /** 场景库:AI 提炼候选(null = 未运行)。 */
499
- const [sceneCandidates, setSceneCandidates] = useState<import('../../protocol.ts').SceneCard[] | null>(null)
500
- /** 场景库:当前打开的场景名。 */
501
- const [sceneDetailName, setSceneDetailName] = useState<string | null>(null)
502
- /** 场景库:上传标签输入。 */
503
- const [sceneUploadLabel, setSceneUploadLabel] = useState('全景')
504
- /** 场景库:编辑草稿(null = 不在编辑)。 */
505
- const [sceneDraft, setSceneDraft] = useState<import('../../protocol.ts').SceneCard | null>(null)
506
- const roleImageInputRef = useRef<HTMLInputElement | null>(null)
507
540
  /** AI 建议的剧情线候选(null = 未运行)。 */
508
541
  const [plotlineSuggestions, setPlotlineSuggestions] = useState<Plotline[] | null>(null)
509
542
  /** 剧情健康检查报告(null = 未运行)。 */
@@ -531,9 +564,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
531
564
  }, [])
532
565
  /** AI 助手悬浮窗:是否打开。 */
533
566
  const [assistantOpen, setAssistantOpen] = useState(false)
534
- /** 工作进度悬浮窗:是否打开。 */
567
+ /** AI进度悬浮窗:是否打开。 */
535
568
  const [progressOpen, setProgressOpen] = useState(false)
536
- /** 工作进度悬浮窗位置(localStorage 记忆)。 */
569
+ /** AI进度悬浮窗位置(localStorage 记忆)。 */
537
570
  const [progressPos, setProgressPos] = useState(() => {
538
571
  try {
539
572
  const raw = window.localStorage.getItem('dsh-novel-forge.progress.float')
@@ -544,7 +577,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
544
577
  } catch { /* ignore */ }
545
578
  return { x: 60, y: 120 }
546
579
  })
547
- /** 工作进度悬浮窗尺寸(localStorage 记忆)。 */
580
+ /** AI进度悬浮窗尺寸(localStorage 记忆)。 */
548
581
  const [progressSize, setProgressSize] = useState(() => {
549
582
  try {
550
583
  const raw = window.localStorage.getItem('dsh-novel-forge.progress.size')
@@ -577,7 +610,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
577
610
  } catch { /* ignore */ }
578
611
  return { w: 420, h: 460 }
579
612
  })
580
- /** 拖拽/缩放状态(target 区分 AI 助手 / 工作进度两个悬浮窗)。 */
613
+ /** 拖拽/缩放状态(target 区分 AI 助手 / AI进度两个悬浮窗)。 */
581
614
  const dragState = useRef<{ type: 'move' | 'resize'; target: 'assistant' | 'progress'; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null)
582
615
 
583
616
  /** 悬浮窗位置/尺寸持久化。 */
@@ -1174,68 +1207,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1174
1207
  }
1175
1208
 
1176
1209
  /** 生成单个角色的形象锚点(复用角色库 visual 能力)。 */
1177
- const handleRoleVisual = async (name: string): Promise<void> => {
1178
- setBusy(true)
1179
- setBusyLabel(`生成「${name}」形象锚点…`)
1180
- setError('')
1181
- try {
1182
- const activePlan = (project?.mangaPlans ?? []).find(pl => pl.active)
1183
- await api.roles({ op: 'visual', name, styleId: activePlan?.styleId, filterId: activePlan?.filterId })
1184
- await refresh(false)
1185
- pushProgress(`已生成「${name}」形象锚点`, 'done')
1186
- } catch (err) {
1187
- setError((err as Error).message)
1188
- pushProgress(`生成「${name}」形象锚点失败:${(err as Error).message}`, 'error')
1189
- } finally {
1190
- setBusy(false)
1191
- setBusyLabel('')
1192
- }
1193
- }
1194
-
1195
- /** 上传角色图:读取本地图片为 dataURL 并写入角色卡(带用途标签 → 图集)。 */
1196
- const handleRoleImageUpload = async (name: string, file: File, label = '立绘'): Promise<void> => {
1197
- const dataUrl = await new Promise<string>((resolve, reject) => {
1198
- const reader = new FileReader()
1199
- reader.onload = () => resolve(String(reader.result))
1200
- reader.onerror = () => reject(reader.error ?? new Error('读取图片失败'))
1201
- reader.readAsDataURL(file)
1202
- })
1203
- setBusy(true)
1204
- setBusyLabel(`上传「${name}」${label}…`)
1205
- setError('')
1206
- try {
1207
- await api.roles({ op: 'image', name, dataUrl, label })
1208
- await refresh(false)
1209
- pushProgress(`已上传「${name}」${label}`, 'done')
1210
- } catch (err) {
1211
- setError((err as Error).message)
1212
- pushProgress(`上传「${name}」${label}失败:${(err as Error).message}`, 'error')
1213
- } finally {
1214
- setBusy(false)
1215
- setBusyLabel('')
1216
- setRoleImageTarget(null)
1217
- if (roleImageInputRef.current !== null) roleImageInputRef.current.value = ''
1218
- }
1219
- }
1220
-
1221
- /** 用豆包/Seedream 为角色生成参考图。 */
1222
- const handleRoleImageGenerate = async (name: string): Promise<void> => {
1223
- setBusy(true)
1224
- setBusyLabel(`用豆包生成「${name}」参考图…`)
1225
- setError('')
1226
- try {
1227
- await api.roles({ op: 'imageGenerate', name })
1228
- await refresh(false)
1229
- pushProgress(`已生成「${name}」参考图`, 'done')
1230
- } catch (err) {
1231
- setError((err as Error).message)
1232
- pushProgress(`生成「${name}」参考图失败:${(err as Error).message}`, 'error')
1233
- } finally {
1234
- setBusy(false)
1235
- setBusyLabel('')
1236
- }
1237
- }
1238
-
1239
1210
  /** 作者复盘补跑:全书缺失章节(流式)。 */
1240
1211
  const handleAuthorBackfillAll = async (): Promise<void> => {
1241
1212
  const missing = chapters.filter(c => c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error' && c.authorReview === undefined).length
@@ -1882,7 +1853,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
1882
1853
  lastDeltaChars.current = 0
1883
1854
  liveChars.current = 0
1884
1855
  currentJobNo.current = frame.no
1885
- // 任务开始:不自动弹出进度悬浮窗(导航「工作进度」显示呼吸绿点提示,
1856
+ // 任务开始:不自动弹出进度悬浮窗(导航「AI进度」显示呼吸绿点提示,
1886
1857
  // 想看进度时手动点导航打开;liveBar 数据照常累积)。
1887
1858
  setProject(prev => prev === null ? prev : {
1888
1859
  ...prev,
@@ -2094,6 +2065,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2094
2065
  imageApiKey: configDraft.imageApiKey,
2095
2066
  imageApiModel: configDraft.imageApiModel,
2096
2067
  imageApiEnabled: configDraft.imageApiEnabled ?? false,
2068
+ imageModels: configDraft.imageModels ?? [],
2097
2069
  })
2098
2070
  setConfig(result.config)
2099
2071
  setConfigDraft(result.config)
@@ -2107,6 +2079,42 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2107
2079
  }
2108
2080
  }
2109
2081
 
2082
+ /** 生图接口测试状态(模型 id → 结果)。 */
2083
+ const [imageTestState, setImageTestState] = useState<Record<string, { testing: boolean; ok?: boolean; ms?: number; message?: string; modelFound?: boolean }>>({})
2084
+ /** 测试一条生图模型:连通 + 延迟。 */
2085
+ const testImageModel = async (m: ImageModelConfig): Promise<void> => {
2086
+ if (m.baseURL.trim() === '' || m.apiKey.trim() === '') {
2087
+ setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: '请先填写接口地址和 API Key' } }))
2088
+ return
2089
+ }
2090
+ setImageTestState(prev => ({ ...prev, [m.id]: { testing: true } }))
2091
+ try {
2092
+ const r = await api.imageTest({ baseURL: m.baseURL, apiKey: m.apiKey, model: m.model })
2093
+ setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: r.ok, ms: r.ms, message: r.message } }))
2094
+ } catch (err) {
2095
+ setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: (err as Error).message } }))
2096
+ }
2097
+ }
2098
+ /** 更新一条生图模型配置。 */
2099
+ const updateImageModel = (id: string, patch: Partial<ImageModelConfig>): void => {
2100
+ setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => m.id === id ? { ...m, ...patch } : m) })
2101
+ }
2102
+ /** 新增一条生图模型(第一条默认启用)。 */
2103
+ const addImageModel = (): void => {
2104
+ setConfigDraft(prev => prev === null ? prev : {
2105
+ ...prev,
2106
+ imageModels: [...(prev.imageModels ?? []), { id: 'img-' + Date.now().toString(36), name: '', baseURL: '', apiKey: '', model: '', enabled: (prev.imageModels ?? []).length === 0 }],
2107
+ })
2108
+ }
2109
+ /** 删除一条生图模型。 */
2110
+ const removeImageModel = (id: string): void => {
2111
+ setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).filter(m => m.id !== id) })
2112
+ }
2113
+ /** 启用一条(其余自动停用)。 */
2114
+ const enableImageModel = (id: string): void => {
2115
+ setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => ({ ...m, enabled: m.id === id })) })
2116
+ }
2117
+
2110
2118
  /** Export the book. */
2111
2119
  const handleExport = async (format: 'txt' | 'md'): Promise<void> => {
2112
2120
  setBusy(true)
@@ -2358,7 +2366,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2358
2366
  })()
2359
2367
 
2360
2368
  return (
2361
- <div className={css.panel} data-nf-theme={panelTheme}>
2369
+ <div className={css.panel} data-nf-theme={panelTheme} data-nf-mode={themeMode === 'system' ? undefined : themeMode} data-nf-density={themeDensity}>
2362
2370
  {viewMode === 'shelf' ? (
2363
2371
  /* 书架首页:默认视图,选择一本书进入工作台或阅读页 */
2364
2372
  <ShelfView
@@ -2460,7 +2468,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2460
2468
  {tab.id === 'workflow' && chapters.length > 0 && (
2461
2469
  <span className={`${css.navTabBadge} ${css.navTabBadgeDone}`}>{journeyPercent}%</span>
2462
2470
  )}
2463
- {/* 任务进行中:工作进度导航显示呼吸绿点(悬停看任务名),不自动弹窗 */}
2471
+ {/* 任务进行中:AI进度导航显示呼吸绿点(悬停看任务名),不自动弹窗 */}
2464
2472
  {tab.id === 'progress' && busy && (
2465
2473
  <span
2466
2474
  className={`${css.navTabBadge} ${css.navTabBadgeLive}`}
@@ -2998,7 +3006,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
2998
3006
  </div>
2999
3007
  )}
3000
3008
 
3001
- {/* 待办队列(活动输出已移入「工具 → 工作进度」悬浮窗) */}
3009
+ {/* 待办队列(活动输出已移入「工具 → AI进度」悬浮窗) */}
3002
3010
  <div className={css.card}>
3003
3011
  <span className={css.cardTitle}>待办队列</span>
3004
3012
  {todos.length === 0 ? (
@@ -3774,236 +3782,302 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
3774
3782
 
3775
3783
  {activeTab === 'settings' && configDraft !== null && (
3776
3784
  <>
3777
- {/* ① 路径与输出 */}
3778
- <div className={`${css.card} ${css.settingsCard}`}>
3779
- <span className={css.cardTitle}>📁 路径与输出</span>
3780
- <div className={css.field}>
3781
- <label className={css.fieldLabel}>{tt('settings.outlinePath')}</label>
3782
- <input className={css.input} value={configDraft.outlinePath} onChange={e => { setConfigDraft({ ...configDraft, outlinePath: e.target.value }) }} />
3783
- </div>
3784
- <div className={css.field}>
3785
- <label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
3786
- <input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
3785
+ {/* 设置页内子导航(大容器) */}
3786
+ <div className={`${css.card} ${css.settingsCard}`} style={{ padding: '20px 22px', gap: 14 }}>
3787
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
3788
+ <span className={css.cardTitle} style={{ fontSize: 20, fontWeight: 700 }}>⚙️ 设置</span>
3789
+ <span className={css.meta}>当前:{config?.provider} / {config?.model} · {config?.outputDir}</span>
3787
3790
  </div>
3788
- <div className={css.row}>
3789
- <button type="button" className={css.button} onClick={() => { void api.openFolder() }}>{tt('settings.openFolder')}</button>
3791
+ <div className={css.row} style={{ gap: 8, flexWrap: 'wrap' }}>
3792
+ {SETTINGS_SECTIONS.map(s => (
3793
+ <button
3794
+ key={s.id}
3795
+ type="button"
3796
+ className={`${css.button} ${settingsTab === s.id ? css.buttonPrimary : ''}`}
3797
+ style={{ fontSize: 14, padding: '8px 12px', flex: 1, minWidth: 104, justifyContent: 'center' }}
3798
+ onClick={() => { changeSettingsTab(s.id) }}
3799
+ >
3800
+ {s.icon} {s.label}
3801
+ </button>
3802
+ ))}
3790
3803
  </div>
3791
3804
  </div>
3792
3805
 
3793
- {/* ② 模型与推理 */}
3794
- <div className={`${css.card} ${css.settingsCard}`}>
3795
- <span className={css.cardTitle}>🤖 模型与推理</span>
3796
- <div className={css.row} style={{ alignItems: 'flex-start' }}>
3797
- <div className={css.field} style={{ flex: 1 }}>
3798
- <label className={css.fieldLabel}>{tt('settings.provider')}</label>
3799
- <input className={css.input} value={configDraft.provider} onChange={e => { setConfigDraft({ ...configDraft, provider: e.target.value }) }} />
3800
- </div>
3801
- <div className={css.field} style={{ flex: 1.4 }}>
3802
- <label className={css.fieldLabel}>{tt('settings.model')}</label>
3803
- <select
3804
- className={css.input}
3805
- value={modelCustomMode ? '__custom__' : configDraft.model}
3806
- onChange={e => {
3807
- const v = e.target.value
3808
- if (v === '__custom__') {
3809
- setModelCustomMode(true)
3810
- } else {
3811
- setModelCustomMode(false)
3812
- setConfigDraft({ ...configDraft, model: v })
3813
- }
3814
- }}
3815
- >
3816
- {MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}
3817
- <option value="__custom__">{tt('settings.modelCustom')}</option>
3818
- </select>
3819
- {modelCustomMode && (
3820
- <input
3806
+ {settingsTab === 'model' && (
3807
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 26 }}>
3808
+ <span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
3809
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
3810
+ <div className={css.field}>
3811
+ <label className={css.fieldLabel}>{tt('settings.provider')}</label>
3812
+ <input className={css.input} value={configDraft.provider} onChange={e => { setConfigDraft({ ...configDraft, provider: e.target.value }) }} />
3813
+ </div>
3814
+ <div className={css.field}>
3815
+ <label className={css.fieldLabel}>{tt('settings.model')}</label>
3816
+ <select
3821
3817
  className={css.input}
3822
- style={{ marginTop: 6 }}
3823
- value={configDraft.model}
3824
- placeholder={tt('settings.modelCustomPlaceholder')}
3825
- onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }}
3826
- />
3827
- )}
3818
+ value={modelCustomMode ? '__custom__' : configDraft.model}
3819
+ onChange={e => {
3820
+ const v = e.target.value
3821
+ if (v === '__custom__') {
3822
+ setModelCustomMode(true)
3823
+ } else {
3824
+ setModelCustomMode(false)
3825
+ setConfigDraft({ ...configDraft, model: v })
3826
+ }
3827
+ }}
3828
+ >
3829
+ {MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}
3830
+ <option value="__custom__">{tt('settings.modelCustom')}</option>
3831
+ </select>
3832
+ {modelCustomMode && (
3833
+ <input
3834
+ className={css.input}
3835
+ style={{ marginTop: 6 }}
3836
+ value={configDraft.model}
3837
+ placeholder={tt('settings.modelCustomPlaceholder')}
3838
+ onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }}
3839
+ />
3840
+ )}
3841
+ </div>
3842
+ <div className={css.field}>
3843
+ <label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label>
3844
+ <select
3845
+ className={css.input}
3846
+ value={configDraft.reasoningEffort ?? 'off'}
3847
+ onChange={e => { setConfigDraft({ ...configDraft, reasoningEffort: e.target.value as NovelConfig['reasoningEffort'] }) }}
3848
+ >
3849
+ {REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
3850
+ </select>
3851
+ <span className={css.meta}>{tt('settings.reasoningHint')}</span>
3852
+ </div>
3853
+ <div className={css.field}>
3854
+ <label className={css.fieldLabel}>{tt('settings.analysisReasoning')}</label>
3855
+ <select
3856
+ className={css.input}
3857
+ value={configDraft.analysisReasoning ?? 'low'}
3858
+ onChange={e => { setConfigDraft({ ...configDraft, analysisReasoning: e.target.value as NovelConfig['analysisReasoning'] }) }}
3859
+ >
3860
+ {REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
3861
+ </select>
3862
+ <span className={css.meta}>{tt('settings.analysisReasoningHint')}</span>
3863
+ </div>
3828
3864
  </div>
3829
3865
  </div>
3830
- <div className={css.row} style={{ alignItems: 'flex-start' }}>
3831
- <div className={css.field} style={{ flex: 1 }}>
3832
- <label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label>
3833
- <select
3834
- className={css.input}
3835
- value={configDraft.reasoningEffort ?? 'off'}
3836
- onChange={e => { setConfigDraft({ ...configDraft, reasoningEffort: e.target.value as NovelConfig['reasoningEffort'] }) }}
3837
- >
3838
- {REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
3839
- </select>
3840
- <span className={css.meta}>{tt('settings.reasoningHint')}</span>
3841
- </div>
3842
- <div className={css.field} style={{ flex: 1 }}>
3843
- <label className={css.fieldLabel}>{tt('settings.analysisReasoning')}</label>
3844
- <select
3845
- className={css.input}
3846
- value={configDraft.analysisReasoning ?? 'low'}
3847
- onChange={e => { setConfigDraft({ ...configDraft, analysisReasoning: e.target.value as NovelConfig['analysisReasoning'] }) }}
3848
- >
3849
- {REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
3850
- </select>
3851
- <span className={css.meta}>{tt('settings.analysisReasoningHint')}</span>
3866
+ )}
3867
+
3868
+ {settingsTab === 'writing' && (
3869
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 26 }}>
3870
+ <span className={css.cardTitle}><PenLine size={18} style={{ verticalAlign: -3 }} /> 写作与审稿</span>
3871
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
3872
+ <div className={css.field}>
3873
+ <label className={css.fieldLabel}>{tt('settings.chapterChars')}</label>
3874
+ <input className={css.input} type="number" min={1000} max={20000} value={configDraft.chapterChars} onChange={e => { setConfigDraft({ ...configDraft, chapterChars: Number(e.target.value) }) }} />
3875
+ </div>
3876
+ <div className={css.field}>
3877
+ <label className={css.fieldLabel}>{tt('settings.maxTokens')}</label>
3878
+ <input className={css.input} type="number" min={2000} max={64000} value={configDraft.maxTokens} onChange={e => { setConfigDraft({ ...configDraft, maxTokens: Number(e.target.value) }) }} />
3879
+ </div>
3880
+ <div className={css.field}>
3881
+ <label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label>
3882
+ <input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => { setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) }) }} />
3883
+ <span className={css.meta}>通过判定:综合评分 ≥ 此分数(默认 70),或无 high 级问题且评分 ≥ 60;建议设 60-80。</span>
3884
+ </div>
3885
+ <div className={css.field}>
3886
+ <label className={css.fieldLabel}>{tt('settings.autoReview')}</label>
3887
+ <select
3888
+ className={css.input}
3889
+ value={configDraft.autoReview ? '1' : '0'}
3890
+ onChange={e => { setConfigDraft({ ...configDraft, autoReview: e.target.value === '1' }) }}
3891
+ >
3892
+ <option value="1">✓ 是</option>
3893
+ <option value="0">✗ 否</option>
3894
+ </select>
3895
+ </div>
3896
+ <div className={css.field}>
3897
+ <label className={css.fieldLabel}>{tt('settings.autoAuthorReview')}</label>
3898
+ <select
3899
+ className={css.input}
3900
+ value={configDraft.autoAuthorReview ? '1' : '0'}
3901
+ onChange={e => { setConfigDraft({ ...configDraft, autoAuthorReview: e.target.value === '1' }) }}
3902
+ >
3903
+ <option value="1">✓ 是</option>
3904
+ <option value="0">✗ 否</option>
3905
+ </select>
3906
+ <span className={css.meta}>{tt('settings.autoAuthorReviewHint')}</span>
3907
+ </div>
3908
+ <div className={css.field}>
3909
+ <label className={css.fieldLabel}>{tt('settings.autoReviewAfterRevise')}</label>
3910
+ <select
3911
+ className={css.input}
3912
+ value={configDraft.autoReviewAfterRevise ? '1' : '0'}
3913
+ onChange={e => { setConfigDraft({ ...configDraft, autoReviewAfterRevise: e.target.value === '1' }) }}
3914
+ >
3915
+ <option value="1">✓ 是</option>
3916
+ <option value="0">✗ 否</option>
3917
+ </select>
3918
+ <span className={css.meta}>{tt('settings.autoReviewAfterReviseHint')}</span>
3919
+ </div>
3920
+ <div className={css.field}>
3921
+ <label className={css.fieldLabel}>编辑器字号(正文编辑 / 工作区)</label>
3922
+ <select
3923
+ className={css.input}
3924
+ value={editorFontSize}
3925
+ onChange={e => { changeEditorFontSize(Number(e.target.value)) }}
3926
+ >
3927
+ {[12, 13, 14, 15, 16, 18, 20, 22, 24].map(v => (
3928
+ <option key={v} value={v}>{v}px</option>
3929
+ ))}
3930
+ </select>
3931
+ <span className={css.meta}>更改的是章节正文编辑区与润色/修订工作区(原稿 / 草稿 / diff)的显示字号,不影响面板主题。</span>
3932
+ </div>
3852
3933
  </div>
3853
3934
  </div>
3854
- </div>
3935
+ )}
3855
3936
 
3856
- {/* ③ 生图(豆包) */}
3857
- <div className={`${css.card} ${css.settingsCard}`}>
3858
- <span className={css.cardTitle}>🎨 生图(豆包)</span>
3859
- <div className={css.row}>
3860
- <div className={css.field} style={{ flex: 1 }}>
3861
- <label className={css.fieldLabel}>豆包 API Key(生图)</label>
3862
- <input className={css.input} type="password" placeholder="ark-..." value={configDraft.imageApiKey ?? ''} onChange={e => { setConfigDraft({ ...configDraft, imageApiKey: e.target.value }) }} />
3937
+ {settingsTab === 'image' && (
3938
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 24 }}>
3939
+ <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center' }}>
3940
+ <span className={css.cardTitle}><Palette size={18} style={{ verticalAlign: -3 }} /> 生图模型</span>
3941
+ <span className={css.row} style={{ gap: 8, alignItems: 'center' }}>
3942
+ <span className={css.meta}>启用生图功能</span>
3943
+ <button
3944
+ type="button"
3945
+ role="switch"
3946
+ aria-checked={configDraft.imageApiEnabled === true}
3947
+ className={`${css.switch} ${configDraft.imageApiEnabled === true ? css.switchOn : ''}`}
3948
+ onClick={() => { setConfigDraft(prev => prev === null ? prev : { ...prev, imageApiEnabled: prev.imageApiEnabled !== true }) }}
3949
+ >
3950
+ <span className={css.switchKnob} />
3951
+ </button>
3952
+ </span>
3863
3953
  </div>
3864
- <div className={css.field} style={{ flex: 1 }}>
3865
- <label className={css.fieldLabel}>豆包生图模型</label>
3866
- <input className={css.input} placeholder="doubao-seedream-5-0-pro-260628" value={configDraft.imageApiModel ?? ''} onChange={e => { setConfigDraft({ ...configDraft, imageApiModel: e.target.value }) }} />
3954
+ <span className={css.meta}>支持多套 OpenAI 兼容生图接口(豆包 / 即梦 / 其他平台);启用一条作为生成模型,保存后角色详情显示「AI 生图」按钮。</span>
3955
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
3956
+ {(configDraft.imageModels ?? []).map(m => (
3957
+ <div key={m.id} style={{ border: m.enabled ? '1px solid var(--nf-accent)' : '1px solid var(--nf-border)', borderRadius: 12, padding: 14, display: 'flex', flexDirection: 'column', gap: 12, background: m.enabled ? 'var(--nf-accent-soft)' : 'transparent' }}>
3958
+ <div className={css.row} style={{ alignItems: 'flex-end', flexWrap: 'wrap' }}>
3959
+ <div className={css.field} style={{ flex: 1, minWidth: 180 }}>
3960
+ <label className={css.fieldLabel}>名称</label>
3961
+ <input className={css.input} placeholder="如:豆包 Seedream" value={m.name} onChange={e => { updateImageModel(m.id, { name: e.target.value }) }} />
3962
+ </div>
3963
+ <div className={css.field} style={{ flex: 1, minWidth: 180 }}>
3964
+ <label className={css.fieldLabel}>模型 id</label>
3965
+ <input className={css.input} placeholder="doubao-seedream-5-0-pro-260628" value={m.model} onChange={e => { updateImageModel(m.id, { model: e.target.value }) }} />
3966
+ </div>
3967
+ <div className={css.field} style={{ flex: 1, minWidth: 200 }}>
3968
+ <label className={css.fieldLabel}>接口地址</label>
3969
+ <input className={css.input} placeholder="https://ark.cn-beijing.volces.com/api/v3" value={m.baseURL} onChange={e => { updateImageModel(m.id, { baseURL: e.target.value }) }} />
3970
+ </div>
3971
+ </div>
3972
+ <div className={css.field}>
3973
+ <label className={css.fieldLabel}>API Key</label>
3974
+ <input className={css.input} type="password" placeholder="ark-..." value={m.apiKey} onChange={e => { updateImageModel(m.id, { apiKey: e.target.value }) }} />
3975
+ </div>
3976
+ <div className={css.row} style={{ gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
3977
+ <button type="button" className={`${css.button} ${css.buttonSmall} ${m.enabled ? css.buttonPrimary : ''}`} onClick={() => { enableImageModel(m.id) }}>
3978
+ {m.enabled ? '✓ 已启用' : '设为启用'}
3979
+ </button>
3980
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={imageTestState[m.id]?.testing === true} onClick={() => { void testImageModel(m) }}>
3981
+ <PlugZap size={13} style={{ verticalAlign: -2 }} /> {imageTestState[m.id]?.testing === true ? '测试中…' : '测试'}
3982
+ </button>
3983
+ <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { removeImageModel(m.id) }}>🗑 删除</button>
3984
+ <span className={css.meta}>{m.enabled ? '当前生效' : '未启用'}</span>
3985
+ {(() => {
3986
+ const st = imageTestState[m.id]
3987
+ if (st === undefined || st.testing) return null
3988
+ return st.ok === true
3989
+ ? <span style={{ color: 'var(--nf-success)', fontSize: 12, fontWeight: 600 }}>✓ 连通 · {st.ms}ms{st.modelFound === true ? '' : st.modelFound === false ? '(模型未在列表)' : ''}</span>
3990
+ : <span style={{ color: 'var(--nf-error)', fontSize: 12 }}>✗ {st.message ?? '失败'}</span>
3991
+ })()}
3992
+ </div>
3993
+ </div>
3994
+ ))}
3995
+ {(configDraft.imageModels ?? []).length === 0 && (
3996
+ <div className={css.meta}>还没有生图模型——点下方「+ 添加生图模型」添加第一条(默认启用)。</div>
3997
+ )}
3867
3998
  </div>
3868
- <div className={css.field} style={{ flex: 1 }}>
3869
- <label className={css.fieldLabel}>启用豆包生图</label>
3870
- <select
3871
- className={css.input}
3872
- value={configDraft.imageApiEnabled ? '1' : '0'}
3873
- onChange={e => { setConfigDraft({ ...configDraft, imageApiEnabled: e.target.value === '1' }) }}
3874
- >
3875
- <option value="0">关(默认)</option>
3876
- <option value="1">开</option>
3877
- </select>
3878
- <span className={css.meta}>开启后角色详情显示「豆包生成」参考图按钮</span>
3999
+ <div className={css.row}>
4000
+ <button type="button" className={css.button} onClick={addImageModel}>+ 添加生图模型</button>
3879
4001
  </div>
3880
4002
  </div>
3881
- </div>
4003
+ )}
3882
4004
 
3883
- {/* ④ 写作与审稿参数 */}
3884
- <div className={`${css.card} ${css.settingsCard}`}>
3885
- <span className={css.cardTitle}>✍️ 写作与审稿参数</span>
3886
- <div className={css.row}>
3887
- <div className={css.field} style={{ flex: 1 }}>
3888
- <label className={css.fieldLabel}>{tt('settings.chapterChars')}</label>
3889
- <input className={css.input} type="number" min={1000} max={20000} value={configDraft.chapterChars} onChange={e => { setConfigDraft({ ...configDraft, chapterChars: Number(e.target.value) }) }} />
3890
- </div>
3891
- <div className={css.field} style={{ flex: 1 }}>
3892
- <label className={css.fieldLabel}>{tt('settings.maxTokens')}</label>
3893
- <input className={css.input} type="number" min={2000} max={64000} value={configDraft.maxTokens} onChange={e => { setConfigDraft({ ...configDraft, maxTokens: Number(e.target.value) }) }} />
3894
- </div>
3895
- <div className={css.field} style={{ flex: 1 }}>
3896
- <label className={css.fieldLabel}>{tt('settings.editorFontSize')}</label>
3897
- <select
3898
- className={css.input}
3899
- value={editorFontSize}
3900
- onChange={e => { changeEditorFontSize(Number(e.target.value)) }}
3901
- >
3902
- {[12, 13, 14, 15, 16, 18, 20, 22, 24].map(v => (
3903
- <option key={v} value={v}>{v}px</option>
3904
- ))}
3905
- </select>
3906
- <span className={css.meta}>{tt('settings.editorFontSizeHint')}</span>
4005
+ {settingsTab === 'files' && (
4006
+ <div className={`${css.card} ${css.settingsCard}`} style={{ gap: 24 }}>
4007
+ <span className={css.cardTitle}><Folder size={18} style={{ verticalAlign: -3 }} /> 路径与文件</span>
4008
+ <div className={css.field}>
4009
+ <label className={css.fieldLabel}>{tt('settings.outlinePath')}</label>
4010
+ <input className={css.input} value={configDraft.outlinePath} onChange={e => { setConfigDraft({ ...configDraft, outlinePath: e.target.value }) }} />
3907
4011
  </div>
3908
- </div>
3909
- <div className={css.row}>
3910
- <div className={css.field} style={{ flex: 1 }}>
3911
- <label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label>
3912
- <input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => { setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) }) }} />
3913
- <span className={css.meta}>通过判定:综合评分 ≥ 此分数(默认 70),或无 high 级问题且评分 ≥ 60;建议设 60-80。</span>
4012
+ <div className={css.field}>
4013
+ <label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
4014
+ <input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
3914
4015
  </div>
3915
- <div className={css.field} style={{ flex: 1 }}>
3916
- <label className={css.fieldLabel}>{tt('settings.autoReview')}</label>
3917
- <select
3918
- className={css.input}
3919
- value={configDraft.autoReview ? '1' : '0'}
3920
- onChange={e => { setConfigDraft({ ...configDraft, autoReview: e.target.value === '1' }) }}
3921
- >
3922
- <option value="1">✓ 是</option>
3923
- <option value="0">✗ 否</option>
3924
- </select>
4016
+ <div className={css.row}>
4017
+ <button type="button" className={css.button} onClick={() => { void api.openFolder() }}>{tt('settings.openFolder')}</button>
3925
4018
  </div>
3926
- <div className={css.field} style={{ flex: 1 }}>
3927
- <label className={css.fieldLabel}>{tt('settings.autoAuthorReview')}</label>
3928
- <select
3929
- className={css.input}
3930
- value={configDraft.autoAuthorReview ? '1' : '0'}
3931
- onChange={e => { setConfigDraft({ ...configDraft, autoAuthorReview: e.target.value === '1' }) }}
3932
- >
3933
- <option value="1">✓ 是</option>
3934
- <option value="0">✗ 否</option>
3935
- </select>
3936
- <span className={css.meta}>{tt('settings.autoAuthorReviewHint')}</span>
4019
+
4020
+ <div style={{ borderTop: '1px solid var(--nf-border)', margin: '14px 0 12px' }} />
4021
+
4022
+ <span className={css.cardTitle} style={{ fontSize: 13 }}>💾 保存与导出</span>
4023
+ <div className={css.row}>
4024
+ <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSaveConfig() }}>
4025
+ {tt('settings.save')}
4026
+ </button>
3937
4027
  </div>
3938
- <div className={css.field} style={{ flex: 1 }}>
3939
- <label className={css.fieldLabel}>{tt('settings.autoReviewAfterRevise')}</label>
3940
- <select
3941
- className={css.input}
3942
- value={configDraft.autoReviewAfterRevise ? '1' : '0'}
3943
- onChange={e => { setConfigDraft({ ...configDraft, autoReviewAfterRevise: e.target.value === '1' }) }}
3944
- >
3945
- <option value="1">✓ 是</option>
3946
- <option value="0">✗ 否</option>
3947
- </select>
3948
- <span className={css.meta}>{tt('settings.autoReviewAfterReviseHint')}</span>
4028
+ <div className={css.row}>
4029
+ <button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('txt') }}>
4030
+ {tt('settings.exportTxt')}
4031
+ </button>
4032
+ <button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('md') }}>
4033
+ {tt('settings.exportMd')}
4034
+ </button>
3949
4035
  </div>
3950
4036
  </div>
3951
- </div>
4037
+ )}
3952
4038
 
3953
- {/* ⑤ 保存与导出 */}
3954
- <div className={`${css.card} ${css.settingsCard}`}>
3955
- <div className={css.row}>
3956
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSaveConfig() }}>
3957
- {tt('settings.save')}
3958
- </button>
3959
- <span className={css.meta}>当前:{config?.provider} / {config?.model} · {config?.outputDir}</span>
3960
- </div>
3961
- <div className={css.row}>
3962
- <span className={css.cardTitle}>{tt('settings.export')}</span>
3963
- <button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('txt') }}>
3964
- {tt('settings.exportTxt')}
3965
- </button>
3966
- <button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('md') }}>
3967
- {tt('settings.exportMd')}
3968
- </button>
4039
+ {settingsTab === 'appearance' && (
4040
+ <div className={`${css.card} ${css.settingsCard}`}>
4041
+ <span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
4042
+ <span className={css.meta}>选择适合长时间创作的界面颜色和显示密度。主题只保存在当前设备,不影响小说内容和任务状态。</span>
4043
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
4044
+ <div className={css.field}>
4045
+ <label className={css.fieldLabel} style={{ fontSize: 13 }}>显示模式</label>
4046
+ <select className={css.input} style={{ padding: '10px 12px', fontSize: 14 }} value={themeMode} onChange={e => { changeThemeMode(e.target.value as 'system' | 'light' | 'dark') }}>
4047
+ <option value="system">跟随系统</option>
4048
+ <option value="light">浅色</option>
4049
+ <option value="dark">深色</option>
4050
+ </select>
4051
+ <span className={css.meta}>强制浅色/深色只作用于小说工坊面板</span>
4052
+ </div>
4053
+ <div className={css.field}>
4054
+ <label className={css.fieldLabel} style={{ fontSize: 13 }}>主题风格</label>
4055
+ <select className={css.input} style={{ padding: '10px 12px', fontSize: 14 }} value={panelTheme} onChange={e => { changePanelTheme(e.target.value as 'liquid' | 'classic' | 'neumorph') }}>
4056
+ <option value="liquid">液态玻璃 · 清新绿(默认)</option>
4057
+ <option value="classic">经典毛玻璃 · 沉静蓝</option>
4058
+ <option value="neumorph">新拟物 · 柔和浅色</option>
4059
+ </select>
4060
+ <span className={css.meta}>{tt('settings.themeHint')}</span>
4061
+ </div>
4062
+ <div className={css.field}>
4063
+ <label className={css.fieldLabel} style={{ fontSize: 13 }}>界面密度</label>
4064
+ <select className={css.input} style={{ padding: '10px 12px', fontSize: 14 }} value={themeDensity} onChange={e => { changeThemeDensity(e.target.value as 'comfort' | 'compact' | 'spacious') }}>
4065
+ <option value="comfort">舒适(默认)</option>
4066
+ <option value="compact">紧凑</option>
4067
+ <option value="spacious">宽松</option>
4068
+ </select>
4069
+ <span className={css.meta}>卡片 / 导航 / 内容的间距密度</span>
4070
+ </div>
4071
+ </div>
4072
+ <div className={css.row} style={{ justifyContent: 'flex-end' }}>
4073
+ <button type="button" className={css.button} style={{ padding: '9px 18px', fontSize: 13.5 }} onClick={resetTheme}>
4074
+ <RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题
4075
+ </button>
4076
+ </div>
3969
4077
  </div>
3970
- </div>
4078
+ )}
3971
4079
  </>
3972
4080
  )}
3973
-
3974
- {activeTab === 'settings' && (
3975
- <div className={`${css.card} ${css.settingsCard}`}>
3976
- <span className={css.cardTitle}>{tt('settings.theme')}</span>
3977
- <div className={css.row} style={{ gap: 8, flexWrap: 'wrap' }}>
3978
- <button
3979
- type="button"
3980
- className={`${css.button} ${css.buttonSmall} ${panelTheme === 'liquid' ? css.buttonPrimary : ''}`}
3981
- onClick={() => { changePanelTheme('liquid') }}
3982
- title="iOS 液态玻璃质感 · 绿色强调(当前默认)"
3983
- >
3984
- 🧊 {tt('settings.themeLiquid')}
3985
- </button>
3986
- <button
3987
- type="button"
3988
- className={`${css.button} ${css.buttonSmall} ${panelTheme === 'classic' ? css.buttonPrimary : ''}`}
3989
- onClick={() => { changePanelTheme('classic') }}
3990
- title="经典 iOS 毛玻璃 · 蓝色强调"
3991
- >
3992
- 💠 {tt('settings.themeClassic')}
3993
- </button>
3994
- <button
3995
- type="button"
3996
- className={`${css.button} ${css.buttonSmall} ${panelTheme === 'neumorph' ? css.buttonPrimary : ''}`}
3997
- onClick={() => { changePanelTheme('neumorph') }}
3998
- title="新拟物派 · 双阴影立体(仅浅色;深色下自动回退液态)"
3999
- >
4000
- 🔘 {tt('settings.themeNeumorph')}
4001
- </button>
4002
- <span className={css.meta}>{tt('settings.themeHint')}</span>
4003
- </div>
4004
- </div>
4005
- )}
4006
-
4007
4081
  {activeTab === 'book' && bookTab === 'facts' && (
4008
4082
  <div className={css.card}>
4009
4083
  <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
@@ -4390,12 +4464,8 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4390
4464
  <RunPanel api={api} totalChapters={chapters.length} />
4391
4465
  )}
4392
4466
 
4393
- {activeTab === 'styleLib' && (
4394
- <StyleGallery />
4395
- )}
4396
-
4397
4467
  {activeTab === 'manhua' && (
4398
- <MangaWorkspace api={api} project={project} chapters={chapters} onProjectChanged={() => refresh(false)} />
4468
+ <MangaWorkspace api={api} project={project} chapters={chapters} onProjectChanged={() => refresh(false)} imageApiEnabled={config?.imageApiEnabled === true} imageModels={config?.imageModels} onProgress={(text, kind) => pushProgress(text, kind ?? 'info')} />
4399
4469
  )}
4400
4470
 
4401
4471
  {activeTab === 'breakdown' && (
@@ -4465,563 +4535,6 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
4465
4535
  </div>
4466
4536
  )}
4467
4537
 
4468
- {activeTab === 'roleImage' && (
4469
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
4470
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4471
- <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🖼️ 角色形象</span>
4472
- <span className={css.meta}>角色形象锚点 / 生图提示词包 / 参考图集——复制提示词即可在即梦、豆包等工具出图</span>
4473
- </div>
4474
-
4475
- <input
4476
- ref={roleImageInputRef}
4477
- type="file"
4478
- accept="image/*"
4479
- style={{ display: 'none' }}
4480
- onChange={e => {
4481
- const file = e.target.files?.[0]
4482
- const target = roleImageTarget
4483
- if (file === undefined || target === null) return
4484
- const readDataUrl = (): Promise<string> => new Promise((resolve, reject) => {
4485
- const reader = new FileReader()
4486
- reader.onload = () => resolve(String(reader.result))
4487
- reader.onerror = () => reject(reader.error ?? new Error('读取图片失败'))
4488
- reader.readAsDataURL(file!)
4489
- })
4490
- void (async () => {
4491
- const dataUrl = await readDataUrl()
4492
- if (target.endsWith('||scene')) {
4493
- const sceneName = target.slice(0, -'||scene'.length)
4494
- setBusy(true)
4495
- setBusyLabel(`上传场景图「${sceneName}」${roleImageLabel}…`)
4496
- setError('')
4497
- try {
4498
- await api.scenes({ op: 'image', name: sceneName, dataUrl, label: roleImageLabel })
4499
- await refresh(false)
4500
- pushProgress(`已上传场景「${sceneName}」${roleImageLabel}`, 'done')
4501
- } catch (err) {
4502
- setError((err as Error).message)
4503
- pushProgress(`场景图上传失败:${(err as Error).message}`, 'error')
4504
- } finally {
4505
- setBusy(false)
4506
- setBusyLabel('')
4507
- setRoleImageTarget(null)
4508
- if (roleImageInputRef.current !== null) roleImageInputRef.current.value = ''
4509
- }
4510
- } else {
4511
- await handleRoleImageUpload(target, file, roleImageLabel)
4512
- }
4513
- })()
4514
- }}
4515
- />
4516
-
4517
- <div style={{ display: 'flex', flexDirection: 'column', gap: 10, flex: 1, minHeight: 0, overflowY: 'auto' }}>
4518
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6, justifyContent: 'space-between' }}>
4519
- <span className={css.row} style={{ gap: 6 }}>
4520
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void (async () => {
4521
- setBusy(true)
4522
- setBusyLabel('⚠️ 提炼本书视觉规则…')
4523
- setError('')
4524
- try {
4525
- const result = await api.visualRules({ op: 'extract' })
4526
- await refresh(false)
4527
- pushProgress(`已提炼 ${result.rules.length} 条视觉规则(已注入所有提示词)`, 'done')
4528
- } catch (err) {
4529
- setError((err as Error).message)
4530
- pushProgress(`视觉规则提炼失败:${(err as Error).message}`, 'error')
4531
- } finally {
4532
- setBusy(false)
4533
- setBusyLabel('')
4534
- }
4535
- })() }}>⚠️ 提炼视觉规则</button>
4536
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void refresh(false); pushProgress('角色库已刷新', 'done') }}>🔄 刷新角色库</button>
4537
- </span>
4538
- </div>
4539
-
4540
- {(project?.visualRules ?? []).length > 0 && (
4541
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center', marginTop: 6 }}>
4542
- <span className={css.meta} style={{ fontSize: 11 }}>⚠️ 本书视觉规则:</span>
4543
- {(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>)}
4544
- </div>
4545
- )}
4546
-
4547
- {(() => {
4548
- const roles = project?.roles ?? []
4549
- if (roles.length === 0) {
4550
- return (
4551
- <div className={css.shelfEmpty} style={{ minHeight: 140, flex: 1 }}>
4552
- <span className={css.shelfEmptyIcon}>🎭</span>
4553
- <span className={css.shelfEmptyTitle}>角色库为空</span>
4554
- <span className={css.meta}>请先到「角色库」提炼角色并生成形象锚点</span>
4555
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setActiveTab('roles') }}>去角色库</button>
4556
- </div>
4557
- )
4558
- }
4559
- const detailRole = detailRoleName !== null ? roles.find(r => r.name === detailRoleName) : undefined
4560
- const group = (role: RoleRecord) => {
4561
- const g = role.gallery ?? []
4562
- const byLabel = (key: string) => g.filter(x => x.label === key)
4563
- const byPrefix = (pre: string) => g.filter(x => x.label.startsWith(pre))
4564
- return {
4565
- portrait: byLabel('立绘')[0],
4566
- sheet: byLabel('四视图')[0],
4567
- expressions: byPrefix('表情-'),
4568
- others: g.filter(x => x.label !== '立绘' && x.label !== '四视图' && !x.label.startsWith('表情-')),
4569
- }
4570
- }
4571
- const uploadInDetail = (role: RoleRecord) => {
4572
- setRoleImageLabel(detailUploadLabel.trim() !== '' ? detailUploadLabel.trim() : '立绘')
4573
- setRoleImageTarget(role.name)
4574
- roleImageInputRef.current?.click()
4575
- }
4576
- return (
4577
- <>
4578
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 10 }}>
4579
- {roles.map(r => (
4580
- <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' }}>
4581
- <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' }}>
4582
- {r.imageUrl !== undefined
4583
- ? <img src={r.imageUrl} alt={r.name} style={{ width: '100%', height: '100%', objectFit: 'cover', objectPosition: 'center 20%' }} />
4584
- : <span className={css.meta}>暂无形象</span>}
4585
- <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>
4586
- </div>
4587
- <div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
4588
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
4589
- <b style={{ fontSize: 13 }}>{r.name}</b>
4590
- <span className={css.meta} style={{ fontSize: 10 }}>{r.roleLabel}</span>
4591
- </div>
4592
- <div className={css.meta} style={{ fontSize: 10, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
4593
- {r.imagePrompt !== undefined ? '✓ 锚点' : '⚠ 无锚点'} · {(r.identity ?? '').slice(0, 16)}
4594
- </div>
4595
- </div>
4596
- </div>
4597
- ))}
4598
- </div>
4599
-
4600
- {detailRole !== undefined && (
4601
- <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) }}>
4602
- <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()}>
4603
- <button type="button" className={css.iconButton} style={{ position: 'absolute', top: 12, right: 12, fontSize: 18 }} title="关闭" aria-label="关闭" onClick={() => { setDetailRoleName(null) }}>✕</button>
4604
- <div style={{ marginBottom: 12 }}>
4605
- <div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
4606
- <b style={{ fontSize: 22 }}>{detailRole.name}</b>
4607
- <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{detailRole.roleLabel}</span>
4608
- </div>
4609
- <div className={css.meta} style={{ marginTop: 4 }}>{detailRole.identity}</div>
4610
- </div>
4611
-
4612
- {(project?.visualRules ?? []).length > 0 && (
4613
- <div style={{ border: '1px solid var(--nf-warn, #b8860b)', borderRadius: 10, padding: '6px 10px', marginBottom: 10, fontSize: 11 }}>
4614
- <b>⚠️ 本书视觉规则(已内嵌到下方所有提示词)</b>
4615
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 4 }}>
4616
- {(project?.visualRules ?? []).map((r, i) => <span key={i} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '1px 8px', fontSize: 10 }}>{r}</span>)}
4617
- </div>
4618
- </div>
4619
- )}
4620
-
4621
- <details style={{ marginBottom: 10, fontSize: 12 }}>
4622
- <summary style={{ cursor: 'pointer', color: 'var(--nf-text-2)' }}>🖼 图集({(detailRole.gallery ?? []).length} 张 · 点击展开/收起)</summary>
4623
- <div style={{ marginTop: 8 }}>
4624
- {(() => {
4625
- const g = group(detailRole)
4626
- const hasMain = g.portrait !== undefined || g.sheet !== undefined
4627
- return (
4628
- <>
4629
- {hasMain && (
4630
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 10, marginBottom: 12 }}>
4631
- {g.portrait !== undefined && (
4632
- <div style={{ border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden' }}>
4633
- <img src={g.portrait.dataUrl} alt="立绘" style={{ width: '100%', maxHeight: 380, objectFit: 'contain', display: 'block', background: '#111' }} />
4634
- <div className={css.meta} style={{ padding: '2px 8px', fontSize: 11 }}>立绘</div>
4635
- </div>
4636
- )}
4637
- {g.sheet !== undefined && (
4638
- <div style={{ border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden' }}>
4639
- <img src={g.sheet.dataUrl} alt="四视图" style={{ width: '100%', maxHeight: 380, objectFit: 'contain', display: 'block', background: '#111' }} />
4640
- <div className={css.meta} style={{ padding: '2px 8px', fontSize: 11 }}>四视图</div>
4641
- </div>
4642
- )}
4643
- </div>
4644
- )}
4645
- {g.expressions.length > 0 && (
4646
- <div style={{ marginBottom: 12 }}>
4647
- <b style={{ fontSize: 13 }}>表情设定</b>
4648
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: 8, marginTop: 6 }}>
4649
- {g.expressions.map(img => (
4650
- <div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
4651
- <img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 110, objectFit: 'cover', display: 'block' }} />
4652
- <div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label.replace('表情-', '')}</div>
4653
- <button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
4654
- try {
4655
- await api.roles({ op: 'removeImage', name: detailRole.name, label: img.label })
4656
- await refresh(false)
4657
- pushProgress(`已删除「${detailRole.name}」${img.label}`, 'done')
4658
- } catch (err) { setError((err as Error).message) }
4659
- })() }}>×</button>
4660
- </div>
4661
- ))}
4662
- </div>
4663
- </div>
4664
- )}
4665
- {g.others.length > 0 && (
4666
- <div style={{ marginBottom: 12 }}>
4667
- <b style={{ fontSize: 13 }}>场景 / 细节</b>
4668
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8, marginTop: 6 }}>
4669
- {g.others.map(img => (
4670
- <div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
4671
- <img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 90, objectFit: 'cover', display: 'block' }} />
4672
- <div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label}</div>
4673
- <button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
4674
- try {
4675
- await api.roles({ op: 'removeImage', name: detailRole.name, label: img.label })
4676
- await refresh(false)
4677
- pushProgress(`已删除「${detailRole.name}」${img.label}`, 'done')
4678
- } catch (err) { setError((err as Error).message) }
4679
- })() }}>×</button>
4680
- </div>
4681
- ))}
4682
- </div>
4683
- </div>
4684
- )}
4685
- </>
4686
- )
4687
- })()}
4688
- </div>
4689
- </details>
4690
-
4691
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 14, marginBottom: 12 }}>
4692
- <div>
4693
- <b style={{ fontSize: 13 }}>基本信息</b>
4694
- <div style={{ fontSize: 12, lineHeight: 1.8, marginTop: 4 }}>
4695
- <div><span className={css.meta}>性格:</span>{(detailRole.traits ?? []).join('、') || '—'}</div>
4696
- <div><span className={css.meta}>目标:</span>{detailRole.goals || '—'}</div>
4697
- <div><span className={css.meta}>关系:</span>{(detailRole.relations ?? []).join(';') || '—'}</div>
4698
- <div><span className={css.meta}>成长线:</span>{(detailRole.arc ?? []).join(';') || '—'}</div>
4699
- </div>
4700
- </div>
4701
- <div>
4702
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4703
- <b style={{ fontSize: 13 }}>📄 提示词包</b>
4704
- {detailRole.imagePrompt !== undefined && detailRole.promptKit === undefined && (
4705
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void (async () => {
4706
- setBusy(true)
4707
- setBusyLabel(`精修「${detailRole.name}」提示词…`)
4708
- setError('')
4709
- try {
4710
- const activePlan = (project?.mangaPlans ?? []).find(pl => pl.active)
4711
- await api.roles({ op: 'promptKit', name: detailRole.name, styleId: activePlan?.styleId, filterId: activePlan?.filterId })
4712
- await refresh(false)
4713
- pushProgress(`已生成「${detailRole.name}」四类精修提示词`, 'done')
4714
- } catch (err) {
4715
- setError((err as Error).message)
4716
- pushProgress(`提示词精修失败:${(err as Error).message}`, 'error')
4717
- } finally {
4718
- setBusy(false)
4719
- setBusyLabel('')
4720
- }
4721
- })() }}>✨ 生成精修版</button>
4722
- )}
4723
- </div>
4724
- {detailRole.imagePrompt === undefined && <span className={css.meta}>未生成锚点,先点「✨ 生成锚点」</span>}
4725
- {(() => {
4726
- const anchor = detailRole.imagePrompt
4727
- if (anchor === undefined) return null
4728
- const rules = project?.visualRules ?? []
4729
- const rulesZh = rules.length > 0 ? '\n【本书视觉规则】' + rules.join(';') : ''
4730
- const expressions = detailRole.expressions ?? ['平静']
4731
- const expName = (n: string) => n.replace(/^表情-/, '')
4732
- const kit = detailRole.promptKit
4733
- const blocks: Array<{ key: string; title: string; zh: string; en: string }> = []
4734
- if (kit !== undefined) {
4735
- blocks.push({ key: 'portrait', title: '立绘', zh: kit.portrait.zh, en: kit.portrait.en })
4736
- blocks.push({ key: 'sheet', title: '四视图', zh: kit.sheet.zh, en: kit.sheet.en })
4737
- for (const e of kit.expressions) blocks.push({ key: 'exp-' + e.name, title: `表情·${e.name}`, zh: e.zh, en: e.en })
4738
- blocks.push({ key: 'details', title: '细节', zh: kit.details.zh, en: kit.details.en })
4739
- } else {
4740
- blocks.push({ key: 'portrait', title: '立绘', zh: '正面站立全身人像,3D动漫,超精细建模,纯白纯色背景,' + anchor.zh + '。角色设计稿,服装磨损、标志物细节完整展示,无多余杂物,全身完整无裁切。' + rulesZh, en: anchor.en + ', full body, front view, plain white background, 3d render, character design sheet' })
4741
- blocks.push({ key: 'sheet', title: '四视图', zh: '角色设定四视图,3D动漫,超精细建模,纯白纯色背景,' + anchor.zh + '。同一角色正面/左侧面/右侧面/背面,四个视角分别描述,全身完整,服装与标志物细节完整展示,无多余杂物。' + rulesZh, en: anchor.en + ', character sheet, front view, left side view, right side view, back view, full body, plain white background, 3d render' })
4742
- for (const n of expressions) blocks.push({ key: 'exp-' + n, title: `表情·${expName(n)}`, zh: '脸部特写(头部到锁骨),3D动漫,超精细建模,纯白纯色背景,' + anchor.zh + `。表情:${expName(n)},五官与角色定稿完全一致,皮肤纹理细节完整,无多余杂物。` + rulesZh, en: anchor.en + `, facial close-up, head to collarbone, expression: ${expName(n)}, plain white background, 3d render, consistent with character design, detailed skin texture, no extra objects` })
4743
- blocks.push({ key: 'details', title: '细节', zh: `多组局部细节集合参考图,3D动漫,超精细建模,纯白纯色背景:${anchor.tags.map(t => t + '特写').join(';')}。细节清晰锐利,角色细节参考稿,无多余杂物。` + rulesZh, en: anchor.tags.join(', ') + ', multi-panel detail reference sheet, plain white background, 3d render, macro close-up, sharp details, character detail sheet, no extra objects' })
4744
- }
4745
- return (
4746
- <div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginTop: 8 }}>
4747
- {blocks.map(b => (
4748
- <div key={b.key} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: '6px 8px' }}>
4749
- <div className={css.row} style={{ justifyContent: 'space-between' }}>
4750
- <b style={{ fontSize: 11 }}>{b.title}</b>
4751
- <span className={css.row} style={{ gap: 4 }}>
4752
- <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ fontSize: 10 }} onClick={() => {
4753
- void navigator.clipboard?.writeText(b.zh).then(() => { pushProgress(`已复制「${detailRole.name}」${b.title}中文提示词`, 'done') }).catch(() => { /* ignore */ })
4754
- }}>复制</button>
4755
- <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ fontSize: 10 }} onClick={() => {
4756
- void navigator.clipboard?.writeText(b.en).then(() => { pushProgress(`已复制「${detailRole.name}」${b.title}英文标签`, 'done') }).catch(() => { /* ignore */ })
4757
- }}>EN</button>
4758
- </span>
4759
- </div>
4760
- <div className={css.meta} style={{ fontSize: 11, lineHeight: 1.5, marginTop: 3 }}>{b.zh}</div>
4761
- <details style={{ marginTop: 3 }}>
4762
- <summary style={{ cursor: 'pointer', fontSize: 10, color: 'var(--nf-text-2)' }}>🌐 英文标签(点击展开)</summary>
4763
- <div className={css.meta} style={{ fontSize: 9, fontFamily: 'monospace', marginTop: 2, wordBreak: 'break-all', color: 'var(--nf-text-2)' }}>{b.en}</div>
4764
- </details>
4765
- </div>
4766
- ))}
4767
- <div className={css.meta} style={{ fontSize: 10 }}>关键词:{(anchor.tags ?? []).join('、')}</div>
4768
- </div>
4769
- )
4770
- })()}
4771
- </div>
4772
- </div>
4773
-
4774
- <div style={{ borderTop: '1px solid var(--nf-border)', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
4775
- {detailRole.imagePrompt === undefined && (
4776
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRoleVisual(detailRole.name) }}>✨ 生成锚点</button>
4777
- )}
4778
- {detailRole.imagePrompt !== undefined && config?.imageApiEnabled === true && (
4779
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void handleRoleImageGenerate(detailRole.name) }}>豆包生成</button>
4780
- )}
4781
- {detailRole.imagePrompt !== undefined && (
4782
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => {
4783
- const text = [detailRole.imagePrompt!.en, detailRole.imagePrompt!.tags.join(', ')].filter(Boolean).join('\n')
4784
- void navigator.clipboard?.writeText(text).then(() => { pushProgress(`已复制「${detailRole.name}」英文生图提示词`, 'done') }).catch(() => { /* ignore */ })
4785
- }}>复制提示词</button>
4786
- )}
4787
- <input
4788
- className={css.input}
4789
- style={{ width: 140, fontSize: 12, padding: '3px 6px' }}
4790
- placeholder="图集标签"
4791
- value={detailUploadLabel}
4792
- onChange={e => { setDetailUploadLabel(e.target.value) }}
4793
- />
4794
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { uploadInDetail(detailRole) }}>📤 上传图</button>
4795
- </div>
4796
- </div>
4797
- </div>
4798
- )}
4799
- </>
4800
- )
4801
- })()}
4802
- </div>
4803
- </div>
4804
- )}
4805
-
4806
- {activeTab === 'scenes' && (
4807
- <div className={css.card} style={{ flex: 1, minHeight: 0 }}>
4808
- <div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
4809
- <span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🏞️ 场景库</span>
4810
- <span className={css.meta}>场景视觉锚点:提炼自正文,供漫剧分镜/生图锁定"在哪、什么氛围"</span>
4811
- </div>
4812
-
4813
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6, margin: '8px 0' }}>
4814
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void (async () => {
4815
- setBusy(true)
4816
- setBusyLabel('✨ 从全书提炼场景…')
4817
- setError('')
4818
- try {
4819
- const result = await api.scenes({ op: 'extract' })
4820
- setSceneCandidates(result.candidates ?? [])
4821
- pushProgress(`提炼出 ${(result.candidates ?? []).length} 个候选场景,可采纳或修改后采纳`, 'done')
4822
- } catch (err) {
4823
- setError((err as Error).message)
4824
- pushProgress(`场景提炼失败:${(err as Error).message}`, 'error')
4825
- } finally {
4826
- setBusy(false)
4827
- setBusyLabel('')
4828
- }
4829
- })() }}>{busy ? '⏳ 提炼中…' : '✨ 从全书提炼场景'}</button>
4830
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void refresh(false); pushProgress('场景库已刷新', 'done') }}>🔄 刷新</button>
4831
- {sceneCandidates !== null && (
4832
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneCandidates(null) }}>收起候选</button>
4833
- )}
4834
- </div>
4835
-
4836
- {sceneDraft !== null && (
4837
- <div style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: 10, marginBottom: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
4838
- <b style={{ fontSize: 13 }}>编辑场景</b>
4839
- <input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="场景名" value={sceneDraft.name} onChange={e => { setSceneDraft({ ...sceneDraft, name: e.target.value }) }} />
4840
- <input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="一句话定位" value={sceneDraft.summary} onChange={e => { setSceneDraft({ ...sceneDraft, summary: e.target.value }) }} />
4841
- <div className={css.row} style={{ gap: 6 }}>
4842
- <input className={css.input} style={{ fontSize: 12, padding: '4px 8px', flex: 1 }} placeholder="幕归属(第一幕后场…)" value={sceneDraft.act ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, act: e.target.value }) }} />
4843
- <input className={css.input} style={{ fontSize: 12, padding: '4px 8px', flex: 1 }} placeholder="时间光态(雨夜/闭店后…)" value={sceneDraft.moment ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, moment: e.target.value }) }} />
4844
- </div>
4845
- <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) }) }} />
4846
- <input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="人物在场状态(含标志物细节)" value={sceneDraft.characterState ?? ''} onChange={e => { setSceneDraft({ ...sceneDraft, characterState: e.target.value }) }} />
4847
- <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) }) }} />
4848
- <textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="中文生图提示词" value={sceneDraft.zh} onChange={e => { setSceneDraft({ ...sceneDraft, zh: e.target.value }) }} />
4849
- <textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="英文生图提示词" value={sceneDraft.en} onChange={e => { setSceneDraft({ ...sceneDraft, en: e.target.value }) }} />
4850
- <div className={css.row} style={{ gap: 6 }}>
4851
- <button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || sceneDraft.name.trim() === ''} onClick={() => { void (async () => {
4852
- setBusy(true)
4853
- try {
4854
- await api.scenes({ op: 'update', scene: { ...sceneDraft, name: sceneDraft.name.trim() } })
4855
- await refresh(false)
4856
- setSceneDraft(null)
4857
- pushProgress(`已保存场景「${sceneDraft.name.trim()}」`, 'done')
4858
- } catch (err) {
4859
- setError((err as Error).message)
4860
- } finally {
4861
- setBusy(false)
4862
- }
4863
- })() }}>保存</button>
4864
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(null) }}>取消</button>
4865
- </div>
4866
- </div>
4867
- )}
4868
-
4869
- {sceneCandidates !== null && sceneCandidates.length > 0 && (
4870
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
4871
- {sceneCandidates.map(s => (
4872
- <div key={s.name} style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: '8px 10px', fontSize: 12 }}>
4873
- <div className={css.row} style={{ flexWrap: 'wrap', gap: 6, justifyContent: 'space-between' }}>
4874
- <span><b>{s.name}</b> <span className={css.meta}>· {(s.moods ?? []).join('、')}</span></span>
4875
- <span className={css.row} style={{ gap: 4 }}>
4876
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void (async () => {
4877
- try {
4878
- await api.scenes({ op: 'adopt', scene: s })
4879
- setSceneCandidates((sceneCandidates ?? []).filter(x => x.name !== s.name))
4880
- await refresh(false)
4881
- pushProgress(`已采纳场景「${s.name}」`, 'done')
4882
- } catch (err) { setError((err as Error).message) }
4883
- })() }}>采纳</button>
4884
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(s); setSceneCandidates(null) }}>修改后采纳</button>
4885
- </span>
4886
- </div>
4887
- <div className={css.meta} style={{ marginTop: 2 }}>{s.summary}</div>
4888
- <div className={css.meta} style={{ marginTop: 2, fontSize: 11 }}>要素:{(s.elements ?? []).join(';')}</div>
4889
- </div>
4890
- ))}
4891
- </div>
4892
- )}
4893
-
4894
- {(() => {
4895
- const scenes = project?.scenes ?? []
4896
- const detail = sceneDetailName !== null ? scenes.find(s => s.name === sceneDetailName) : undefined
4897
- if (scenes.length === 0 && sceneCandidates === null) {
4898
- return (
4899
- <div className={css.shelfEmpty} style={{ minHeight: 140, flex: 1 }}>
4900
- <span className={css.shelfEmptyIcon}>🏞️</span>
4901
- <span className={css.shelfEmptyTitle}>场景库为空</span>
4902
- <span className={css.meta}>点「✨ 从全书提炼场景」自动建立,或手动新增</span>
4903
- </div>
4904
- )
4905
- }
4906
- return (
4907
- <>
4908
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 10 }}>
4909
- {scenes.map(s => {
4910
- const cover = (s.gallery ?? [])[0]
4911
- return (
4912
- <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' }}>
4913
- <div style={{ height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--nf-bg-inset)', borderBottom: '1px solid var(--nf-border)', position: 'relative' }}>
4914
- {cover !== undefined
4915
- ? <img src={cover.dataUrl} alt={s.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
4916
- : <span className={css.meta}>暂无场景图</span>}
4917
- <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>
4918
- </div>
4919
- <div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
4920
- <b style={{ fontSize: 13 }}>{s.name}</b>
4921
- <div className={css.meta} style={{ fontSize: 10 }}>{(s.moods ?? []).join('、') || s.summary.slice(0, 16)}</div>
4922
- </div>
4923
- </div>
4924
- )
4925
- })}
4926
- </div>
4927
-
4928
- {detail !== undefined && (
4929
- <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) }}>
4930
- <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()}>
4931
- <button type="button" className={css.iconButton} style={{ position: 'absolute', top: 12, right: 12, fontSize: 18 }} title="关闭" aria-label="关闭" onClick={() => { setSceneDetailName(null) }}>✕</button>
4932
- <div style={{ marginBottom: 12 }}>
4933
- <b style={{ fontSize: 20 }}>{detail.name}</b>
4934
- <div className={css.meta} style={{ marginTop: 4 }}>{detail.summary}</div>
4935
- </div>
4936
-
4937
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 8, marginBottom: 12 }}>
4938
- {(detail.gallery ?? []).map(img => (
4939
- <div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
4940
- <img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 100, objectFit: 'cover', display: 'block' }} />
4941
- <div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label}</div>
4942
- <button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void (async () => {
4943
- try {
4944
- await api.scenes({ op: 'removeImage', name: detail.name, label: img.label })
4945
- await refresh(false)
4946
- pushProgress(`已删除「${detail.name}」${img.label}`, 'done')
4947
- } catch (err) { setError((err as Error).message) }
4948
- })() }}>×</button>
4949
- </div>
4950
- ))}
4951
- {(detail.gallery ?? []).length === 0 && <span className={css.meta}>暂无场景图</span>}
4952
- </div>
4953
-
4954
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 14, marginBottom: 12 }}>
4955
- <div style={{ fontSize: 12, lineHeight: 1.8 }}>
4956
- {(detail.act !== undefined || detail.moment !== undefined) && (
4957
- <div style={{ marginBottom: 6 }}>
4958
- {(detail.act !== undefined && detail.act !== '') && <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)', marginRight: 6 }}>{detail.act}</span>}
4959
- {(detail.moment !== undefined && detail.moment !== '') && <span className={css.meta}>{detail.moment}</span>}
4960
- </div>
4961
- )}
4962
- {(detail.beats ?? []).length > 0 && (
4963
- <div style={{ marginBottom: 8 }}>
4964
- <b style={{ fontSize: 13 }}>关键镜头</b>
4965
- <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>
4966
- </div>
4967
- )}
4968
- {detail.characterState !== undefined && detail.characterState !== '' && (
4969
- <div style={{ marginBottom: 8 }}>
4970
- <b style={{ fontSize: 13 }}>人物状态</b>
4971
- <div className={css.meta} style={{ marginTop: 2 }}>{detail.characterState}</div>
4972
- </div>
4973
- )}
4974
- <b style={{ fontSize: 13 }}>环境构成</b>
4975
- <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>
4976
- <div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>色调光影</b></div>
4977
- <div style={{ marginTop: 4 }}>{(detail.palette ?? []).join(';') || '—'}</div>
4978
- <div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>氛围</b></div>
4979
- <div style={{ marginTop: 4 }}>{(detail.moods ?? []).join('、') || '—'}</div>
4980
- <div style={{ marginTop: 8 }}><b style={{ fontSize: 13 }}>依据</b></div>
4981
- <div className={css.meta} style={{ marginTop: 4 }}>{detail.source || '—'}</div>
4982
- </div>
4983
- <div>
4984
- <b style={{ fontSize: 13 }}>生图提示词</b>
4985
- <div className={css.meta} style={{ fontSize: 11, lineHeight: 1.6, marginTop: 4 }}>{detail.zh}</div>
4986
- <div className={css.meta} style={{ fontSize: 10, fontFamily: 'monospace', marginTop: 4, wordBreak: 'break-all' }}>{detail.en}</div>
4987
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 8 }}>
4988
- {(detail.tags ?? []).map(t => <span key={t} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '2px 8px', fontSize: 11 }}>{t}</span>)}
4989
- </div>
4990
- </div>
4991
- </div>
4992
-
4993
- <div style={{ borderTop: '1px solid var(--nf-border)', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
4994
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => {
4995
- const text = [detail.en, detail.tags.join(', ')].filter(Boolean).join('\n')
4996
- void navigator.clipboard?.writeText(text).then(() => { pushProgress(`已复制「${detail.name}」英文生图提示词`, 'done') }).catch(() => { /* ignore */ })
4997
- }}>复制提示词</button>
4998
- <input className={css.input} style={{ width: 130, fontSize: 12, padding: '3px 6px' }} placeholder="图集标签" value={sceneUploadLabel} onChange={e => { setSceneUploadLabel(e.target.value) }} />
4999
- <button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
5000
- setRoleImageLabel(sceneUploadLabel.trim() !== '' ? sceneUploadLabel.trim() : '全景')
5001
- setSceneUploadLabel(sceneUploadLabel.trim() !== '' ? sceneUploadLabel.trim() : '全景')
5002
- setRoleImageTarget(detail.name + '||scene')
5003
- roleImageInputRef.current?.click()
5004
- }}>📤 上传场景图</button>
5005
- <button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setSceneDraft(detail); setSceneDetailName(null) }}>✏️ 编辑</button>
5006
- <button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ color: 'var(--nf-danger, #e05)' }} onClick={() => { void (async () => {
5007
- if (!window.confirm(`确定删除场景「${detail.name}」?`)) return
5008
- try {
5009
- await api.scenes({ op: 'remove', name: detail.name })
5010
- await refresh(false)
5011
- setSceneDetailName(null)
5012
- pushProgress(`已删除场景「${detail.name}」`, 'done')
5013
- } catch (err) { setError((err as Error).message) }
5014
- })() }}>🗑 删除</button>
5015
- </div>
5016
- </div>
5017
- </div>
5018
- )}
5019
- </>
5020
- )
5021
- })()}
5022
- </div>
5023
- )}
5024
-
5025
4538
  </>)}
5026
4539
  </div>
5027
4540
  </div>
@@ -5058,7 +4571,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
5058
4571
  </div>
5059
4572
  )}
5060
4573
 
5061
- {/* 工作进度悬浮窗:可拖动、可拉大小(同 AI 助手),承接活动输出 + 当前任务进度 */}
4574
+ {/* AI进度悬浮窗:可拖动、可拉大小(同 AI 助手),承接活动输出 + 当前任务进度 */}
5062
4575
  {progressOpen && (
5063
4576
  <div
5064
4577
  className={css.assistantFloat}
@@ -5072,7 +4585,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
5072
4585
  }}
5073
4586
  >
5074
4587
  <span>
5075
- 📊 工作进度
4588
+ 📊 AI进度
5076
4589
  {busy && <span style={{ color: 'var(--nf-accent)' }}> 🟢 任务进行中</span>}
5077
4590
  <span className={css.meta}>(拖动标题栏移动 · 右下角拉大小)</span>
5078
4591
  </span>
@@ -5082,7 +4595,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
5082
4595
  清空
5083
4596
  </button>
5084
4597
  )}
5085
- <button type="button" className={css.iconButton} title="关闭" aria-label="关闭工作进度" onClick={() => { setProgressOpen(false) }}>×</button>
4598
+ <button type="button" className={css.iconButton} title="关闭" aria-label="关闭AI进度" onClick={() => { setProgressOpen(false) }}>×</button>
5086
4599
  </span>
5087
4600
  </div>
5088
4601
  <div className={css.assistantFloatBody} style={{ padding: 10, display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>