@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
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 改编模式:P0 分析 → P1 改编方案(勾选/填新值→映射表/规则/影响) → P2 术语替换执行。
|
|
3
|
+
*/
|
|
4
|
+
import { useRef, useState } from 'react'
|
|
5
|
+
import { FileText, Wand2, Upload, Download, ListChecks, Play } from 'lucide-react'
|
|
6
|
+
import type { NovelApi } from '../api.ts'
|
|
7
|
+
import type { AdaptAnalyzeResponse, AdaptationProposal, AdaptExecuteResponse } from '../../protocol.ts'
|
|
8
|
+
import { readFileTextSmart } from '../text.ts'
|
|
9
|
+
import css from './panel.module.css'
|
|
10
|
+
|
|
11
|
+
const MUTABILITY_LABEL: Record<string, string> = {
|
|
12
|
+
locked: '🔒 建议保留', big: '🟡 可改影响大', small: '🟢 可改影响小', free: '🟣 可自由改', visual: '📦 仅视觉包装',
|
|
13
|
+
};
|
|
14
|
+
const MUTABILITY_CLS: Record<string, string> = {
|
|
15
|
+
locked: 'var(--nf-muted)', big: 'var(--nf-warn)', small: 'var(--nf-info)', free: 'var(--nf-accent)', visual: 'var(--nf-muted)',
|
|
16
|
+
};
|
|
17
|
+
const RISK_LABEL: Record<string, string> = { high: '高风险', medium: '中风险', low: '低风险' };
|
|
18
|
+
|
|
19
|
+
interface EditState { enabled: boolean; target: string }
|
|
20
|
+
|
|
21
|
+
export function AdaptModeView({ api }: { api: NovelApi }) {
|
|
22
|
+
const [text, setText] = useState('');
|
|
23
|
+
const [result, setResult] = useState<AdaptAnalyzeResponse | null>(null);
|
|
24
|
+
const [edits, setEdits] = useState<Record<string, EditState>>({});
|
|
25
|
+
const [proposal, setProposal] = useState<AdaptationProposal | null>(null);
|
|
26
|
+
const [executed, setExecuted] = useState<AdaptExecuteResponse | null>(null);
|
|
27
|
+
const [busy, setBusy] = useState(false);
|
|
28
|
+
const [busyLabel, setBusyLabel] = useState('');
|
|
29
|
+
const [error, setError] = useState('');
|
|
30
|
+
const fileRef = useRef<HTMLInputElement | null>(null);
|
|
31
|
+
|
|
32
|
+
const onFile = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
|
33
|
+
const file = e.target.files?.[0];
|
|
34
|
+
if (file === undefined) return;
|
|
35
|
+
try { setText(await readFileTextSmart(file)) } catch (err) { setError((err as Error).message) }
|
|
36
|
+
e.target.value = '';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const analyze = async (): Promise<void> => {
|
|
40
|
+
if (text.trim().length < 200) { setError('请粘贴或上传完整小说全文(至少 200 字符)'); return }
|
|
41
|
+
setBusy(true); setBusyLabel('分析中…'); setError(''); setProposal(null); setExecuted(null);
|
|
42
|
+
try {
|
|
43
|
+
const res = await api.adaptAnalyze(text);
|
|
44
|
+
setResult(res);
|
|
45
|
+
const next: Record<string, EditState> = {};
|
|
46
|
+
for (const d of res.dimensions) {
|
|
47
|
+
next[d.key] = { enabled: d.mutability !== 'locked', target: d.candidates?.[0] ?? '' };
|
|
48
|
+
}
|
|
49
|
+
setEdits(next);
|
|
50
|
+
} catch (err) { setError((err as Error).message) } finally { setBusy(false); setBusyLabel('') }
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const setEdit = (key: string, patch: Partial<EditState>): void => {
|
|
54
|
+
setEdits(prev => ({ ...prev, [key]: { ...prev[key], ...patch } }));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const selectedDims = result === null ? [] : result.dimensions.filter(d => {
|
|
58
|
+
const e = edits[d.key];
|
|
59
|
+
return e?.enabled === true && (e.target ?? '').trim() !== '';
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const buildSelections = () => selectedDims.map(d => ({ key: d.key, title: d.title, current: d.current, target: (edits[d.key]?.target ?? '').trim(), mutability: d.mutability }));
|
|
63
|
+
|
|
64
|
+
const propose = async (): Promise<void> => {
|
|
65
|
+
if (result === null || text.trim().length < 200) { setError('请先分析'); return }
|
|
66
|
+
const selections = buildSelections();
|
|
67
|
+
if (selections.length === 0) { setError('请至少选择一条要改的维度并填写新值'); return }
|
|
68
|
+
setBusy(true); setBusyLabel('生成改编方案…'); setError(''); setExecuted(null);
|
|
69
|
+
try {
|
|
70
|
+
const res = await api.adaptPropose({ text, selections, dimensions: result.dimensions });
|
|
71
|
+
setProposal(res.proposal);
|
|
72
|
+
} catch (err) { setError((err as Error).message) } finally { setBusy(false); setBusyLabel('') }
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const execute = async (): Promise<void> => {
|
|
76
|
+
if (proposal === null || proposal.mappings.length === 0) { setError('请先生成改编方案'); return }
|
|
77
|
+
setBusy(true); setBusyLabel('执行术语替换…'); setError('');
|
|
78
|
+
try {
|
|
79
|
+
const res = await api.adaptExecute({ text, mappings: proposal.mappings, mode: 'replace' });
|
|
80
|
+
setExecuted(res);
|
|
81
|
+
} catch (err) { setError((err as Error).message) } finally { setBusy(false); setBusyLabel('') }
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const downloadAdapted = (): void => {
|
|
85
|
+
if (executed === null) return;
|
|
86
|
+
const blob = new Blob([executed.adaptedText], { type: 'text/markdown;charset=utf-8' });
|
|
87
|
+
const url = URL.createObjectURL(blob);
|
|
88
|
+
const a = document.createElement('a');
|
|
89
|
+
a.href = url;
|
|
90
|
+
const name = result?.bookName !== undefined && result.bookName !== '' ? result.bookName + '·改编版.md' : '改编版.md';
|
|
91
|
+
a.download = name;
|
|
92
|
+
a.click();
|
|
93
|
+
URL.revokeObjectURL(url);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className={css.authorPageBody}>
|
|
98
|
+
<div className={css.authorPageHeader}>
|
|
99
|
+
<div>
|
|
100
|
+
<h2 className={css.panelTitle} style={{ margin: 0 }}>🎬 改编模式</h2>
|
|
101
|
+
<span className={css.meta}>上传全文 → 分析可改范围 → 勾选/填新值 → 生成映射表 → 术语替换执行。</span>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div className={css.adaptInputCard}>
|
|
106
|
+
<div className={css.rowEnd} style={{ justifyContent: 'space-between' }}>
|
|
107
|
+
<span className={css.meta}>粘贴全文,或上传 txt/md 文件</span>
|
|
108
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => fileRef.current?.click()}><Upload size={13} style={{ verticalAlign: -2 }} /> 上传文件</button>
|
|
109
|
+
</div>
|
|
110
|
+
<input ref={fileRef} type="file" accept=".txt,.md,text/plain,text/markdown" style={{ display: 'none' }} onChange={e => { void onFile(e) }} />
|
|
111
|
+
<textarea className={css.input + ' ' + css.adaptTextarea} value={text} onChange={e => setText(e.target.value)} placeholder="粘贴小说全文(含章节标题/分卷更好)…" />
|
|
112
|
+
<div className={css.rowEnd}><span className={css.meta}>已输入 {text.length} 字</span><button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={() => { void analyze() }} disabled={busy}><Wand2 size={14} style={{ verticalAlign: -2 }} /> {busy && busyLabel === '分析中…' ? '分析中…' : '开始分析'}</button></div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{error !== '' && <div className={css.noticeError}>{error}</div>}
|
|
116
|
+
|
|
117
|
+
{result === null ? (
|
|
118
|
+
<div className={css.shelfEmpty} style={{ minHeight: 160 }}>
|
|
119
|
+
<span className={css.shelfEmptyIcon}><FileText size={30} /></span>
|
|
120
|
+
<span className={css.shelfEmptyTitle}>等待分析</span>
|
|
121
|
+
<span className={css.meta}>分析会读取全文并提炼「原文设定卡片」与「可改范围矩阵」。</span>
|
|
122
|
+
</div>
|
|
123
|
+
) : (
|
|
124
|
+
<div className={css.adaptResult}>
|
|
125
|
+
<div className={css.authorPageHeader} style={{ alignItems: 'center' }}>
|
|
126
|
+
<h3 style={{ margin: 0 }}>《{result.bookName}》 · {result.chapters} 章</h3>
|
|
127
|
+
<span className={css.meta}>{result.note}</span>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{result.outline !== undefined && result.outline !== '' && (
|
|
131
|
+
<details className={css.adaptOutline}><summary>反推大纲(点击展开)</summary><pre className={css.adaptOutlinePre}>{result.outline}</pre></details>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
<div className={css.authorPageHeader} style={{ alignItems: 'center' }}>
|
|
135
|
+
<b>可改范围矩阵(勾选要改 + 填新值)</b>
|
|
136
|
+
<button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={() => { void propose() }} disabled={busy}><ListChecks size={13} style={{ verticalAlign: -2 }} /> {busy && busyLabel === '生成改编方案…' ? '生成中…' : '生成改编方案'} ({selectedDims.length})</button>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div className={css.adaptMatrix}>
|
|
140
|
+
{result.dimensions.map((d, i) => {
|
|
141
|
+
const e = edits[d.key];
|
|
142
|
+
const enabled = e?.enabled === true;
|
|
143
|
+
const target = e?.target ?? '';
|
|
144
|
+
return (
|
|
145
|
+
<div key={d.key + '-' + i} className={css.dimCard + (enabled ? ' ' + css.dimCardSelected : '')}>
|
|
146
|
+
<div className={css.dimCardHead}>
|
|
147
|
+
<span className={css.dimTitle}>{d.title}</span>
|
|
148
|
+
<span className={css.badge} style={{ color: MUTABILITY_CLS[d.mutability] ?? 'var(--nf-muted)' }}>{MUTABILITY_LABEL[d.mutability] ?? d.mutability}</span>
|
|
149
|
+
<span className={css.meta}>风险:{RISK_LABEL[d.risk] ?? d.risk}</span>
|
|
150
|
+
</div>
|
|
151
|
+
<div className={css.dimCurrent}><b>当前值</b>:{d.current}</div>
|
|
152
|
+
{d.mutability !== 'locked' && (
|
|
153
|
+
<div className={css.dimEdit}>
|
|
154
|
+
<input type="checkbox" checked={enabled} onChange={e2 => setEdit(d.key, { enabled: e2.target.checked })} />
|
|
155
|
+
<input className={css.input} value={target} disabled={!enabled} placeholder="改为什么?" onChange={e2 => setEdit(d.key, { target: e2.target.value })} />
|
|
156
|
+
{(d.candidates ?? []).length > 0 && <span className={css.meta}>候选:{(d.candidates ?? []).join(' / ')}</span>}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
{(d.evidence !== undefined && d.evidence !== '') && <div className={css.meta}>证据:{d.evidence}</div>}
|
|
160
|
+
<div className={css.meta}>影响:{d.impact}</div>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
})}
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
{proposal !== null && (
|
|
167
|
+
<div className={css.adaptProposal}>
|
|
168
|
+
<div className={css.authorPageHeader} style={{ alignItems: 'center' }}>
|
|
169
|
+
<b>改编方案</b>
|
|
170
|
+
<button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={() => { void execute() }} disabled={busy}><Play size={13} style={{ verticalAlign: -2 }} /> {busy && busyLabel === '执行术语替换…' ? '执行中…' : '执行改编(术语替换)'}</button>
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<h4 style={{ margin: '10px 0 4px' }}>映射表</h4>
|
|
174
|
+
<table className={css.adaptTable}><thead><tr><th>原值</th><th>新值</th><th>范围</th></tr></thead><tbody>
|
|
175
|
+
{proposal.mappings.map((m, i2) => (<tr key={i2}><td>{m.source}</td><td>{m.target}</td><td>{m.scope}</td></tr>))}
|
|
176
|
+
</tbody></table>
|
|
177
|
+
|
|
178
|
+
<h4 style={{ margin: '10px 0 4px' }}>改编规则</h4>
|
|
179
|
+
<div className={css.meta}><b>保留:</b>{proposal.rules.preserve.join(';') || '—'}</div>
|
|
180
|
+
<div className={css.meta}><b>允许变:</b>{proposal.rules.change.join(';') || '—'}</div>
|
|
181
|
+
<div className={css.meta}><b>红线:</b>{proposal.rules.constraints.join(';') || '—'}</div>
|
|
182
|
+
|
|
183
|
+
<h4 style={{ margin: '10px 0 4px' }}>联动影响</h4>
|
|
184
|
+
<div className={css.adaptImpacts}>
|
|
185
|
+
{proposal.impacts.map((im, i3) => (<div key={i3} className={css.adaptImpact}><b style={{ color: im.risk === 'high' ? 'var(--nf-error)' : im.risk === 'medium' ? 'var(--nf-warn)' : 'var(--nf-info)' }}>[{RISK_LABEL[im.risk] ?? im.risk}]</b> {im.item}:{im.detail}{im.chapters !== undefined && im.chapters.length > 0 ? `(章:${im.chapters.join(',')})` : ''}</div>))}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
|
|
190
|
+
{executed !== null && (
|
|
191
|
+
<div className={css.adaptProposal}>
|
|
192
|
+
<div className={css.authorPageHeader} style={{ alignItems: 'center' }}>
|
|
193
|
+
<b>改编结果(术语替换)</b>
|
|
194
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={downloadAdapted}><Download size={13} style={{ verticalAlign: -2 }} /> 下载改编版</button>
|
|
195
|
+
</div>
|
|
196
|
+
<div className={css.meta}>命中:{executed.hits.filter(h => h.count > 0).map(h => h.source + '→' + h.target + '×' + h.count).join(',') || '无命中'}</div>
|
|
197
|
+
<details className={css.adaptOutline}><summary>改编正文预览(点击展开)</summary><pre className={css.adaptOutlinePre}>{executed.adaptedText.slice(0, 8000)}</pre></details>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 作者资产库/总数据页:跨书可复用的笔法/红线/套路/角色模板/世界观模板。
|
|
3
|
+
* 支持 新增 / 编辑 / 删除;数据持久化到 ~/.dsh/dsh-novel-forge-author-assets.json。
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
6
|
+
import { Plus, Trash2, Pencil } from 'lucide-react'
|
|
7
|
+
import type { NovelApi } from '../api.ts'
|
|
8
|
+
import type { AuthorStyleAsset } from '../../protocol.ts'
|
|
9
|
+
import css from './panel.module.css'
|
|
10
|
+
|
|
11
|
+
const KIND_LABELS: Record<AuthorStyleAsset['kind'], string> = {
|
|
12
|
+
style: '笔法',
|
|
13
|
+
antiAi: '红线/反AI',
|
|
14
|
+
progression: '推进模式',
|
|
15
|
+
genre: '题材',
|
|
16
|
+
roleTemplate: '角色模板',
|
|
17
|
+
worldTemplate: '世界观模板',
|
|
18
|
+
custom: '自定义',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const KIND_OPTIONS = Object.keys(KIND_LABELS) as AuthorStyleAsset['kind'][]
|
|
22
|
+
|
|
23
|
+
const KIND_COLORS: Record<AuthorStyleAsset['kind'], string> = {
|
|
24
|
+
style: 'var(--nf-info)',
|
|
25
|
+
antiAi: 'var(--nf-warn)',
|
|
26
|
+
progression: 'var(--nf-accent)',
|
|
27
|
+
genre: 'var(--nf-info)',
|
|
28
|
+
roleTemplate: 'var(--nf-accent)',
|
|
29
|
+
worldTemplate: 'var(--nf-warn)',
|
|
30
|
+
custom: 'var(--nf-muted)',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 计算资产的分组(分类):题材按顶层题材名('/' 之前),其余按类型。 */
|
|
34
|
+
function assetCategory(a: AuthorStyleAsset): string {
|
|
35
|
+
if (a.kind === 'genre') {
|
|
36
|
+
const top = a.name.split('/')[0]?.trim()
|
|
37
|
+
return top !== undefined && top !== '' ? top : '题材'
|
|
38
|
+
}
|
|
39
|
+
return KIND_LABELS[a.kind] ?? '其他'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface EditorState {
|
|
43
|
+
id?: string
|
|
44
|
+
name: string; kind: AuthorStyleAsset['kind']; summary: string; content: string; tags: string; sourceBooks: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function emptyEditor(): EditorState {
|
|
48
|
+
return { name: '', kind: 'style', summary: '', content: '', tags: '', sourceBooks: '' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function AuthorAssetsView({ api }: { api: NovelApi }) {
|
|
52
|
+
const [assets, setAssets] = useState<AuthorStyleAsset[]>([]);
|
|
53
|
+
const [editing, setEditing] = useState<EditorState | null>(null);
|
|
54
|
+
const [error, setError] = useState('');
|
|
55
|
+
const [busy, setBusy] = useState(false);
|
|
56
|
+
const [kindFilter, setKindFilter] = useState<AuthorStyleAsset['kind'] | 'all'>('all');
|
|
57
|
+
|
|
58
|
+
const load = async (): Promise<void> => {
|
|
59
|
+
try { const res = await api.authorAssets(); setAssets(res.assets.items) } catch (err) { setError((err as Error).message) }
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** 导入默认:把书架书的写作资产/角色 + 内置全局库批量沉淀到作者资产库。 */
|
|
63
|
+
const importDefaults = async (): Promise<void> => {
|
|
64
|
+
setBusy(true); setError('');
|
|
65
|
+
try { const res = await api.authorAssetsImportDefault(); setAssets(res.assets.items) } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// 打开即加载;若资产库为空,自动导入默认(书架资产 + 内置全局库)。
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
let cancelled = false
|
|
71
|
+
void (async () => {
|
|
72
|
+
try {
|
|
73
|
+
const res = await api.authorAssets()
|
|
74
|
+
if (cancelled) return
|
|
75
|
+
if (res.assets.items.length === 0) {
|
|
76
|
+
const imp = await api.authorAssetsImportDefault()
|
|
77
|
+
if (!cancelled) setAssets(imp.assets.items)
|
|
78
|
+
} else {
|
|
79
|
+
setAssets(res.assets.items)
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
if (!cancelled) setError((err as Error).message)
|
|
83
|
+
}
|
|
84
|
+
})()
|
|
85
|
+
return () => { cancelled = true }
|
|
86
|
+
}, [api]);
|
|
87
|
+
|
|
88
|
+
const startNew = (): void => { setEditing(emptyEditor()); setError('') };
|
|
89
|
+
const startEdit = (a: AuthorStyleAsset): void => {
|
|
90
|
+
setEditing({ id: a.id, name: a.name, kind: a.kind, summary: a.summary, content: a.content, tags: a.tags.join('、'), sourceBooks: a.sourceBooks.join('、') });
|
|
91
|
+
setError('');
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const save = async (): Promise<void> => {
|
|
95
|
+
if (editing === null) return;
|
|
96
|
+
if (editing.name.trim() === '') { setError('请填写资产名'); return }
|
|
97
|
+
setBusy(true); setError('');
|
|
98
|
+
try {
|
|
99
|
+
const asset: AuthorStyleAsset = {
|
|
100
|
+
id: editing.id ?? '', name: editing.name.trim(), kind: editing.kind, summary: editing.summary.trim(), content: editing.content.trim(),
|
|
101
|
+
sourceBooks: editing.sourceBooks.split(/[、,,\s]+/).map(s => s.trim()).filter(Boolean),
|
|
102
|
+
tags: editing.tags.split(/[、,,\s]+/).map(s => s.trim()).filter(Boolean),
|
|
103
|
+
createdAt: '', updatedAt: '',
|
|
104
|
+
};
|
|
105
|
+
await api.authorAssetsUpsert(asset); setEditing(null); await load();
|
|
106
|
+
} catch (err) { setError((err as Error).message) } finally { setBusy(false) }
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const del = async (id: string): Promise<void> => {
|
|
110
|
+
setBusy(true); setError('');
|
|
111
|
+
try { await api.authorAssetsRemove(id); await load() } catch (err) { setError((err as Error).message) } finally { setBusy(false) }
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const visible = useMemo(() => (kindFilter === 'all' ? assets : assets.filter(a => a.kind === kindFilter)), [assets, kindFilter]);
|
|
115
|
+
|
|
116
|
+
/** 按分类分组展示(题材按顶层题材名,其余按类型)。 */
|
|
117
|
+
const grouped = useMemo(() => {
|
|
118
|
+
const map = new Map<string, AuthorStyleAsset[]>()
|
|
119
|
+
for (const a of visible) {
|
|
120
|
+
const cat = assetCategory(a)
|
|
121
|
+
const arr = map.get(cat) ?? []
|
|
122
|
+
arr.push(a)
|
|
123
|
+
map.set(cat, arr)
|
|
124
|
+
}
|
|
125
|
+
return Array.from(map.entries()).sort((x, y) => x[0].localeCompare(y[0], 'zh'))
|
|
126
|
+
}, [visible]);
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div className={css.authorPageBody}>
|
|
130
|
+
<div className={css.authorPageHero}>
|
|
131
|
+
<div className={css.authorPageHeader}>
|
|
132
|
+
<div>
|
|
133
|
+
<h2 className={css.panelTitle} style={{ margin: 0 }}>🧬 作者资产库 · 总数据</h2>
|
|
134
|
+
<span className={css.meta}>跨书可复用的笔法/红线/套路/角色模板/世界观模板。任意新书与改编都能调用。</span>
|
|
135
|
+
</div>
|
|
136
|
+
<div className={css.rowEnd}>
|
|
137
|
+
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { void importDefaults() }} disabled={busy} title="把书架里所有书的写作资产/角色 + 内置题材/反AI/风格模板/推进模式批量导入">导入默认资产</button>
|
|
138
|
+
<button type="button" className={css.button + ' ' + css.buttonPrimary} onClick={startNew}><Plus size={14} style={{ verticalAlign: -2 }} /> 新增资产</button>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
<div className={css.assetFilterBar}>
|
|
142
|
+
{KIND_OPTIONS.map(k => (
|
|
143
|
+
<button key={k} type="button" className={css.assetFilterChip + (kindFilter === k ? ' ' + css.assetFilterChipActive : '')} onClick={() => setKindFilter(kindFilter === k ? 'all' : k)}>{KIND_LABELS[k]} {assets.filter(a => a.kind === k).length}</button>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{error !== '' && <div className={css.noticeError}>{error}</div>}
|
|
149
|
+
|
|
150
|
+
{editing !== null ? (
|
|
151
|
+
<div className={css.assetEditor}>
|
|
152
|
+
<h3 style={{ margin: 0 }}>{editing.id !== '' && editing.id !== undefined ? '编辑资产' : '新增资产'}</h3>
|
|
153
|
+
<div className={css.assetEditorRow}>
|
|
154
|
+
<label className={css.assetLabel}>资产名 <input className={css.input} value={editing.name} onChange={e => setEditing({ ...editing, name: e.target.value })} placeholder="如:短句快节奏爽文风" /></label>
|
|
155
|
+
<label className={css.assetLabel}>类型 <select className={css.input} value={editing.kind} onChange={e => setEditing({ ...editing, kind: e.target.value as AuthorStyleAsset['kind'] })}>{KIND_OPTIONS.map(k => <option key={k} value={k}>{KIND_LABELS[k]}</option>)}</select></label>
|
|
156
|
+
</div>
|
|
157
|
+
<label className={css.assetLabel}>一句话摘要 <input className={css.input} value={editing.summary} onChange={e => setEditing({ ...editing, summary: e.target.value })} placeholder="这个资产解决什么问题 / 有什么特点" /></label>
|
|
158
|
+
<label className={css.assetLabel}>内容(按类型约定的描述 / 规则 / 结构化文本)
|
|
159
|
+
<textarea className={`${css.input} ${css.assetTextarea}`} value={editing.content} onChange={e => setEditing({ ...editing, content: e.target.value })} placeholder={'角色模板示例:\n姓名:林越\n定位:痞坏型主角\n口头禅:"有点意思"\n性格:表面懒散、内里狠辣\n成长弧线:从底层游街到一方枭雄'} />
|
|
160
|
+
</label>
|
|
161
|
+
<div className={css.assetEditorRow}>
|
|
162
|
+
<label className={css.assetLabel}>标签(顿号分隔) <input className={css.input} value={editing.tags} onChange={e => setEditing({ ...editing, tags: e.target.value })} placeholder="扮猪吃虎、快节奏、冷面" /></label>
|
|
163
|
+
<label className={css.assetLabel}>来源书(顿号分隔) <input className={css.input} value={editing.sourceBooks} onChange={e => setEditing({ ...editing, sourceBooks: e.target.value })} placeholder="《某某传》《某某录》" /></label>
|
|
164
|
+
</div>
|
|
165
|
+
<div className={css.rowEnd}><button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={save} disabled={busy}>{busy ? '保存中…' : '保存'}</button>{(editing.id !== '' && editing.id !== undefined) && <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => setEditing(null)}>取消</button>}</div>
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
168
|
+
|
|
169
|
+
{visible.length === 0 ? (
|
|
170
|
+
<div className={css.shelfEmpty} style={{ minHeight: 160 }}>
|
|
171
|
+
<span className={css.shelfEmptyIcon}><Plus size={30} /></span>
|
|
172
|
+
<span className={css.shelfEmptyTitle}>还没有资产</span>
|
|
173
|
+
<span className={css.meta}>点「新增资产」收藏你的笔法/角色模板/套路,跨书调用。</span>
|
|
174
|
+
</div>
|
|
175
|
+
) : (
|
|
176
|
+
<div className={css.assetGroups}>
|
|
177
|
+
{grouped.map(([cat, items]) => (
|
|
178
|
+
<div key={cat} className={css.assetGroup}>
|
|
179
|
+
<div className={css.assetGroupHeader}>{cat} <span className={css.meta}>{items.length}</span></div>
|
|
180
|
+
<div className={css.assetGrid}>
|
|
181
|
+
{items.map(a => (
|
|
182
|
+
<div key={a.id} className={css.assetCard}>
|
|
183
|
+
<div className={css.assetCardTop}>
|
|
184
|
+
<span className={css.assetKind} style={{ color: KIND_COLORS[a.kind] }}>{KIND_LABELS[a.kind]}</span>
|
|
185
|
+
<div className={css.rowEnd}>
|
|
186
|
+
<button type="button" className={css.iconBtn} title="编辑" onClick={() => startEdit(a)}><Pencil size={13} /></button>
|
|
187
|
+
<button type="button" className={css.iconBtn} title="删除" onClick={() => { void del(a.id) }}><Trash2 size={13} /></button>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
<div className={css.assetCardName}>{a.name}</div>
|
|
191
|
+
{a.summary !== '' && <div className={css.meta}>{a.summary}</div>}
|
|
192
|
+
<div className={css.assetCardContent}>{a.content}</div>
|
|
193
|
+
<div className={css.assetCardFooter}>
|
|
194
|
+
{a.tags.map(t => <span key={t} className={css.badge}>{t}</span>)}
|
|
195
|
+
{a.sourceBooks.length > 0 && <span className={css.meta}>来源:{a.sourceBooks.join(' / ')}</span>}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 作者级首页容器:书架页左侧导航 + 内容区。
|
|
3
|
+
* 导航复用工作区 .panelNav 玻璃卡片样式(同款),导航项为作者级:书架/改编/资产库/全局资产库/AI 进度/设置。
|
|
4
|
+
*/
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { Library, Wand2, Boxes, Brush, BarChart3, Settings, Info } from 'lucide-react'
|
|
7
|
+
import type { NovelApi } from '../api.ts'
|
|
8
|
+
import type { BookshelfSnapshot } from '../../protocol.ts'
|
|
9
|
+
import { ShelfView } from './ShelfView.tsx'
|
|
10
|
+
import { AdaptModeView } from './AdaptModeView.tsx'
|
|
11
|
+
import { AuthorAssetsView } from './AuthorAssetsView.tsx'
|
|
12
|
+
import { ProgressHomeView } from './ProgressHomeView.tsx'
|
|
13
|
+
import { SettingsView } from './SettingsView.tsx'
|
|
14
|
+
import css from './panel.module.css'
|
|
15
|
+
|
|
16
|
+
type AuthorNav = 'shelf' | 'adapt' | 'assets' | 'library' | 'progress' | 'settings';
|
|
17
|
+
|
|
18
|
+
const NAV_ITEMS: Array<{ id: Exclude<AuthorNav, 'settings'>; label: string; icon: JSX.Element; hint: string }> = [
|
|
19
|
+
{ id: 'shelf', label: '书架', icon: <Library size={18} />, hint: '我的书' },
|
|
20
|
+
{ id: 'adapt', label: '改编模式', icon: <Wand2 size={18} />, hint: '上传全文→可改范围' },
|
|
21
|
+
{ id: 'assets', label: '作者资产库', icon: <Boxes size={18} />, hint: '跨书总数据' },
|
|
22
|
+
{ id: 'library', label: '全局写作资产库', icon: <Brush size={18} />, hint: '内置题材/规则/模板' },
|
|
23
|
+
{ id: 'progress', label: 'AI 进度', icon: <BarChart3 size={18} />, hint: '书架级聚合进度' },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
function Placeholder({ title, hint }: { title: string; hint: string }) {
|
|
27
|
+
return (
|
|
28
|
+
<div className={css.authorPageBody}>
|
|
29
|
+
<div className={css.authorPageHeader}><h2 className={css.panelTitle} style={{ margin: 0 }}>{title}</h2><span className={css.meta}>{hint}</span></div>
|
|
30
|
+
<div className={css.shelfEmpty} style={{ minHeight: 160 }}>
|
|
31
|
+
<span className={css.shelfEmptyIcon}><Info size={30} /></span>
|
|
32
|
+
<span className={css.shelfEmptyTitle}>此入口为占位</span>
|
|
33
|
+
<span className={css.meta}>后续版本实现。当前「全局写作资产库」可进入某本书「创作资产」页查看内置题材库/反AI规则库/风格模板;「设置」为独立设置页。</span>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function AuthorHome({ api, shelf, onOpenBook, onReadBook, onAddBook, onImportBook, onOpenSettings, onTheme, onBackground, onOpacity, adaptEnabled }: {
|
|
40
|
+
api: NovelApi; shelf: BookshelfSnapshot;
|
|
41
|
+
onOpenBook: (id: string) => void; onReadBook: (id: string) => void; onAddBook: () => void; onImportBook: () => void;
|
|
42
|
+
/** 兼容旧入口:首页设置现为独立设置页,此回调保留但不再使用。 */
|
|
43
|
+
onOpenSettings?: () => void;
|
|
44
|
+
/** 主题/模式/密度变化回调(供面板根容器实时生效)。 */
|
|
45
|
+
onTheme?: (theme: 'liquid' | 'neumorph' | 'macos' | 'clay', mode: 'system' | 'light' | 'dark', density: 'comfort' | 'compact' | 'spacious') => void;
|
|
46
|
+
/** 自定义背景变化回调。 */
|
|
47
|
+
onBackground?: (bg: string | undefined, blur: number) => void;
|
|
48
|
+
/** 玻璃透明度变化回调。 */
|
|
49
|
+
onOpacity?: (n: number) => void;
|
|
50
|
+
/** 是否启用改编模式(默认 false=隐藏入口)。 */
|
|
51
|
+
adaptEnabled?: boolean;
|
|
52
|
+
}) {
|
|
53
|
+
const [nav, setNav] = useState<AuthorNav>('shelf');
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={css.authorHome}>
|
|
57
|
+
<nav className={css.panelNav} aria-label="作者级导航">
|
|
58
|
+
<div className={css.navTitle}>
|
|
59
|
+
<div className={css.navTitleLogo}>书</div>
|
|
60
|
+
<div style={{ minWidth: 0 }}>
|
|
61
|
+
<div className={css.navTitleName}>小说工坊</div>
|
|
62
|
+
<div className={css.navTitleBook}>作者级 · 跨书</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<div className={css.navGroup}>
|
|
66
|
+
<div className={css.navGroupLabel}>作者</div>
|
|
67
|
+
{NAV_ITEMS.filter(item => item.id !== 'adapt' || adaptEnabled).map(item => (
|
|
68
|
+
<button key={item.id} type="button" role="tab" aria-selected={nav === item.id} data-active={nav === item.id ? '' : undefined} className={css.navTab} title={item.hint} onClick={() => setNav(item.id)}>
|
|
69
|
+
<span className={css.navTabIcon}>{item.icon}</span>
|
|
70
|
+
<span className={css.navTabLabel}>{item.label}</span>
|
|
71
|
+
</button>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
<div className={css.navSpacer} />
|
|
75
|
+
<button type="button" role="tab" aria-selected={nav === 'settings'} data-active={nav === 'settings' ? '' : undefined} className={css.navTab} title="设置" onClick={() => setNav('settings')}>
|
|
76
|
+
<span className={css.navTabIcon}><Settings size={18} /></span>
|
|
77
|
+
<span className={css.navTabLabel}>设置</span>
|
|
78
|
+
</button>
|
|
79
|
+
<div className={css.navAbout}>
|
|
80
|
+
<div className={css.navAboutRow}><span>ℹ️ v1.7.3 · 改编/资产库</span></div>
|
|
81
|
+
</div>
|
|
82
|
+
</nav>
|
|
83
|
+
<div className={css.authorContent}>
|
|
84
|
+
{nav === 'shelf' && <ShelfView api={api} shelf={shelf} onOpenBook={onOpenBook} onReadBook={onReadBook} onAddBook={onAddBook} onImportBook={onImportBook} />}
|
|
85
|
+
{nav === 'adapt' && <AdaptModeView api={api} />}
|
|
86
|
+
{nav === 'assets' && <AuthorAssetsView api={api} />}
|
|
87
|
+
{nav === 'library' && <Placeholder title="🎨 全局写作资产库" hint="内置题材基底库 / 反AI规则库 / 风格模板 / 推进模式(跨书)" />}
|
|
88
|
+
{nav === 'progress' && <ProgressHomeView shelf={shelf} onOpenBook={onOpenBook} />}
|
|
89
|
+
{nav === 'settings' && <SettingsView api={api} onTheme={onTheme} onBackground={onBackground} onOpacity={onOpacity} />}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -188,7 +188,11 @@ export function CreateBookView({
|
|
|
188
188
|
<label className={css.fieldLabel}>大纲</label>
|
|
189
189
|
<div
|
|
190
190
|
className={`${css.dropzone} ${dragActive ? css.dropzoneActive : ''}`}
|
|
191
|
+
role="button"
|
|
192
|
+
tabIndex={0}
|
|
193
|
+
aria-label="选择或拖入 docx 大纲文件"
|
|
191
194
|
onClick={() => { outlineFileRef.current?.click() }}
|
|
195
|
+
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); outlineFileRef.current?.click() } }}
|
|
192
196
|
onDragOver={e => { e.preventDefault(); setDragActive(true) }}
|
|
193
197
|
onDragLeave={() => { setDragActive(false) }}
|
|
194
198
|
onDrop={e => {
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* B) txt/md 全本 → 点击选择文件,浏览器读取内容 → 服务器拆章预览 → 确认后导入。
|
|
5
5
|
* Mode B 也保留「服务器本地路径」高级选项(大文件或服务器端已有文件时用)。
|
|
6
6
|
*/
|
|
7
|
-
import { useRef, useState } from 'react'
|
|
7
|
+
import { useEffect, useRef, useState } from 'react'
|
|
8
8
|
import type { NovelApi } from '../api.ts'
|
|
9
9
|
import type { BookImportTextPreviewResponse } from '../../protocol.ts'
|
|
10
|
+
import { readFileTextSmart } from '../text.ts'
|
|
10
11
|
import css from './panel.module.css'
|
|
11
12
|
|
|
12
13
|
type ImportMode = 'dir' | 'text'
|
|
@@ -57,7 +58,7 @@ export function ImportModal({
|
|
|
57
58
|
setFileName(file.name)
|
|
58
59
|
setPreviewing(true)
|
|
59
60
|
try {
|
|
60
|
-
const text = await file
|
|
61
|
+
const text = await readFileTextSmart(file)
|
|
61
62
|
setFileText(text)
|
|
62
63
|
const p = await api.bookImportTextPreview(text, file.name)
|
|
63
64
|
setPreview(p)
|
|
@@ -118,8 +119,17 @@ export function ImportModal({
|
|
|
118
119
|
|
|
119
120
|
const switchMode = (m: ImportMode) => { setMode(m); setError(''); setResult(null); setPreview(null) }
|
|
120
121
|
|
|
122
|
+
// Esc 关闭弹窗。
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
125
|
+
if (e.key === 'Escape') onClose()
|
|
126
|
+
}
|
|
127
|
+
window.addEventListener('keydown', onKey)
|
|
128
|
+
return () => { window.removeEventListener('keydown', onKey) }
|
|
129
|
+
}, [onClose])
|
|
130
|
+
|
|
121
131
|
return (
|
|
122
|
-
<div className={css.importModalOverlay} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
|
132
|
+
<div className={css.importModalOverlay} role="dialog" aria-modal="true" aria-label="导入小说" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
|
123
133
|
<div className={css.importModal}>
|
|
124
134
|
<div className={css.importModalHead}>
|
|
125
135
|
<span className={css.panelTitle} style={{ margin: 0 }}>📥 导入小说</span>
|