@waterwx/dsh-novel-forge 1.7.2 → 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/README.md +1 -1
- package/lib/client.js +3788 -600
- package/lib/client.js.map +1 -1
- package/lib/index.js +1001 -83
- 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 +206 -10
- package/lib/types/shot-language.d.ts +46 -0
- package/lib/types/story-beat-language.d.ts +35 -0
- package/package.json +4 -2
- package/skills/chapter-batch/SKILL.md +81 -0
- 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/CreateBookView.tsx +4 -0
- package/src/client/panel/ImportModal.tsx +13 -3
- package/src/client/panel/NovelPanel.tsx +48 -16
- package/src/client/panel/ProgressHomeView.tsx +87 -0
- package/src/client/panel/RunPanel.tsx +1 -1
- package/src/client/panel/SceneLibrary.tsx +1 -1
- package/src/client/panel/SettingsView.tsx +285 -0
- package/src/client/panel/ShelfView.tsx +2 -2
- package/src/client/panel/StoryboardTab.tsx +42 -10
- 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 +256 -14
- package/src/index.ts +21 -0
- package/src/protocol.ts +218 -10
- package/src/routes.ts +204 -7
- package/src/run.ts +5 -4
- package/src/shot-language.ts +181 -0
- package/src/story-beat-language.ts +130 -0
- package/lib/types/client/panel/RoleVisualPanel.d.ts +0 -15
|
@@ -13,6 +13,7 @@ import { BarChart3, Book, BookMarked, BookOpen, Brain, Clapperboard, Factory, Fi
|
|
|
13
13
|
import { AssistantTab } from './AssistantTab.tsx'
|
|
14
14
|
import { AssetsTab } from './AssetsTab.tsx'
|
|
15
15
|
import { ShelfView } from './ShelfView.tsx'
|
|
16
|
+
import { AuthorHome } from './AuthorHome.tsx'
|
|
16
17
|
import { ReaderView } from './ReaderView.tsx'
|
|
17
18
|
import { RunPanel } from './RunPanel.tsx'
|
|
18
19
|
import { CreateBookView } from './CreateBookView.tsx'
|
|
@@ -73,36 +74,36 @@ const NAV_GROUPS: ReadonlyArray<{ id: string; label: string; items: ReadonlyArra
|
|
|
73
74
|
id: 'create',
|
|
74
75
|
label: '创作',
|
|
75
76
|
items: [
|
|
76
|
-
{ id: 'workflow', label: tt('tab.workflow'), icon: <Wrench size={
|
|
77
|
-
{ id: 'overview', label: tt('tab.overview'), icon: <FileText size={
|
|
78
|
-
{ id: 'blurb', label: '简介 / 封面', icon: <BookOpen size={
|
|
79
|
-
{ id: 'plan', label: tt('tab.plan'), icon: <BookMarked size={
|
|
80
|
-
{ id: 'plotlines', label: '长线管理', icon: <ScrollText size={
|
|
81
|
-
{ id: 'book', label: '本书设定', icon: <Library size={
|
|
77
|
+
{ id: 'workflow', label: tt('tab.workflow'), icon: <Wrench size={18} /> },
|
|
78
|
+
{ id: 'overview', label: tt('tab.overview'), icon: <FileText size={18} /> },
|
|
79
|
+
{ id: 'blurb', label: '简介 / 封面', icon: <BookOpen size={18} /> },
|
|
80
|
+
{ id: 'plan', label: tt('tab.plan'), icon: <BookMarked size={18} /> },
|
|
81
|
+
{ id: 'plotlines', label: '长线管理', icon: <ScrollText size={18} /> },
|
|
82
|
+
{ id: 'book', label: '本书设定', icon: <Library size={18} /> },
|
|
82
83
|
],
|
|
83
84
|
},
|
|
84
85
|
{
|
|
85
86
|
id: 'tools',
|
|
86
87
|
label: '工具',
|
|
87
88
|
items: [
|
|
88
|
-
{ id: 'assistant', label: tt('tab.assistant'), icon: <MessageSquare size={
|
|
89
|
-
{ id: 'progress', label: 'AI 进度', icon: <BarChart3 size={
|
|
90
|
-
{ id: 'breakdown', label: '拆书分析', icon: <Search size={
|
|
91
|
-
{ id: 'manhua', label: '漫剧工作台', icon: <Clapperboard size={
|
|
92
|
-
{ id: 'run', label: '生产单', icon: <Factory size={
|
|
89
|
+
{ id: 'assistant', label: tt('tab.assistant'), icon: <MessageSquare size={18} /> },
|
|
90
|
+
{ id: 'progress', label: 'AI 进度', icon: <BarChart3 size={18} /> },
|
|
91
|
+
{ id: 'breakdown', label: '拆书分析', icon: <Search size={18} /> },
|
|
92
|
+
{ id: 'manhua', label: '漫剧工作台', icon: <Clapperboard size={18} /> },
|
|
93
|
+
{ id: 'run', label: '生产单', icon: <Factory size={18} /> },
|
|
93
94
|
],
|
|
94
95
|
},
|
|
95
96
|
{
|
|
96
97
|
id: 'assets',
|
|
97
98
|
label: '资产',
|
|
98
99
|
items: [
|
|
99
|
-
{ id: 'assets', label: '创作资产', icon: <Wrench size={
|
|
100
|
+
{ id: 'assets', label: '创作资产', icon: <Wrench size={18} /> },
|
|
100
101
|
],
|
|
101
102
|
},
|
|
102
103
|
]
|
|
103
104
|
|
|
104
105
|
/** Settings tab — pinned to the bottom of the nav rail. */
|
|
105
|
-
const SETTINGS_TAB: { id: NovelTab; label: string; icon: ReactElement } = { id: 'settings', label: tt('tab.settings'), icon: <Settings size={
|
|
106
|
+
const SETTINGS_TAB: { id: NovelTab; label: string; icon: ReactElement } = { id: 'settings', label: tt('tab.settings'), icon: <Settings size={18} /> }
|
|
106
107
|
|
|
107
108
|
/** Common DeepSeek model presets shown in settings; users can also type any model id. */
|
|
108
109
|
const MODEL_PRESETS = ['deepseek-v4-flash', 'deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] as const
|
|
@@ -138,6 +139,7 @@ function statusBadge(chapter: ChapterPlan): { cls: string; label: string } {
|
|
|
138
139
|
case 'approved': return { cls: css.badgeDone, label: tt('plan.approved') }
|
|
139
140
|
case 'rejected': return { cls: css.badgeRejected, label: tt('plan.rejected') }
|
|
140
141
|
case 'error': return { cls: css.badgeError, label: tt('plan.error') }
|
|
142
|
+
default: return { cls: css.badgeError, label: tt('plan.error') }
|
|
141
143
|
}
|
|
142
144
|
}
|
|
143
145
|
|
|
@@ -315,6 +317,12 @@ function DiffList({ original, draft, fontSize }: { original: string; draft: stri
|
|
|
315
317
|
export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
316
318
|
const [activeTab, setActiveTab] = useState<NovelTab>('workflow')
|
|
317
319
|
const [config, setConfig] = useState<NovelConfig | null>(null)
|
|
320
|
+
/** 自定义背景(URL / dataURL),覆盖主题默认背景。 */
|
|
321
|
+
const [themeBg, setThemeBg] = useState<string | undefined>(undefined)
|
|
322
|
+
/** 自定义背景遮罩/模糊强度 0-80。 */
|
|
323
|
+
const [themeBgBlur, setThemeBgBlur] = useState(0)
|
|
324
|
+
/** 玻璃透明度 0-100(100=主题原样)。 */
|
|
325
|
+
const [themeOpacity, setThemeOpacity] = useState(100)
|
|
318
326
|
const [project, setProject] = useState<ProjectState | null>(null)
|
|
319
327
|
const [generatedFiles, setGeneratedFiles] = useState<string[]>([])
|
|
320
328
|
const [outlineText, setOutlineText] = useState('')
|
|
@@ -747,6 +755,17 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
747
755
|
}
|
|
748
756
|
}, [api, refresh, refreshShelf])
|
|
749
757
|
|
|
758
|
+
/** 首页「设置」与书内设置同入口:激活当前/首选书并跳到设置页。 */
|
|
759
|
+
const openSettingsFromHome = async (): Promise<void> => {
|
|
760
|
+
const targetId = shelf?.activeBookId ?? shelf?.books[0]?.id ?? null
|
|
761
|
+
if (targetId === null || targetId === undefined) {
|
|
762
|
+
setError('请先在书架开一本书;设置页以书籍工作台为入口')
|
|
763
|
+
return
|
|
764
|
+
}
|
|
765
|
+
await activateBook(targetId, 'workspace')
|
|
766
|
+
setActiveTab('settings')
|
|
767
|
+
}
|
|
768
|
+
|
|
750
769
|
/** Handle a docx file (pick or drag): parse locally, save outline. */
|
|
751
770
|
const handleDocxFile = useCallback(async (file: File) => {
|
|
752
771
|
setBusy(true)
|
|
@@ -2363,12 +2382,20 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2363
2382
|
n += assets?.antiAiRules?.length ?? 0
|
|
2364
2383
|
return n
|
|
2365
2384
|
})()
|
|
2385
|
+
/** 自定义背景:优先用户显式选择(themeBg),否则用 config 里的持久化值。 */
|
|
2386
|
+
const effectiveBg = themeBg !== undefined && themeBg !== '' ? themeBg : (config?.themeBackground ?? '')
|
|
2387
|
+
const effectiveBgBlur = themeBgBlur > 0 ? themeBgBlur : (config?.themeBackgroundBlur ?? 0)
|
|
2388
|
+
const effectiveOpacity = themeOpacity > 0 ? themeOpacity : (config?.themeOpacity ?? 100)
|
|
2389
|
+
const panelBgStyle: Record<string, string> = {}
|
|
2390
|
+
if (effectiveBg !== '') panelBgStyle['--nf-glass-bg-image' as string] = 'url(' + effectiveBg + ')'
|
|
2391
|
+
if (effectiveBgBlur > 0) panelBgStyle['--nf-glass-bg-dim' as string] = String(effectiveBgBlur)
|
|
2392
|
+
panelBgStyle['--nf-glass-opacity' as string] = String(effectiveOpacity) + '%'
|
|
2366
2393
|
|
|
2367
2394
|
return (
|
|
2368
|
-
<div className={css.panel} data-nf-theme={panelTheme} data-nf-mode={themeMode === 'system' ? undefined : themeMode} data-nf-density={themeDensity}>
|
|
2395
|
+
<div className={css.panel} data-nf-theme={panelTheme} data-nf-mode={themeMode === 'system' ? undefined : themeMode} data-nf-density={themeDensity} style={panelBgStyle}>
|
|
2369
2396
|
{viewMode === 'shelf' ? (
|
|
2370
|
-
/*
|
|
2371
|
-
<
|
|
2397
|
+
/* 书架首页:作者级左侧导航 + 书架/改编/资产库/设置 */
|
|
2398
|
+
<AuthorHome
|
|
2372
2399
|
api={api}
|
|
2373
2400
|
shelf={shelf ?? { books: [], activeBookId: null }}
|
|
2374
2401
|
onOpenBook={async (id) => {
|
|
@@ -2379,6 +2406,11 @@ export function NovelPanel({ controller, api }: NovelPanelProps) {
|
|
|
2379
2406
|
}}
|
|
2380
2407
|
onAddBook={() => { setViewMode('create') }}
|
|
2381
2408
|
onImportBook={() => { setShowImport(true) }}
|
|
2409
|
+
onOpenSettings={() => { void openSettingsFromHome() }}
|
|
2410
|
+
onTheme={(t, m, d) => { changePanelTheme(t); changeThemeMode(m); changeThemeDensity(d) }}
|
|
2411
|
+
onBackground={(bg, blur) => { setThemeBg(bg); setThemeBgBlur(blur) }}
|
|
2412
|
+
onOpacity={(n) => { setThemeOpacity(n) }}
|
|
2413
|
+
adaptEnabled={config?.enableAdaptMode === true}
|
|
2382
2414
|
/>
|
|
2383
2415
|
) : viewMode === 'create' ? (
|
|
2384
2416
|
/* 开书向导:独立页面 */
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 首页 AI 进度视图:书架级聚合进度——总览 + 每本书进度条 + 进行中高亮。
|
|
3
|
+
* 读取 BookshelfSnapshot,不依赖打开某本书。
|
|
4
|
+
*/
|
|
5
|
+
import { useMemo } from 'react'
|
|
6
|
+
import { BarChart3, BookOpen, PenLine, CheckCircle2, Clock, RefreshCw } from 'lucide-react'
|
|
7
|
+
import type { BookshelfSnapshot } from '../../protocol.ts'
|
|
8
|
+
import css from './panel.module.css'
|
|
9
|
+
|
|
10
|
+
function relativeTime(iso: string): string {
|
|
11
|
+
const t = new Date(iso).getTime()
|
|
12
|
+
if (!Number.isFinite(t)) return '—'
|
|
13
|
+
const diff = Date.now() - t
|
|
14
|
+
if (diff < 60_000) return '刚刚'
|
|
15
|
+
if (diff < 3_600_000) return Math.floor(diff / 60_000) + ' 分钟前'
|
|
16
|
+
if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + ' 小时前'
|
|
17
|
+
if (diff < 7 * 86_400_000) return Math.floor(diff / 86_400_000) + ' 天前'
|
|
18
|
+
return new Date(t).toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' })
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function bookStatus(book: BookshelfSnapshot['books'][number]): 'none' | 'done' | 'active' {
|
|
22
|
+
if (!book.hasProject) return 'none'
|
|
23
|
+
return book.total > 0 && book.done >= book.total ? 'done' : 'active'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ProgressHomeView({ shelf, onOpenBook }: {
|
|
27
|
+
shelf: BookshelfSnapshot;
|
|
28
|
+
onOpenBook: (id: string) => void;
|
|
29
|
+
}) {
|
|
30
|
+
const stats = useMemo(() => {
|
|
31
|
+
let active = 0, done = 0, none = 0, chapters = 0, written = 0;
|
|
32
|
+
for (const b of shelf.books) {
|
|
33
|
+
const s = bookStatus(b)
|
|
34
|
+
if (s === 'none') none++; else if (s === 'done') done++; else active++;
|
|
35
|
+
chapters += b.total;
|
|
36
|
+
written += b.done;
|
|
37
|
+
}
|
|
38
|
+
return { active, done, none, chapters, written }
|
|
39
|
+
}, [shelf.books]);
|
|
40
|
+
|
|
41
|
+
const progressPct = stats.chapters > 0 ? Math.round((stats.written / stats.chapters) * 100) : 0;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className={css.authorPageBody}>
|
|
45
|
+
<div className={css.authorPageHeader}><h2 className={css.panelTitle} style={{ margin: 0 }}>📊 AI 进度</h2><span className={css.meta}>跨书架聚合进度与最近活动</span></div>
|
|
46
|
+
|
|
47
|
+
<div className={css.progressOverview}>
|
|
48
|
+
<div className={css.progressOverviewCard}><span className={css.progressOverviewNum}>{shelf.books.length}</span><span className={css.meta}>总书数</span></div>
|
|
49
|
+
<div className={css.progressOverviewCard}><span className={css.progressOverviewNum}>{stats.active}</span><span className={css.meta}>进行中</span></div>
|
|
50
|
+
<div className={css.progressOverviewCard}><span className={css.progressOverviewNum}>{stats.done}</span><span className={css.meta}>已完结</span></div>
|
|
51
|
+
<div className={css.progressOverviewCard}><span className={css.progressOverviewNum}>{stats.written}/{stats.chapters}</span><span className={css.meta}>已完成章节</span></div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div className={css.progressTotalBar}><div className={css.progressTotalFill} style={{ width: progressPct + '%' }} /><span className={css.meta}>全书架整体进度 {progressPct}%</span></div>
|
|
55
|
+
|
|
56
|
+
{shelf.books.length === 0 ? (
|
|
57
|
+
<div className={css.shelfEmpty} style={{ minHeight: 160 }}>
|
|
58
|
+
<span className={css.shelfEmptyIcon}><BarChart3 size={30} /></span>
|
|
59
|
+
<span className={css.shelfEmptyTitle}>还没有书</span>
|
|
60
|
+
<span className={css.meta}>去书架开一本书或导入全文后,这里会显示进度。</span>
|
|
61
|
+
</div>
|
|
62
|
+
) : (
|
|
63
|
+
<div className={css.progressBookList}>
|
|
64
|
+
{shelf.books.map(book => {
|
|
65
|
+
const s = bookStatus(book)
|
|
66
|
+
const ratio = book.total > 0 ? Math.min(book.done / book.total, 1) : 0;
|
|
67
|
+
const statusLabel = s === 'none' ? '未开书' : s === 'done' ? '已完结' : '进行中';
|
|
68
|
+
return (
|
|
69
|
+
<div key={book.id} className={css.progressBook + (s === 'active' ? ' ' + css.progressBookActive : '')}>
|
|
70
|
+
<div className={css.progressBookHead}>
|
|
71
|
+
<span className={css.progressBookName} title={book.bookName}>{book.bookName}</span>
|
|
72
|
+
<span className={css.badge + (s === 'done' ? ' ' + css.badgeDone : s === 'active' ? ' ' + css.badgeWritten : ' ' + css.badgePending)}>{statusLabel}</span>
|
|
73
|
+
{s === 'active' && <span className={css.meta + ' ' + css.progressLive}><RefreshCw size={12} style={{ verticalAlign: -2 }} /> 创作中</span>}
|
|
74
|
+
</div>
|
|
75
|
+
<div className={css.progressBookBar}><div className={css.progressBookFill} style={{ width: Math.round(ratio * 100) + '%' }} /></div>
|
|
76
|
+
<div className={css.rowEnd} style={{ justifyContent: 'space-between' }}>
|
|
77
|
+
<span className={css.meta}>{book.total > 0 ? '已完成 ' + book.done + ' / ' + book.total + ' 章' : '尚未规划章节'} · 更新于 {relativeTime(book.updatedAt)}</span>
|
|
78
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => onOpenBook(book.id)}><PenLine size={12} style={{ verticalAlign: -2 }} /> 进入</button>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
})}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -169,7 +169,7 @@ export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters:
|
|
|
169
169
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
|
|
170
170
|
<div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
|
|
171
171
|
<span className={css.meta}>范围:第 {run.startNo} - {run.endNo} 章 · 当前:第 {run.currentNo} 章</span>
|
|
172
|
-
<span className={css.meta}>新生成 {run.stats
|
|
172
|
+
<span className={css.meta}>新生成 {run.stats?.generated ?? 0} · 修订通过 {run.stats?.revised ?? 0} · 豁免 {run.stats?.exempted ?? 0} · 重生成 {run.stats?.regenerated ?? 0} · 失败 {run.stats?.error ?? 0}</span>
|
|
173
173
|
</div>
|
|
174
174
|
<div className={css.bigProgressBar}>
|
|
175
175
|
<div className={css.bigProgressBarFill} style={{ width: `${Math.round(ratio * 100)}%` }} />
|
|
@@ -266,7 +266,7 @@ export function SceneLibrary({
|
|
|
266
266
|
{scenes.map(s => {
|
|
267
267
|
const cover = (s.gallery ?? [])[0]
|
|
268
268
|
return (
|
|
269
|
-
<div key={s.name} onClick={() => { openDetail(s.name) }} style={{ cursor: 'pointer', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-12)', overflow: 'hidden', background: 'var(--nf-bg-inset)', display: 'flex', flexDirection: 'column' }}>
|
|
269
|
+
<div key={s.name} onClick={() => { openDetail(s.name) }} role="button" tabIndex={0} aria-label={`打开场景 ${s.name}`} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDetail(s.name) } }} style={{ cursor: 'pointer', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-12)', overflow: 'hidden', background: 'var(--nf-bg-inset)', display: 'flex', flexDirection: 'column' }}>
|
|
270
270
|
<div style={{ height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--nf-bg-inset)', borderBottom: '1px solid var(--nf-border)', position: 'relative' }}>
|
|
271
271
|
{cover !== undefined
|
|
272
272
|
? <img src={cover.dataUrl} alt={s.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
@@ -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
|
+
}
|
|
@@ -223,14 +223,14 @@ export function ShelfView({
|
|
|
223
223
|
))}
|
|
224
224
|
|
|
225
225
|
{/* 开书入口:进入独立向导页 */}
|
|
226
|
-
<div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onAddBook}>
|
|
226
|
+
<div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onAddBook} role="button" tabIndex={0} aria-label="开一本新书" onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onAddBook() } }}>
|
|
227
227
|
<div className={css.bookAddIcon}><Plus size={28} /></div>
|
|
228
228
|
<span>开一本新书</span>
|
|
229
229
|
<span className={css.meta}>书名 + 大纲,开书即建项目</span>
|
|
230
230
|
</div>
|
|
231
231
|
|
|
232
232
|
{/* 导入入口:已有项目目录 / txt/md 全本 */}
|
|
233
|
-
<div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onImportBook}>
|
|
233
|
+
<div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onImportBook} role="button" tabIndex={0} aria-label="导入已有小说" onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onImportBook() } }}>
|
|
234
234
|
<div className={css.bookAddIcon}><Download size={28} /></div>
|
|
235
235
|
<span>导入已有小说</span>
|
|
236
236
|
<span className={css.meta}>项目目录或 txt/md 全本</span>
|