@waterwx/dsh-novel-forge 1.7.3 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +3219 -662
- package/lib/client.js.map +1 -1
- package/lib/index.js +629 -41
- package/lib/index.js.map +1 -1
- package/lib/types/author-assets.d.ts +25 -0
- package/lib/types/client/api.d.ts +18 -0
- package/lib/types/client/panel/AdaptModeView.d.ts +4 -0
- package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
- package/lib/types/client/panel/AuthorHome.d.ts +20 -0
- package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
- package/lib/types/client/panel/SettingsView.d.ts +14 -0
- package/lib/types/client/text.d.ts +9 -0
- package/lib/types/engine.d.ts +24 -1
- package/lib/types/index.d.ts +8 -0
- package/lib/types/protocol.d.ts +192 -0
- package/package.json +1 -1
- package/src/author-assets.ts +166 -0
- package/src/client/api.ts +41 -0
- package/src/client/mount.tsx._new +97 -0
- package/src/client/mount.tsx._nf +97 -0
- package/src/client/mount.tsx._tmp +97 -0
- package/src/client/panel/AdaptModeView.tsx +204 -0
- package/src/client/panel/AuthorAssetsView.tsx +206 -0
- package/src/client/panel/AuthorHome.tsx +93 -0
- package/src/client/panel/ImportModal.tsx +2 -1
- package/src/client/panel/NovelPanel.tsx +47 -16
- package/src/client/panel/ProgressHomeView.tsx +87 -0
- package/src/client/panel/SettingsView.tsx +285 -0
- package/src/client/panel/panel.module.css +325 -0
- package/src/client/sidebar-entry.ts +1 -1
- package/src/client/text.ts +28 -0
- package/src/engine.ts +221 -0
- package/src/index.ts +20 -0
- package/src/protocol.ts +196 -0
- package/src/routes.ts +191 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共享设置页(作者首页「设置」与书内「设置」同入口)。
|
|
3
|
+
* 自给自足:挂载时通过 api.status() 拉取全局配置,支持 model/writing/image/files/appearance 五组,
|
|
4
|
+
* 保存走 api.patchConfig。不与某个书绑定,因此新装用户无书也能用。
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useRef, useState } from 'react'
|
|
7
|
+
import { Brain, PenLine, Palette, Folder, Sparkles, RotateCcw, PlugZap, Plus, Trash2, Upload, X, Image } from 'lucide-react'
|
|
8
|
+
import type { NovelApi } from '../api.ts'
|
|
9
|
+
import type { NovelConfig, ImageModelConfig } from '../../protocol.ts'
|
|
10
|
+
import { tt } from './helpers.ts'
|
|
11
|
+
import css from './panel.module.css'
|
|
12
|
+
|
|
13
|
+
const MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
|
|
14
|
+
const REASONING_OPTIONS = ['off', 'low', 'high', 'max'] as const
|
|
15
|
+
const REASONING_LABEL: Record<string, string> = {
|
|
16
|
+
off: tt('settings.reasoning.off'),
|
|
17
|
+
low: tt('settings.reasoning.low'),
|
|
18
|
+
high: tt('settings.reasoning.high'),
|
|
19
|
+
max: tt('settings.reasoning.max'),
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SETTINGS_SECTIONS: Array<{ id: 'model' | 'writing' | 'image' | 'files' | 'appearance' | 'background'; label: string; icon: JSX.Element }> = [
|
|
23
|
+
{ id: 'model', label: '模型与推理', icon: <Brain size={16} /> },
|
|
24
|
+
{ id: 'writing', label: '写作与审稿', icon: <PenLine size={16} /> },
|
|
25
|
+
{ id: 'image', label: '生图模型', icon: <Palette size={16} /> },
|
|
26
|
+
{ id: 'files', label: '路径与文件', icon: <Folder size={16} /> },
|
|
27
|
+
{ id: 'appearance', label: '外观与主题', icon: <Sparkles size={16} /> },
|
|
28
|
+
{ id: 'background', label: '自定义背景', icon: <Image size={16} /> },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
type SettingsTab = 'model' | 'writing' | 'image' | 'files' | 'appearance' | 'background'
|
|
32
|
+
type ThemeMode = 'system' | 'light' | 'dark'
|
|
33
|
+
type ThemeName = 'liquid' | 'neumorph' | 'macos' | 'clay'
|
|
34
|
+
type ThemeDensity = 'comfort' | 'compact' | 'spacious'
|
|
35
|
+
|
|
36
|
+
function readLS<T extends string>(key: string, fallback: T, allowed: readonly T[]): T {
|
|
37
|
+
try {
|
|
38
|
+
const v = window.localStorage.getItem(key) as T | null
|
|
39
|
+
return v !== null && (allowed as readonly string[]).includes(v) ? v : fallback
|
|
40
|
+
} catch { return fallback }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function SettingsView({ api, onTheme, onSettingsTab, onEditorFontSize, onBackground, onOpacity }: {
|
|
44
|
+
api: NovelApi;
|
|
45
|
+
onTheme?: (theme: ThemeName, mode: ThemeMode, density: ThemeDensity) => void;
|
|
46
|
+
onSettingsTab?: (tab: SettingsTab) => void;
|
|
47
|
+
onEditorFontSize?: (n: number) => void;
|
|
48
|
+
onBackground?: (bg: string | undefined, blur: number) => void;
|
|
49
|
+
onOpacity?: (n: number) => void;
|
|
50
|
+
}) {
|
|
51
|
+
const [config, setConfig] = useState<NovelConfig | null>(null);
|
|
52
|
+
const [configDraft, setConfigDraft] = useState<NovelConfig | null>(null);
|
|
53
|
+
const [busy, setBusy] = useState(false);
|
|
54
|
+
const [error, setError] = useState('');
|
|
55
|
+
const [notice, setNotice] = useState('');
|
|
56
|
+
const [modelCustomMode, setModelCustomMode] = useState(false);
|
|
57
|
+
const [settingsTab, setSettingsTabState] = useState<SettingsTab>(() => readLS<SettingsTab>('dsh-novel-forge.settings.tab', 'model', ['model','writing','image','files','appearance','background'] as const));
|
|
58
|
+
const [imageTestState, setImageTestState] = useState<Record<string, { testing: boolean; ok?: boolean; ms?: number; message?: string; modelFound?: boolean }>>({});
|
|
59
|
+
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 } });
|
|
60
|
+
const [panelTheme, setPanelTheme] = useState<ThemeName>(() => readLS<ThemeName>('dsh-novel-forge.theme', 'liquid', ['liquid','neumorph','macos','clay'] as const));
|
|
61
|
+
const [themeMode, setThemeMode] = useState<ThemeMode>(() => readLS<ThemeMode>('dsh-novel-forge.theme.mode', 'system', ['system','light','dark'] as const));
|
|
62
|
+
const [themeDensity, setThemeDensity] = useState<ThemeDensity>(() => readLS<ThemeDensity>('dsh-novel-forge.theme.density', 'comfort', ['comfort','compact','spacious'] as const));
|
|
63
|
+
|
|
64
|
+
const loadConfig = async (): Promise<void> => {
|
|
65
|
+
try {
|
|
66
|
+
const res = await api.status();
|
|
67
|
+
setConfig(res.config);
|
|
68
|
+
setConfigDraft(res.config);
|
|
69
|
+
} catch (err) { setError((err as Error).message) }
|
|
70
|
+
};
|
|
71
|
+
useEffect(() => { void loadConfig() }, []);
|
|
72
|
+
|
|
73
|
+
useEffect(() => { try { window.localStorage.setItem('dsh-novel-forge.settings.tab', settingsTab) } catch { /* ignore */ } }, [settingsTab]);
|
|
74
|
+
|
|
75
|
+
const changeSettingsTab = (next: SettingsTab): void => { setSettingsTabState(next); onSettingsTab?.(next) };
|
|
76
|
+
|
|
77
|
+
const save = async (): Promise<void> => {
|
|
78
|
+
if (configDraft === null) return;
|
|
79
|
+
setBusy(true); setError(''); setNotice('');
|
|
80
|
+
try {
|
|
81
|
+
const result = await api.patchConfig({
|
|
82
|
+
outlinePath: configDraft.outlinePath,
|
|
83
|
+
outputDir: configDraft.outputDir,
|
|
84
|
+
provider: configDraft.provider,
|
|
85
|
+
model: configDraft.model,
|
|
86
|
+
reasoningEffort: configDraft.reasoningEffort ?? 'off',
|
|
87
|
+
chapterChars: configDraft.chapterChars,
|
|
88
|
+
maxTokens: configDraft.maxTokens,
|
|
89
|
+
reviewPassScore: configDraft.reviewPassScore,
|
|
90
|
+
autoReview: configDraft.autoReview,
|
|
91
|
+
autoAuthorReview: configDraft.autoAuthorReview,
|
|
92
|
+
autoReviewAfterRevise: configDraft.autoReviewAfterRevise,
|
|
93
|
+
imageApiKey: configDraft.imageApiKey,
|
|
94
|
+
imageApiModel: configDraft.imageApiModel,
|
|
95
|
+
imageApiEnabled: configDraft.imageApiEnabled ?? false,
|
|
96
|
+
themeBackground: configDraft.themeBackground ?? '',
|
|
97
|
+
themeBackgroundBlur: configDraft.themeBackgroundBlur ?? 0,
|
|
98
|
+
themeOpacity: configDraft.themeOpacity ?? 100,
|
|
99
|
+
imageModels: configDraft.imageModels ?? [],
|
|
100
|
+
});
|
|
101
|
+
setConfig(result.config);
|
|
102
|
+
setConfigDraft(result.config);
|
|
103
|
+
setNotice(tt('settings.saved'));
|
|
104
|
+
} catch (err) { setError((err as Error).message) } finally { setBusy(false) }
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// 生图模型操作
|
|
108
|
+
const patchImageModel = (id: string, patch: Partial<ImageModelConfig>): void => {
|
|
109
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => m.id === id ? { ...m, ...patch } : m) })
|
|
110
|
+
};
|
|
111
|
+
const addImageModel = (): void => {
|
|
112
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: [...(prev.imageModels ?? []), { id: 'img-' + Date.now().toString(36), name: '', baseURL: '', apiKey: '', model: '', enabled: (prev.imageModels ?? []).length === 0 }] })
|
|
113
|
+
};
|
|
114
|
+
const removeImageModel = (id: string): void => {
|
|
115
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).filter(m => m.id !== id) })
|
|
116
|
+
};
|
|
117
|
+
const enableImageModel = (id: string): void => {
|
|
118
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, imageModels: (prev.imageModels ?? []).map(m => ({ ...m, enabled: m.id === id })) })
|
|
119
|
+
};
|
|
120
|
+
const testImageModel = async (m: ImageModelConfig): Promise<void> => {
|
|
121
|
+
if (m.baseURL.trim() === '' || m.apiKey.trim() === '') { setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: '请先填写接口地址和 API Key' } })); return }
|
|
122
|
+
setImageTestState(prev => ({ ...prev, [m.id]: { testing: true } }));
|
|
123
|
+
try { const r = await api.imageTest({ baseURL: m.baseURL, apiKey: m.apiKey, model: m.model }); setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: r.ok, ms: r.ms, message: r.message } })) } catch (err) { setImageTestState(prev => ({ ...prev, [m.id]: { testing: false, ok: false, message: (err as Error).message } })) }
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// 主题/密度/字号(写 localStorage,可选通知父级)
|
|
127
|
+
const changeTheme = (theme: ThemeName, mode: ThemeMode, density: ThemeDensity): void => {
|
|
128
|
+
setPanelTheme(theme); setThemeMode(mode); setThemeDensity(density);
|
|
129
|
+
try {
|
|
130
|
+
window.localStorage.setItem('dsh-novel-forge.theme', theme);
|
|
131
|
+
window.localStorage.setItem('dsh-novel-forge.theme.mode', mode);
|
|
132
|
+
window.localStorage.setItem('dsh-novel-forge.theme.density', density);
|
|
133
|
+
} catch { /* ignore */ }
|
|
134
|
+
onTheme?.(theme, mode, density);
|
|
135
|
+
};
|
|
136
|
+
const changeEditorFont = (n: number): void => {
|
|
137
|
+
const v = Math.min(24, Math.max(12, n)); setEditorFontSize(v);
|
|
138
|
+
try { window.localStorage.setItem('dsh-novel-forge.editor.fontSize', String(v)) } catch { /* ignore */ }
|
|
139
|
+
onEditorFontSize?.(v);
|
|
140
|
+
};
|
|
141
|
+
const bgFileRef = useRef<HTMLInputElement | null>(null);
|
|
142
|
+
const applyBackground = (bg: string | undefined): void => {
|
|
143
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, themeBackground: bg ?? '' });
|
|
144
|
+
onBackground?.(bg ?? undefined, configDraft?.themeBackgroundBlur ?? 0);
|
|
145
|
+
};
|
|
146
|
+
const applyBlur = (blur: number): void => {
|
|
147
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, themeBackgroundBlur: blur });
|
|
148
|
+
onBackground?.(configDraft?.themeBackground ?? undefined, blur);
|
|
149
|
+
};
|
|
150
|
+
const applyOpacity = (n: number): void => {
|
|
151
|
+
setConfigDraft(prev => prev === null ? prev : { ...prev, themeOpacity: n });
|
|
152
|
+
onOpacity?.(n);
|
|
153
|
+
};
|
|
154
|
+
const onBgFile = async (file: File | undefined): Promise<void> => {
|
|
155
|
+
if (file === undefined) return;
|
|
156
|
+
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
157
|
+
const fr = new FileReader();
|
|
158
|
+
fr.onload = () => resolve(fr.result as string);
|
|
159
|
+
fr.onerror = () => reject(fr.error ?? new Error('读取失败'));
|
|
160
|
+
fr.readAsDataURL(file);
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
// 上传到服务端存盘,返回可访问 URL(不往 settings.yaml 塞大 base64)。
|
|
164
|
+
const res = await api.themeBackgroundUpload(dataUrl);
|
|
165
|
+
applyBackground(res.url);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
setError((err as Error).message);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const resetTheme = (): void => {
|
|
171
|
+
try { window.localStorage.removeItem('dsh-novel-forge.theme'); window.localStorage.removeItem('dsh-novel-forge.theme.mode'); window.localStorage.removeItem('dsh-novel-forge.theme.density') } catch { /* ignore */ }
|
|
172
|
+
changeTheme('liquid', 'system', 'comfort');
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
if (configDraft === null) {
|
|
176
|
+
return (
|
|
177
|
+
<div className={css.authorPageBody}><div className={css.meta}>正在加载设置…</div>{error !== '' && <div className={css.noticeError}>{error}</div>}</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div className={css.authorPageBody} data-nf-theme={panelTheme} data-nf-mode={themeMode === 'system' ? undefined : themeMode} data-nf-density={themeDensity}>
|
|
183
|
+
<div className={css.authorPageHeader}>
|
|
184
|
+
<div><h2 className={css.panelTitle} style={{ margin: 0 }}>⚙️ 设置</h2><span className={css.meta}>当前:{config?.provider} / {config?.model} · {config?.outputDir}</span></div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
|
|
188
|
+
{SETTINGS_SECTIONS.map(s => (
|
|
189
|
+
<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>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{error !== '' && <div className={css.noticeError}>{error}</div>}
|
|
194
|
+
{notice !== '' && <div className={css.meta}>{notice}</div>}
|
|
195
|
+
|
|
196
|
+
{settingsTab === 'model' && (
|
|
197
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
|
|
198
|
+
<span className={css.cardTitle}><Brain size={18} style={{ verticalAlign: -3 }} /> 模型与推理</span>
|
|
199
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.provider')}</label><input className={css.input} value={configDraft.provider} onChange={e => setConfigDraft({ ...configDraft, provider: e.target.value })} /></div>
|
|
200
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.model')}</label><select className={css.input} value={modelCustomMode ? '__custom__' : configDraft.model} onChange={e => { const v = e.target.value; if (v === '__custom__') setModelCustomMode(true); else { setModelCustomMode(false); setConfigDraft({ ...configDraft, model: v }) } }}>{MODEL_PRESETS.map(m => <option key={m} value={m}>{m}</option>)}<option value="__custom__">{tt('settings.modelCustom')}</option></select>{modelCustomMode && <input className={css.input} value={configDraft.model} placeholder={tt('settings.modelCustomPlaceholder')} onChange={e => setConfigDraft({ ...configDraft, model: e.target.value })} />}</div>
|
|
201
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.reasoningEffort')}</label><select className={css.input} value={configDraft.reasoningEffort ?? 'off'} onChange={e => setConfigDraft({ ...configDraft, reasoningEffort: e.target.value as NovelConfig['reasoningEffort'] })}>{REASONING_OPTIONS.map(v => <option key={v} value={v}>{REASONING_LABEL[v]}</option>)}</select><span className={css.meta}>{tt('settings.reasoningHint')}</span></div>
|
|
202
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.analysisReasoning')}</label><select className={css.input} value={configDraft.analysisReasoning ?? 'low'} onChange={e => setConfigDraft({ ...configDraft, analysisReasoning: e.target.value as NovelConfig['analysisReasoning'] })}>{REASONING_OPTIONS.map(v => <option key={v} value={v}>{REASONING_LABEL[v]}</option>)}</select><span className={css.meta}>{tt('settings.analysisReasoningHint')}</span></div>
|
|
203
|
+
</div>
|
|
204
|
+
)}
|
|
205
|
+
|
|
206
|
+
{settingsTab === 'writing' && (
|
|
207
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
|
|
208
|
+
<span className={css.cardTitle}><PenLine size={18} style={{ verticalAlign: -3 }} /> 写作与审稿</span>
|
|
209
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.chapterChars')}</label><input className={css.input} type="number" min={1000} max={20000} value={configDraft.chapterChars} onChange={e => setConfigDraft({ ...configDraft, chapterChars: Number(e.target.value) })} /></div>
|
|
210
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.maxTokens')}</label><input className={css.input} type="number" min={2000} max={64000} value={configDraft.maxTokens} onChange={e => setConfigDraft({ ...configDraft, maxTokens: Number(e.target.value) })} /></div>
|
|
211
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.reviewPassScore')}</label><input className={css.input} type="number" min={0} max={100} value={configDraft.reviewPassScore} onChange={e => setConfigDraft({ ...configDraft, reviewPassScore: Number(e.target.value) })} /><span className={css.meta}>通过判定:综合评分 ≥ 此分数(默认 70),或无 high 级问题且评分 ≥ 60;建议设 60-80。</span></div>
|
|
212
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.autoReview')}</label><select className={css.input} value={configDraft.autoReview ? '1' : '0'} onChange={e => setConfigDraft({ ...configDraft, autoReview: e.target.value === '1' })}><option value="1">✓ 是</option><option value="0">✗ 否</option></select></div>
|
|
213
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.autoAuthorReview')}</label><select className={css.input} value={configDraft.autoAuthorReview ? '1' : '0'} onChange={e => setConfigDraft({ ...configDraft, autoAuthorReview: e.target.value === '1' })}><option value="1">✓ 是</option><option value="0">✗ 否</option></select><span className={css.meta}>{tt('settings.autoAuthorReviewHint')}</span></div>
|
|
214
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.autoReviewAfterRevise')}</label><select className={css.input} value={configDraft.autoReviewAfterRevise ? '1' : '0'} onChange={e => setConfigDraft({ ...configDraft, autoReviewAfterRevise: e.target.value === '1' })}><option value="1">✓ 是</option><option value="0">✗ 否</option></select><span className={css.meta}>{tt('settings.autoReviewAfterReviseHint')}</span></div>
|
|
215
|
+
<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>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
{settingsTab === 'image' && (
|
|
220
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
|
|
221
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', alignItems: 'center' }}><span className={css.cardTitle}><Palette size={18} style={{ verticalAlign: -3 }} /> 生图模型</span><span className={css.meta}>启用生图功能</span></div>
|
|
222
|
+
<span className={css.meta}>支持多套 OpenAI 兼容生图接口(豆包 / 即梦 / 其他平台);启用一条作为生成模型。</span>
|
|
223
|
+
{(configDraft.imageModels ?? []).map(m => (
|
|
224
|
+
<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' }}>
|
|
225
|
+
<div className={css.row} style={{ alignItems: 'flex-end', flexWrap: 'wrap' }}>
|
|
226
|
+
<div className={css.field} style={{ flex: 1, minWidth: 160 }}><label className={css.fieldLabel}>名称</label><input className={css.input} value={m.name} onChange={e => patchImageModel(m.id, { name: e.target.value })} /></div>
|
|
227
|
+
<div className={css.field} style={{ flex: 1, minWidth: 150 }}><label className={css.fieldLabel}>模型 id</label><input className={css.input} value={m.model} onChange={e => patchImageModel(m.id, { model: e.target.value })} /></div>
|
|
228
|
+
<div className={css.field} style={{ flex: 1, minWidth: 200 }}><label className={css.fieldLabel}>接口地址</label><input className={css.input} value={m.baseURL} onChange={e => patchImageModel(m.id, { baseURL: e.target.value })} /></div>
|
|
229
|
+
</div>
|
|
230
|
+
<div className={css.field}><label className={css.fieldLabel}>API Key</label><input className={css.input} type="password" value={m.apiKey} onChange={e => patchImageModel(m.id, { apiKey: e.target.value })} /></div>
|
|
231
|
+
<div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap', alignItems: 'center' }}>
|
|
232
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall + (m.enabled ? ' ' + css.buttonPrimary : '')} onClick={() => enableImageModel(m.id)}>{m.enabled ? '✓ 已启用' : '设为启用'}</button>
|
|
233
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} disabled={imageTestState[m.id]?.testing === true} onClick={() => { void testImageModel(m) }}><PlugZap size={13} style={{ verticalAlign: -2 }} /> {imageTestState[m.id]?.testing === true ? '测试中…' : '测试'}</button>
|
|
234
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => removeImageModel(m.id)}><Trash2 size={13} style={{ verticalAlign: -2 }} /> 删除</button>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
))}
|
|
238
|
+
<div className={css.row}><button type="button" className={css.button} onClick={addImageModel}><Plus size={13} style={{ verticalAlign: -2 }} /> 添加生图模型</button></div>
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
|
|
242
|
+
{settingsTab === 'files' && (
|
|
243
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-24)' }}>
|
|
244
|
+
<span className={css.cardTitle}><Folder size={18} style={{ verticalAlign: -3 }} /> 路径与文件</span>
|
|
245
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.outlinePath')}</label><input className={css.input} value={configDraft.outlinePath} onChange={e => setConfigDraft({ ...configDraft, outlinePath: e.target.value })} /></div>
|
|
246
|
+
<div className={css.field}><label className={css.fieldLabel}>{tt('settings.outputDir')}</label><input className={css.input} value={configDraft.outputDir} onChange={e => setConfigDraft({ ...configDraft, outputDir: e.target.value })} /></div>
|
|
247
|
+
<div className={css.row}><button type="button" className={css.button} onClick={() => { void api.openFolder() }}>{tt('settings.openFolder')}</button></div>
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{settingsTab === 'appearance' && (
|
|
252
|
+
<div className={css.card + ' ' + css.settingsCard}>
|
|
253
|
+
<span className={css.cardTitle}><Sparkles size={18} style={{ verticalAlign: -3 }} /> 外观与主题</span>
|
|
254
|
+
<div className={css.field}><label className={css.fieldLabel}>显示模式</label><select className={css.input} value={themeMode} onChange={e => changeTheme(panelTheme, e.target.value as ThemeMode, themeDensity)}><option value="system">跟随系统</option><option value="light">浅色</option><option value="dark">深色</option></select></div>
|
|
255
|
+
<div className={css.field}><label className={css.fieldLabel}>主题风格</label><select className={css.input} value={panelTheme} onChange={e => changeTheme(e.target.value as ThemeName, themeMode, themeDensity)}><option value="liquid">液态玻璃 · 清新绿(默认)</option><option value="neumorph">新拟物 · 柔和浅色</option><option value="macos">macOS · 玻璃(蓝,随外观浅/深)</option><option value="clay">粘土拟态 · 柔和黏土</option></select></div>
|
|
256
|
+
<div className={css.field}><label className={css.fieldLabel}>界面密度</label><select className={css.input} value={themeDensity} onChange={e => changeTheme(panelTheme, themeMode, e.target.value as ThemeDensity)}><option value="comfort">舒适(默认)</option><option value="compact">紧凑</option><option value="spacious">宽松</option></select></div>
|
|
257
|
+
<div className={css.field}><label className={css.fieldLabel}>玻璃透明度(0-100)</label><input className={css.input} type="range" min={0} max={100} value={configDraft.themeOpacity ?? 100} onChange={e => applyOpacity(Number(e.target.value))} /><span className={css.meta}>100=当前主题原样;调低会让玻璃/卡片变透,新拟态、黏土等实色主题能透出背景图。</span></div>
|
|
258
|
+
<div className={css.row} style={{ justifyContent: 'flex-end' }}><button type="button" className={css.button} onClick={resetTheme}><RotateCcw size={14} style={{ verticalAlign: -2 }} /> 恢复默认主题</button></div>
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
|
|
262
|
+
{settingsTab === 'background' && (
|
|
263
|
+
<div className={css.card + ' ' + css.settingsCard} style={{ gap: 'var(--nf-space-12)' }}>
|
|
264
|
+
<span className={css.cardTitle}><Image size={18} style={{ verticalAlign: -3 }} /> 自定义背景</span>
|
|
265
|
+
<span className={css.meta}>填图片 URL 或上传图片作为小说工坊背景(首页 + 书内)。留空 = 使用主题默认背景。</span>
|
|
266
|
+
<div className={css.row} style={{ gap: 'var(--nf-space-8)', flexWrap: 'wrap' }}>
|
|
267
|
+
<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)} />
|
|
268
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => bgFileRef.current?.click()}><Upload size={13} style={{ verticalAlign: -2 }} /> 上传图片</button>
|
|
269
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => applyBackground(undefined)}><X size={13} style={{ verticalAlign: -2 }} /> 清除</button>
|
|
270
|
+
</div>
|
|
271
|
+
<input ref={bgFileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { void onBgFile(e.target.files?.[0]); e.target.value = '' }} />
|
|
272
|
+
{(configDraft.themeBackground ?? '') !== '' && (
|
|
273
|
+
<div className={css.adaptInputCard} style={{ padding: 8 }}>
|
|
274
|
+
<div style={{ width: 180, height: 100, borderRadius: 10, backgroundImage: 'url(' + configDraft.themeBackground + ')', backgroundSize: 'cover', backgroundPosition: 'center' }} />
|
|
275
|
+
<div className={css.meta}>已设置背景(预览)</div>
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
<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>
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
|
|
282
|
+
<div className={css.row}><button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void save() }}>{busy ? '保存中…' : tt('settings.save')}</button></div>
|
|
283
|
+
</div>
|
|
284
|
+
);
|
|
285
|
+
}
|
|
@@ -41,6 +41,8 @@ body[data-ds-dark-theme] .entry {
|
|
|
41
41
|
align-items: center;
|
|
42
42
|
justify-content: center;
|
|
43
43
|
flex-shrink: 0;
|
|
44
|
+
width: 20px;
|
|
45
|
+
height: 20px;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
.entryLabel {
|
|
@@ -658,6 +660,7 @@ body[data-ds-dark-theme] .panel[data-nf-theme='neumorph'] .buttonPrimary {
|
|
|
658
660
|
z-index: 0;
|
|
659
661
|
pointer-events: none;
|
|
660
662
|
background:
|
|
663
|
+
linear-gradient(rgba(0, 0, 0, calc(var(--nf-glass-bg-dim, 0) / 100)), rgba(0, 0, 0, calc(var(--nf-glass-bg-dim, 0) / 100))),
|
|
661
664
|
var(--nf-glass-bg-image, none) center / cover no-repeat,
|
|
662
665
|
radial-gradient(46% 36% at 18% 12%, var(--nf-glass-orb-a), transparent 70%),
|
|
663
666
|
radial-gradient(52% 42% at 88% 22%, var(--nf-glass-orb-b), transparent 72%),
|
|
@@ -981,6 +984,10 @@ body[data-ds-dark-theme] .panel[data-nf-theme='neumorph'] .buttonPrimary {
|
|
|
981
984
|
.navTabIcon {
|
|
982
985
|
flex: none;
|
|
983
986
|
width: 22px;
|
|
987
|
+
height: 22px;
|
|
988
|
+
display: inline-flex;
|
|
989
|
+
align-items: center;
|
|
990
|
+
justify-content: center;
|
|
984
991
|
text-align: center;
|
|
985
992
|
font-size: var(--nf-fs-16, 17px);
|
|
986
993
|
line-height: 1;
|
|
@@ -2079,6 +2086,28 @@ select.input {
|
|
|
2079
2086
|
gap: var(--nf-space-10, 10px);
|
|
2080
2087
|
}
|
|
2081
2088
|
|
|
2089
|
+
/* 按分类分组的资产列表 */
|
|
2090
|
+
.assetGroups {
|
|
2091
|
+
display: flex;
|
|
2092
|
+
flex-direction: column;
|
|
2093
|
+
gap: var(--nf-space-14, 14px);
|
|
2094
|
+
}
|
|
2095
|
+
.assetGroup {
|
|
2096
|
+
display: flex;
|
|
2097
|
+
flex-direction: column;
|
|
2098
|
+
gap: var(--nf-space-10, 10px);
|
|
2099
|
+
}
|
|
2100
|
+
.assetGroupHeader {
|
|
2101
|
+
font-size: 14px;
|
|
2102
|
+
font-weight: 600;
|
|
2103
|
+
color: var(--nf-accent);
|
|
2104
|
+
padding-bottom: 2px;
|
|
2105
|
+
border-bottom: 1px solid var(--nf-glass-border);
|
|
2106
|
+
display: flex;
|
|
2107
|
+
align-items: center;
|
|
2108
|
+
gap: 8px;
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2082
2111
|
.assetStat {
|
|
2083
2112
|
border: 1px solid var(--nf-glass-border);
|
|
2084
2113
|
border-radius: var(--nf-radius-14, 14px);
|
|
@@ -2498,6 +2527,279 @@ select.input {
|
|
|
2498
2527
|
gap: var(--nf-container-gap, 14px);
|
|
2499
2528
|
}
|
|
2500
2529
|
|
|
2530
|
+
/* ---- 作者级首页(书架左侧导航 + 内容区) ---- */
|
|
2531
|
+
.authorHome {
|
|
2532
|
+
display: flex;
|
|
2533
|
+
flex: 1;
|
|
2534
|
+
min-height: 0;
|
|
2535
|
+
overflow: hidden;
|
|
2536
|
+
position: relative;
|
|
2537
|
+
z-index: 1;
|
|
2538
|
+
}
|
|
2539
|
+
.authorRail {
|
|
2540
|
+
width: 180px;
|
|
2541
|
+
flex-shrink: 0;
|
|
2542
|
+
display: flex;
|
|
2543
|
+
flex-direction: column;
|
|
2544
|
+
gap: 4px;
|
|
2545
|
+
padding: 14px 10px;
|
|
2546
|
+
border-right: 1px solid var(--nf-glass-border);
|
|
2547
|
+
background: var(--nf-glass-face);
|
|
2548
|
+
}
|
|
2549
|
+
.authorRailTitle {
|
|
2550
|
+
display: flex;
|
|
2551
|
+
align-items: center;
|
|
2552
|
+
gap: 8px;
|
|
2553
|
+
font-weight: 600;
|
|
2554
|
+
padding: 4px 8px 12px;
|
|
2555
|
+
color: var(--nf-accent);
|
|
2556
|
+
}
|
|
2557
|
+
.authorRailBtn {
|
|
2558
|
+
display: flex;
|
|
2559
|
+
align-items: center;
|
|
2560
|
+
gap: 10px;
|
|
2561
|
+
padding: 8px 12px;
|
|
2562
|
+
border-radius: 10px;
|
|
2563
|
+
background: transparent;
|
|
2564
|
+
border: 1px solid transparent;
|
|
2565
|
+
color: var(--nf-text);
|
|
2566
|
+
cursor: pointer;
|
|
2567
|
+
text-align: left;
|
|
2568
|
+
width: 100%;
|
|
2569
|
+
font-size: 14px;
|
|
2570
|
+
}
|
|
2571
|
+
.authorRailBtn:hover { background: color-mix(in srgb, var(--nf-accent) 12%, transparent); color: var(--nf-text); }
|
|
2572
|
+
.authorRailBtnActive {
|
|
2573
|
+
background: color-mix(in srgb, var(--nf-accent) 16%, var(--nf-glass-face));
|
|
2574
|
+
border-color: var(--nf-accent);
|
|
2575
|
+
color: var(--nf-accent);
|
|
2576
|
+
font-weight: 600;
|
|
2577
|
+
box-shadow: inset 0 0 0 1px var(--nf-accent);
|
|
2578
|
+
}
|
|
2579
|
+
.authorRailAbout { padding: 8px 12px; }
|
|
2580
|
+
.authorContent { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
2581
|
+
|
|
2582
|
+
/* ---- 作者级页面通用 ---- */
|
|
2583
|
+
.authorPageBody {
|
|
2584
|
+
display: flex;
|
|
2585
|
+
flex-direction: column;
|
|
2586
|
+
gap: 14px;
|
|
2587
|
+
padding: var(--nf-container-pad, 20px 22px);
|
|
2588
|
+
overflow: auto;
|
|
2589
|
+
flex: 1;
|
|
2590
|
+
min-height: 0;
|
|
2591
|
+
}
|
|
2592
|
+
.authorPageHeader {
|
|
2593
|
+
display: flex;
|
|
2594
|
+
align-items: flex-end;
|
|
2595
|
+
justify-content: space-between;
|
|
2596
|
+
gap: 14px;
|
|
2597
|
+
flex-wrap: wrap;
|
|
2598
|
+
}
|
|
2599
|
+
.authorPageHero {
|
|
2600
|
+
display: flex;
|
|
2601
|
+
flex-direction: column;
|
|
2602
|
+
gap: var(--nf-space-12, 12px);
|
|
2603
|
+
padding: var(--nf-card-pad, 20px 22px);
|
|
2604
|
+
border: 1px solid var(--nf-glass-border);
|
|
2605
|
+
border-radius: var(--nf-radius-22, 22px);
|
|
2606
|
+
background: var(--nf-glass-face);
|
|
2607
|
+
box-shadow: var(--nf-shadow), inset 0 1.5px 0 var(--nf-glass-sheen);
|
|
2608
|
+
-webkit-backdrop-filter: blur(26px) saturate(175%);
|
|
2609
|
+
backdrop-filter: blur(26px) saturate(175%);
|
|
2610
|
+
}
|
|
2611
|
+
.noticeError {
|
|
2612
|
+
padding: 10px 14px;
|
|
2613
|
+
border-radius: 10px;
|
|
2614
|
+
background: color-mix(in srgb, var(--nf-error) 14%, transparent);
|
|
2615
|
+
color: var(--nf-error);
|
|
2616
|
+
border: 1px solid var(--nf-error);
|
|
2617
|
+
}
|
|
2618
|
+
.rowEnd { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
2619
|
+
|
|
2620
|
+
/* ---- 作者资产库 ---- */
|
|
2621
|
+
.assetFilterBar { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
2622
|
+
.assetFilterChip {
|
|
2623
|
+
padding: 4px 10px;
|
|
2624
|
+
border-radius: 999px;
|
|
2625
|
+
border: 1px solid var(--nf-glass-border);
|
|
2626
|
+
background: transparent;
|
|
2627
|
+
color: var(--nf-text);
|
|
2628
|
+
cursor: pointer;
|
|
2629
|
+
}
|
|
2630
|
+
.assetFilterChipActive { background: var(--nf-accent); color: #fff; border-color: transparent; }
|
|
2631
|
+
.assetEditor {
|
|
2632
|
+
display: flex;
|
|
2633
|
+
flex-direction: column;
|
|
2634
|
+
gap: 12px;
|
|
2635
|
+
padding: 16px;
|
|
2636
|
+
border: 1px solid var(--nf-glass-border);
|
|
2637
|
+
border-radius: 14px;
|
|
2638
|
+
background: var(--nf-glass-face);
|
|
2639
|
+
}
|
|
2640
|
+
.assetEditorRow { display: flex; gap: 12px; flex-wrap: wrap; }
|
|
2641
|
+
.assetLabel {
|
|
2642
|
+
display: flex;
|
|
2643
|
+
flex-direction: column;
|
|
2644
|
+
gap: 4px;
|
|
2645
|
+
font-size: 12px;
|
|
2646
|
+
color: var(--nf-muted);
|
|
2647
|
+
flex: 1;
|
|
2648
|
+
min-width: 180px;
|
|
2649
|
+
}
|
|
2650
|
+
.assetTextarea { min-height: 160px; resize: vertical; font-family: inherit; }
|
|
2651
|
+
.assetGrid {
|
|
2652
|
+
display: grid;
|
|
2653
|
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
2654
|
+
gap: 14px;
|
|
2655
|
+
}
|
|
2656
|
+
.assetCard {
|
|
2657
|
+
display: flex;
|
|
2658
|
+
flex-direction: column;
|
|
2659
|
+
gap: 8px;
|
|
2660
|
+
padding: 14px;
|
|
2661
|
+
border: 1px solid var(--nf-glass-border);
|
|
2662
|
+
border-radius: 14px;
|
|
2663
|
+
background: var(--nf-glass-face);
|
|
2664
|
+
box-shadow: var(--nf-shadow), inset 0 1px 0 var(--nf-glass-sheen);
|
|
2665
|
+
}
|
|
2666
|
+
.assetCardTop { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
2667
|
+
.assetKind { font-size: 12px; font-weight: 600; }
|
|
2668
|
+
.assetCardName { font-weight: 600; }
|
|
2669
|
+
.assetCardContent { white-space: pre-wrap; font-size: 13px; color: var(--nf-text); max-height: 150px; overflow: auto; }
|
|
2670
|
+
.assetCardFooter { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
|
2671
|
+
.iconBtn {
|
|
2672
|
+
display: inline-flex;
|
|
2673
|
+
align-items: center;
|
|
2674
|
+
justify-content: center;
|
|
2675
|
+
padding: 4px;
|
|
2676
|
+
border: 1px solid transparent;
|
|
2677
|
+
background: transparent;
|
|
2678
|
+
border-radius: 8px;
|
|
2679
|
+
color: var(--nf-text);
|
|
2680
|
+
cursor: pointer;
|
|
2681
|
+
}
|
|
2682
|
+
.iconBtn:hover { background: rgba(0,0,0,0.06); }
|
|
2683
|
+
|
|
2684
|
+
/* ---- 改编模式 ---- */
|
|
2685
|
+
.adaptInputCard {
|
|
2686
|
+
display: flex;
|
|
2687
|
+
flex-direction: column;
|
|
2688
|
+
gap: 12px;
|
|
2689
|
+
padding: 16px;
|
|
2690
|
+
border: 1px solid var(--nf-glass-border);
|
|
2691
|
+
border-radius: 14px;
|
|
2692
|
+
background: var(--nf-glass-face);
|
|
2693
|
+
}
|
|
2694
|
+
.adaptTextarea { min-height: 220px; resize: vertical; }
|
|
2695
|
+
.adaptResult { display: flex; flex-direction: column; gap: 14px; }
|
|
2696
|
+
.adaptOutline {
|
|
2697
|
+
padding: 10px 14px;
|
|
2698
|
+
border-radius: 12px;
|
|
2699
|
+
background: var(--nf-glass-face);
|
|
2700
|
+
border: 1px solid var(--nf-glass-border);
|
|
2701
|
+
}
|
|
2702
|
+
.adaptMatrix {
|
|
2703
|
+
display: grid;
|
|
2704
|
+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
2705
|
+
gap: 12px;
|
|
2706
|
+
}
|
|
2707
|
+
.dimCard {
|
|
2708
|
+
display: flex;
|
|
2709
|
+
flex-direction: column;
|
|
2710
|
+
gap: 8px;
|
|
2711
|
+
padding: 14px;
|
|
2712
|
+
border: 1px solid var(--nf-glass-border);
|
|
2713
|
+
border-radius: 14px;
|
|
2714
|
+
background: var(--nf-glass-face);
|
|
2715
|
+
}
|
|
2716
|
+
.dimCardHead { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
2717
|
+
.dimTitle { font-weight: 600; }
|
|
2718
|
+
.dimCurrent { font-size: 13px; white-space: pre-wrap; }
|
|
2719
|
+
.dimCardSelected { border-color: var(--nf-accent); box-shadow: 0 0 0 1px var(--nf-accent); }
|
|
2720
|
+
.dimEdit { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
2721
|
+
.adaptOutlinePre { white-space: pre-wrap; font-size: 13px; max-height: 260px; overflow: auto; margin: 0; font-family: inherit; }
|
|
2722
|
+
.adaptProposal {
|
|
2723
|
+
display: flex;
|
|
2724
|
+
flex-direction: column;
|
|
2725
|
+
gap: 8px;
|
|
2726
|
+
padding: 14px;
|
|
2727
|
+
border: 1px solid var(--nf-glass-border);
|
|
2728
|
+
border-radius: 14px;
|
|
2729
|
+
background: var(--nf-glass-face);
|
|
2730
|
+
}
|
|
2731
|
+
.adaptTable { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
2732
|
+
.adaptTable th, .adaptTable td { border: 1px solid var(--nf-glass-border); padding: 6px 8px; text-align: left; }
|
|
2733
|
+
.adaptImpacts { display: flex; flex-direction: column; gap: 4px; }
|
|
2734
|
+
.adaptImpact { font-size: 13px; }
|
|
2735
|
+
|
|
2736
|
+
/* ---- 首页 AI 进度 ---- */
|
|
2737
|
+
.progressOverview {
|
|
2738
|
+
display: grid;
|
|
2739
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
2740
|
+
gap: 12px;
|
|
2741
|
+
}
|
|
2742
|
+
.progressOverviewCard {
|
|
2743
|
+
display: flex;
|
|
2744
|
+
flex-direction: column;
|
|
2745
|
+
gap: 4px;
|
|
2746
|
+
padding: 14px;
|
|
2747
|
+
border: 1px solid var(--nf-glass-border);
|
|
2748
|
+
border-radius: 14px;
|
|
2749
|
+
background: var(--nf-glass-face);
|
|
2750
|
+
box-shadow: var(--nf-shadow), inset 0 1px 0 var(--nf-glass-sheen);
|
|
2751
|
+
}
|
|
2752
|
+
.progressOverviewNum {
|
|
2753
|
+
font-size: 24px;
|
|
2754
|
+
font-weight: 700;
|
|
2755
|
+
color: var(--nf-accent);
|
|
2756
|
+
}
|
|
2757
|
+
.progressTotalBar {
|
|
2758
|
+
position: relative;
|
|
2759
|
+
height: 14px;
|
|
2760
|
+
border-radius: 999px;
|
|
2761
|
+
background: var(--nf-glass-face);
|
|
2762
|
+
border: 1px solid var(--nf-glass-border);
|
|
2763
|
+
overflow: hidden;
|
|
2764
|
+
}
|
|
2765
|
+
.progressTotalFill {
|
|
2766
|
+
position: absolute;
|
|
2767
|
+
inset: 0 auto 0 0;
|
|
2768
|
+
height: 100%;
|
|
2769
|
+
background: linear-gradient(90deg, var(--nf-accent), var(--nf-info));
|
|
2770
|
+
border-radius: 999px;
|
|
2771
|
+
}
|
|
2772
|
+
.progressTotalBar .meta {
|
|
2773
|
+
position: absolute;
|
|
2774
|
+
inset: 0;
|
|
2775
|
+
display: flex;
|
|
2776
|
+
align-items: center;
|
|
2777
|
+
justify-content: center;
|
|
2778
|
+
font-size: 11px;
|
|
2779
|
+
font-weight: 600;
|
|
2780
|
+
color: var(--nf-text);
|
|
2781
|
+
}
|
|
2782
|
+
.progressBookList {
|
|
2783
|
+
display: flex;
|
|
2784
|
+
flex-direction: column;
|
|
2785
|
+
gap: 12px;
|
|
2786
|
+
}
|
|
2787
|
+
.progressBook {
|
|
2788
|
+
display: flex;
|
|
2789
|
+
flex-direction: column;
|
|
2790
|
+
gap: 8px;
|
|
2791
|
+
padding: 14px;
|
|
2792
|
+
border: 1px solid var(--nf-glass-border);
|
|
2793
|
+
border-radius: 14px;
|
|
2794
|
+
background: var(--nf-glass-face);
|
|
2795
|
+
}
|
|
2796
|
+
.progressBookActive { border-color: var(--nf-accent); box-shadow: 0 0 0 1px var(--nf-accent); }
|
|
2797
|
+
.progressBookHead { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
|
2798
|
+
.progressBookName { font-weight: 600; }
|
|
2799
|
+
.progressLive { color: var(--nf-accent); font-weight: 600; }
|
|
2800
|
+
.progressBookBar { height: 8px; border-radius: 999px; background: var(--nf-glass-face-hover, rgba(0,0,0,0.05)); overflow: hidden; }
|
|
2801
|
+
.progressBookFill { height: 100%; background: var(--nf-accent); border-radius: 999px; }
|
|
2802
|
+
|
|
2501
2803
|
.shelfHeader {
|
|
2502
2804
|
display: flex;
|
|
2503
2805
|
flex-direction: column;
|
|
@@ -3847,3 +4149,26 @@ select.input {
|
|
|
3847
4149
|
.switchOn .switchKnob {
|
|
3848
4150
|
transform: translateX(18px);
|
|
3849
4151
|
}
|
|
4152
|
+
|
|
4153
|
+
/* ---- 玻璃透明度滑块:实色主题(新拟态/黏土)调低时变透,透出背景图 ---- */
|
|
4154
|
+
/* 默认 --nf-glass-opacity=100%,color-mix 透明 0% → 与主题原值一致,不改动现有效果。 */
|
|
4155
|
+
.panel[data-nf-theme='neumorph']:not([data-nf-mode='dark']),
|
|
4156
|
+
body:not([data-ds-dark-theme]) .panel[data-nf-theme='neumorph'][data-nf-mode='light'] {
|
|
4157
|
+
--nf-glass-face: color-mix(in srgb, #e4e9f2, transparent calc(100% - var(--nf-glass-opacity, 100%)));
|
|
4158
|
+
--nf-bg-raise: color-mix(in srgb, #e4e9f2, transparent calc(100% - var(--nf-glass-opacity, 100%)));
|
|
4159
|
+
}
|
|
4160
|
+
.panel[data-nf-mode='dark'][data-nf-theme='neumorph'],
|
|
4161
|
+
body[data-ds-dark-theme] .panel[data-nf-theme='neumorph']:not([data-nf-mode]) {
|
|
4162
|
+
--nf-glass-face: color-mix(in srgb, #2b2f36, transparent calc(100% - var(--nf-glass-opacity, 100%)));
|
|
4163
|
+
--nf-bg-raise: color-mix(in srgb, #2b2f36, transparent calc(100% - var(--nf-glass-opacity, 100%)));
|
|
4164
|
+
}
|
|
4165
|
+
.panel[data-nf-theme='clay']:not([data-nf-mode='dark']),
|
|
4166
|
+
body:not([data-ds-dark-theme]) .panel[data-nf-theme='clay'][data-nf-mode='light'] {
|
|
4167
|
+
--nf-glass-face: color-mix(in srgb, #efe4d6, transparent calc(100% - var(--nf-glass-opacity, 100%)));
|
|
4168
|
+
--nf-bg-raise: color-mix(in srgb, #efe4d6, transparent calc(100% - var(--nf-glass-opacity, 100%)));
|
|
4169
|
+
}
|
|
4170
|
+
.panel[data-nf-mode='dark'][data-nf-theme='clay'],
|
|
4171
|
+
body[data-ds-dark-theme] .panel[data-nf-theme='clay']:not([data-nf-mode]) {
|
|
4172
|
+
--nf-glass-face: color-mix(in srgb, #37302b, transparent calc(100% - var(--nf-glass-opacity, 100%)));
|
|
4173
|
+
--nf-bg-raise: color-mix(in srgb, #37302b, transparent calc(100% - var(--nf-glass-opacity, 100%)));
|
|
4174
|
+
}
|
|
@@ -11,7 +11,7 @@ import css from './panel/panel.module.css'
|
|
|
11
11
|
export const ENTRY_SELECTOR = '[data-dsh-novelforge-entry]'
|
|
12
12
|
|
|
13
13
|
/** Inline icon: an open book / writing glyph. */
|
|
14
|
-
const ICON = '<svg viewBox="0 0
|
|
14
|
+
const ICON = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.67 19a2 2 0 0 0 1.416-.588l6.154-6.172a6 6 0 0 0-8.49-8.49L5.586 9.914A2 2 0 0 0 5 11.328V18a1 1 0 0 0 1 1z"/><path d="M16 8 2 22"/><path d="M17.5 15H9"/></svg>'
|
|
15
15
|
|
|
16
16
|
/** Find the sidebar shell root element. */
|
|
17
17
|
function sidebarRoot(): HTMLElement | undefined {
|