@waterwx/dsh-novel-forge 1.3.1-alpha → 1.3.2-alpha

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.
@@ -1,340 +1,354 @@
1
- /**
2
- * 共享设置页(作者首页「设置」与书内「设置」同入口)。
3
- * 自给自足:挂载时通过 api.status() 拉取全局配置,支持 model/writing/image/files/appearance 五组,
4
- * 保存走 api.patchConfig。不与某个书绑定,因此新装用户无书也能用。
5
- */
6
- import { useEffect, useRef, useState } from 'react'
7
- import { Brain, Folder, Sparkles, RotateCcw, Upload, X, Image } from 'lucide-react'
8
- import type { NovelApi } from '../api.ts'
9
- import type { MoveOutputDirResponse, NovelConfig } from '../../protocol.ts'
10
- import { tt } from './helpers.ts'
11
- import { ModelManager } from './ModelManager.tsx'
12
- import { ReasoningSection } from './ReasoningSection.tsx'
13
- import { SubPage } from './SubPage.tsx'
14
- import css from './panel.module.css'
15
-
16
-
17
- const SETTINGS_SECTIONS: Array<{ id: 'model' | 'files' | 'appearance'; label: string; icon: JSX.Element }> = [
18
- { id: 'model', label: '模型', icon: <Brain size={16} /> },
19
- { id: 'files', label: '路径与文件', icon: <Folder size={16} /> },
20
- { id: 'appearance', label: '外观与主题', icon: <Sparkles size={16} /> },
21
- ];
22
-
23
- type SettingsTab = 'model' | 'files' | 'appearance'
24
- type ThemeMode = 'system' | 'light' | 'dark'
25
- type ThemeDensity = 'comfort' | 'compact' | 'spacious'
26
-
27
- function readLS<T extends string>(key: string, fallback: T, allowed: readonly T[]): T {
28
- try {
29
- const v = window.localStorage.getItem(key) as T | null
30
- return v !== null && (allowed as readonly string[]).includes(v) ? v : fallback
31
- } catch { return fallback }
32
- }
33
-
34
- export function SettingsView({ api, variant = 'page', onSettingsTab, onEditorFontSize, onBackground, themeMode, themeDensity, onChangeThemeMode, onChangeThemeDensity }: {
35
- api: NovelApi;
36
- /** page = 全屏 SubPage(保留给旧入口);drawer = 右滑抽屉内容(无壳)。 */
37
- variant?: 'page' | 'drawer';
38
- onSettingsTab?: (tab: SettingsTab) => void;
39
- onEditorFontSize?: (n: number) => void;
40
- onBackground?: (bg: string | undefined, blur: number) => void;
41
- /** 全局显示模式(联动整个面板,NovelPanel 落 localStorage)。 */
42
- themeMode: 'system' | 'light' | 'dark';
43
- /** 全局界面密度。 */
44
- themeDensity: 'comfort' | 'compact' | 'spacious';
45
- /** 改变全局显示模式。 */
46
- onChangeThemeMode: (m: 'system' | 'light' | 'dark') => void;
47
- /** 改变全局界面密度。 */
48
- onChangeThemeDensity: (d: 'comfort' | 'compact' | 'spacious') => void;
49
- }) {
50
- const [config, setConfig] = useState<NovelConfig | null>(null);
51
- const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null);
52
- const [busy, setBusy] = useState(false);
53
- const [error, setError] = useState('');
54
- const [notice, setNotice] = useState('');
55
- // —— 输出目录迁移弹窗 ——
56
- const [moveOpen, setMoveOpen] = useState(false);
57
- const [moveTarget, setMoveTarget] = useState('');
58
- const [movePreview, setMovePreview] = useState<MoveOutputDirResponse | null>(null);
59
- const [moveBusy, setMoveBusy] = useState(false);
60
- const [moveError, setMoveError] = useState('');
61
- const [settingsTab, setSettingsTabState] = useState<SettingsTab>(() => readLS<SettingsTab>('dsh-novel-forge.settings.tab', 'model', ['model','files','appearance'] as const));
62
- const [editorFontSize, setEditorFontSize] = useState<number>(() => { try { const v = Number(window.localStorage.getItem('dsh-novel-forge.editor.fontSize')); return v >= 12 && v <= 24 ? v : 14 } catch { return 14 } });
63
- const loadConfig = async (): Promise<void> => {
64
- try {
65
- const res = await api.status();
66
- setConfig(res.config);
67
- setConfigDraft(res.config);
68
- } catch (err) { setError((err as Error).message) }
69
- };
70
- useEffect(() => { void loadConfig() }, []);
71
-
72
- useEffect(() => { try { window.localStorage.setItem('dsh-novel-forge.settings.tab', settingsTab) } catch { /* ignore */ } }, [settingsTab]);
73
-
74
- const changeSettingsTab = (next: SettingsTab): void => { setSettingsTabState(next); onSettingsTab?.(next) };
75
-
76
- /** 打开输出目录迁移弹窗:先 dryRun 预览当前目录内容。 */
77
- const openMoveDialog = async (): Promise<void> => {
78
- setMoveOpen(true); setMoveTarget(''); setMovePreview(null); setMoveError('');
79
- try { setMovePreview(await api.moveOutputDir(undefined, true)) } catch (err) { setMoveError((err as Error).message) }
80
- };
81
-
82
- /** 确认迁移:搬迁文件 → 服务端联动书架/默认目录 → 刷新配置。 */
83
- const confirmMove = async (): Promise<void> => {
84
- const to = moveTarget.trim();
85
- if (to === '') { setMoveError('请填写新目录的完整路径'); return }
86
- setMoveBusy(true); setMoveError('');
87
- try {
88
- const r = await api.moveOutputDir(to);
89
- setMoveOpen(false);
90
- setNotice(`目录迁移完成:${r.files.length} 个条目 → ${r.to}${r.movedBooks > 0 ? `(书架联动重指向 ${r.movedBooks} 本)` : ''}`);
91
- await loadConfig();
92
- } catch (err) { setMoveError((err as Error).message) } finally { setMoveBusy(false) }
93
- };
94
-
95
- const formatBytes = (n: number): string => n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`;
96
-
97
- const save = async (): Promise<void> => {
98
- if (configDraft === null) return;
99
- setBusy(true); setError(''); setNotice('');
100
- try {
101
- const result = await api.patchConfig({
102
- outlinePath: configDraft.outlinePath,
103
- outputDir: configDraft.outputDir,
104
- provider: configDraft.provider,
105
- model: configDraft.model,
106
- generateModel: configDraft.generateModel,
107
- reviewModel: configDraft.reviewModel,
108
- auditModel: configDraft.auditModel,
109
- fallbackModel: configDraft.fallbackModel ?? '',
110
- autoTimeline: configDraft.autoTimeline ?? true,
111
- reasoningEffort: configDraft.reasoningEffort ?? 'off',
112
- chapterChars: configDraft.chapterChars,
113
- maxTokens: configDraft.maxTokens,
114
- reviewPassScore: configDraft.reviewPassScore,
115
- autoReview: configDraft.autoReview,
116
- autoAuthorReview: configDraft.autoAuthorReview,
117
- autoReviewAfterRevise: configDraft.autoReviewAfterRevise,
118
- themeBackground: configDraft.themeBackground ?? '',
119
- themeBackgroundBlur: configDraft.themeBackgroundBlur ?? 0,
120
- savedModels: configDraft.savedModels ?? [],
121
- });
122
- setConfig(result.config);
123
- setConfigDraft(result.config);
124
- setNotice(tt('settings.saved'));
125
- } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
126
- };
127
-
128
- // 显示模式/密度由全局(NovelPanel)统一管理,设置页只调用 onChange 回调。
129
- const changeEditorFont = (n: number): void => {
130
- const v = Math.min(24, Math.max(12, n)); setEditorFontSize(v);
131
- try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
132
- onEditorFontSize?.(v);
133
- };
134
- const bgFileRef = useRef<HTMLInputElement | null>(null);
135
- const applyBackground = (bg: string | undefined): void => {
136
- setConfigDraft(prev => prev === null ? prev : { ...prev, themeBackground: bg ?? '' });
137
- onBackground?.(bg ?? undefined, configDraft?.themeBackgroundBlur ?? 0);
138
- };
139
- const applyBlur = (blur: number): void => {
140
- setConfigDraft(prev => prev === null ? prev : { ...prev, themeBackgroundBlur: blur });
141
- onBackground?.(configDraft?.themeBackground ?? undefined, blur);
142
- };
143
- const onBgFile = async (file: File | undefined): Promise<void> => {
144
- if (file === undefined) return;
145
- const dataUrl = await new Promise<string>((resolve, reject) => {
146
- const fr = new FileReader();
147
- fr.onload = () => resolve(fr.result as string);
148
- fr.onerror = () => reject(fr.error ?? new Error('读取失败'));
149
- fr.readAsDataURL(file);
150
- });
151
- try {
152
- // 上传到服务端存盘,返回可访问 URL(不往 settings.yaml 塞大 base64)。
153
- const res = await api.themeBackgroundUpload(dataUrl);
154
- applyBackground(res.url);
155
- } catch (err) {
156
- setError((err as Error).message);
157
- }
158
- };
159
- const resetTheme = (): void => {
160
- onChangeThemeMode('system');
161
- onChangeThemeDensity('comfort');
162
- };
163
-
164
- if (configDraft === null) {
165
- if (variant === 'drawer') {
166
- return <div style={{ padding: 'var(--nf-space-16)' }}><div className={css.meta}>正在加载设置…</div>{error !== '' && <div className={css.noticeError}>{error}</div>}</div>
167
- }
168
- return (
169
- <SubPage title="设置" meta="正在加载…">
170
- <div className={css.meta}>正在加载设置…</div>
171
- {error !== '' && <div className={css.noticeError}>{error}</div>}
172
- </SubPage>
173
- );
174
- }
175
-
176
- const content = (
177
- <>
178
- <div className={css.shelfToolbar} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
179
- {SETTINGS_SECTIONS.map(s => (
180
- <button key={s.id} type="button" className={css.button + (settingsTab === s.id ? ' ' + css.buttonPrimary : '')} style={{ flex: 1, minWidth: 104, justifyContent: 'center' }} onClick={() => changeSettingsTab(s.id)}>{s.icon} {s.label}</button>
181
- ))}
182
- {variant === 'drawer' && (
183
- <button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void save() }}>{busy ? '保存中…' : tt('settings.save')}</button>
184
- )}
185
- </div>
186
-
187
- {error !== '' && <div className={css.noticeError}>{error}</div>}
188
- {notice !== '' && <div className={css.meta}>{notice}</div>}
189
-
190
- {settingsTab === 'model' && (
191
- <>
192
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
193
- <span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
194
- <ModelManager
195
- api={api}
196
- provider={configDraft.provider}
197
- model={configDraft.model}
198
- savedModels={configDraft.savedModels ?? []}
199
- onProvider={v => setConfigDraft({ ...configDraft, provider: v })}
200
- onModel={v => setConfigDraft({ ...configDraft, model: v })}
201
- onSavedModels={models => setConfigDraft({ ...configDraft, savedModels: models })}
202
- />
203
- </div>
204
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
205
- <span className={css.cardTitle}>备用模型(故障自动切换)</span>
206
- <div className={css.field}>
207
- <label className={css.fieldLabel}>主模型调用失败(限流 / 超时 / 额度 / 服务不可用)时,自动换它重试一次</label>
208
- <select
209
- className={css.input}
210
- value={configDraft.fallbackModel ?? ''}
211
- onChange={e => setConfigDraft({ ...configDraft, fallbackModel: e.target.value })}
212
- >
213
- <option value="">不切换(关闭)</option>
214
- {[...new Set([configDraft.model, ...(configDraft.savedModels ?? []).map(m => m.model)])]
215
- .filter(m => m !== '')
216
- .map(m => <option key={m} value={m}>{m}</option>)}
217
- </select>
218
- </div>
219
- <span className={css.meta}>
220
- 只对可重试的失败生效:用户取消、参数错误不会切换;正文生成只在「一个字都没产出」时才换模型重试,避免重复生成。
221
- </span>
222
- </div>
223
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
224
- <span className={css.cardTitle}>故事时间线</span>
225
- <label className={css.row} style={{ gap: 'var(--nf-space-8)', alignItems: 'center' }}>
226
- <input
227
- type="checkbox"
228
- checked={configDraft.autoTimeline ?? true}
229
- onChange={e => setConfigDraft({ ...configDraft, autoTimeline: e.target.checked })}
230
- />
231
- <span>出章后自动抽取时间线事件(时间 / 地点 / 在场角色 / 事件)</span>
232
- </label>
233
- <span className={css.meta}>
234
- 抽取失败不会阻断出章;可在侧柜「故事时间线」里手工抽取、修正,并一键检查时间倒流 / 地点瞬移等矛盾。
235
- </span>
236
- </div>
237
- <ReasoningSection
238
- reasoningEffort={configDraft.reasoningEffort ?? 'off'}
239
- analysisReasoning={configDraft.analysisReasoning ?? 'low'}
240
- onChange={patch => setConfigDraft({ ...configDraft, ...patch })}
241
- />
242
- </>
243
- )}
244
-
245
- {settingsTab === 'files' && (
246
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
247
- <span className={css.cardTitle}><Folder size={18} style={{ verticalAlign: -3 }} /> 路径与文件</span>
248
- <div className={css.field}>
249
- <label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
250
- <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
251
- <input className={css.input} style={{ flex: 1, minWidth: 220 }} value={configDraft.outputDir} onChange={e => setConfigDraft({ ...configDraft, outputDir: e.target.value })} />
252
- <button type="button" className={css.button} onClick={() => { void openMoveDialog() }} title="把当前输出目录的全部文件搬到新位置,书架与配置自动联动">更改位置…</button>
253
- </div>
254
- <span className={css.meta}>含章节、项目状态、助手历史等;「更改位置」会整目录搬迁并联动书架,直接改路径仅更新默认值。</span>
255
- </div>
256
- <div className={css.row}><button type="button" className={css.button} onClick={() => { void api.openFolder() }}>{tt('settings.openFolder')}</button></div>
257
- </div>
258
- )}
259
-
260
- {settingsTab === 'appearance' && (
261
- <>
262
- <div className={css.card + ' ' + css.settingsCard}>
263
- <span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
264
- <div className={css.field}><label className={css.fieldLabel}>显示模式</label><select className={css.input} value={themeMode} onChange={e => onChangeThemeMode(e.target.value as ThemeMode)}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></div>
265
- <div className={css.field}><label className={css.fieldLabel}>主题风格</label><span className={css.input} style={{ display: 'inline-flex', alignItems: 'center' }}>墨纸 · 暖编辑案头</span><span className={css.meta}>统一墨纸风格,无需再选皮肤;切换明暗即达纸白/墨色。</span></div>
266
- <div className={css.field}><label className={css.fieldLabel}>界面密度</label><select className={css.input} value={themeDensity} onChange={e => onChangeThemeDensity(e.target.value as ThemeDensity)}><option value="comfort">舒适(默认)</option><option value="compact">紧凑</option><option value="spacious">宽松</option></select></div>
267
- <div className={css.field}><label className={css.fieldLabel}>编辑器字号(正文编辑 / 工作区)</label><select className={css.input} value={editorFontSize} onChange={e => changeEditorFont(Number(e.target.value))}>{[12,13,14,15,16,18,20,22,24].map(v => <option key={v} value={v}>{v}px</option>)}</select></div>
268
- <div className={css.row} style={{ justifyContent: 'flex-end' }}><button type="button" className={css.button} onClick={resetTheme}><RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题</button></div>
269
- </div>
270
-
271
- <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
272
- <span className={css.cardTitle}><Image size={18} style={{ verticalAlign: -3 }} /> 自定义背景</span>
273
- <span className={css.meta}>填图片 URL 或上传图片作为小说工坊背景(首页 + 书内)。留空 = 使用主题默认背景。</span>
274
- <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
275
- <input className={css.input} style={{ flex: 1, minWidth: 220 }} placeholder="https://… 图片地址" value={(configDraft.themeBackground ?? '').startsWith('data:') ? '' : (configDraft.themeBackground ?? '')} onChange={e => applyBackground(e.target.value.trim() !== '' ? e.target.value.trim() : undefined)} />
276
- <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => bgFileRef.current?.click()}><Upload size={13} style={{ verticalAlign: -2 }} /> 上传图片</button>
277
- <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => applyBackground(undefined)}><X size={13} style={{ verticalAlign: -2 }} /> 清除</button>
278
- </div>
279
- <input ref={bgFileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { void onBgFile(e.target.files?.[0]); e.target.value = '' }} />
280
- {(configDraft.themeBackground ?? '') !== '' && (
281
- <div className={css.adaptInputCard} style={{ padding: 8 }}>
282
- <div style={{ width: 180, height: 100, borderRadius: 10, backgroundImage: 'url(' + configDraft.themeBackground + ')', backgroundSize: 'cover', backgroundPosition: 'center' }} />
283
- <div className={css.meta}>已设置背景(预览)</div>
284
- </div>
285
- )}
286
- <div className={css.field}><label className={css.fieldLabel}>遮罩 / 模糊强度(0-80)</label><input className={css.input} type="range" min={0} max={80} value={configDraft.themeBackgroundBlur ?? 0} onChange={e => applyBlur(Number(e.target.value))} /><span className={css.meta}>越大背景越暗,保证文字可读(图很花时调高)。</span></div>
287
- </div>
288
- </>
289
- )}
290
-
291
- {moveOpen && (
292
- <div className={css.importModalOverlay} role="dialog" aria-modal="true" aria-label="更改输出目录" onClick={e => { if (e.target === e.currentTarget && !moveBusy) setMoveOpen(false) }}>
293
- <div className={css.importModal}>
294
- <div className={css.importModalHead}>
295
- <span className={css.cardTitle}>更改输出目录</span>
296
- <button type="button" className={css.buttonSmall + ' ' + css.button} disabled={moveBusy} onClick={() => { setMoveOpen(false) }}><X size={14} /></button>
297
- </div>
298
- <div className={css.importModalBody}>
299
- <div className={css.field}>
300
- <label className={css.fieldLabel}>当前目录</label>
301
- <span className={css.meta}>{movePreview?.from ?? configDraft.outputDir}</span>
302
- </div>
303
- {movePreview !== null && (
304
- <div className={css.field}>
305
- <label className={css.fieldLabel}>将被搬走的内容</label>
306
- <span className={css.meta}>{movePreview.files.length} 个条目 · 约 {formatBytes(movePreview.bytes)}{movePreview.files.length > 0 ? `(如 ${movePreview.files.slice(0, 3).join('、')}${movePreview.files.length > 3 ? ' …' : ''})` : '(空目录)'}</span>
307
- </div>
308
- )}
309
- <div className={css.field}>
310
- <label className={css.fieldLabel}>新目录(绝对路径)</label>
311
- <input className={css.input} placeholder="例如 D:\\Novels\\我的书" value={moveTarget} onChange={e => setMoveTarget(e.target.value)} disabled={moveBusy} autoFocus />
312
- <span className={css.meta}>目标必须为空或不存在;文件搬迁后书架与默认配置自动联动,成功后才删除旧目录,中途失败数据无损。</span>
313
- </div>
314
- {moveError !== '' && <span className={css.meta} style={{ color: 'var(--nf-danger, #e5484d)' }}>{moveError}</span>}
315
- </div>
316
- <div className={css.importModalActions}>
317
- <button type="button" className={css.button} disabled={moveBusy} onClick={() => { setMoveOpen(false) }}>取消</button>
318
- <button type="button" className={css.button} disabled={moveBusy || moveTarget.trim() === ''} onClick={() => { void confirmMove() }}>{moveBusy ? '搬迁中…' : '确认搬迁'}</button>
319
- </div>
320
- </div>
321
- </div>
322
- )}
323
- </>
324
- );
325
-
326
- if (variant === 'drawer') {
327
- return <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-12)', padding: 'var(--nf-space-16)' }}>{content}</div>;
328
- }
329
- return (
330
- <SubPage
331
- title="设置"
332
- meta={`当前模型:${config?.provider} / ${config?.model} · 输出目录:${config?.outputDir}`}
333
- actions={(
334
- <button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void save() }}>{busy ? '保存中…' : tt('settings.save')}</button>
335
- )}
336
- >
337
- {content}
338
- </SubPage>
339
- );
340
- }
1
+ /**
2
+ * 共享设置页(作者首页「设置」与书内「设置」同入口)。
3
+ * 自给自足:挂载时通过 api.status() 拉取全局配置,支持 model/writing/image/files/appearance 五组,
4
+ * 保存走 api.patchConfig。不与某个书绑定,因此新装用户无书也能用。
5
+ */
6
+ import { useEffect, useRef, useState } from 'react'
7
+ import { Brain, Folder, Sparkles, RotateCcw, Upload, X, Image } from 'lucide-react'
8
+ import type { NovelApi } from '../api.ts'
9
+ import type { MoveOutputDirResponse, NovelConfig } from '../../protocol.ts'
10
+ import { tt } from './helpers.ts'
11
+ import { RUN_CHIP_MODE_OPTIONS, readRunChipMode, writeRunChipMode, type RunChipMode } from '../chip-prefs.ts'
12
+ import { ModelManager } from './ModelManager.tsx'
13
+ import { ReasoningSection } from './ReasoningSection.tsx'
14
+ import { SubPage } from './SubPage.tsx'
15
+ import css from './panel.module.css'
16
+
17
+
18
+ const SETTINGS_SECTIONS: Array<{ id: 'model' | 'files' | 'appearance'; label: string; icon: JSX.Element }> = [
19
+ { id: 'model', label: '模型', icon: <Brain size={16} /> },
20
+ { id: 'files', label: '路径与文件', icon: <Folder size={16} /> },
21
+ { id: 'appearance', label: '外观与主题', icon: <Sparkles size={16} /> },
22
+ ];
23
+
24
+ type SettingsTab = 'model' | 'files' | 'appearance'
25
+ type ThemeMode = 'system' | 'light' | 'dark'
26
+ type ThemeDensity = 'comfort' | 'compact' | 'spacious'
27
+
28
+ function readLS<T extends string>(key: string, fallback: T, allowed: readonly T[]): T {
29
+ try {
30
+ const v = window.localStorage.getItem(key) as T | null
31
+ return v !== null && (allowed as readonly string[]).includes(v) ? v : fallback
32
+ } catch { return fallback }
33
+ }
34
+
35
+ export function SettingsView({ api, variant = 'page', onSettingsTab, onEditorFontSize, onBackground, themeMode, themeDensity, onChangeThemeMode, onChangeThemeDensity }: {
36
+ api: NovelApi;
37
+ /** page = 全屏 SubPage(保留给旧入口);drawer = 右滑抽屉内容(无壳)。 */
38
+ variant?: 'page' | 'drawer';
39
+ onSettingsTab?: (tab: SettingsTab) => void;
40
+ onEditorFontSize?: (n: number) => void;
41
+ onBackground?: (bg: string | undefined, blur: number) => void;
42
+ /** 全局显示模式(联动整个面板,NovelPanel 落 localStorage)。 */
43
+ themeMode: 'system' | 'light' | 'dark';
44
+ /** 全局界面密度。 */
45
+ themeDensity: 'comfort' | 'compact' | 'spacious';
46
+ /** 改变全局显示模式。 */
47
+ onChangeThemeMode: (m: 'system' | 'light' | 'dark') => void;
48
+ /** 改变全局界面密度。 */
49
+ onChangeThemeDensity: (d: 'comfort' | 'compact' | 'spacious') => void;
50
+ }) {
51
+ /** 右上角生成状态芯片的显示偏好(三态;存 localStorage,选择即时生效)。 */
52
+ const [runChipMode, setRunChipMode] = useState<RunChipMode>(() => readRunChipMode())
53
+ const [config, setConfig] = useState<NovelConfig | null>(null);
54
+ const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null);
55
+ const [busy, setBusy] = useState(false);
56
+ const [error, setError] = useState('');
57
+ const [notice, setNotice] = useState('');
58
+ // —— 输出目录迁移弹窗 ——
59
+ const [moveOpen, setMoveOpen] = useState(false);
60
+ const [moveTarget, setMoveTarget] = useState('');
61
+ const [movePreview, setMovePreview] = useState<MoveOutputDirResponse | null>(null);
62
+ const [moveBusy, setMoveBusy] = useState(false);
63
+ const [moveError, setMoveError] = useState('');
64
+ const [settingsTab, setSettingsTabState] = useState<SettingsTab>(() => readLS<SettingsTab>('dsh-novel-forge.settings.tab', 'model', ['model','files','appearance'] as const));
65
+ const [editorFontSize, setEditorFontSize] = useState<number>(() => { try { const v = Number(window.localStorage.getItem('dsh-novel-forge.editor.fontSize')); return v >= 12 && v <= 24 ? v : 14 } catch { return 14 } });
66
+ const loadConfig = async (): Promise<void> => {
67
+ try {
68
+ const res = await api.status();
69
+ setConfig(res.config);
70
+ setConfigDraft(res.config);
71
+ } catch (err) { setError((err as Error).message) }
72
+ };
73
+ useEffect(() => { void loadConfig() }, []);
74
+
75
+ useEffect(() => { try { window.localStorage.setItem('dsh-novel-forge.settings.tab', settingsTab) } catch { /* ignore */ } }, [settingsTab]);
76
+
77
+ const changeSettingsTab = (next: SettingsTab): void => { setSettingsTabState(next); onSettingsTab?.(next) };
78
+
79
+ /** 打开输出目录迁移弹窗:先 dryRun 预览当前目录内容。 */
80
+ const openMoveDialog = async (): Promise<void> => {
81
+ setMoveOpen(true); setMoveTarget(''); setMovePreview(null); setMoveError('');
82
+ try { setMovePreview(await api.moveOutputDir(undefined, true)) } catch (err) { setMoveError((err as Error).message) }
83
+ };
84
+
85
+ /** 确认迁移:搬迁文件 → 服务端联动书架/默认目录 → 刷新配置。 */
86
+ const confirmMove = async (): Promise<void> => {
87
+ const to = moveTarget.trim();
88
+ if (to === '') { setMoveError('请填写新目录的完整路径'); return }
89
+ setMoveBusy(true); setMoveError('');
90
+ try {
91
+ const r = await api.moveOutputDir(to);
92
+ setMoveOpen(false);
93
+ setNotice(`目录迁移完成:${r.files.length} 个条目 → ${r.to}${r.movedBooks > 0 ? `(书架联动重指向 ${r.movedBooks} 本)` : ''}`);
94
+ await loadConfig();
95
+ } catch (err) { setMoveError((err as Error).message) } finally { setMoveBusy(false) }
96
+ };
97
+
98
+ const formatBytes = (n: number): string => n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : n >= 1024 ? `${(n / 1024).toFixed(1)} KB` : `${n} B`;
99
+
100
+ const save = async (): Promise<void> => {
101
+ if (configDraft === null) return;
102
+ setBusy(true); setError(''); setNotice('');
103
+ try {
104
+ const result = await api.patchConfig({
105
+ outlinePath: configDraft.outlinePath,
106
+ outputDir: configDraft.outputDir,
107
+ provider: configDraft.provider,
108
+ model: configDraft.model,
109
+ generateModel: configDraft.generateModel,
110
+ reviewModel: configDraft.reviewModel,
111
+ auditModel: configDraft.auditModel,
112
+ fallbackModel: configDraft.fallbackModel ?? '',
113
+ autoTimeline: configDraft.autoTimeline ?? true,
114
+ reasoningEffort: configDraft.reasoningEffort ?? 'off',
115
+ chapterChars: configDraft.chapterChars,
116
+ maxTokens: configDraft.maxTokens,
117
+ reviewPassScore: configDraft.reviewPassScore,
118
+ autoReview: configDraft.autoReview,
119
+ autoAuthorReview: configDraft.autoAuthorReview,
120
+ autoReviewAfterRevise: configDraft.autoReviewAfterRevise,
121
+ themeBackground: configDraft.themeBackground ?? '',
122
+ themeBackgroundBlur: configDraft.themeBackgroundBlur ?? 0,
123
+ savedModels: configDraft.savedModels ?? [],
124
+ });
125
+ setConfig(result.config);
126
+ setConfigDraft(result.config);
127
+ setNotice(tt('settings.saved'));
128
+ } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
129
+ };
130
+
131
+ // 显示模式/密度由全局(NovelPanel)统一管理,设置页只调用 onChange 回调。
132
+ const changeEditorFont = (n: number): void => {
133
+ const v = Math.min(24, Math.max(12, n)); setEditorFontSize(v);
134
+ try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
135
+ onEditorFontSize?.(v);
136
+ };
137
+ const bgFileRef = useRef<HTMLInputElement | null>(null);
138
+ const applyBackground = (bg: string | undefined): void => {
139
+ setConfigDraft(prev => prev === null ? prev : { ...prev, themeBackground: bg ?? '' });
140
+ onBackground?.(bg ?? undefined, configDraft?.themeBackgroundBlur ?? 0);
141
+ };
142
+ const applyBlur = (blur: number): void => {
143
+ setConfigDraft(prev => prev === null ? prev : { ...prev, themeBackgroundBlur: blur });
144
+ onBackground?.(configDraft?.themeBackground ?? undefined, blur);
145
+ };
146
+ const onBgFile = async (file: File | undefined): Promise<void> => {
147
+ if (file === undefined) return;
148
+ const dataUrl = await new Promise<string>((resolve, reject) => {
149
+ const fr = new FileReader();
150
+ fr.onload = () => resolve(fr.result as string);
151
+ fr.onerror = () => reject(fr.error ?? new Error('读取失败'));
152
+ fr.readAsDataURL(file);
153
+ });
154
+ try {
155
+ // 上传到服务端存盘,返回可访问 URL(不往 settings.yaml 塞大 base64)。
156
+ const res = await api.themeBackgroundUpload(dataUrl);
157
+ applyBackground(res.url);
158
+ } catch (err) {
159
+ setError((err as Error).message);
160
+ }
161
+ };
162
+ const resetTheme = (): void => {
163
+ onChangeThemeMode('system');
164
+ onChangeThemeDensity('comfort');
165
+ };
166
+
167
+ if (configDraft === null) {
168
+ if (variant === 'drawer') {
169
+ return <div style={{ padding: 'var(--nf-space-16)' }}><div className={css.meta}>正在加载设置…</div>{error !== '' && <div className={css.noticeError}>{error}</div>}</div>
170
+ }
171
+ return (
172
+ <SubPage title="设置" meta="正在加载…">
173
+ <div className={css.meta}>正在加载设置…</div>
174
+ {error !== '' && <div className={css.noticeError}>{error}</div>}
175
+ </SubPage>
176
+ );
177
+ }
178
+
179
+ const content = (
180
+ <>
181
+ <div className={css.shelfToolbar} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
182
+ {SETTINGS_SECTIONS.map(s => (
183
+ <button key={s.id} type="button" className={css.button + (settingsTab === s.id ? ' ' + css.buttonPrimary : '')} style={{ flex: 1, minWidth: 104, justifyContent: 'center' }} onClick={() => changeSettingsTab(s.id)}>{s.icon} {s.label}</button>
184
+ ))}
185
+ {variant === 'drawer' && (
186
+ <button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void save() }}>{busy ? '保存中…' : tt('settings.save')}</button>
187
+ )}
188
+ </div>
189
+
190
+ {error !== '' && <div className={css.noticeError}>{error}</div>}
191
+ {notice !== '' && <div className={css.meta}>{notice}</div>}
192
+
193
+ {settingsTab === 'model' && (
194
+ <>
195
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
196
+ <span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
197
+ <ModelManager
198
+ api={api}
199
+ provider={configDraft.provider}
200
+ model={configDraft.model}
201
+ savedModels={configDraft.savedModels ?? []}
202
+ onProvider={v => setConfigDraft({ ...configDraft, provider: v })}
203
+ onModel={v => setConfigDraft({ ...configDraft, model: v })}
204
+ onSavedModels={models => setConfigDraft({ ...configDraft, savedModels: models })}
205
+ />
206
+ </div>
207
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
208
+ <span className={css.cardTitle}>备用模型(故障自动切换)</span>
209
+ <div className={css.field}>
210
+ <label className={css.fieldLabel}>主模型调用失败(限流 / 超时 / 额度 / 服务不可用)时,自动换它重试一次</label>
211
+ <select
212
+ className={css.input}
213
+ value={configDraft.fallbackModel ?? ''}
214
+ onChange={e => setConfigDraft({ ...configDraft, fallbackModel: e.target.value })}
215
+ >
216
+ <option value="">不切换(关闭)</option>
217
+ {[...new Set([configDraft.model, ...(configDraft.savedModels ?? []).map(m => m.model)])]
218
+ .filter(m => m !== '')
219
+ .map(m => <option key={m} value={m}>{m}</option>)}
220
+ </select>
221
+ </div>
222
+ <span className={css.meta}>
223
+ 只对可重试的失败生效:用户取消、参数错误不会切换;正文生成只在「一个字都没产出」时才换模型重试,避免重复生成。
224
+ </span>
225
+ </div>
226
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
227
+ <span className={css.cardTitle}>故事时间线</span>
228
+ <label className={css.row} style={{ gap: 'var(--nf-space-8)', alignItems: 'center' }}>
229
+ <input
230
+ type="checkbox"
231
+ checked={configDraft.autoTimeline ?? true}
232
+ onChange={e => setConfigDraft({ ...configDraft, autoTimeline: e.target.checked })}
233
+ />
234
+ <span>出章后自动抽取时间线事件(时间 / 地点 / 在场角色 / 事件)</span>
235
+ </label>
236
+ <span className={css.meta}>
237
+ 抽取失败不会阻断出章;可在侧柜「故事时间线」里手工抽取、修正,并一键检查时间倒流 / 地点瞬移等矛盾。
238
+ </span>
239
+ </div>
240
+ <ReasoningSection
241
+ reasoningEffort={configDraft.reasoningEffort ?? 'off'}
242
+ analysisReasoning={configDraft.analysisReasoning ?? 'low'}
243
+ onChange={patch => setConfigDraft({ ...configDraft, ...patch })}
244
+ />
245
+ </>
246
+ )}
247
+
248
+ {settingsTab === 'files' && (
249
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
250
+ <span className={css.cardTitle}><Folder size={18} style={{ verticalAlign: -3 }} /> 路径与文件</span>
251
+ <div className={css.field}>
252
+ <label className={css.fieldLabel}>{tt('settings.outputDir')}</label>
253
+ <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
254
+ <input className={css.input} style={{ flex: 1, minWidth: 220 }} value={configDraft.outputDir} onChange={e => setConfigDraft({ ...configDraft, outputDir: e.target.value })} />
255
+ <button type="button" className={css.button} onClick={() => { void openMoveDialog() }} title="把当前输出目录的全部文件搬到新位置,书架与配置自动联动">更改位置…</button>
256
+ </div>
257
+ <span className={css.meta}>含章节、项目状态、助手历史等;「更改位置」会整目录搬迁并联动书架,直接改路径仅更新默认值。</span>
258
+ </div>
259
+ <div className={css.row}><button type="button" className={css.button} onClick={() => { void api.openFolder() }}>{tt('settings.openFolder')}</button></div>
260
+ </div>
261
+ )}
262
+
263
+ {settingsTab === 'appearance' && (
264
+ <>
265
+ <div className={css.card + ' ' + css.settingsCard}>
266
+ <span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
267
+ <div className={css.field}><label className={css.fieldLabel}>显示模式</label><select className={css.input} value={themeMode} onChange={e => onChangeThemeMode(e.target.value as ThemeMode)}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></div>
268
+ <div className={css.field}><label className={css.fieldLabel}>主题风格</label><span className={css.input} style={{ display: 'inline-flex', alignItems: 'center' }}>墨纸 · 暖编辑案头</span><span className={css.meta}>统一墨纸风格,无需再选皮肤;切换明暗即达纸白/墨色。</span></div>
269
+ <div className={css.field}><label className={css.fieldLabel}>界面密度</label><select className={css.input} value={themeDensity} onChange={e => onChangeThemeDensity(e.target.value as ThemeDensity)}><option value="comfort">舒适(默认)</option><option value="compact">紧凑</option><option value="spacious">宽松</option></select></div>
270
+ <div className={css.field}><label className={css.fieldLabel}>编辑器字号(正文编辑 / 工作区)</label><select className={css.input} value={editorFontSize} onChange={e => changeEditorFont(Number(e.target.value))}>{[12,13,14,15,16,18,20,22,24].map(v => <option key={v} value={v}>{v}px</option>)}</select></div>
271
+ <div className={css.field}>
272
+ <label className={css.fieldLabel}>生成状态芯片(右上角)</label>
273
+ <select
274
+ className={css.input}
275
+ value={runChipMode}
276
+ onChange={e => { const next = e.target.value as RunChipMode; setRunChipMode(next); writeRunChipMode(next) }}
277
+ >
278
+ {RUN_CHIP_MODE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
279
+ </select>
280
+ <span className={css.meta}>固定在窗口右上角,显示生产单 生成中 / 已暂停 / 批完成 / 出错,点击可打开小说工坊。「关闭」时同时停掉状态轮询。存于浏览器本地,即时生效。</span>
281
+ </div>
282
+ <div className={css.row} style={{ justifyContent: 'flex-end' }}><button type="button" className={css.button} onClick={resetTheme}><RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题</button></div>
283
+ </div>
284
+
285
+ <div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
286
+ <span className={css.cardTitle}><Image size={18} style={{ verticalAlign: -3 }} /> 自定义背景</span>
287
+ <span className={css.meta}>填图片 URL 或上传图片作为小说工坊背景(首页 + 书内)。留空 = 使用主题默认背景。</span>
288
+ <div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
289
+ <input className={css.input} style={{ flex: 1, minWidth: 220 }} placeholder="https://… 图片地址" value={(configDraft.themeBackground ?? '').startsWith('data:') ? '' : (configDraft.themeBackground ?? '')} onChange={e => applyBackground(e.target.value.trim() !== '' ? e.target.value.trim() : undefined)} />
290
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => bgFileRef.current?.click()}><Upload size={13} style={{ verticalAlign: -2 }} /> 上传图片</button>
291
+ <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => applyBackground(undefined)}><X size={13} style={{ verticalAlign: -2 }} /> 清除</button>
292
+ </div>
293
+ <input ref={bgFileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { void onBgFile(e.target.files?.[0]); e.target.value = '' }} />
294
+ {(configDraft.themeBackground ?? '') !== '' && (
295
+ <div className={css.adaptInputCard} style={{ padding: 8 }}>
296
+ <div style={{ width: 180, height: 100, borderRadius: 10, backgroundImage: 'url(' + configDraft.themeBackground + ')', backgroundSize: 'cover', backgroundPosition: 'center' }} />
297
+ <div className={css.meta}>已设置背景(预览)</div>
298
+ </div>
299
+ )}
300
+ <div className={css.field}><label className={css.fieldLabel}>遮罩 / 模糊强度(0-80)</label><input className={css.input} type="range" min={0} max={80} value={configDraft.themeBackgroundBlur ?? 0} onChange={e => applyBlur(Number(e.target.value))} /><span className={css.meta}>越大背景越暗,保证文字可读(图很花时调高)。</span></div>
301
+ </div>
302
+ </>
303
+ )}
304
+
305
+ {moveOpen && (
306
+ <div className={css.importModalOverlay} role="dialog" aria-modal="true" aria-label="更改输出目录" onClick={e => { if (e.target === e.currentTarget && !moveBusy) setMoveOpen(false) }}>
307
+ <div className={css.importModal}>
308
+ <div className={css.importModalHead}>
309
+ <span className={css.cardTitle}>更改输出目录</span>
310
+ <button type="button" className={css.buttonSmall + ' ' + css.button} disabled={moveBusy} onClick={() => { setMoveOpen(false) }}><X size={14} /></button>
311
+ </div>
312
+ <div className={css.importModalBody}>
313
+ <div className={css.field}>
314
+ <label className={css.fieldLabel}>当前目录</label>
315
+ <span className={css.meta}>{movePreview?.from ?? configDraft.outputDir}</span>
316
+ </div>
317
+ {movePreview !== null && (
318
+ <div className={css.field}>
319
+ <label className={css.fieldLabel}>将被搬走的内容</label>
320
+ <span className={css.meta}>{movePreview.files.length} 个条目 · 约 {formatBytes(movePreview.bytes)}{movePreview.files.length > 0 ? `(如 ${movePreview.files.slice(0, 3).join('、')}${movePreview.files.length > 3 ? ' …' : ''})` : '(空目录)'}</span>
321
+ </div>
322
+ )}
323
+ <div className={css.field}>
324
+ <label className={css.fieldLabel}>新目录(绝对路径)</label>
325
+ <input className={css.input} placeholder="例如 D:\\Novels\\我的书" value={moveTarget} onChange={e => setMoveTarget(e.target.value)} disabled={moveBusy} autoFocus />
326
+ <span className={css.meta}>目标必须为空或不存在;文件搬迁后书架与默认配置自动联动,成功后才删除旧目录,中途失败数据无损。</span>
327
+ </div>
328
+ {moveError !== '' && <span className={css.meta} style={{ color: 'var(--nf-danger, #e5484d)' }}>{moveError}</span>}
329
+ </div>
330
+ <div className={css.importModalActions}>
331
+ <button type="button" className={css.button} disabled={moveBusy} onClick={() => { setMoveOpen(false) }}>取消</button>
332
+ <button type="button" className={css.button} disabled={moveBusy || moveTarget.trim() === ''} onClick={() => { void confirmMove() }}>{moveBusy ? '搬迁中…' : '确认搬迁'}</button>
333
+ </div>
334
+ </div>
335
+ </div>
336
+ )}
337
+ </>
338
+ );
339
+
340
+ if (variant === 'drawer') {
341
+ return <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-12)', padding: 'var(--nf-space-16)' }}>{content}</div>;
342
+ }
343
+ return (
344
+ <SubPage
345
+ title="设置"
346
+ meta={`当前模型:${config?.provider} / ${config?.model} · 输出目录:${config?.outputDir}`}
347
+ actions={(
348
+ <button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void save() }}>{busy ? '保存中…' : tt('settings.save')}</button>
349
+ )}
350
+ >
351
+ {content}
352
+ </SubPage>
353
+ );
354
+ }