@waterwx/dsh-novel-forge 1.6.0 → 1.7.2
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.
- package/lib/client.js +37690 -2269
- package/lib/client.js.map +1 -1
- package/lib/index.js +767 -56
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +3 -0
- package/lib/types/client/locales.d.ts +0 -1
- package/lib/types/client/panel/FlowGuide.d.ts +21 -0
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +18 -0
- package/lib/types/client/panel/MangaWorkspace.d.ts +6 -4
- package/lib/types/client/panel/RoleVisualPanel.d.ts +5 -3
- package/lib/types/client/panel/SceneLibrary.d.ts +1 -1
- package/lib/types/client/panel/StoryboardTab.d.ts +6 -2
- package/lib/types/engine.d.ts +23 -9
- package/lib/types/index.d.ts +6 -4
- package/lib/types/protocol.d.ts +171 -3
- package/package.json +100 -98
- package/src/client/api.ts +9 -0
- package/src/client/locales.ts +0 -1
- package/src/client/panel/AssetsTab.tsx +22 -22
- package/src/client/panel/AssistantTab.tsx +3 -3
- package/src/client/panel/BookshelfBar.tsx +1 -1
- package/src/client/panel/CreateBookView.tsx +10 -10
- package/src/client/panel/FlowGuide.tsx +83 -0
- package/src/client/panel/MangaRoleLibrary.tsx +810 -0
- package/src/client/panel/MangaWorkspace.tsx +359 -293
- package/src/client/panel/NovelPanel.tsx +465 -307
- package/src/client/panel/ReaderView.tsx +4 -4
- package/src/client/panel/RunPanel.tsx +9 -9
- package/src/client/panel/SceneLibrary.tsx +69 -68
- package/src/client/panel/ShelfView.tsx +28 -35
- package/src/client/panel/StoryboardTab.tsx +106 -42
- package/src/client/panel/StyleCard.tsx +13 -13
- package/src/client/panel/StyleGallery.tsx +2 -2
- package/src/client/panel/WorldTab.tsx +5 -5
- package/src/client/panel/panel.module.css +1189 -454
- package/src/client/panel/views.tsx +24 -24
- package/src/engine.ts +530 -59
- package/src/index.ts +37 -6
- package/src/protocol.ts +175 -3
- package/src/routes.ts +229 -1
- package/src/client/panel/RoleVisualPanel.tsx +0 -468
|
@@ -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'
|
|
@@ -17,7 +18,6 @@ import { RunPanel } from './RunPanel.tsx'
|
|
|
17
18
|
import { CreateBookView } from './CreateBookView.tsx'
|
|
18
19
|
import { ImportModal } from './ImportModal.tsx'
|
|
19
20
|
import { WorldTab } from './WorldTab.tsx'
|
|
20
|
-
import { StoryboardTab } from './StoryboardTab.tsx'
|
|
21
21
|
import { MangaWorkspace } from './MangaWorkspace.tsx'
|
|
22
22
|
import { AuditIssueRow, PlotlineCard, PlotlineHealthPanel, PlotlinePlanPanel, PlotlineSuggestionPanel, RoleCandidateRow, RoleCard, StatCell, TodoRow } from './views.tsx'
|
|
23
23
|
import { extractDocxTextFromBuffer } from '../docx.ts'
|
|
@@ -26,6 +26,7 @@ import type {
|
|
|
26
26
|
BookshelfSnapshot,
|
|
27
27
|
ChapterPlan,
|
|
28
28
|
Foreshadow,
|
|
29
|
+
ImageModelConfig,
|
|
29
30
|
JobFrame,
|
|
30
31
|
NovelConfig,
|
|
31
32
|
Plotline,
|
|
@@ -67,46 +68,55 @@ interface ProgressLine {
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
/** The navigation groups (AI-Novel-Writing-Assistant style grouping). */
|
|
70
|
-
const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArray<{ id: NovelTab; label: string; icon:
|
|
71
|
+
const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArray<{ id: NovelTab; label: string; icon: ReactElement }> }> = [
|
|
71
72
|
{
|
|
72
73
|
id: 'create',
|
|
73
74
|
label: '创作',
|
|
74
75
|
items: [
|
|
75
|
-
{ id: 'workflow', label: tt('tab.workflow'), icon:
|
|
76
|
-
{ id: 'overview', label: tt('tab.overview'), icon:
|
|
77
|
-
{ id: 'blurb', label: '简介 / 封面', icon:
|
|
78
|
-
{ id: 'plan', label: tt('tab.plan'), icon:
|
|
79
|
-
{ id: 'plotlines', label: '长线管理', icon:
|
|
80
|
-
{ id: 'book', label: '本书设定', icon:
|
|
76
|
+
{ id: 'workflow', label: tt('tab.workflow'), icon: <Wrench size={16} /> },
|
|
77
|
+
{ id: 'overview', label: tt('tab.overview'), icon: <FileText size={16} /> },
|
|
78
|
+
{ id: 'blurb', label: '简介 / 封面', icon: <BookOpen size={16} /> },
|
|
79
|
+
{ id: 'plan', label: tt('tab.plan'), icon: <BookMarked size={16} /> },
|
|
80
|
+
{ id: 'plotlines', label: '长线管理', icon: <ScrollText size={16} /> },
|
|
81
|
+
{ id: 'book', label: '本书设定', icon: <Library size={16} /> },
|
|
81
82
|
],
|
|
82
83
|
},
|
|
83
84
|
{
|
|
84
85
|
id: 'tools',
|
|
85
86
|
label: '工具',
|
|
86
87
|
items: [
|
|
87
|
-
{ id: 'assistant', label: tt('tab.assistant'), icon:
|
|
88
|
-
{ id: 'progress', label: '
|
|
89
|
-
{ id: 'breakdown', label: '拆书分析', icon:
|
|
90
|
-
{ id: 'manhua', label: '漫剧工作台', icon:
|
|
91
|
-
{ id: 'run', label: '生产单', icon:
|
|
88
|
+
{ id: 'assistant', label: tt('tab.assistant'), icon: <MessageSquare size={16} /> },
|
|
89
|
+
{ id: 'progress', label: 'AI 进度', icon: <BarChart3 size={16} /> },
|
|
90
|
+
{ id: 'breakdown', label: '拆书分析', icon: <Search size={16} /> },
|
|
91
|
+
{ id: 'manhua', label: '漫剧工作台', icon: <Clapperboard size={16} /> },
|
|
92
|
+
{ id: 'run', label: '生产单', icon: <Factory size={16} /> },
|
|
92
93
|
],
|
|
93
94
|
},
|
|
94
95
|
{
|
|
95
96
|
id: 'assets',
|
|
96
97
|
label: '资产',
|
|
97
98
|
items: [
|
|
98
|
-
{ id: 'assets', label: '创作资产', icon:
|
|
99
|
+
{ id: 'assets', label: '创作资产', icon: <Wrench size={16} /> },
|
|
99
100
|
],
|
|
100
101
|
},
|
|
101
102
|
]
|
|
102
103
|
|
|
103
104
|
/** Settings tab — pinned to the bottom of the nav rail. */
|
|
104
|
-
const SETTINGS_TAB: { id: NovelTab; label: string; icon:
|
|
105
|
+
const SETTINGS_TAB: { id: NovelTab; label: string; icon: ReactElement } = { id: 'settings', label: tt('tab.settings'), icon: <Settings size={16} /> }
|
|
105
106
|
|
|
106
107
|
/** Common DeepSeek model presets shown in settings; users can also type any model id. */
|
|
107
108
|
const MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
|
|
108
109
|
const REASONING_OPTIONS = ['off', 'low', 'high', 'max'] as const
|
|
109
110
|
|
|
111
|
+
/** 设置页内子导航分组。 */
|
|
112
|
+
const SETTINGS_SECTIONS: ReadonlyArray<{ id: 'model' | 'writing' | 'image' | 'files' | 'appearance'; label: string; icon: ReactElement }> = [
|
|
113
|
+
{ id: 'model', label: '模型与推理', icon: <Brain size={16} /> },
|
|
114
|
+
{ id: 'writing', label: '写作与审稿', icon: <PenLine size={16} /> },
|
|
115
|
+
{ id: 'image', label: '生图模型', icon: <Palette size={16} /> },
|
|
116
|
+
{ id: 'files', label: '路径与文件', icon: <Folder size={16} /> },
|
|
117
|
+
{ id: 'appearance', label: '外观与主题', icon: <Sparkles size={16} /> },
|
|
118
|
+
]
|
|
119
|
+
|
|
110
120
|
/** 构建时注入的插件版本(tsdown define 替换为字符串字面量)。 */
|
|
111
121
|
declare const __NOVEL_FORGE_VERSION__: string | undefined
|
|
112
122
|
const PLUGIN_VERSION: string = typeof __NOVEL_FORGE_VERSION__ !== 'undefined' ? __NOVEL_FORGE_VERSION__ : '0.0.0'
|
|
@@ -215,7 +225,7 @@ function paragraphDiff(oldText: string, newText: string): DiffRow[] {
|
|
|
215
225
|
function renderBeats(beats: string): ReactElement {
|
|
216
226
|
const lines = beats.split('\n')
|
|
217
227
|
return (
|
|
218
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
228
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-2)' }}>
|
|
219
229
|
{lines.map((line, i) => {
|
|
220
230
|
const trimmed = line.trim()
|
|
221
231
|
const match = /^([^::]{2,14})[::]/.exec(trimmed)
|
|
@@ -371,17 +381,61 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
371
381
|
setEditorFontSize(v)
|
|
372
382
|
try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
|
|
373
383
|
}
|
|
374
|
-
/** 面板主题(localStorage 记忆):'liquid'=iOS 液态玻璃(绿) / '
|
|
375
|
-
const [panelTheme, setPanelTheme] = useState<'liquid' | '
|
|
384
|
+
/** 面板主题(localStorage 记忆):'liquid'=iOS 液态玻璃(绿) / 'neumorph'=新拟物(浅色) / 'macos'=macOS 玻璃(蓝,随外观自动浅深) / 'clay'=粘土拟态。 */
|
|
385
|
+
const [panelTheme, setPanelTheme] = useState<'liquid' | 'neumorph' | 'macos' | 'clay'>(() => {
|
|
376
386
|
try {
|
|
377
387
|
const v = window.localStorage.getItem('dsh-novel-forge.theme')
|
|
378
|
-
return v === '
|
|
388
|
+
return v === 'neumorph' || v === 'macos' || v === 'clay' ? v : 'liquid'
|
|
379
389
|
} catch { return 'liquid' }
|
|
380
390
|
})
|
|
381
|
-
const changePanelTheme = (next: 'liquid' | '
|
|
391
|
+
const changePanelTheme = (next: 'liquid' | 'neumorph' | 'macos' | 'clay'): void => {
|
|
382
392
|
setPanelTheme(next)
|
|
383
393
|
try { window.localStorage.setItem('dsh-novel-forge.theme', next) } catch { /* ignore */ }
|
|
384
394
|
}
|
|
395
|
+
/** 显示模式(跟随系统 / 强制浅色 / 强制深色),localStorage 记忆,只作用于小说工坊面板。 */
|
|
396
|
+
const [themeMode, setThemeMode] = useState<'system' | 'light' | 'dark'>(() => {
|
|
397
|
+
try {
|
|
398
|
+
const v = window.localStorage.getItem('dsh-novel-forge.theme.mode')
|
|
399
|
+
return v === 'light' || v === 'dark' ? v : 'system'
|
|
400
|
+
} catch { return 'system' }
|
|
401
|
+
})
|
|
402
|
+
const changeThemeMode = (next: 'system' | 'light' | 'dark'): void => {
|
|
403
|
+
setThemeMode(next)
|
|
404
|
+
try { window.localStorage.setItem('dsh-novel-forge.theme.mode', next) } catch { /* ignore */ }
|
|
405
|
+
}
|
|
406
|
+
/** 界面密度(舒适 / 紧凑 / 宽松),localStorage 记忆。 */
|
|
407
|
+
const [themeDensity, setThemeDensity] = useState<'comfort' | 'compact' | 'spacious'>(() => {
|
|
408
|
+
try {
|
|
409
|
+
const v = window.localStorage.getItem('dsh-novel-forge.theme.density')
|
|
410
|
+
return v === 'compact' || v === 'spacious' ? v : 'comfort'
|
|
411
|
+
} catch { return 'comfort' }
|
|
412
|
+
})
|
|
413
|
+
const changeThemeDensity = (next: 'comfort' | 'compact' | 'spacious'): void => {
|
|
414
|
+
setThemeDensity(next)
|
|
415
|
+
try { window.localStorage.setItem('dsh-novel-forge.theme.density', next) } catch { /* ignore */ }
|
|
416
|
+
}
|
|
417
|
+
/** 恢复默认主题:清掉主题/模式/密度记忆。 */
|
|
418
|
+
const resetTheme = (): void => {
|
|
419
|
+
try {
|
|
420
|
+
window.localStorage.removeItem('dsh-novel-forge.theme')
|
|
421
|
+
window.localStorage.removeItem('dsh-novel-forge.theme.mode')
|
|
422
|
+
window.localStorage.removeItem('dsh-novel-forge.theme.density')
|
|
423
|
+
} catch { /* ignore */ }
|
|
424
|
+
setPanelTheme('liquid')
|
|
425
|
+
setThemeMode('system')
|
|
426
|
+
setThemeDensity('comfort')
|
|
427
|
+
}
|
|
428
|
+
/** 设置页内子导航:当前分组(localStorage 记忆)。 */
|
|
429
|
+
const [settingsTab, setSettingsTab] = useState<'model' | 'writing' | 'image' | 'files' | 'appearance'>(() => {
|
|
430
|
+
try {
|
|
431
|
+
const v = window.localStorage.getItem('dsh-novel-forge.settings.tab')
|
|
432
|
+
return v === 'writing' || v === 'image' || v === 'files' || v === 'appearance' ? v : 'model'
|
|
433
|
+
} catch { return 'model' }
|
|
434
|
+
})
|
|
435
|
+
const changeSettingsTab = (next: 'model' | 'writing' | 'image' | 'files' | 'appearance'): void => {
|
|
436
|
+
setSettingsTab(next)
|
|
437
|
+
try { window.localStorage.setItem('dsh-novel-forge.settings.tab', next) } catch { /* ignore */ }
|
|
438
|
+
}
|
|
385
439
|
/** 有未采纳草稿的章节号(refresh 后检测到遗留草稿时提示)。 */
|
|
386
440
|
const [draftNo, setDraftNo] = useState<number | null>(null)
|
|
387
441
|
/** 大纲页「更新大纲」编辑区是否展开。 */
|
|
@@ -509,9 +563,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
509
563
|
}, [])
|
|
510
564
|
/** AI 助手悬浮窗:是否打开。 */
|
|
511
565
|
const [assistantOpen, setAssistantOpen] = useState(false)
|
|
512
|
-
/**
|
|
566
|
+
/** AI进度悬浮窗:是否打开。 */
|
|
513
567
|
const [progressOpen, setProgressOpen] = useState(false)
|
|
514
|
-
/**
|
|
568
|
+
/** AI进度悬浮窗位置(localStorage 记忆)。 */
|
|
515
569
|
const [progressPos, setProgressPos] = useState(() => {
|
|
516
570
|
try {
|
|
517
571
|
const raw = window.localStorage.getItem('dsh-novel-forge.progress.float')
|
|
@@ -522,7 +576,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
522
576
|
} catch { /* ignore */ }
|
|
523
577
|
return { x: 60, y: 120 }
|
|
524
578
|
})
|
|
525
|
-
/**
|
|
579
|
+
/** AI进度悬浮窗尺寸(localStorage 记忆)。 */
|
|
526
580
|
const [progressSize, setProgressSize] = useState(() => {
|
|
527
581
|
try {
|
|
528
582
|
const raw = window.localStorage.getItem('dsh-novel-forge.progress.size')
|
|
@@ -555,7 +609,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
555
609
|
} catch { /* ignore */ }
|
|
556
610
|
return { w: 420, h: 460 }
|
|
557
611
|
})
|
|
558
|
-
/** 拖拽/缩放状态(target 区分 AI 助手 /
|
|
612
|
+
/** 拖拽/缩放状态(target 区分 AI 助手 / AI进度两个悬浮窗)。 */
|
|
559
613
|
const dragState = useRef<{ type: 'move' | 'resize'; target: 'assistant' | 'progress'; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null)
|
|
560
614
|
|
|
561
615
|
/** 悬浮窗位置/尺寸持久化。 */
|
|
@@ -1798,7 +1852,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
1798
1852
|
lastDeltaChars.current = 0
|
|
1799
1853
|
liveChars.current = 0
|
|
1800
1854
|
currentJobNo.current = frame.no
|
|
1801
|
-
//
|
|
1855
|
+
// 任务开始:不自动弹出进度悬浮窗(导航「AI进度」显示呼吸绿点提示,
|
|
1802
1856
|
// 想看进度时手动点导航打开;liveBar 数据照常累积)。
|
|
1803
1857
|
setProject(prev => prev === null ? prev : {
|
|
1804
1858
|
...prev,
|
|
@@ -2010,6 +2064,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2010
2064
|
imageApiKey: configDraft.imageApiKey,
|
|
2011
2065
|
imageApiModel: configDraft.imageApiModel,
|
|
2012
2066
|
imageApiEnabled: configDraft.imageApiEnabled ?? false,
|
|
2067
|
+
imageModels: configDraft.imageModels ?? [],
|
|
2013
2068
|
})
|
|
2014
2069
|
setConfig(result.config)
|
|
2015
2070
|
setConfigDraft(result.config)
|
|
@@ -2023,6 +2078,42 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2023
2078
|
}
|
|
2024
2079
|
}
|
|
2025
2080
|
|
|
2081
|
+
/** 生图接口测试状态(模型 id → 结果)。 */
|
|
2082
|
+
const [imageTestState, setImageTestState] = useState<Record<string, { testing: boolean; ok?: boolean; ms?: number; message?: string; modelFound?: boolean }>>({})
|
|
2083
|
+
/** 测试一条生图模型:连通 + 延迟。 */
|
|
2084
|
+
const testImageModel = async (m: ImageModelConfig): Promise<void> => {
|
|
2085
|
+
if (m.baseURL.trim() === '' || m.apiKey.trim() === '') {
|
|
2086
|
+
setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: '请先填写接口地址和 API Key' } }))
|
|
2087
|
+
return
|
|
2088
|
+
}
|
|
2089
|
+
setImageTestState(prev => ({ ...prev, [m.id]: { testing: true } }))
|
|
2090
|
+
try {
|
|
2091
|
+
const r = await api.imageTest({ baseURL: m.baseURL, apiKey: m.apiKey, model: m.model })
|
|
2092
|
+
setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: r.ok, ms: r.ms, message: r.message } }))
|
|
2093
|
+
} catch (err) {
|
|
2094
|
+
setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: (err as Error).message } }))
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
/** 更新一条生图模型配置。 */
|
|
2098
|
+
const updateImageModel = (id: string, patch: Partial<ImageModelConfig>): void => {
|
|
2099
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => m.id === id ? { ...m, ...patch } : m) })
|
|
2100
|
+
}
|
|
2101
|
+
/** 新增一条生图模型(第一条默认启用)。 */
|
|
2102
|
+
const addImageModel = (): void => {
|
|
2103
|
+
setConfigDraft(prev => prev === null ? prev : {
|
|
2104
|
+
...prev,
|
|
2105
|
+
imageModels: [...(prev.imageModels ?? []), { id: 'img-' + Date.now().toString(36), name: '', baseURL: '', apiKey: '', model: '', enabled: (prev.imageModels ?? []).length === 0 }],
|
|
2106
|
+
})
|
|
2107
|
+
}
|
|
2108
|
+
/** 删除一条生图模型。 */
|
|
2109
|
+
const removeImageModel = (id: string): void => {
|
|
2110
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).filter(m => m.id !== id) })
|
|
2111
|
+
}
|
|
2112
|
+
/** 启用一条(其余自动停用)。 */
|
|
2113
|
+
const enableImageModel = (id: string): void => {
|
|
2114
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => ({ ...m, enabled: m.id === id })) })
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2026
2117
|
/** Export the book. */
|
|
2027
2118
|
const handleExport = async (format: 'txt' | 'md'): Promise<void> => {
|
|
2028
2119
|
setBusy(true)
|
|
@@ -2274,7 +2365,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2274
2365
|
})()
|
|
2275
2366
|
|
|
2276
2367
|
return (
|
|
2277
|
-
<div className={css.panel} data-nf-theme={panelTheme}>
|
|
2368
|
+
<div className={css.panel} data-nf-theme={panelTheme} data-nf-mode={themeMode === 'system' ? undefined : themeMode} data-nf-density={themeDensity}>
|
|
2278
2369
|
{viewMode === 'shelf' ? (
|
|
2279
2370
|
/* 书架首页:默认视图,选择一本书进入工作台或阅读页 */
|
|
2280
2371
|
<ShelfView
|
|
@@ -2376,7 +2467,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2376
2467
|
{tab.id === 'workflow' && chapters.length > 0 && (
|
|
2377
2468
|
<span className={`${css.navTabBadge} ${css.navTabBadgeDone}`}>{journeyPercent}%</span>
|
|
2378
2469
|
)}
|
|
2379
|
-
{/*
|
|
2470
|
+
{/* 任务进行中:AI进度导航显示呼吸绿点(悬停看任务名),不自动弹窗 */}
|
|
2380
2471
|
{tab.id === 'progress' && busy && (
|
|
2381
2472
|
<span
|
|
2382
2473
|
className={`${css.navTabBadge} ${css.navTabBadgeLive}`}
|
|
@@ -2483,7 +2574,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2483
2574
|
<div className={css.card} style={{ borderColor: 'var(--nf-info)' }}>
|
|
2484
2575
|
<div className={css.busyRow}>
|
|
2485
2576
|
<span style={{ color: 'var(--nf-info)' }}>第 {draftNo} 章有未采纳的润色/修订草稿(原稿未被改动)</span>
|
|
2486
|
-
<span style={{ display: 'flex', gap: 8 }}>
|
|
2577
|
+
<span style={{ display: 'flex', gap: 'var(--nf-space-8)' }}>
|
|
2487
2578
|
<button type="button" className={`${css.button} ${css.buttonSmall} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void openWorkspace(draftNo) }}>
|
|
2488
2579
|
打开工作区
|
|
2489
2580
|
</button>
|
|
@@ -2510,7 +2601,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2510
2601
|
)}
|
|
2511
2602
|
<span className={css.cardTitle}>第 {workspace.no} 章《{workspace.title}》</span>
|
|
2512
2603
|
<span className={css.meta}>{workspace.original.length} 字</span>
|
|
2513
|
-
<span style={{ display: 'flex', gap: 4, alignItems: 'center', marginLeft: 'auto' }}>
|
|
2604
|
+
<span style={{ display: 'flex', gap: 'var(--nf-space-4)', alignItems: 'center', marginLeft: 'auto' }}>
|
|
2514
2605
|
<button type="button" className={css.iconButton} title="减小字号" aria-label="减小字号" onClick={() => { changeEditorFontSize(editorFontSize - 1) }}>A−</button>
|
|
2515
2606
|
<span className={css.meta}>{editorFontSize}px</span>
|
|
2516
2607
|
<button type="button" className={css.iconButton} title="增大字号" aria-label="增大字号" onClick={() => { changeEditorFontSize(editorFontSize + 1) }}>A+</button>
|
|
@@ -2625,12 +2716,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2625
2716
|
</div>
|
|
2626
2717
|
<div className={css.meta}><b>总评:</b>{wsCheckReport.verdict}</div>
|
|
2627
2718
|
{wsCheckReport.issues.length > 0 && (
|
|
2628
|
-
<ul style={{ margin: 0, paddingLeft: 16, display: 'flex', flexDirection: 'column', gap:
|
|
2719
|
+
<ul style={{ margin: 0, paddingLeft: 'var(--nf-space-16)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', fontSize: editorFontSize - 1, maxHeight: '45vh', overflowY: 'auto' }}>
|
|
2629
2720
|
{wsCheckReport.issues.map((issue, i) => (
|
|
2630
|
-
<li key={i} style={{ color: severityColor(issue.severity), display: 'flex', gap: 6, alignItems: 'flex-start' }}>
|
|
2721
|
+
<li key={i} style={{ color: severityColor(issue.severity), display: 'flex', gap: 'var(--nf-space-6)', alignItems: 'flex-start' }}>
|
|
2631
2722
|
<input
|
|
2632
2723
|
type="checkbox"
|
|
2633
|
-
style={{ marginTop:
|
|
2724
|
+
style={{ marginTop: 'var(--nf-space-2)' }}
|
|
2634
2725
|
checked={wsChecked.includes(i)}
|
|
2635
2726
|
onChange={e => {
|
|
2636
2727
|
setWsChecked(prev => e.target.checked ? [...prev, i] : prev.filter(x => x !== i))
|
|
@@ -2656,7 +2747,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2656
2747
|
<div className={css.wsPreview} style={{ flex: 1, minHeight: 0 }}>
|
|
2657
2748
|
<div className={css.busyRow}>
|
|
2658
2749
|
<span className={css.meta}>优化预览({workspace.draft.length} 字)</span>
|
|
2659
|
-
<span style={{ display: 'flex', gap: 8 }}>
|
|
2750
|
+
<span style={{ display: 'flex', gap: 'var(--nf-space-8)' }}>
|
|
2660
2751
|
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWsShowDiff(v => !v) }}>
|
|
2661
2752
|
{wsShowDiff ? '显示文本' : '查看对比'}
|
|
2662
2753
|
</button>
|
|
@@ -2779,17 +2870,17 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2779
2870
|
</button>
|
|
2780
2871
|
</div>
|
|
2781
2872
|
{auditStatus?.status === 'running' && (
|
|
2782
|
-
<div className={css.meta} style={{ marginTop: 8 }}>
|
|
2873
|
+
<div className={css.meta} style={{ marginTop: 'var(--nf-space-8)' }}>
|
|
2783
2874
|
🔍 全书质检中:{auditStatus.completedBatches}/{auditStatus.totalBatches > 0 ? auditStatus.totalBatches : '…'} 批
|
|
2784
2875
|
{auditStatus.totalBatches > 0 && (
|
|
2785
|
-
<div className={css.dashJourneyBar} style={{ marginTop: 4 }}>
|
|
2876
|
+
<div className={css.dashJourneyBar} style={{ marginTop: 'var(--nf-space-4)' }}>
|
|
2786
2877
|
<div className={css.dashJourneyFill} style={{ width: `${Math.round((auditStatus.completedBatches / auditStatus.totalBatches) * 100)}%` }} />
|
|
2787
2878
|
</div>
|
|
2788
2879
|
)}
|
|
2789
2880
|
</div>
|
|
2790
2881
|
)}
|
|
2791
2882
|
{auditStatus?.status === 'error' && auditStatus.error !== undefined && (
|
|
2792
|
-
<div className={css.meta} style={{ color: 'var(--nf-error)', marginTop: 8 }}>全书质检失败:{auditStatus.error}</div>
|
|
2883
|
+
<div className={css.meta} style={{ color: 'var(--nf-error)', marginTop: 'var(--nf-space-8)' }}>全书质检失败:{auditStatus.error}</div>
|
|
2793
2884
|
)}
|
|
2794
2885
|
<div className={css.assetGrid}>
|
|
2795
2886
|
<StatCell
|
|
@@ -2821,7 +2912,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2821
2912
|
</div>
|
|
2822
2913
|
|
|
2823
2914
|
{/* 剧情线进度 | 作者复盘趋势:精简摘要卡,并排(点击跳转详情页) */}
|
|
2824
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 14, alignItems: 'stretch' }}>
|
|
2915
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 'var(--nf-space-14)', alignItems: 'stretch' }}>
|
|
2825
2916
|
<div className={css.card}>
|
|
2826
2917
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
2827
2918
|
<span className={css.cardTitle}>🧵 {tt('plotlines.workflowTitle')}({(project?.plotlines ?? []).length} 条)</span>
|
|
@@ -2900,7 +2991,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2900
2991
|
</button>
|
|
2901
2992
|
</div>
|
|
2902
2993
|
{auditIssues.length > 0 && (
|
|
2903
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
2994
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
|
|
2904
2995
|
{auditIssues.map((issue, i) => (
|
|
2905
2996
|
<AuditIssueRow
|
|
2906
2997
|
key={i}
|
|
@@ -2914,7 +3005,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2914
3005
|
</div>
|
|
2915
3006
|
)}
|
|
2916
3007
|
|
|
2917
|
-
{/* 待办队列(活动输出已移入「工具 →
|
|
3008
|
+
{/* 待办队列(活动输出已移入「工具 → AI进度」悬浮窗) */}
|
|
2918
3009
|
<div className={css.card}>
|
|
2919
3010
|
<span className={css.cardTitle}>待办队列</span>
|
|
2920
3011
|
{todos.length === 0 ? (
|
|
@@ -3032,7 +3123,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3032
3123
|
) : (
|
|
3033
3124
|
/* 只读展示:大纲是开书时的出生证明 */
|
|
3034
3125
|
<>
|
|
3035
|
-
<div className={css.meta} style={{ marginBottom: 6 }}>
|
|
3126
|
+
<div className={css.meta} style={{ marginBottom: 'var(--nf-space-6)' }}>
|
|
3036
3127
|
大纲是本书的「出生证明」;已写章节可点右上角「🔄 反推大纲」从正文反推,或「更新大纲」手动修订。
|
|
3037
3128
|
</div>
|
|
3038
3129
|
<pre className={css.outlineReadonly}>{project.outline}</pre>
|
|
@@ -3095,7 +3186,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3095
3186
|
</button>
|
|
3096
3187
|
</div>
|
|
3097
3188
|
{/* 封面 */}
|
|
3098
|
-
<div className={css.row} style={{ alignItems: 'flex-start', gap: 14 }}>
|
|
3189
|
+
<div className={css.row} style={{ alignItems: 'flex-start', gap: 'var(--nf-space-14)' }}>
|
|
3099
3190
|
<div className={css.coverPreview}>
|
|
3100
3191
|
{coverDataUrl !== null ? (
|
|
3101
3192
|
<img src={coverDataUrl} alt="封面" />
|
|
@@ -3103,7 +3194,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3103
3194
|
<span className={css.coverPlaceholder}>暂无封面</span>
|
|
3104
3195
|
)}
|
|
3105
3196
|
</div>
|
|
3106
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
3197
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)' }}>
|
|
3107
3198
|
<div className={css.row}>
|
|
3108
3199
|
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy || project === null} onClick={() => { coverFileRef.current?.click() }}>
|
|
3109
3200
|
📤 上传封面
|
|
@@ -3194,9 +3285,9 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3194
3285
|
</div>
|
|
3195
3286
|
<span className={css.meta}>{tt('sensitive.hint')}</span>
|
|
3196
3287
|
{sensHits.length > 0 && (
|
|
3197
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 300, overflowY: 'auto', fontSize: 12 }}>
|
|
3288
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', maxHeight: 300, overflowY: 'auto', fontSize: 'var(--nf-fs-12)' }}>
|
|
3198
3289
|
{sensHits.map((hit, i) => (
|
|
3199
|
-
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', border: '1px solid var(--nf-border)', borderRadius: 6, padding: '
|
|
3290
|
+
<div key={i} style={{ display: 'flex', gap: 'var(--nf-space-8)', alignItems: 'flex-start', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-6)', padding: 'var(--nf-space-4) var(--nf-space-8)' }}>
|
|
3200
3291
|
<span className={css.badge} style={{ borderColor: 'var(--nf-warn)', color: 'var(--nf-warn)', flex: 'none' }}>
|
|
3201
3292
|
{hit.chapterNo > 0 ? `第${hit.chapterNo}章` : '文本'}
|
|
3202
3293
|
</span>
|
|
@@ -3223,7 +3314,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3223
3314
|
{chapters.length > 0 && (
|
|
3224
3315
|
<div className={css.card}>
|
|
3225
3316
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3226
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
|
|
3317
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
|
|
3227
3318
|
{(volumes !== undefined && volumes.length > 0) && (
|
|
3228
3319
|
<>
|
|
3229
3320
|
<button
|
|
@@ -3280,7 +3371,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3280
3371
|
<span className={css.chapterNum}>{chapter.no}</span>
|
|
3281
3372
|
<div className={css.chapterMain}>
|
|
3282
3373
|
<div className={css.chapterTitle}>
|
|
3283
|
-
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ padding: '
|
|
3374
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ padding: 'var(--nf-space-2) var(--nf-space-6)' }} onClick={() => { void handleToggleChapter(chapter.no) }}>
|
|
3284
3375
|
{expanded ? '−' : '+'}
|
|
3285
3376
|
</button>
|
|
3286
3377
|
<span>{chapter.title}</span>
|
|
@@ -3291,7 +3382,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3291
3382
|
</div>
|
|
3292
3383
|
{!expanded && <div className={css.chapterBeats} title={chapter.beats}>{chapter.beats}</div>}
|
|
3293
3384
|
{expanded && (
|
|
3294
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
3385
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
|
|
3295
3386
|
<div className={css.meta}><b>{tt('plan.beats')}:</b></div>
|
|
3296
3387
|
<div className={css.meta}>{renderBeats(chapter.beats)}</div>
|
|
3297
3388
|
{chapter.summary !== undefined && chapter.summary !== '' && (
|
|
@@ -3308,7 +3399,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3308
3399
|
</div>
|
|
3309
3400
|
<div className={css.meta}><b>{tt('plan.reviewVerdict')}:</b> {review.verdict}</div>
|
|
3310
3401
|
{review.issues.length > 0 && (
|
|
3311
|
-
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 12 }}>
|
|
3402
|
+
<ul style={{ margin: 0, paddingLeft: 'var(--nf-space-18)', fontSize: 'var(--nf-fs-12)' }}>
|
|
3312
3403
|
{review.issues.map((issue, i) => (
|
|
3313
3404
|
<li key={i} style={{ color: severityColor(issue.severity) }}>
|
|
3314
3405
|
[{issue.severity}] {issue.item} → {issue.suggestion}
|
|
@@ -3354,7 +3445,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3354
3445
|
</div>
|
|
3355
3446
|
)}
|
|
3356
3447
|
{(chapter.status === 'rejected' || chapter.status === 'written' || chapter.status === 'approved') && (
|
|
3357
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
3448
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
|
|
3358
3449
|
<div className={css.meta} style={{ fontWeight: 600 }}>
|
|
3359
3450
|
润色 / 修订 — 在右上角打开工作区:左栏原文可直接选中文字做局部修订,右栏输入指令后预览,确认后再应用(未应用不改动原稿)
|
|
3360
3451
|
</div>
|
|
@@ -3435,16 +3526,16 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3435
3526
|
)}
|
|
3436
3527
|
|
|
3437
3528
|
{activeTab === 'book' && (
|
|
3438
|
-
<div className={css.card} style={{ gap: 12 }}>
|
|
3529
|
+
<div className={css.card} style={{ gap: 'var(--nf-space-12)' }}>
|
|
3439
3530
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3440
|
-
<span className={css.cardTitle} style={{ fontSize:
|
|
3531
|
+
<span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-16)', fontWeight: 700 }}>📚 本书设定</span>
|
|
3441
3532
|
<span className={css.meta}>当前书的知识与资料(参考 AI-Novel-Writing-Assistant:世界观/角色准备收进书内)</span>
|
|
3442
3533
|
</div>
|
|
3443
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
|
|
3534
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
|
|
3444
3535
|
<button
|
|
3445
3536
|
type="button"
|
|
3446
3537
|
className={`${css.button} ${bookTab === 'bible' ? css.buttonPrimary : ''}`}
|
|
3447
|
-
style={{ fontSize: 14, flex: 1 }}
|
|
3538
|
+
style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
|
|
3448
3539
|
onClick={() => { changeBookTab('bible') }}
|
|
3449
3540
|
title="设定库:题材 / 世界观规则 / 人物摘要 / 红线 / 文风"
|
|
3450
3541
|
>
|
|
@@ -3453,7 +3544,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3453
3544
|
<button
|
|
3454
3545
|
type="button"
|
|
3455
3546
|
className={`${css.button} ${bookTab === 'world' ? css.buttonPrimary : ''}`}
|
|
3456
|
-
style={{ fontSize: 14, flex: 1 }}
|
|
3547
|
+
style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
|
|
3457
3548
|
onClick={() => { changeBookTab('world') }}
|
|
3458
3549
|
title="大世界:境界体系 / 地理区域 / 势力分布(注入生成与审稿提示词)"
|
|
3459
3550
|
>
|
|
@@ -3462,7 +3553,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3462
3553
|
<button
|
|
3463
3554
|
type="button"
|
|
3464
3555
|
className={`${css.button} ${bookTab === 'roles' ? css.buttonPrimary : ''}`}
|
|
3465
|
-
style={{ fontSize: 14, flex: 1 }}
|
|
3556
|
+
style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
|
|
3466
3557
|
onClick={() => { changeBookTab('roles') }}
|
|
3467
3558
|
title="角色库:全书角色主表(定位 / 关系网 / 成长线 / 知情度)"
|
|
3468
3559
|
>
|
|
@@ -3471,7 +3562,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3471
3562
|
<button
|
|
3472
3563
|
type="button"
|
|
3473
3564
|
className={`${css.button} ${bookTab === 'facts' ? css.buttonPrimary : ''}`}
|
|
3474
|
-
style={{ fontSize: 14, flex: 1 }}
|
|
3565
|
+
style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
|
|
3475
3566
|
onClick={() => { changeBookTab('facts') }}
|
|
3476
3567
|
title="编年录与复盘记录"
|
|
3477
3568
|
>
|
|
@@ -3492,13 +3583,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3492
3583
|
{bible === undefined ? (
|
|
3493
3584
|
<span className={css.meta}>{tt('bible.none')}</span>
|
|
3494
3585
|
) : (
|
|
3495
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
3586
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
|
|
3496
3587
|
{bible.genre !== '' && (
|
|
3497
3588
|
<div><b>{tt('bible.genre')}:</b> <span className={css.meta}>{bible.genre}</span></div>
|
|
3498
3589
|
)}
|
|
3499
3590
|
{/* 世界观规则独立卡片(可编辑,每行一条) */}
|
|
3500
3591
|
{bible.worldRules.length > 0 && (
|
|
3501
|
-
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: '
|
|
3592
|
+
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)' }}>
|
|
3502
3593
|
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
3503
3594
|
<b>{tt('bible.worldRules')}({bible.worldRules.length})</b>
|
|
3504
3595
|
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setWorldRulesDraft(bible.worldRules.join('\n')) }}>
|
|
@@ -3506,7 +3597,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3506
3597
|
</button>
|
|
3507
3598
|
</div>
|
|
3508
3599
|
{worldRulesDraft !== '' && (
|
|
3509
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 6 }}>
|
|
3600
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', marginTop: 'var(--nf-space-6)' }}>
|
|
3510
3601
|
<textarea
|
|
3511
3602
|
className={css.textarea}
|
|
3512
3603
|
style={{ minHeight: 120 }}
|
|
@@ -3525,7 +3616,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3525
3616
|
</div>
|
|
3526
3617
|
)}
|
|
3527
3618
|
{worldRulesDraft === '' && (
|
|
3528
|
-
<ul style={{ margin: '4px 0 0', paddingLeft: 18, fontSize: 12 }}>{bible.worldRules.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
|
3619
|
+
<ul style={{ margin: '4px 0 0', paddingLeft: 'var(--nf-space-18)', fontSize: 'var(--nf-fs-12)' }}>{bible.worldRules.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
|
3529
3620
|
)}
|
|
3530
3621
|
</div>
|
|
3531
3622
|
)}
|
|
@@ -3533,7 +3624,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3533
3624
|
<div>
|
|
3534
3625
|
<b>{tt('bible.characters')}({bible.characters.length})</b>
|
|
3535
3626
|
{bible.characters.map(card => (
|
|
3536
|
-
<div key={card.name} style={{ marginTop: 4, fontSize: 12 }}>
|
|
3627
|
+
<div key={card.name} style={{ marginTop: 'var(--nf-space-4)', fontSize: 'var(--nf-fs-12)' }}>
|
|
3537
3628
|
<b>{card.name}</b> <span className={css.meta}>[{card.role}] {card.traits.join('、')}</span>
|
|
3538
3629
|
{card.goals !== '' && <div className={css.meta}>目标:{card.goals}</div>}
|
|
3539
3630
|
{card.relations !== '' && <div className={css.meta}>关系:{card.relations}</div>}
|
|
@@ -3544,13 +3635,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3544
3635
|
{bible.redLines.length > 0 && (
|
|
3545
3636
|
<div>
|
|
3546
3637
|
<b>{tt('bible.redLines')}({bible.redLines.length})</b>
|
|
3547
|
-
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 12, color: 'var(--nf-error)' }}>{bible.redLines.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
|
3638
|
+
<ul style={{ margin: 0, paddingLeft: 'var(--nf-space-18)', fontSize: 'var(--nf-fs-12)', color: 'var(--nf-error)' }}>{bible.redLines.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
|
3548
3639
|
</div>
|
|
3549
3640
|
)}
|
|
3550
3641
|
{bible.style.length > 0 && (
|
|
3551
3642
|
<div>
|
|
3552
3643
|
<b>{tt('bible.style')}({bible.style.length})</b>
|
|
3553
|
-
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 12 }}>{bible.style.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
|
3644
|
+
<ul style={{ margin: 0, paddingLeft: 'var(--nf-space-18)', fontSize: 'var(--nf-fs-12)' }}>{bible.style.map((r, i) => <li key={i}>{r}</li>)}</ul>
|
|
3554
3645
|
</div>
|
|
3555
3646
|
)}
|
|
3556
3647
|
</div>
|
|
@@ -3600,7 +3691,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3600
3691
|
<span className={css.meta}>角色库是全书角色主表:定位(女主/女配/配角/反派)、身份、关系网、成长线、知情度——生成与审稿都会按定位规格刻画互动。点「从编年录刷新状态」可在每张卡上显示角色当前状态。</span>
|
|
3601
3692
|
|
|
3602
3693
|
{roleCandidates !== null && (
|
|
3603
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, border: '1px solid var(--nf-info)', borderRadius: 12, padding: 10 }}>
|
|
3694
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', border: '1px solid var(--nf-info)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-10)' }}>
|
|
3604
3695
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3605
3696
|
<b>✨ AI 提炼候选({roleCandidates.length})</b>
|
|
3606
3697
|
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setRoleCandidates(null) }}>收起</button>
|
|
@@ -3619,7 +3710,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3619
3710
|
)}
|
|
3620
3711
|
|
|
3621
3712
|
{roleDraft !== null && (
|
|
3622
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, border: '1px solid var(--nf-accent)', borderRadius: 12, padding: 10 }}>
|
|
3713
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-10)' }}>
|
|
3623
3714
|
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
3624
3715
|
<div className={css.field} style={{ flex: 1, minWidth: 140 }}>
|
|
3625
3716
|
<label className={css.fieldLabel}>角色名</label>
|
|
@@ -3669,7 +3760,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3669
3760
|
{(project?.roles ?? []).length === 0 ? (
|
|
3670
3761
|
<span className={css.meta}>角色库为空——点「✨ 从全书提炼角色」自动建立,或手动新增。</span>
|
|
3671
3762
|
) : (
|
|
3672
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
3763
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
|
|
3673
3764
|
{(project?.roles ?? []).map(r => (
|
|
3674
3765
|
<RoleCard
|
|
3675
3766
|
key={r.name}
|
|
@@ -3690,246 +3781,313 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3690
3781
|
|
|
3691
3782
|
{activeTab === 'settings' && configDraft !== null && (
|
|
3692
3783
|
<>
|
|
3693
|
-
{/*
|
|
3784
|
+
{/* 设置页内子导航(大容器) */}
|
|
3694
3785
|
<div className={`${css.card} ${css.settingsCard}`}>
|
|
3695
|
-
<
|
|
3696
|
-
|
|
3697
|
-
<
|
|
3698
|
-
<input className={css.input} value={configDraft.outlinePath} onChange={e => { setConfigDraft({ ...configDraft, outlinePath: e.target.value }) }} />
|
|
3699
|
-
</div>
|
|
3700
|
-
<div className={css.field}>
|
|
3701
|
-
<label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
|
|
3702
|
-
<input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
|
|
3786
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center', gap: 'var(--nf-space-8)' }}>
|
|
3787
|
+
<span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-20)', fontWeight: 700 }}>⚙️ 设置</span>
|
|
3788
|
+
<span className={css.meta}>当前:{config?.provider} / {config?.model} · {config?.outputDir}</span>
|
|
3703
3789
|
</div>
|
|
3704
|
-
<div className={css.row}>
|
|
3705
|
-
|
|
3790
|
+
<div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
|
|
3791
|
+
{SETTINGS_SECTIONS.map(s => (
|
|
3792
|
+
<button
|
|
3793
|
+
key={s.id}
|
|
3794
|
+
type="button"
|
|
3795
|
+
className={`${css.button} ${settingsTab === s.id ? css.buttonPrimary : ''}`}
|
|
3796
|
+
style={{ fontSize: 'var(--nf-fs-14)', padding: 'var(--nf-space-8) var(--nf-space-12)', flex: 1, minWidth: 104, justifyContent: 'center' }}
|
|
3797
|
+
onClick={() => { changeSettingsTab(s.id) }}
|
|
3798
|
+
>
|
|
3799
|
+
{s.icon} {s.label}
|
|
3800
|
+
</button>
|
|
3801
|
+
))}
|
|
3706
3802
|
</div>
|
|
3707
3803
|
</div>
|
|
3708
3804
|
|
|
3709
|
-
{
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
className={css.input}
|
|
3721
|
-
value={modelCustomMode ? '__custom__' : configDraft.model}
|
|
3722
|
-
onChange={e => {
|
|
3723
|
-
const v = e.target.value
|
|
3724
|
-
if (v === '__custom__') {
|
|
3725
|
-
setModelCustomMode(true)
|
|
3726
|
-
} else {
|
|
3727
|
-
setModelCustomMode(false)
|
|
3728
|
-
setConfigDraft({ ...configDraft, model: v })
|
|
3729
|
-
}
|
|
3730
|
-
}}
|
|
3731
|
-
>
|
|
3732
|
-
{MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}
|
|
3733
|
-
<option value="__custom__">{tt('settings.modelCustom')}</option>
|
|
3734
|
-
</select>
|
|
3735
|
-
{modelCustomMode && (
|
|
3736
|
-
<input
|
|
3805
|
+
{settingsTab === 'model' && (
|
|
3806
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
|
|
3807
|
+
<span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
|
|
3808
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
|
|
3809
|
+
<div className={css.field}>
|
|
3810
|
+
<label className={css.fieldLabel}>{tt('settings.provider')}</label>
|
|
3811
|
+
<input className={css.input} value={configDraft.provider} onChange={e => { setConfigDraft({ ...configDraft, provider: e.target.value }) }} />
|
|
3812
|
+
</div>
|
|
3813
|
+
<div className={css.field}>
|
|
3814
|
+
<label className={css.fieldLabel}>{tt('settings.model')}</label>
|
|
3815
|
+
<select
|
|
3737
3816
|
className={css.input}
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3817
|
+
value={modelCustomMode ? '__custom__' : configDraft.model}
|
|
3818
|
+
onChange={e => {
|
|
3819
|
+
const v = e.target.value
|
|
3820
|
+
if (v === '__custom__') {
|
|
3821
|
+
setModelCustomMode(true)
|
|
3822
|
+
} else {
|
|
3823
|
+
setModelCustomMode(false)
|
|
3824
|
+
setConfigDraft({ ...configDraft, model: v })
|
|
3825
|
+
}
|
|
3826
|
+
}}
|
|
3827
|
+
>
|
|
3828
|
+
{MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}
|
|
3829
|
+
<option value="__custom__">{tt('settings.modelCustom')}</option>
|
|
3830
|
+
</select>
|
|
3831
|
+
{modelCustomMode && (
|
|
3832
|
+
<input
|
|
3833
|
+
className={css.input}
|
|
3834
|
+
style={{ marginTop: 'var(--nf-space-6)' }}
|
|
3835
|
+
value={configDraft.model}
|
|
3836
|
+
placeholder={tt('settings.modelCustomPlaceholder')}
|
|
3837
|
+
onChange={e => { setConfigDraft({ ...configDraft, model: e.target.value }) }}
|
|
3838
|
+
/>
|
|
3839
|
+
)}
|
|
3840
|
+
</div>
|
|
3841
|
+
<div className={css.field}>
|
|
3842
|
+
<label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label>
|
|
3843
|
+
<select
|
|
3844
|
+
className={css.input}
|
|
3845
|
+
value={configDraft.reasoningEffort ?? 'off'}
|
|
3846
|
+
onChange={e => { setConfigDraft({ ...configDraft, reasoningEffort: e.target.value as NovelConfig['reasoningEffort'] }) }}
|
|
3847
|
+
>
|
|
3848
|
+
{REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
|
|
3849
|
+
</select>
|
|
3850
|
+
<span className={css.meta}>{tt('settings.reasoningHint')}</span>
|
|
3851
|
+
</div>
|
|
3852
|
+
<div className={css.field}>
|
|
3853
|
+
<label className={css.fieldLabel}>{tt('settings.analysisReasoning')}</label>
|
|
3854
|
+
<select
|
|
3855
|
+
className={css.input}
|
|
3856
|
+
value={configDraft.analysisReasoning ?? 'low'}
|
|
3857
|
+
onChange={e => { setConfigDraft({ ...configDraft, analysisReasoning: e.target.value as NovelConfig['analysisReasoning'] }) }}
|
|
3858
|
+
>
|
|
3859
|
+
{REASONING_OPTIONS.map(v => <option key={v} value={v}>{tt(`settings.reasoning.${v}`)}</option>)}
|
|
3860
|
+
</select>
|
|
3861
|
+
<span className={css.meta}>{tt('settings.analysisReasoningHint')}</span>
|
|
3862
|
+
</div>
|
|
3744
3863
|
</div>
|
|
3745
3864
|
</div>
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
{
|
|
3755
|
-
</
|
|
3756
|
-
<
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
<
|
|
3761
|
-
className={css.
|
|
3762
|
-
value={configDraft.
|
|
3763
|
-
|
|
3764
|
-
>
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3865
|
+
)}
|
|
3866
|
+
|
|
3867
|
+
{settingsTab === 'writing' && (
|
|
3868
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
|
|
3869
|
+
<span className={css.cardTitle}><PenLine size={18} style={{ verticalAlign: -3 }} /> 写作与审稿</span>
|
|
3870
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
|
|
3871
|
+
<div className={css.field}>
|
|
3872
|
+
<label className={css.fieldLabel}>{tt('settings.chapterChars')}</label>
|
|
3873
|
+
<input className={css.input} type="number" min={1000} max={20000} value={configDraft.chapterChars} onChange={e => { setConfigDraft({ ...configDraft, chapterChars: Number(e.target.value) }) }} />
|
|
3874
|
+
</div>
|
|
3875
|
+
<div className={css.field}>
|
|
3876
|
+
<label className={css.fieldLabel}>{tt('settings.maxTokens')}</label>
|
|
3877
|
+
<input className={css.input} type="number" min={2000} max={64000} value={configDraft.maxTokens} onChange={e => { setConfigDraft({ ...configDraft, maxTokens: Number(e.target.value) }) }} />
|
|
3878
|
+
</div>
|
|
3879
|
+
<div className={css.field}>
|
|
3880
|
+
<label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label>
|
|
3881
|
+
<input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => { setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) }) }} />
|
|
3882
|
+
<span className={css.meta}>通过判定:综合评分 ≥ 此分数(默认 70),或无 high 级问题且评分 ≥ 60;建议设 60-80。</span>
|
|
3883
|
+
</div>
|
|
3884
|
+
<div className={css.field}>
|
|
3885
|
+
<label className={css.fieldLabel}>{tt('settings.autoReview')}</label>
|
|
3886
|
+
<select
|
|
3887
|
+
className={css.input}
|
|
3888
|
+
value={configDraft.autoReview ? '1' : '0'}
|
|
3889
|
+
onChange={e => { setConfigDraft({ ...configDraft, autoReview: e.target.value === '1' }) }}
|
|
3890
|
+
>
|
|
3891
|
+
<option value="1">✓ 是</option>
|
|
3892
|
+
<option value="0">✗ 否</option>
|
|
3893
|
+
</select>
|
|
3894
|
+
</div>
|
|
3895
|
+
<div className={css.field}>
|
|
3896
|
+
<label className={css.fieldLabel}>{tt('settings.autoAuthorReview')}</label>
|
|
3897
|
+
<select
|
|
3898
|
+
className={css.input}
|
|
3899
|
+
value={configDraft.autoAuthorReview ? '1' : '0'}
|
|
3900
|
+
onChange={e => { setConfigDraft({ ...configDraft, autoAuthorReview: e.target.value === '1' }) }}
|
|
3901
|
+
>
|
|
3902
|
+
<option value="1">✓ 是</option>
|
|
3903
|
+
<option value="0">✗ 否</option>
|
|
3904
|
+
</select>
|
|
3905
|
+
<span className={css.meta}>{tt('settings.autoAuthorReviewHint')}</span>
|
|
3906
|
+
</div>
|
|
3907
|
+
<div className={css.field}>
|
|
3908
|
+
<label className={css.fieldLabel}>{tt('settings.autoReviewAfterRevise')}</label>
|
|
3909
|
+
<select
|
|
3910
|
+
className={css.input}
|
|
3911
|
+
value={configDraft.autoReviewAfterRevise ? '1' : '0'}
|
|
3912
|
+
onChange={e => { setConfigDraft({ ...configDraft, autoReviewAfterRevise: e.target.value === '1' }) }}
|
|
3913
|
+
>
|
|
3914
|
+
<option value="1">✓ 是</option>
|
|
3915
|
+
<option value="0">✗ 否</option>
|
|
3916
|
+
</select>
|
|
3917
|
+
<span className={css.meta}>{tt('settings.autoReviewAfterReviseHint')}</span>
|
|
3918
|
+
</div>
|
|
3919
|
+
<div className={css.field}>
|
|
3920
|
+
<label className={css.fieldLabel}>编辑器字号(正文编辑 / 工作区)</label>
|
|
3921
|
+
<select
|
|
3922
|
+
className={css.input}
|
|
3923
|
+
value={editorFontSize}
|
|
3924
|
+
onChange={e => { changeEditorFontSize(Number(e.target.value)) }}
|
|
3925
|
+
>
|
|
3926
|
+
{[12, 13, 14, 15, 16, 18, 20, 22, 24].map(v => (
|
|
3927
|
+
<option key={v} value={v}>{v}px</option>
|
|
3928
|
+
))}
|
|
3929
|
+
</select>
|
|
3930
|
+
<span className={css.meta}>更改的是章节正文编辑区与润色/修订工作区(原稿 / 草稿 / diff)的显示字号,不影响面板主题。</span>
|
|
3931
|
+
</div>
|
|
3768
3932
|
</div>
|
|
3769
3933
|
</div>
|
|
3770
|
-
|
|
3934
|
+
)}
|
|
3771
3935
|
|
|
3772
|
-
{
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3936
|
+
{settingsTab === 'image' && (
|
|
3937
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
|
|
3938
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
3939
|
+
<span className={css.cardTitle}><Palette size={18} style={{ verticalAlign: -3 }} /> 生图模型</span>
|
|
3940
|
+
<span className={css.row} style={{ gap: 'var(--nf-space-8)', alignItems: 'center' }}>
|
|
3941
|
+
<span className={css.meta}>启用生图功能</span>
|
|
3942
|
+
<button
|
|
3943
|
+
type="button"
|
|
3944
|
+
role="switch"
|
|
3945
|
+
aria-checked={configDraft.imageApiEnabled === true}
|
|
3946
|
+
className={`${css.switch} ${configDraft.imageApiEnabled === true ? css.switchOn : ''}`}
|
|
3947
|
+
onClick={() => { setConfigDraft(prev => prev === null ? prev : { ...prev, imageApiEnabled: prev.imageApiEnabled !== true }) }}
|
|
3948
|
+
>
|
|
3949
|
+
<span className={css.switchKnob} />
|
|
3950
|
+
</button>
|
|
3951
|
+
</span>
|
|
3779
3952
|
</div>
|
|
3780
|
-
<
|
|
3781
|
-
|
|
3782
|
-
|
|
3953
|
+
<span className={css.meta}>支持多套 OpenAI 兼容生图接口(豆包 / 即梦 / 其他平台);启用一条作为生成模型,保存后角色详情显示「AI 生图」按钮。</span>
|
|
3954
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-20)' }}>
|
|
3955
|
+
{(configDraft.imageModels ?? []).map(m => (
|
|
3956
|
+
<div key={m.id} style={{ border: m.enabled ? '1px solid var(--nf-accent)' : '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-14)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-12)', background: m.enabled ? 'var(--nf-accent-soft)' : 'transparent' }}>
|
|
3957
|
+
<div className={css.row} style={{ alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
|
3958
|
+
<div className={css.field} style={{ flex: 1, minWidth: 180 }}>
|
|
3959
|
+
<label className={css.fieldLabel}>名称</label>
|
|
3960
|
+
<input className={css.input} placeholder="如:豆包 Seedream" value={m.name} onChange={e => { updateImageModel(m.id, { name: e.target.value }) }} />
|
|
3961
|
+
</div>
|
|
3962
|
+
<div className={css.field} style={{ flex: 1, minWidth: 180 }}>
|
|
3963
|
+
<label className={css.fieldLabel}>模型 id</label>
|
|
3964
|
+
<input className={css.input} placeholder="doubao-seedream-5-0-pro-260628" value={m.model} onChange={e => { updateImageModel(m.id, { model: e.target.value }) }} />
|
|
3965
|
+
</div>
|
|
3966
|
+
<div className={css.field} style={{ flex: 1, minWidth: 200 }}>
|
|
3967
|
+
<label className={css.fieldLabel}>接口地址</label>
|
|
3968
|
+
<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 }) }} />
|
|
3969
|
+
</div>
|
|
3970
|
+
</div>
|
|
3971
|
+
<div className={css.field}>
|
|
3972
|
+
<label className={css.fieldLabel}>API Key</label>
|
|
3973
|
+
<input className={css.input} type="password" placeholder="ark-..." value={m.apiKey} onChange={e => { updateImageModel(m.id, { apiKey: e.target.value }) }} />
|
|
3974
|
+
</div>
|
|
3975
|
+
<div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
3976
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${m.enabled ? css.buttonPrimary : ''}`} onClick={() => { enableImageModel(m.id) }}>
|
|
3977
|
+
{m.enabled ? '✓ 已启用' : '设为启用'}
|
|
3978
|
+
</button>
|
|
3979
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={imageTestState[m.id]?.testing === true} onClick={() => { void testImageModel(m) }}>
|
|
3980
|
+
<PlugZap size={13} style={{ verticalAlign: -2 }} /> {imageTestState[m.id]?.testing === true ? '测试中…' : '测试'}
|
|
3981
|
+
</button>
|
|
3982
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { removeImageModel(m.id) }}>🗑 删除</button>
|
|
3983
|
+
<span className={css.meta}>{m.enabled ? '当前生效' : '未启用'}</span>
|
|
3984
|
+
{(() => {
|
|
3985
|
+
const st = imageTestState[m.id]
|
|
3986
|
+
if (st === undefined || st.testing) return null
|
|
3987
|
+
return st.ok === true
|
|
3988
|
+
? <span style={{ color: 'var(--nf-success)', fontSize: 'var(--nf-fs-12)', fontWeight: 600 }}>✓ 连通 · {st.ms}ms{st.modelFound === true ? '' : st.modelFound === false ? '(模型未在列表)' : ''}</span>
|
|
3989
|
+
: <span style={{ color: 'var(--nf-error)', fontSize: 'var(--nf-fs-12)' }}>✗ {st.message ?? '失败'}</span>
|
|
3990
|
+
})()}
|
|
3991
|
+
</div>
|
|
3992
|
+
</div>
|
|
3993
|
+
))}
|
|
3994
|
+
{(configDraft.imageModels ?? []).length === 0 && (
|
|
3995
|
+
<div className={css.meta}>还没有生图模型——点下方「+ 添加生图模型」添加第一条(默认启用)。</div>
|
|
3996
|
+
)}
|
|
3783
3997
|
</div>
|
|
3784
|
-
<div className={css.
|
|
3785
|
-
<
|
|
3786
|
-
<select
|
|
3787
|
-
className={css.input}
|
|
3788
|
-
value={configDraft.imageApiEnabled ? '1' : '0'}
|
|
3789
|
-
onChange={e => { setConfigDraft({ ...configDraft, imageApiEnabled: e.target.value === '1' }) }}
|
|
3790
|
-
>
|
|
3791
|
-
<option value="0">关(默认)</option>
|
|
3792
|
-
<option value="1">开</option>
|
|
3793
|
-
</select>
|
|
3794
|
-
<span className={css.meta}>开启后角色详情显示「豆包生成」参考图按钮</span>
|
|
3998
|
+
<div className={css.row}>
|
|
3999
|
+
<button type="button" className={css.button} onClick={addImageModel}>+ 添加生图模型</button>
|
|
3795
4000
|
</div>
|
|
3796
4001
|
</div>
|
|
3797
|
-
|
|
4002
|
+
)}
|
|
3798
4003
|
|
|
3799
|
-
{
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
<
|
|
3805
|
-
<input className={css.input} type="number" min={1000} max={20000} value={configDraft.chapterChars} onChange={e => { setConfigDraft({ ...configDraft, chapterChars: Number(e.target.value) }) }} />
|
|
3806
|
-
</div>
|
|
3807
|
-
<div className={css.field} style={{ flex: 1 }}>
|
|
3808
|
-
<label className={css.fieldLabel}>{tt('settings.maxTokens')}</label>
|
|
3809
|
-
<input className={css.input} type="number" min={2000} max={64000} value={configDraft.maxTokens} onChange={e => { setConfigDraft({ ...configDraft, maxTokens: Number(e.target.value) }) }} />
|
|
3810
|
-
</div>
|
|
3811
|
-
<div className={css.field} style={{ flex: 1 }}>
|
|
3812
|
-
<label className={css.fieldLabel}>{tt('settings.editorFontSize')}</label>
|
|
3813
|
-
<select
|
|
3814
|
-
className={css.input}
|
|
3815
|
-
value={editorFontSize}
|
|
3816
|
-
onChange={e => { changeEditorFontSize(Number(e.target.value)) }}
|
|
3817
|
-
>
|
|
3818
|
-
{[12, 13, 14, 15, 16, 18, 20, 22, 24].map(v => (
|
|
3819
|
-
<option key={v} value={v}>{v}px</option>
|
|
3820
|
-
))}
|
|
3821
|
-
</select>
|
|
3822
|
-
<span className={css.meta}>{tt('settings.editorFontSizeHint')}</span>
|
|
4004
|
+
{settingsTab === 'files' && (
|
|
4005
|
+
<div className={`${css.card} ${css.settingsCard}`} style={{ gap: 'var(--nf-space-24)' }}>
|
|
4006
|
+
<span className={css.cardTitle}><Folder size={18} style={{ verticalAlign: -3 }} /> 路径与文件</span>
|
|
4007
|
+
<div className={css.field}>
|
|
4008
|
+
<label className={css.fieldLabel}>{tt('settings.outlinePath')}</label>
|
|
4009
|
+
<input className={css.input} value={configDraft.outlinePath} onChange={e => { setConfigDraft({ ...configDraft, outlinePath: e.target.value }) }} />
|
|
3823
4010
|
</div>
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
<label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label>
|
|
3828
|
-
<input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => { setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) }) }} />
|
|
3829
|
-
<span className={css.meta}>通过判定:综合评分 ≥ 此分数(默认 70),或无 high 级问题且评分 ≥ 60;建议设 60-80。</span>
|
|
4011
|
+
<div className={css.field}>
|
|
4012
|
+
<label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
|
|
4013
|
+
<input className={css.input} value={configDraft.outputDir} onChange={e => { setConfigDraft({ ...configDraft, outputDir: e.target.value }) }} />
|
|
3830
4014
|
</div>
|
|
3831
|
-
<div className={css.
|
|
3832
|
-
<
|
|
3833
|
-
<select
|
|
3834
|
-
className={css.input}
|
|
3835
|
-
value={configDraft.autoReview ? '1' : '0'}
|
|
3836
|
-
onChange={e => { setConfigDraft({ ...configDraft, autoReview: e.target.value === '1' }) }}
|
|
3837
|
-
>
|
|
3838
|
-
<option value="1">✓ 是</option>
|
|
3839
|
-
<option value="0">✗ 否</option>
|
|
3840
|
-
</select>
|
|
4015
|
+
<div className={css.row}>
|
|
4016
|
+
<button type="button" className={css.button} onClick={() => { void api.openFolder() }}>{tt('settings.openFolder')}</button>
|
|
3841
4017
|
</div>
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
<option value="0">✗ 否</option>
|
|
3851
|
-
</select>
|
|
3852
|
-
<span className={css.meta}>{tt('settings.autoAuthorReviewHint')}</span>
|
|
4018
|
+
|
|
4019
|
+
<div style={{ borderTop: '1px solid var(--nf-border)', margin: '14px 0 12px' }} />
|
|
4020
|
+
|
|
4021
|
+
<span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-14)' }}>💾 保存与导出</span>
|
|
4022
|
+
<div className={css.row}>
|
|
4023
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void handleSaveConfig() }}>
|
|
4024
|
+
{tt('settings.save')}
|
|
4025
|
+
</button>
|
|
3853
4026
|
</div>
|
|
3854
|
-
<div className={css.
|
|
3855
|
-
<
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
>
|
|
3861
|
-
<option value="1">✓ 是</option>
|
|
3862
|
-
<option value="0">✗ 否</option>
|
|
3863
|
-
</select>
|
|
3864
|
-
<span className={css.meta}>{tt('settings.autoReviewAfterReviseHint')}</span>
|
|
4027
|
+
<div className={css.row}>
|
|
4028
|
+
<button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('txt') }}>
|
|
4029
|
+
{tt('settings.exportTxt')}
|
|
4030
|
+
</button>
|
|
4031
|
+
<button type="button" className={css.button} disabled={busy || chapters.length === 0} onClick={() => { void handleExport('md') }}>
|
|
4032
|
+
{tt('settings.exportMd')}
|
|
4033
|
+
</button>
|
|
3865
4034
|
</div>
|
|
3866
4035
|
</div>
|
|
3867
|
-
|
|
4036
|
+
)}
|
|
3868
4037
|
|
|
3869
|
-
{
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
<
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
{
|
|
3884
|
-
|
|
4038
|
+
{settingsTab === 'appearance' && (
|
|
4039
|
+
<div className={`${css.card} ${css.settingsCard}`}>
|
|
4040
|
+
<span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
|
|
4041
|
+
<span className={css.meta}>选择适合长时间创作的界面颜色和显示密度。主题只保存在当前设备,不影响小说内容和任务状态。</span>
|
|
4042
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-24)' }}>
|
|
4043
|
+
<div className={css.field}>
|
|
4044
|
+
<label className={css.fieldLabel} style={{ fontSize: 'var(--nf-fs-14)' }}>显示模式</label>
|
|
4045
|
+
<select className={css.input} style={{ padding: 'var(--nf-space-10) var(--nf-space-12)', fontSize: 'var(--nf-fs-14)' }} value={themeMode} onChange={e => { changeThemeMode(e.target.value as 'system' | 'light' | 'dark') }}>
|
|
4046
|
+
<option value="system">跟随系统</option>
|
|
4047
|
+
<option value="light">浅色</option>
|
|
4048
|
+
<option value="dark">深色</option>
|
|
4049
|
+
</select>
|
|
4050
|
+
<span className={css.meta}>强制浅色/深色只作用于小说工坊面板</span>
|
|
4051
|
+
</div>
|
|
4052
|
+
<div className={css.field}>
|
|
4053
|
+
<label className={css.fieldLabel} style={{ fontSize: 'var(--nf-fs-14)' }}>主题风格</label>
|
|
4054
|
+
<select className={css.input} style={{ padding: 'var(--nf-space-10) var(--nf-space-12)', fontSize: 'var(--nf-fs-14)' }} value={panelTheme} onChange={e => { changePanelTheme(e.target.value as 'liquid' | 'neumorph' | 'macos' | 'clay') }}>
|
|
4055
|
+
<option value="liquid">液态玻璃 · 清新绿(默认)</option>
|
|
4056
|
+
<option value="neumorph">新拟物 · 柔和浅色</option>
|
|
4057
|
+
<option value="macos">macOS · 玻璃(蓝,随外观浅/深)</option>
|
|
4058
|
+
<option value="clay">粘土拟态 · 柔和黏土</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: 'var(--nf-fs-14)' }}>界面密度</label>
|
|
4064
|
+
<select className={css.input} style={{ padding: 'var(--nf-space-10) var(--nf-space-12)', fontSize: 'var(--nf-fs-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: 'var(--nf-space-10) var(--nf-space-18)', fontSize: 'var(--nf-fs-14)' }} onClick={resetTheme}>
|
|
4074
|
+
<RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题
|
|
4075
|
+
</button>
|
|
4076
|
+
</div>
|
|
3885
4077
|
</div>
|
|
3886
|
-
|
|
4078
|
+
)}
|
|
3887
4079
|
</>
|
|
3888
4080
|
)}
|
|
3889
|
-
|
|
3890
|
-
{activeTab === 'settings' && (
|
|
3891
|
-
<div className={`${css.card} ${css.settingsCard}`}>
|
|
3892
|
-
<span className={css.cardTitle}>{tt('settings.theme')}</span>
|
|
3893
|
-
<div className={css.row} style={{ gap: 8, flexWrap: 'wrap' }}>
|
|
3894
|
-
<button
|
|
3895
|
-
type="button"
|
|
3896
|
-
className={`${css.button} ${css.buttonSmall} ${panelTheme === 'liquid' ? css.buttonPrimary : ''}`}
|
|
3897
|
-
onClick={() => { changePanelTheme('liquid') }}
|
|
3898
|
-
title="iOS 液态玻璃质感 · 绿色强调(当前默认)"
|
|
3899
|
-
>
|
|
3900
|
-
🧊 {tt('settings.themeLiquid')}
|
|
3901
|
-
</button>
|
|
3902
|
-
<button
|
|
3903
|
-
type="button"
|
|
3904
|
-
className={`${css.button} ${css.buttonSmall} ${panelTheme === 'classic' ? css.buttonPrimary : ''}`}
|
|
3905
|
-
onClick={() => { changePanelTheme('classic') }}
|
|
3906
|
-
title="经典 iOS 毛玻璃 · 蓝色强调"
|
|
3907
|
-
>
|
|
3908
|
-
💠 {tt('settings.themeClassic')}
|
|
3909
|
-
</button>
|
|
3910
|
-
<button
|
|
3911
|
-
type="button"
|
|
3912
|
-
className={`${css.button} ${css.buttonSmall} ${panelTheme === 'neumorph' ? css.buttonPrimary : ''}`}
|
|
3913
|
-
onClick={() => { changePanelTheme('neumorph') }}
|
|
3914
|
-
title="新拟物派 · 双阴影立体(仅浅色;深色下自动回退液态)"
|
|
3915
|
-
>
|
|
3916
|
-
🔘 {tt('settings.themeNeumorph')}
|
|
3917
|
-
</button>
|
|
3918
|
-
<span className={css.meta}>{tt('settings.themeHint')}</span>
|
|
3919
|
-
</div>
|
|
3920
|
-
</div>
|
|
3921
|
-
)}
|
|
3922
|
-
|
|
3923
4081
|
{activeTab === 'book' && bookTab === 'facts' && (
|
|
3924
4082
|
<div className={css.card}>
|
|
3925
4083
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
3926
|
-
<span className={css.cardTitle} style={{ fontSize:
|
|
4084
|
+
<span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-16)', fontWeight: 700 }}>📚 编年 / 复盘</span>
|
|
3927
4085
|
</div>
|
|
3928
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
|
|
4086
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
|
|
3929
4087
|
<button
|
|
3930
4088
|
type="button"
|
|
3931
4089
|
className={`${css.button} ${archiveTab === 'facts' ? css.buttonPrimary : ''}`}
|
|
3932
|
-
style={{ fontSize: 14, flex: 1 }}
|
|
4090
|
+
style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
|
|
3933
4091
|
onClick={() => { changeArchiveTab('facts') }}
|
|
3934
4092
|
title="编年录:客观事实流水(第 N 章 · 事件),生成/审稿查证用"
|
|
3935
4093
|
>
|
|
@@ -3938,7 +4096,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3938
4096
|
<button
|
|
3939
4097
|
type="button"
|
|
3940
4098
|
className={`${css.button} ${archiveTab === 'reviews' ? css.buttonPrimary : ''}`}
|
|
3941
|
-
style={{ fontSize: 14, flex: 1 }}
|
|
4099
|
+
style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
|
|
3942
4100
|
onClick={() => { changeArchiveTab('reviews') }}
|
|
3943
4101
|
title="复盘记录:每章作者复盘(钩子 / 推进 / 衔接 / 趋势)"
|
|
3944
4102
|
>
|
|
@@ -3980,12 +4138,12 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
3980
4138
|
</span>
|
|
3981
4139
|
)
|
|
3982
4140
|
})()}
|
|
3983
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
|
4141
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)', overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
|
3984
4142
|
{(() => {
|
|
3985
4143
|
const groupsWithReviews = chapterGroups.filter(group => group.chapters.some(c => c.authorReview !== undefined))
|
|
3986
4144
|
if (groupsWithReviews.length === 0) return null
|
|
3987
4145
|
return (
|
|
3988
|
-
<div className={css.row} style={{ justifyContent: 'flex-end', gap: 6 }}>
|
|
4146
|
+
<div className={css.row} style={{ justifyContent: 'flex-end', gap: 'var(--nf-space-6)' }}>
|
|
3989
4147
|
<button
|
|
3990
4148
|
type="button"
|
|
3991
4149
|
className={`${css.button} ${css.buttonSmall}`}
|
|
@@ -4008,10 +4166,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4008
4166
|
if (groupReviewed.length === 0) return null
|
|
4009
4167
|
const volumeExpanded = expandedVolumes[group.no] === true
|
|
4010
4168
|
return (
|
|
4011
|
-
<div key={group.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden' }}>
|
|
4169
|
+
<div key={group.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', overflow: 'hidden' }}>
|
|
4012
4170
|
<button
|
|
4013
4171
|
type="button"
|
|
4014
|
-
style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '
|
|
4172
|
+
style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: 'var(--nf-space-8) var(--nf-space-12)', fontSize: 'var(--nf-fs-14)', display: 'flex', alignItems: 'center', gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}
|
|
4015
4173
|
onClick={() => { setExpandedVolumes(prev => ({ ...prev, [group.no]: !volumeExpanded })) }}
|
|
4016
4174
|
>
|
|
4017
4175
|
<span style={{ color: 'var(--nf-text-3)' }}>{volumeExpanded ? '▾' : '▸'}</span>
|
|
@@ -4019,25 +4177,25 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4019
4177
|
<span className={css.meta}>已复盘 {groupReviewed.length}/{group.chapters.length} 章</span>
|
|
4020
4178
|
</button>
|
|
4021
4179
|
{volumeExpanded && (
|
|
4022
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '
|
|
4180
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', padding: 'var(--nf-space-4) var(--nf-space-8) var(--nf-space-8)', borderTop: '1px solid var(--nf-border)' }}>
|
|
4023
4181
|
{[...groupReviewed].reverse().map(c => {
|
|
4024
4182
|
const ar = c.authorReview!
|
|
4025
4183
|
const expanded = expandedReviewChapter === c.no
|
|
4026
4184
|
return (
|
|
4027
|
-
<div key={c.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, overflow: 'hidden' }}>
|
|
4185
|
+
<div key={c.no} style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-8)', overflow: 'hidden' }}>
|
|
4028
4186
|
<button
|
|
4029
4187
|
type="button"
|
|
4030
|
-
style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: '
|
|
4188
|
+
style={{ width: '100%', textAlign: 'left', border: 'none', background: 'transparent', cursor: 'pointer', padding: 'var(--nf-space-6) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)', display: 'flex', alignItems: 'center', gap: 'var(--nf-space-6)', flexWrap: 'wrap' }}
|
|
4031
4189
|
onClick={() => { setExpandedReviewChapter(expanded ? null : c.no) }}
|
|
4032
4190
|
>
|
|
4033
4191
|
<span style={{ color: 'var(--nf-text-3)' }}>{expanded ? '▾' : '▸'}</span>
|
|
4034
4192
|
<b>第{c.no}章《{c.title}》</b>
|
|
4035
4193
|
<span style={{ color: ar.hookHonored ? 'var(--nf-success)' : 'var(--nf-warn)' }}>钩子{ar.hookHonored ? '✓' : '✗'}</span>
|
|
4036
4194
|
<span style={{ color: ar.endingHook >= 6 ? 'var(--nf-success)' : 'var(--nf-error)' }}>结尾钩子 {ar.endingHook}/10</span>
|
|
4037
|
-
{ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 4 }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
|
|
4195
|
+
{ar.plotlineProgress !== '' && <span className={css.meta} style={{ marginLeft: 'var(--nf-space-4)' }}>{ar.plotlineProgress.slice(0, 40)}{ar.plotlineProgress.length > 40 ? '…' : ''}</span>}
|
|
4038
4196
|
</button>
|
|
4039
4197
|
{expanded && (
|
|
4040
|
-
<div style={{ padding: '
|
|
4198
|
+
<div style={{ padding: 'var(--nf-space-4) var(--nf-space-10) var(--nf-space-8)', fontSize: 'var(--nf-fs-12)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', borderTop: '1px solid var(--nf-border)' }}>
|
|
4041
4199
|
{ar.hookNote !== '' && <span className={css.meta}><b>钩子:</b>{ar.hookNote}</span>}
|
|
4042
4200
|
{ar.plotlineProgress !== '' && <span className={css.meta}><b>推进:</b>{ar.plotlineProgress}</span>}
|
|
4043
4201
|
{ar.continuity !== '' && <span className={css.meta}><b>衔接:</b>{ar.continuity}</span>}
|
|
@@ -4070,10 +4228,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4070
4228
|
</div>
|
|
4071
4229
|
<span className={css.meta}>{tt('facts.hint')}</span>
|
|
4072
4230
|
{(project?.facts ?? []).length > 0 ? (
|
|
4073
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: '60vh', overflowY: 'auto', fontSize: 12 }}>
|
|
4231
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', maxHeight: '60vh', overflowY: 'auto', fontSize: 'var(--nf-fs-12)' }}>
|
|
4074
4232
|
{[...(project?.facts ?? [])].reverse().map((fact, i) => (
|
|
4075
|
-
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
|
4076
|
-
<span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)', flex: 'none', marginTop:
|
|
4233
|
+
<div key={i} style={{ display: 'flex', gap: 'var(--nf-space-8)', alignItems: 'flex-start' }}>
|
|
4234
|
+
<span className={css.badge} style={{ borderColor: 'var(--nf-text-3)', color: 'var(--nf-text-3)', flex: 'none', marginTop: 'var(--nf-space-2)' }}>
|
|
4077
4235
|
第 {fact.chapterNo} 章
|
|
4078
4236
|
</span>
|
|
4079
4237
|
<span className={css.meta}>{fact.text}</span>
|
|
@@ -4091,13 +4249,13 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4091
4249
|
{activeTab === 'plotlines' && (
|
|
4092
4250
|
<div className={css.card}>
|
|
4093
4251
|
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
4094
|
-
<span className={css.cardTitle} style={{ fontSize:
|
|
4252
|
+
<span className={css.cardTitle} style={{ fontSize: 'var(--nf-fs-16)', fontWeight: 700 }}>📜 长线管理</span>
|
|
4095
4253
|
</div>
|
|
4096
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6 }}>
|
|
4254
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
|
|
4097
4255
|
<button
|
|
4098
4256
|
type="button"
|
|
4099
4257
|
className={`${css.button} ${longlineTab === 'plotlines' ? css.buttonPrimary : ''}`}
|
|
4100
|
-
style={{ fontSize: 14, flex: 1 }}
|
|
4258
|
+
style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
|
|
4101
4259
|
onClick={() => { changeLonglineTab('plotlines') }}
|
|
4102
4260
|
title="剧情线:故事明线(主线 / 支线 / 人物 / 悬念)"
|
|
4103
4261
|
>
|
|
@@ -4106,7 +4264,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4106
4264
|
<button
|
|
4107
4265
|
type="button"
|
|
4108
4266
|
className={`${css.button} ${longlineTab === 'foreshadow' ? css.buttonPrimary : ''}`}
|
|
4109
|
-
style={{ fontSize: 14, flex: 1 }}
|
|
4267
|
+
style={{ fontSize: 'var(--nf-fs-14)', flex: 1 }}
|
|
4110
4268
|
onClick={() => { changeLonglineTab('foreshadow') }}
|
|
4111
4269
|
title="伏笔:道具 / 事件级暗线(埋设 → 回收)"
|
|
4112
4270
|
>
|
|
@@ -4142,7 +4300,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4142
4300
|
</div>
|
|
4143
4301
|
</div>
|
|
4144
4302
|
<span className={css.badge} style={{ borderColor: statusColor, color: statusColor }}>{statusLabel}</span>
|
|
4145
|
-
<div className={css.row} style={{ gap: 4 }}>
|
|
4303
|
+
<div className={css.row} style={{ gap: 'var(--nf-space-4)' }}>
|
|
4146
4304
|
{f.status === 'planned' && (
|
|
4147
4305
|
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
|
|
4148
4306
|
void api.foreshadow({ id: f.id, status: 'planted', plantedChapter: doneCount + 1 }).then(r => setProject(prev => prev === null ? prev : { ...prev, foreshadows: r.foreshadows }))
|
|
@@ -4237,7 +4395,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4237
4395
|
)}
|
|
4238
4396
|
|
|
4239
4397
|
{plotlineDraft !== null && (
|
|
4240
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, border: '1px solid var(--nf-accent)', borderRadius: 12, padding: 10 }}>
|
|
4398
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-12)', padding: 'var(--nf-space-10)' }}>
|
|
4241
4399
|
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
4242
4400
|
<div className={css.field} style={{ flex: 2, minWidth: 160 }}>
|
|
4243
4401
|
<label className={css.fieldLabel}>{tt('plotlines.name')}</label>
|
|
@@ -4284,7 +4442,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4284
4442
|
{(project?.plotlines ?? []).length === 0 ? (
|
|
4285
4443
|
<span className={css.meta}>{tt('plotlines.empty')}</span>
|
|
4286
4444
|
) : (
|
|
4287
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
|
4445
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', overflowY: 'auto', flex: 1, minHeight: 0 }}>
|
|
4288
4446
|
{(project?.plotlines ?? []).map(line => (
|
|
4289
4447
|
<PlotlineCard
|
|
4290
4448
|
key={line.id}
|
|
@@ -4307,7 +4465,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4307
4465
|
)}
|
|
4308
4466
|
|
|
4309
4467
|
{activeTab === 'manhua' && (
|
|
4310
|
-
<MangaWorkspace api={api} project={project} chapters={chapters} onProjectChanged={() => refresh(false)} imageApiEnabled={config?.imageApiEnabled === true} onProgress={(text, kind) => pushProgress(text, kind ?? 'info')} />
|
|
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')} />
|
|
4311
4469
|
)}
|
|
4312
4470
|
|
|
4313
4471
|
{activeTab === 'breakdown' && (
|
|
@@ -4352,7 +4510,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4352
4510
|
<span className={css.meta}>选择范围与档位,点「开始拆书」对已写章节做整卷体检</span>
|
|
4353
4511
|
</div>
|
|
4354
4512
|
) : (
|
|
4355
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
4513
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
|
|
4356
4514
|
<span className={css.meta}>
|
|
4357
4515
|
分析 {breakdownResult.chaptersScanned} 章 · {breakdownResult.sections.length} 个小节 · 约 {breakdownResult.usedTokens} token
|
|
4358
4516
|
</span>
|
|
@@ -4362,14 +4520,14 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4362
4520
|
<b>{sec.title}</b>
|
|
4363
4521
|
</div>
|
|
4364
4522
|
{Object.keys(sec.structured).length > 0 && (
|
|
4365
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, fontSize: 12, marginBottom: 6 }}>
|
|
4523
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-2)', fontSize: 'var(--nf-fs-12)', marginBottom: 'var(--nf-space-6)' }}>
|
|
4366
4524
|
{Object.entries(sec.structured).map(([k, v]) => {
|
|
4367
4525
|
const label = (typeof v === 'string') ? v : Array.isArray(v) ? (v as string[]).join('、') : JSON.stringify(v)
|
|
4368
4526
|
return <div key={k} className={css.meta}><b>{k}:</b>{label}</div>
|
|
4369
4527
|
})}
|
|
4370
4528
|
</div>
|
|
4371
4529
|
)}
|
|
4372
|
-
<div className={css.meta} style={{ whiteSpace: 'pre-wrap', fontSize: 12 }}>{sec.markdown}</div>
|
|
4530
|
+
<div className={css.meta} style={{ whiteSpace: 'pre-wrap', fontSize: 'var(--nf-fs-12)' }}>{sec.markdown}</div>
|
|
4373
4531
|
</div>
|
|
4374
4532
|
))}
|
|
4375
4533
|
</div>
|
|
@@ -4413,7 +4571,7 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4413
4571
|
</div>
|
|
4414
4572
|
)}
|
|
4415
4573
|
|
|
4416
|
-
{/*
|
|
4574
|
+
{/* AI进度悬浮窗:可拖动、可拉大小(同 AI 助手),承接活动输出 + 当前任务进度 */}
|
|
4417
4575
|
{progressOpen && (
|
|
4418
4576
|
<div
|
|
4419
4577
|
className={css.assistantFloat}
|
|
@@ -4427,24 +4585,24 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4427
4585
|
}}
|
|
4428
4586
|
>
|
|
4429
4587
|
<span>
|
|
4430
|
-
📊
|
|
4588
|
+
📊 AI 进度
|
|
4431
4589
|
{busy && <span style={{ color: 'var(--nf-accent)' }}> 🟢 任务进行中</span>}
|
|
4432
4590
|
<span className={css.meta}>(拖动标题栏移动 · 右下角拉大小)</span>
|
|
4433
4591
|
</span>
|
|
4434
|
-
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
4592
|
+
<span style={{ display: 'flex', alignItems: 'center', gap: 'var(--nf-space-4)' }}>
|
|
4435
4593
|
{progress.length > 0 && (
|
|
4436
4594
|
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setProgress([]) }} title="清空活动记录">
|
|
4437
4595
|
清空
|
|
4438
4596
|
</button>
|
|
4439
4597
|
)}
|
|
4440
|
-
<button type="button" className={css.iconButton} title="关闭" aria-label="
|
|
4598
|
+
<button type="button" className={css.iconButton} title="关闭" aria-label="关闭AI进度" onClick={() => { setProgressOpen(false) }}>×</button>
|
|
4441
4599
|
</span>
|
|
4442
4600
|
</div>
|
|
4443
|
-
<div className={css.assistantFloatBody} style={{ padding: 10, display: 'flex', flexDirection: 'column', gap: 8, overflow: 'hidden' }}>
|
|
4601
|
+
<div className={css.assistantFloatBody} style={{ padding: 'var(--nf-space-10)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', overflow: 'hidden' }}>
|
|
4444
4602
|
{/* 当前任务大进度条:进行中显示 */}
|
|
4445
4603
|
{(busy && (busyLabel !== '' || liveBar !== null)) && (
|
|
4446
|
-
<div style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: '
|
|
4447
|
-
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--nf-accent)' }}>
|
|
4604
|
+
<div style={{ border: '1px solid var(--nf-accent)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-12)', display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', background: 'color-mix(in srgb, var(--nf-accent) 6%, transparent)' }}>
|
|
4605
|
+
<span style={{ fontSize: 'var(--nf-fs-12)', fontWeight: 600, color: 'var(--nf-accent)' }}>
|
|
4448
4606
|
✍ {busyLabel !== '' ? busyLabel : (liveBar?.text ?? '任务进行中')}…
|
|
4449
4607
|
</span>
|
|
4450
4608
|
{liveBar?.ratio !== undefined && (
|
|
@@ -4456,10 +4614,10 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
4456
4614
|
</div>
|
|
4457
4615
|
)}
|
|
4458
4616
|
{/* 活动记录列表(标题行已并入窗口标题栏,清空按钮在右上角) */}
|
|
4459
|
-
<div className={css.progress} style={{ flex: 1, minHeight: 0, overflowY: 'auto', border: '1px solid var(--nf-border)', borderRadius: 10, background: 'var(--nf-bg-inset)', padding: 8, display: 'flex', flexDirection: 'column' }}>
|
|
4617
|
+
<div className={css.progress} style={{ flex: 1, minHeight: 0, overflowY: 'auto', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', background: 'var(--nf-bg-inset)', padding: 'var(--nf-space-8)', display: 'flex', flexDirection: 'column' }}>
|
|
4460
4618
|
{progress.length === 0 ? (
|
|
4461
|
-
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4, color: 'var(--nf-text-3)', fontSize: 12, minHeight: 120 }}>
|
|
4462
|
-
<span style={{ fontSize: 22 }}>📭</span>
|
|
4619
|
+
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 'var(--nf-space-4)', color: 'var(--nf-text-3)', fontSize: 'var(--nf-fs-12)', minHeight: 120 }}>
|
|
4620
|
+
<span style={{ fontSize: 'var(--nf-fs-22)' }}>📭</span>
|
|
4463
4621
|
<span>暂无活动记录</span>
|
|
4464
4622
|
<span>生成、审稿等操作会显示在这里</span>
|
|
4465
4623
|
</div>
|