@waterwx/dsh-novel-forge 1.5.0 → 1.7.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 +41460 -6241
- package/lib/client.js.map +1 -1
- package/lib/index.js +294 -51
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +1 -0
- package/lib/types/client/panel/MangaWorkspace.d.ts +9 -3
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/client/panel/RoleVisualPanel.d.ts +15 -0
- package/lib/types/client/panel/SceneLibrary.d.ts +14 -0
- package/lib/types/client/panel/StoryboardTab.d.ts +3 -1
- package/lib/types/client/panel/StyleCard.d.ts +8 -0
- package/lib/types/engine.d.ts +9 -2
- package/lib/types/index.d.ts +6 -4
- package/lib/types/protocol.d.ts +57 -3
- package/package.json +99 -98
- package/src/client/api.ts +4 -0
- package/src/client/panel/MangaWorkspace.tsx +296 -184
- package/src/client/panel/NovelPanel.tsx +390 -877
- package/src/client/panel/RoleVisualPanel.tsx +485 -0
- package/src/client/panel/RunPanel.tsx +1 -1
- package/src/client/panel/SceneLibrary.tsx +395 -0
- package/src/client/panel/ShelfView.tsx +28 -35
- package/src/client/panel/StoryboardTab.tsx +369 -286
- package/src/client/panel/StyleCard.tsx +90 -0
- package/src/client/panel/StyleGallery.tsx +57 -108
- package/src/client/panel/panel.module.css +418 -34
- package/src/engine.ts +187 -42
- package/src/index.ts +37 -6
- package/src/protocol.ts +60 -3
- package/src/routes.ts +27 -2
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 场景库(完整版):提炼 / 采纳 / 编辑 / 图集上传 / 详情浮窗。
|
|
3
|
+
* 从 NovelPanel 左导航「场景库」搬入漫剧工作台场景区共用;
|
|
4
|
+
* 浮窗用 createPortal 挂 body(避开 .view backdrop-filter 的 fixed 包含块问题),
|
|
5
|
+
* 提示词全局中英切换 + 复制全部,视觉规则只在主页面显示一次。
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useRef, useState } from 'react'
|
|
8
|
+
import { createPortal } from 'react-dom'
|
|
9
|
+
import type { NovelApi } from '../api.ts'
|
|
10
|
+
import type { ProjectState, SceneCard } from '../../protocol.ts'
|
|
11
|
+
import css from './panel.module.css'
|
|
12
|
+
|
|
13
|
+
export function SceneLibrary({
|
|
14
|
+
api,
|
|
15
|
+
project,
|
|
16
|
+
refresh,
|
|
17
|
+
styleId,
|
|
18
|
+
filterId,
|
|
19
|
+
onProgress,
|
|
20
|
+
}: {
|
|
21
|
+
api: NovelApi
|
|
22
|
+
project: ProjectState | null
|
|
23
|
+
/** 场景库变更已持久化后触发(刷新项目)。 */
|
|
24
|
+
refresh: () => void | Promise<void>
|
|
25
|
+
/** 提炼场景时的漫剧基底风格 id(提示词按方案风格措辞)。 */
|
|
26
|
+
styleId?: string
|
|
27
|
+
/** 可选滤镜风格 id。 */
|
|
28
|
+
filterId?: string
|
|
29
|
+
/** 上报到「AI进度」控制台。 */
|
|
30
|
+
onProgress?: (text: string, kind?: 'info' | 'done' | 'error') => void
|
|
31
|
+
}) {
|
|
32
|
+
const [busy, setBusy] = useState(false)
|
|
33
|
+
const [error, setError] = useState('')
|
|
34
|
+
const [notice, setNotice] = useState('')
|
|
35
|
+
const [candidates, setCandidates] = useState<SceneCard[] | null>(null)
|
|
36
|
+
const [detailName, setDetailName] = useState<string | null>(null)
|
|
37
|
+
const [uploadLabel, setUploadLabel] = useState('全景')
|
|
38
|
+
const [uploadTarget, setUploadTarget] = useState<string | null>(null)
|
|
39
|
+
const [draft, setDraft] = useState<SceneCard | null>(null)
|
|
40
|
+
/** 详情浮窗内提示词全局中英切换。 */
|
|
41
|
+
const [sceneLang, setSceneLang] = useState<'zh' | 'en'>('zh')
|
|
42
|
+
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
43
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
44
|
+
/** 浮窗挂 body 时复制的面板主题变量(--nf-*)。 */
|
|
45
|
+
const [portalVars, setPortalVars] = useState<Record<string, string>>({})
|
|
46
|
+
|
|
47
|
+
const notify = (msg: string): void => { setNotice(msg); setTimeout(() => { setNotice('') }, 3000) }
|
|
48
|
+
const report = (text: string, kind: 'info' | 'done' | 'error' = 'info'): void => { onProgress?.(text, kind) }
|
|
49
|
+
|
|
50
|
+
// Esc 关闭详情浮窗。
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (detailName === null) return
|
|
53
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
54
|
+
if (e.key === 'Escape') setDetailName(null)
|
|
55
|
+
}
|
|
56
|
+
window.addEventListener('keydown', onKey)
|
|
57
|
+
return () => { window.removeEventListener('keydown', onKey) }
|
|
58
|
+
}, [detailName])
|
|
59
|
+
|
|
60
|
+
/** 打开场景详情:记录目标并复制面板主题变量(浮窗在 body 下渲染需要)。 */
|
|
61
|
+
const openDetail = (name: string): void => {
|
|
62
|
+
setDetailName(name)
|
|
63
|
+
setUploadLabel('全景')
|
|
64
|
+
if (rootRef.current !== null) {
|
|
65
|
+
const cs = getComputedStyle(rootRef.current)
|
|
66
|
+
const vars: Record<string, string> = {}
|
|
67
|
+
for (let i = 0; i < cs.length; i++) {
|
|
68
|
+
const key = cs[i]
|
|
69
|
+
if (key.startsWith('--nf-')) {
|
|
70
|
+
const v = cs.getPropertyValue(key).trim()
|
|
71
|
+
if (v !== '') vars[key] = v
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
setPortalVars(vars)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const extract = async (): Promise<void> => {
|
|
79
|
+
setBusy(true); setError('')
|
|
80
|
+
report('按当前方案提炼场景…')
|
|
81
|
+
try {
|
|
82
|
+
const result = await api.scenes({ op: 'extract', styleId, filterId })
|
|
83
|
+
setCandidates(result.candidates ?? [])
|
|
84
|
+
report('场景提炼完成:' + (result.candidates ?? []).length + ' 个候选', 'done')
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const m = (err as Error).message
|
|
87
|
+
setError(m)
|
|
88
|
+
report('场景提炼失败:' + m, 'error')
|
|
89
|
+
} finally { setBusy(false) }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const adopt = async (s: SceneCard): Promise<void> => {
|
|
93
|
+
setBusy(true); setError('')
|
|
94
|
+
report('采纳场景「' + s.name + '」…')
|
|
95
|
+
try {
|
|
96
|
+
await api.scenes({ op: 'adopt', scene: s })
|
|
97
|
+
setCandidates(prev => (prev ?? []).filter(x => x.name !== s.name))
|
|
98
|
+
await refresh()
|
|
99
|
+
report('已采纳场景「' + s.name + '」', 'done')
|
|
100
|
+
} catch (err) {
|
|
101
|
+
const m = (err as Error).message
|
|
102
|
+
setError(m)
|
|
103
|
+
report('采纳场景「' + s.name + '」失败:' + m, 'error')
|
|
104
|
+
} finally { setBusy(false) }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const saveDraft = async (): Promise<void> => {
|
|
108
|
+
if (draft === null) return
|
|
109
|
+
const name = draft.name.trim()
|
|
110
|
+
if (name === '') return
|
|
111
|
+
setBusy(true); setError('')
|
|
112
|
+
report('保存场景「' + name + '」…')
|
|
113
|
+
try {
|
|
114
|
+
await api.scenes({ op: 'update', scene: { ...draft, name } })
|
|
115
|
+
await refresh()
|
|
116
|
+
setDraft(null)
|
|
117
|
+
report('已保存场景「' + name + '」', 'done')
|
|
118
|
+
} catch (err) {
|
|
119
|
+
const m = (err as Error).message
|
|
120
|
+
setError(m)
|
|
121
|
+
report('保存场景「' + name + '」失败:' + m, 'error')
|
|
122
|
+
} finally { setBusy(false) }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const removeScene = async (name: string): Promise<void> => {
|
|
126
|
+
if (!window.confirm('确定删除场景「' + name + '」?')) return
|
|
127
|
+
setBusy(true); setError('')
|
|
128
|
+
report('删除场景「' + name + '」…')
|
|
129
|
+
try {
|
|
130
|
+
await api.scenes({ op: 'remove', name })
|
|
131
|
+
await refresh()
|
|
132
|
+
setDetailName(null)
|
|
133
|
+
report('已删除场景「' + name + '」', 'done')
|
|
134
|
+
} catch (err) {
|
|
135
|
+
const m = (err as Error).message
|
|
136
|
+
setError(m)
|
|
137
|
+
report('删除场景「' + name + '」失败:' + m, 'error')
|
|
138
|
+
} finally { setBusy(false) }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const uploadImage = async (file: File): Promise<void> => {
|
|
142
|
+
const target = uploadTarget
|
|
143
|
+
if (target === null) return
|
|
144
|
+
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
145
|
+
const reader = new FileReader()
|
|
146
|
+
reader.onload = () => resolve(String(reader.result))
|
|
147
|
+
reader.onerror = () => reject(reader.error ?? new Error('读取图片失败'))
|
|
148
|
+
reader.readAsDataURL(file)
|
|
149
|
+
})
|
|
150
|
+
const label = uploadLabel.trim() !== '' ? uploadLabel.trim() : '全景'
|
|
151
|
+
setBusy(true); setError('')
|
|
152
|
+
report('上传场景「' + target + '」' + label + '…')
|
|
153
|
+
try {
|
|
154
|
+
await api.scenes({ op: 'image', name: target, dataUrl, label })
|
|
155
|
+
await refresh()
|
|
156
|
+
report('已上传场景「' + target + '」' + label, 'done')
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const m = (err as Error).message
|
|
159
|
+
setError(m)
|
|
160
|
+
report('场景图上传失败:' + m, 'error')
|
|
161
|
+
} finally {
|
|
162
|
+
setBusy(false)
|
|
163
|
+
setUploadTarget(null)
|
|
164
|
+
if (inputRef.current !== null) inputRef.current.value = ''
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const removeImage = async (name: string, label: string): Promise<void> => {
|
|
169
|
+
setBusy(true); setError('')
|
|
170
|
+
try {
|
|
171
|
+
await api.scenes({ op: 'removeImage', name, label })
|
|
172
|
+
await refresh()
|
|
173
|
+
report('已删除「' + name + '」' + label, 'done')
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const m = (err as Error).message
|
|
176
|
+
setError(m)
|
|
177
|
+
report('删除「' + name + '」' + label + ' 失败:' + m, 'error')
|
|
178
|
+
} finally { setBusy(false) }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (project === null) {
|
|
182
|
+
return <div className={css.card}><span className={css.meta}>请先开书或选择一本书,再进入场景库。</span></div>
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const scenes = project.scenes ?? []
|
|
186
|
+
const detail = detailName !== null ? scenes.find(s => s.name === detailName) : undefined
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<div ref={rootRef} className={css.card} style={{ flex: 1, minHeight: 0 }}>
|
|
190
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
191
|
+
<span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🏞️ 场景库</span>
|
|
192
|
+
<span className={css.meta}>场景视觉锚点:提炼自正文,供漫剧分镜/生图锁定"在哪、什么氛围"</span>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6, margin: '8px 0' }}>
|
|
196
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy} onClick={() => { void extract() }}>
|
|
197
|
+
{busy ? '⏳ 提炼中…' : '✨ 按当前方案提炼场景'}
|
|
198
|
+
</button>
|
|
199
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { void refresh(); report('场景库已刷新', 'done') }}>🔄 刷新</button>
|
|
200
|
+
{candidates !== null && (
|
|
201
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setCandidates(null) }}>收起候选</button>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
{/* 视觉规则:只在主页面显示一次(浮窗内不再重复)。 */}
|
|
206
|
+
{(project.visualRules ?? []).length > 0 && (
|
|
207
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center', margin: '4px 0 8px' }}>
|
|
208
|
+
<span className={css.meta} style={{ fontSize: 11 }}>⚠️ 本书视觉规则(已内嵌到所有提示词):</span>
|
|
209
|
+
{(project.visualRules ?? []).map((r, i) => <span key={i} style={{ border: '1px solid var(--nf-warn, #b8860b)', borderRadius: 999, padding: '0 8px', fontSize: 10, color: 'var(--nf-warn, #b8860b)' }}>{r}</span>)}
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{error !== '' && <div className={css.importError}>{error}</div>}
|
|
214
|
+
{notice !== '' && <div className={css.importResult} style={{ padding: '6px 10px' }}>{notice}</div>}
|
|
215
|
+
|
|
216
|
+
{/* 编辑场景 */}
|
|
217
|
+
{draft !== null && (
|
|
218
|
+
<div style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: 10, marginBottom: 10, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
219
|
+
<b style={{ fontSize: 13 }}>编辑场景</b>
|
|
220
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="场景名" value={draft.name} onChange={e => { setDraft({ ...draft, name: e.target.value }) }} />
|
|
221
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="一句话定位" value={draft.summary} onChange={e => { setDraft({ ...draft, summary: e.target.value }) }} />
|
|
222
|
+
<div className={css.row} style={{ gap: 6 }}>
|
|
223
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px', flex: 1 }} placeholder="幕归属(第一幕后场…)" value={draft.act ?? ''} onChange={e => { setDraft({ ...draft, act: e.target.value }) }} />
|
|
224
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px', flex: 1 }} placeholder="时间光态(雨夜/闭店后…)" value={draft.moment ?? ''} onChange={e => { setDraft({ ...draft, moment: e.target.value }) }} />
|
|
225
|
+
</div>
|
|
226
|
+
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 48 }} placeholder="关键镜头(每行一条:人物动作+情绪+镜头)" value={(draft.beats ?? []).join('\n')} onChange={e => { setDraft({ ...draft, beats: e.target.value.split(/\n+/).map(x => x.trim()).filter(Boolean) }) }} />
|
|
227
|
+
<input className={css.input} style={{ fontSize: 12, padding: '4px 8px' }} placeholder="人物在场状态(含标志物细节)" value={draft.characterState ?? ''} onChange={e => { setDraft({ ...draft, characterState: e.target.value }) }} />
|
|
228
|
+
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="环境构成(每行一项)" value={(draft.elements ?? []).join('\n')} onChange={e => { setDraft({ ...draft, elements: e.target.value.split(/\n+/).map(x => x.trim()).filter(Boolean) }) }} />
|
|
229
|
+
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="中文生图提示词" value={draft.zh} onChange={e => { setDraft({ ...draft, zh: e.target.value }) }} />
|
|
230
|
+
<textarea className={css.input} style={{ fontSize: 12, padding: '4px 8px', minHeight: 60 }} placeholder="英文生图提示词" value={draft.en} onChange={e => { setDraft({ ...draft, en: e.target.value }) }} />
|
|
231
|
+
<div className={css.row} style={{ gap: 6 }}>
|
|
232
|
+
<button type="button" className={`${css.button} ${css.buttonPrimary}`} disabled={busy || draft.name.trim() === ''} onClick={() => { void saveDraft() }}>保存</button>
|
|
233
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setDraft(null) }}>取消</button>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
)}
|
|
237
|
+
|
|
238
|
+
{/* 候选场景 */}
|
|
239
|
+
{candidates !== null && candidates.length > 0 && (
|
|
240
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
|
|
241
|
+
{candidates.map(s => (
|
|
242
|
+
<div key={s.name} style={{ border: '1px solid var(--nf-accent)', borderRadius: 10, padding: '8px 10px', fontSize: 12 }}>
|
|
243
|
+
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6, justifyContent: 'space-between' }}>
|
|
244
|
+
<span><b>{s.name}</b> <span className={css.meta}>· {(s.moods ?? []).join('、')}</span>{s.styleId !== undefined && <span className={css.badge}>🎨 {s.styleId}</span>}</span>
|
|
245
|
+
<span className={css.row} style={{ gap: 4 }}>
|
|
246
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => { void adopt(s) }}>采纳</button>
|
|
247
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setDraft(s); setCandidates(null) }}>修改后采纳</button>
|
|
248
|
+
</span>
|
|
249
|
+
</div>
|
|
250
|
+
<div className={css.meta} style={{ marginTop: 2 }}>{s.summary}</div>
|
|
251
|
+
<div className={css.meta} style={{ marginTop: 2, fontSize: 11 }}>要素:{(s.elements ?? []).join(';')}</div>
|
|
252
|
+
</div>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
{scenes.length === 0 && candidates === null ? (
|
|
258
|
+
<div className={css.shelfEmpty} style={{ minHeight: 140, flex: 1 }}>
|
|
259
|
+
<span className={css.shelfEmptyIcon}>🏞️</span>
|
|
260
|
+
<span className={css.shelfEmptyTitle}>场景库为空</span>
|
|
261
|
+
<span className={css.meta}>点「✨ 按当前方案提炼场景」自动建立,或手动新增</span>
|
|
262
|
+
</div>
|
|
263
|
+
) : (
|
|
264
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 10 }}>
|
|
265
|
+
{scenes.map(s => {
|
|
266
|
+
const cover = (s.gallery ?? [])[0]
|
|
267
|
+
return (
|
|
268
|
+
<div key={s.name} onClick={() => { openDetail(s.name) }} style={{ cursor: 'pointer', border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden', background: 'var(--nf-bg-inset)', display: 'flex', flexDirection: 'column' }}>
|
|
269
|
+
<div style={{ height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--nf-bg-inset)', borderBottom: '1px solid var(--nf-border)', position: 'relative' }}>
|
|
270
|
+
{cover !== undefined
|
|
271
|
+
? <img src={cover.dataUrl} alt={s.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
272
|
+
: <span className={css.meta}>暂无场景图</span>}
|
|
273
|
+
<span style={{ position: 'absolute', bottom: 4, right: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', fontSize: 10, borderRadius: 6, padding: '1px 6px' }}>🖼 {(s.gallery ?? []).length}</span>
|
|
274
|
+
</div>
|
|
275
|
+
<div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
276
|
+
<b style={{ fontSize: 13 }}>{s.name}</b>{s.styleId !== undefined && <span className={css.badge} style={{ fontSize: 9 }}>🎨 {s.styleId}</span>}
|
|
277
|
+
<div className={css.meta} style={{ fontSize: 10 }}>{(s.moods ?? []).join('、') || s.summary.slice(0, 16)}</div>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
)
|
|
281
|
+
})}
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{/* 场景详情浮窗(portal 到 body,避免 .view 的 backdrop-filter 破坏 fixed 定位) */}
|
|
286
|
+
{detail !== undefined && createPortal(
|
|
287
|
+
<div style={{ ...portalVars, position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 2147483000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }} onClick={e => { if (e.target === e.currentTarget) setDetailName(null) }}>
|
|
288
|
+
<div style={{ position: 'relative', background: 'var(--nf-bg)', border: '1px solid var(--nf-border)', borderRadius: 16, padding: 16, width: 'min(860px, 100%)', maxHeight: '88vh', overflow: 'auto', marginTop: 24 }}>
|
|
289
|
+
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 10, right: 10 }} title="关闭" aria-label="关闭" onClick={() => { setDetailName(null) }}>✕</button>
|
|
290
|
+
<div style={{ marginBottom: 12 }}>
|
|
291
|
+
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' }}>
|
|
292
|
+
<b style={{ fontSize: 20 }}>{detail.name}</b>
|
|
293
|
+
{detail.styleId !== undefined && <span className={css.badge}>🎨 {detail.styleId}</span>}
|
|
294
|
+
{(detail.act !== undefined && detail.act !== '') && <span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{detail.act}</span>}
|
|
295
|
+
{(detail.moment !== undefined && detail.moment !== '') && <span className={css.meta}>{detail.moment}</span>}
|
|
296
|
+
</div>
|
|
297
|
+
<div className={css.meta} style={{ marginTop: 4 }}>{detail.summary}</div>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* 图集 */}
|
|
301
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: 8, marginBottom: 12 }}>
|
|
302
|
+
{(detail.gallery ?? []).map(img => (
|
|
303
|
+
<div key={img.label} style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden', position: 'relative' }}>
|
|
304
|
+
<img src={img.dataUrl} alt={img.label} style={{ width: '100%', height: 100, objectFit: 'cover', display: 'block' }} />
|
|
305
|
+
<div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '1px 6px', textAlign: 'center' }}>{img.label}</div>
|
|
306
|
+
<button type="button" className={css.iconButton} style={{ position: 'absolute', top: 2, right: 2, fontSize: 11 }} title="删除" aria-label="删除" onClick={() => { void removeImage(detail.name, img.label) }}>×</button>
|
|
307
|
+
</div>
|
|
308
|
+
))}
|
|
309
|
+
{(detail.gallery ?? []).length === 0 && <span className={css.meta}>暂无场景图</span>}
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
{/* 场景信息(紧凑卡片) */}
|
|
313
|
+
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px', marginBottom: 10, fontSize: 12 }}>
|
|
314
|
+
<b style={{ fontSize: 12 }}>场景信息</b>
|
|
315
|
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '6px 14px', marginTop: 4, lineHeight: 1.7 }}>
|
|
316
|
+
<div>
|
|
317
|
+
<b style={{ fontSize: 11 }}>关键镜头</b>
|
|
318
|
+
<div style={{ marginTop: 2 }}>{(detail.beats ?? []).map((b, i) => <div key={i} style={{ fontSize: 11, color: 'var(--nf-text-2)', marginTop: 2 }}>🎬 {b}</div>)}</div>
|
|
319
|
+
</div>
|
|
320
|
+
<div>
|
|
321
|
+
{(detail.characterState !== undefined && detail.characterState !== '') && (
|
|
322
|
+
<div style={{ marginBottom: 6 }}>
|
|
323
|
+
<b style={{ fontSize: 11 }}>人物状态</b>
|
|
324
|
+
<div className={css.meta} style={{ marginTop: 2 }}>{detail.characterState}</div>
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
327
|
+
<b style={{ fontSize: 11 }}>环境构成</b>
|
|
328
|
+
<div style={{ marginTop: 2 }}>{(detail.elements ?? []).map(e => <span key={e} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '1px 8px', fontSize: 11, margin: '0 4px 4px 0', display: 'inline-block' }}>{e}</span>)}</div>
|
|
329
|
+
<div style={{ marginTop: 6 }}><b style={{ fontSize: 11 }}>色调光影</b> <span className={css.meta}>{(detail.palette ?? []).join(';') || '—'}</span></div>
|
|
330
|
+
<div style={{ marginTop: 4 }}><b style={{ fontSize: 11 }}>氛围</b> <span className={css.meta}>{(detail.moods ?? []).join('、') || '—'}</span></div>
|
|
331
|
+
<div style={{ marginTop: 4 }}><b style={{ fontSize: 11 }}>依据</b> <span className={css.meta}>{detail.source || '—'}</span></div>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
{/* 生图提示词:全局中英 + 复制全部 */}
|
|
337
|
+
<div style={{ marginBottom: 12 }}>
|
|
338
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', marginBottom: 6 }}>
|
|
339
|
+
<b style={{ fontSize: 13 }}>🎨 生图提示词</b>
|
|
340
|
+
<span className={css.row} style={{ gap: 6, flexWrap: 'wrap' }}>
|
|
341
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${sceneLang === 'zh' ? css.buttonPrimary : ''}`} onClick={() => { setSceneLang('zh') }}>🇨🇳 中文</button>
|
|
342
|
+
<button type="button" className={`${css.button} ${css.buttonSmall} ${sceneLang === 'en' ? css.buttonPrimary : ''}`} onClick={() => { setSceneLang('en') }}>🇬🇧 English</button>
|
|
343
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => {
|
|
344
|
+
const text = '【中文】\n' + detail.zh + '\n\n【English】\n' + detail.en
|
|
345
|
+
void navigator.clipboard?.writeText(text).then(() => { notify('已复制「' + detail.name + '」中英提示词') }).catch(() => { /* ignore */ })
|
|
346
|
+
}}>📋 复制全部</button>
|
|
347
|
+
</span>
|
|
348
|
+
</div>
|
|
349
|
+
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: 8 }}>
|
|
350
|
+
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
351
|
+
<b style={{ fontSize: 11 }}>{sceneLang === 'zh' ? '🇨🇳 中文' : '🇬🇧 English'}</b>
|
|
352
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ padding: '1px 6px', fontSize: 10 }} onClick={() => {
|
|
353
|
+
void navigator.clipboard?.writeText(sceneLang === 'zh' ? detail.zh : detail.en).then(() => { notify('已复制「' + detail.name + '」' + (sceneLang === 'zh' ? '中文' : '英文') + '提示词') }).catch(() => { /* ignore */ })
|
|
354
|
+
}}>复制</button>
|
|
355
|
+
</div>
|
|
356
|
+
<div className={css.meta} style={{ fontSize: 11, lineHeight: 1.6, marginTop: 4, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{sceneLang === 'zh' ? detail.zh : detail.en}</div>
|
|
357
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 8 }}>
|
|
358
|
+
{(detail.tags ?? []).map(t => <span key={t} style={{ border: '1px solid var(--nf-border)', borderRadius: 999, padding: '2px 8px', fontSize: 11 }}>{t}</span>)}
|
|
359
|
+
</div>
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
{/* 操作条 */}
|
|
364
|
+
<div style={{ borderTop: '1px solid var(--nf-border)', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
|
365
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => {
|
|
366
|
+
const text = [detail.en, detail.tags.join(', ')].filter(Boolean).join('\n')
|
|
367
|
+
void navigator.clipboard?.writeText(text).then(() => { notify('已复制「' + detail.name + '」英文生图提示词') }).catch(() => { /* ignore */ })
|
|
368
|
+
}}>复制提示词</button>
|
|
369
|
+
<input className={css.input} style={{ width: 130, fontSize: 12, padding: '3px 6px' }} placeholder="图集标签" value={uploadLabel} onChange={e => { setUploadLabel(e.target.value) }} />
|
|
370
|
+
<input
|
|
371
|
+
ref={inputRef}
|
|
372
|
+
type="file"
|
|
373
|
+
accept="image/*"
|
|
374
|
+
style={{ display: 'none' }}
|
|
375
|
+
onChange={e => {
|
|
376
|
+
const file = e.target.files?.[0]
|
|
377
|
+
if (file === undefined || uploadTarget === null) return
|
|
378
|
+
void uploadImage(file)
|
|
379
|
+
}}
|
|
380
|
+
/>
|
|
381
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} disabled={busy} onClick={() => {
|
|
382
|
+
setUploadLabel(uploadLabel.trim() !== '' ? uploadLabel.trim() : '全景')
|
|
383
|
+
setUploadTarget(detail.name)
|
|
384
|
+
inputRef.current?.click()
|
|
385
|
+
}}>📤 上传场景图</button>
|
|
386
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} onClick={() => { setDraft(detail); setDetailName(null) }}>✏️ 编辑</button>
|
|
387
|
+
<button type="button" className={`${css.button} ${css.buttonSmall}`} style={{ color: 'var(--nf-danger, #e05)' }} onClick={() => { void removeScene(detail.name) }}>🗑 删除</button>
|
|
388
|
+
</div>
|
|
389
|
+
</div>
|
|
390
|
+
</div>,
|
|
391
|
+
document.body,
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
)
|
|
395
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* 进入小说工坊默认展示;点击书卡进入该书工作台,+ 进入开书向导页。
|
|
4
4
|
*/
|
|
5
5
|
import { useEffect, useMemo, useState } from 'react'
|
|
6
|
+
import { BookOpen, Download, Library, PenLine, Plus, Search } from 'lucide-react'
|
|
6
7
|
import type { NovelApi } from '../api.ts'
|
|
7
8
|
import type { BookshelfSnapshot } from '../../protocol.ts'
|
|
8
9
|
import css from './panel.module.css'
|
|
@@ -94,7 +95,7 @@ function BookCard({
|
|
|
94
95
|
onClick={onOpen}
|
|
95
96
|
title="进入创作工作台(大纲/章节/审稿/设定)"
|
|
96
97
|
>
|
|
97
|
-
|
|
98
|
+
<PenLine size={13} style={{ verticalAlign: -2 }} /> 进入工作台
|
|
98
99
|
</button>
|
|
99
100
|
<button
|
|
100
101
|
type="button"
|
|
@@ -103,7 +104,7 @@ function BookCard({
|
|
|
103
104
|
onClick={onRead}
|
|
104
105
|
title={book.hasProject && book.done > 0 ? '沉浸式阅读已写章节' : '尚无已写章节,先去工作台创作'}
|
|
105
106
|
>
|
|
106
|
-
|
|
107
|
+
<BookOpen size={13} style={{ verticalAlign: -2 }} /> 阅读
|
|
107
108
|
</button>
|
|
108
109
|
</div>
|
|
109
110
|
</div>
|
|
@@ -155,25 +156,11 @@ export function ShelfView({
|
|
|
155
156
|
})
|
|
156
157
|
}, [shelf.books, query, filter])
|
|
157
158
|
|
|
158
|
-
const filters: Array<{ id: 'all' | 'active' | 'done' | 'none'; label: string; count: number }> = [
|
|
159
|
-
{ id: 'all', label: '全部', count: shelf.books.length },
|
|
160
|
-
{ id: 'active', label: '进行中', count: stats.active },
|
|
161
|
-
{ id: 'done', label: '已完结', count: stats.done },
|
|
162
|
-
{ id: 'none', label: '未开书', count: stats.none },
|
|
163
|
-
]
|
|
164
|
-
|
|
165
159
|
return (
|
|
166
160
|
<div className={css.shelfView}>
|
|
167
|
-
<div className={css.
|
|
161
|
+
<div className={css.shelfHero}>
|
|
168
162
|
<div className={css.shelfTitleRow}>
|
|
169
|
-
<h2 className={css.panelTitle} style={{ margin: 0 }}
|
|
170
|
-
{shelf.books.length > 0 && (
|
|
171
|
-
<span className={css.meta}>
|
|
172
|
-
{shelf.books.length} 本书 · {stats.active} 进行中 · {stats.done} 已完结 · {stats.none} 未开书
|
|
173
|
-
</span>
|
|
174
|
-
)}
|
|
175
|
-
</div>
|
|
176
|
-
<div className={css.shelfToolbar}>
|
|
163
|
+
<h2 className={css.panelTitle} style={{ margin: 0 }}><Library size={20} style={{ verticalAlign: -3 }} /> AI小说工坊 · 书架</h2>
|
|
177
164
|
<input
|
|
178
165
|
className={`${css.input} ${css.shelfSearch}`}
|
|
179
166
|
type="search"
|
|
@@ -182,25 +169,31 @@ export function ShelfView({
|
|
|
182
169
|
onChange={e => { setQuery(e.target.value) }}
|
|
183
170
|
/>
|
|
184
171
|
</div>
|
|
185
|
-
|
|
186
|
-
{
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
172
|
+
{shelf.books.length > 0 && (
|
|
173
|
+
<div className={css.shelfStats}>
|
|
174
|
+
{[
|
|
175
|
+
{ id: 'all' as const, label: '总书数', value: shelf.books.length },
|
|
176
|
+
{ id: 'active' as const, label: '进行中', value: stats.active },
|
|
177
|
+
{ id: 'done' as const, label: '已完结', value: stats.done },
|
|
178
|
+
{ id: 'none' as const, label: '未开书', value: stats.none },
|
|
179
|
+
].map(s => (
|
|
180
|
+
<div
|
|
181
|
+
key={s.id}
|
|
182
|
+
className={`${css.shelfStat} ${filter === s.id ? css.shelfStatActive : ''}`}
|
|
183
|
+
title={s.id === 'all' ? '显示全部书籍' : `只看「${s.label}」的书(再点一次取消筛选)`}
|
|
184
|
+
onClick={() => { setFilter(prev => prev === s.id ? 'all' : s.id) }}
|
|
185
|
+
>
|
|
186
|
+
<b>{s.value}</b><span>{s.label}</span>
|
|
187
|
+
</div>
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
198
191
|
</div>
|
|
199
192
|
|
|
200
193
|
{shelf.books.length === 0 ? (
|
|
201
194
|
/* 空书架引导卡 */
|
|
202
195
|
<div className={css.shelfEmpty}>
|
|
203
|
-
<span className={css.shelfEmptyIcon}
|
|
196
|
+
<span className={css.shelfEmptyIcon}><BookOpen size={34} /></span>
|
|
204
197
|
<span className={css.shelfEmptyTitle}>你的创作从这里开始</span>
|
|
205
198
|
<span className={css.meta}>开一本书:粘贴大纲或导入 docx,书名自动识别,开书即建项目</span>
|
|
206
199
|
<button type="button" className={`${css.button} ${css.buttonPrimary}`} onClick={onAddBook}>
|
|
@@ -212,7 +205,7 @@ export function ShelfView({
|
|
|
212
205
|
</div>
|
|
213
206
|
) : visible.length === 0 ? (
|
|
214
207
|
<div className={css.shelfEmpty} style={{ minHeight: 160 }}>
|
|
215
|
-
<span className={css.shelfEmptyIcon}
|
|
208
|
+
<span className={css.shelfEmptyIcon}><Search size={34} /></span>
|
|
216
209
|
<span className={css.shelfEmptyTitle}>没有符合条件的书</span>
|
|
217
210
|
<span className={css.meta}>换个关键词或筛选条件试试</span>
|
|
218
211
|
</div>
|
|
@@ -231,14 +224,14 @@ export function ShelfView({
|
|
|
231
224
|
|
|
232
225
|
{/* 开书入口:进入独立向导页 */}
|
|
233
226
|
<div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onAddBook}>
|
|
234
|
-
<div className={css.bookAddIcon}
|
|
227
|
+
<div className={css.bookAddIcon}><Plus size={28} /></div>
|
|
235
228
|
<span>开一本新书</span>
|
|
236
229
|
<span className={css.meta}>书名 + 大纲,开书即建项目</span>
|
|
237
230
|
</div>
|
|
238
231
|
|
|
239
232
|
{/* 导入入口:已有项目目录 / txt/md 全本 */}
|
|
240
233
|
<div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onImportBook}>
|
|
241
|
-
<div className={css.bookAddIcon}
|
|
234
|
+
<div className={css.bookAddIcon}><Download size={28} /></div>
|
|
242
235
|
<span>导入已有小说</span>
|
|
243
236
|
<span className={css.meta}>项目目录或 txt/md 全本</span>
|
|
244
237
|
</div>
|