@waterwx/dsh-novel-forge 1.7.0 → 1.7.2
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 +2537 -1858
- package/lib/client.js.map +1 -1
- package/lib/index.js +657 -45
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +2 -0
- package/lib/types/client/locales.d.ts +0 -1
- package/lib/types/client/panel/FlowGuide.d.ts +21 -0
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +18 -0
- package/lib/types/client/panel/MangaWorkspace.d.ts +2 -2
- package/lib/types/client/panel/StoryboardTab.d.ts +5 -1
- package/lib/types/engine.d.ts +15 -8
- package/lib/types/protocol.d.ts +124 -0
- package/package.json +2 -1
- package/src/client/api.ts +5 -0
- package/src/client/locales.ts +0 -1
- package/src/client/panel/AssetsTab.tsx +22 -22
- package/src/client/panel/AssistantTab.tsx +3 -3
- package/src/client/panel/BookshelfBar.tsx +1 -1
- package/src/client/panel/CreateBookView.tsx +10 -10
- package/src/client/panel/FlowGuide.tsx +83 -0
- package/src/client/panel/MangaRoleLibrary.tsx +810 -0
- package/src/client/panel/MangaWorkspace.tsx +359 -297
- package/src/client/panel/NovelPanel.tsx +107 -107
- package/src/client/panel/ReaderView.tsx +4 -4
- package/src/client/panel/RunPanel.tsx +8 -8
- package/src/client/panel/SceneLibrary.tsx +68 -67
- package/src/client/panel/StoryboardTab.tsx +105 -41
- package/src/client/panel/StyleCard.tsx +13 -13
- package/src/client/panel/StyleGallery.tsx +2 -2
- package/src/client/panel/WorldTab.tsx +5 -5
- package/src/client/panel/panel.module.css +861 -510
- package/src/client/panel/views.tsx +24 -24
- package/src/engine.ts +482 -51
- package/src/protocol.ts +125 -0
- package/src/routes.ts +203 -0
- package/src/client/panel/RoleVisualPanel.tsx +0 -485
|
@@ -1,485 +0,0 @@
|
|
|
1
|
-
// 角色形象完整面板(提取自 NovelPanel roleImage tab,左导航与漫剧工作台共用)
|
|
2
|
-
// 形象锚点 / 四类生图提示词(中英 Tab 分开)/ 参考图集 / 视觉规则 / 豆包生成
|
|
3
|
-
import { useEffect, useRef, useState } from 'react'
|
|
4
|
-
import { createPortal } from 'react-dom'
|
|
5
|
-
import type { NovelApi } from '../api.ts'
|
|
6
|
-
import type { ImageModelConfig, ProjectState } from '../../protocol.ts'
|
|
7
|
-
import css from './panel.module.css'
|
|
8
|
-
|
|
9
|
-
export function RoleVisualPanel({
|
|
10
|
-
api,
|
|
11
|
-
project,
|
|
12
|
-
refresh,
|
|
13
|
-
styleId,
|
|
14
|
-
filterId,
|
|
15
|
-
imageApiEnabled,
|
|
16
|
-
imageModels,
|
|
17
|
-
onGotoRoles,
|
|
18
|
-
onProgress,
|
|
19
|
-
}: {
|
|
20
|
-
api: NovelApi
|
|
21
|
-
project: ProjectState | null
|
|
22
|
-
refresh: () => void | Promise<void>
|
|
23
|
-
styleId?: string
|
|
24
|
-
filterId?: string
|
|
25
|
-
imageApiEnabled?: boolean
|
|
26
|
-
/** 生图模型库(AI 生图时可选择用哪条)。 */
|
|
27
|
-
imageModels?: ImageModelConfig[]
|
|
28
|
-
onGotoRoles?: () => void
|
|
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 [roleImageTarget, setRoleImageTarget] = useState<string | null>(null)
|
|
36
|
-
const [roleImageLabel, setRoleImageLabel] = useState('立绘')
|
|
37
|
-
const [detailRoleName, setDetailRoleName] = useState<string | null>(null)
|
|
38
|
-
const [detailUploadLabel, setDetailUploadLabel] = useState('立绘')
|
|
39
|
-
/** AI 生图时选择的模型条目 id('' = 用设置中启用的模型)。 */
|
|
40
|
-
const [imageModelId, setImageModelId] = useState('')
|
|
41
|
-
/** 提示词包全局中英切换(浮窗内所有提示词块共用)。 */
|
|
42
|
-
const [kitLang, setKitLang] = useState<'zh' | 'en'>('zh')
|
|
43
|
-
const inputRef = useRef<HTMLInputElement | null>(null)
|
|
44
|
-
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
45
|
-
/** 浮窗挂到 document.body 时复制的面板配色变量(--nf-*),保证主题一致。 */
|
|
46
|
-
const [portalVars, setPortalVars] = useState<Record<string, string>>({})
|
|
47
|
-
|
|
48
|
-
// Esc 关闭浮窗(仅浮窗打开时监听)
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
if (detailRoleName === null) return
|
|
51
|
-
const onKey = (e: KeyboardEvent): void => {
|
|
52
|
-
if (e.key === 'Escape') setDetailRoleName(null)
|
|
53
|
-
}
|
|
54
|
-
window.addEventListener('keydown', onKey)
|
|
55
|
-
return () => { window.removeEventListener('keydown', onKey) }
|
|
56
|
-
}, [detailRoleName])
|
|
57
|
-
|
|
58
|
-
const notify = (msg: string): void => { setNotice(msg); setTimeout(() => { setNotice('') }, 3000) }
|
|
59
|
-
const report = (text: string, kind: 'info' | 'done' | 'error' = 'info'): void => { onProgress?.(text, kind) }
|
|
60
|
-
|
|
61
|
-
/** 打开角色详情浮窗:记录目标并复制当前面板主题变量(浮窗在 body 下渲染需要)。 */
|
|
62
|
-
const openDetail = (name: string): void => {
|
|
63
|
-
setDetailRoleName(name)
|
|
64
|
-
setDetailUploadLabel('立绘')
|
|
65
|
-
if (rootRef.current !== null) {
|
|
66
|
-
const cs = getComputedStyle(rootRef.current)
|
|
67
|
-
const vars: Record<string, string> = {}
|
|
68
|
-
for (let i = 0; i < cs.length; i++) {
|
|
69
|
-
const key = cs[i]
|
|
70
|
-
if (key.startsWith('--nf-')) {
|
|
71
|
-
const v = cs.getPropertyValue(key).trim()
|
|
72
|
-
if (v !== '') vars[key] = v
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
setPortalVars(vars)
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const handleVisual = async (name: string): Promise<void> => {
|
|
80
|
-
setBusy(true); setError('')
|
|
81
|
-
report('生成「' + name + '」形象锚点(按当前方案风格)…')
|
|
82
|
-
try {
|
|
83
|
-
await api.roles({ op: 'visual', name, styleId, filterId })
|
|
84
|
-
await refresh()
|
|
85
|
-
notify('已生成「' + name + '」形象锚点(按当前方案风格)')
|
|
86
|
-
report('已生成「' + name + '」形象锚点(按当前方案风格)', 'done')
|
|
87
|
-
} catch (err) {
|
|
88
|
-
const m = (err as Error).message
|
|
89
|
-
setError(m)
|
|
90
|
-
report('生成「' + name + '」形象锚点失败:' + m, 'error')
|
|
91
|
-
} finally { setBusy(false) }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const handleImageUpload = async (name: string, file: File, label = '立绘'): Promise<void> => {
|
|
95
|
-
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
96
|
-
const reader = new FileReader()
|
|
97
|
-
reader.onload = () => resolve(String(reader.result))
|
|
98
|
-
reader.onerror = () => reject(reader.error ?? new Error('读取图片失败'))
|
|
99
|
-
reader.readAsDataURL(file)
|
|
100
|
-
})
|
|
101
|
-
setBusy(true); setError('')
|
|
102
|
-
report('上传「' + name + '」图集(' + label + ')…')
|
|
103
|
-
try {
|
|
104
|
-
await api.roles({ op: 'image', name, dataUrl, label })
|
|
105
|
-
await refresh()
|
|
106
|
-
notify('已上传「' + name + '」' + label)
|
|
107
|
-
report('已上传「' + name + '」' + label, 'done')
|
|
108
|
-
} catch (err) {
|
|
109
|
-
const m = (err as Error).message
|
|
110
|
-
setError(m)
|
|
111
|
-
report('上传「' + name + '」图集失败:' + m, 'error')
|
|
112
|
-
} finally {
|
|
113
|
-
setBusy(false)
|
|
114
|
-
setRoleImageTarget(null)
|
|
115
|
-
if (inputRef.current !== null) inputRef.current.value = ''
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const handleImageGenerate = async (name: string): Promise<void> => {
|
|
120
|
-
setBusy(true); setError('')
|
|
121
|
-
report('AI 生图「' + name + '」参考图…')
|
|
122
|
-
try {
|
|
123
|
-
await api.roles({ op: 'imageGenerate', name, modelId: imageModelId !== '' ? imageModelId : undefined })
|
|
124
|
-
await refresh()
|
|
125
|
-
notify('已生成「' + name + '」参考图')
|
|
126
|
-
report('已生成「' + name + '」参考图', 'done')
|
|
127
|
-
} catch (err) {
|
|
128
|
-
const m = (err as Error).message
|
|
129
|
-
setError(m)
|
|
130
|
-
report('AI 生图「' + name + '」参考图失败:' + m, 'error')
|
|
131
|
-
} finally { setBusy(false) }
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const extractRules = async (): Promise<void> => {
|
|
135
|
-
setBusy(true); setError('')
|
|
136
|
-
report('从道藏提炼视觉规则…')
|
|
137
|
-
try {
|
|
138
|
-
const result = await api.visualRules({ op: 'extract' })
|
|
139
|
-
await refresh()
|
|
140
|
-
notify('已提炼 ' + result.rules.length + ' 条视觉规则(已注入所有提示词)')
|
|
141
|
-
report('已提炼 ' + result.rules.length + ' 条视觉规则(已注入所有提示词)', 'done')
|
|
142
|
-
} catch (err) {
|
|
143
|
-
const m = (err as Error).message
|
|
144
|
-
setError(m)
|
|
145
|
-
report('提炼视觉规则失败:' + m, 'error')
|
|
146
|
-
} finally { setBusy(false) }
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const refineKit = async (name: string): Promise<void> => {
|
|
150
|
-
setBusy(true); setError('')
|
|
151
|
-
report('生成「' + name + '」四类精修提示词(按当前方案风格)…')
|
|
152
|
-
try {
|
|
153
|
-
await api.roles({ op: 'promptKit', name, styleId, filterId })
|
|
154
|
-
await refresh()
|
|
155
|
-
notify('已生成「' + name + '」四类精修提示词(按当前方案风格)')
|
|
156
|
-
report('已生成「' + name + '」四类精修提示词(按当前方案风格)', 'done')
|
|
157
|
-
} catch (err) {
|
|
158
|
-
const m = (err as Error).message
|
|
159
|
-
setError(m)
|
|
160
|
-
report('生成「' + name + '」精修提示词失败:' + m, 'error')
|
|
161
|
-
} finally { setBusy(false) }
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const removeGallery = async (label: string): Promise<void> => {
|
|
165
|
-
if (detailRoleName === null) return
|
|
166
|
-
try {
|
|
167
|
-
await api.roles({ op: 'removeImage', name: detailRoleName, label })
|
|
168
|
-
await refresh()
|
|
169
|
-
report('已移除「' + detailRoleName + '」图集 ' + label, 'done')
|
|
170
|
-
} catch (err) {
|
|
171
|
-
const m = (err as Error).message
|
|
172
|
-
setError(m)
|
|
173
|
-
report('移除「' + detailRoleName + '」图集 ' + label + ' 失败:' + m, 'error')
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
if (project === null) {
|
|
178
|
-
return <div className={css.card}><span className={css.meta}>请先开书或选择一本书。</span></div>
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const roles = project.roles ?? []
|
|
182
|
-
|
|
183
|
-
return (
|
|
184
|
-
<div ref={rootRef} className={css.card} style={{ flex: 1, minHeight: 0 }}>
|
|
185
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
186
|
-
<span className={css.cardTitle} style={{ fontSize: 17, fontWeight: 700 }}>🖼️ 角色形象</span>
|
|
187
|
-
<span className={css.meta}>{styleId !== undefined ? '按当前方案风格(' + styleId + ')' : '未指定方案风格'} · 复制提示词即可在即梦、豆包等工具出图</span>
|
|
188
|
-
</div>
|
|
189
|
-
|
|
190
|
-
<input
|
|
191
|
-
ref={inputRef}
|
|
192
|
-
type='file'
|
|
193
|
-
accept='image/*'
|
|
194
|
-
style={{ display: 'none' }}
|
|
195
|
-
onChange={e => {
|
|
196
|
-
const file = e.target.files?.[0]
|
|
197
|
-
const target = roleImageTarget
|
|
198
|
-
if (file === undefined || target === null) return
|
|
199
|
-
const readDataUrl = (): Promise<string> => new Promise((resolve, reject) => {
|
|
200
|
-
const reader = new FileReader()
|
|
201
|
-
reader.onload = () => resolve(String(reader.result))
|
|
202
|
-
reader.onerror = () => reject(reader.error ?? new Error('读取图片失败'))
|
|
203
|
-
reader.readAsDataURL(file!)
|
|
204
|
-
})
|
|
205
|
-
void (async () => {
|
|
206
|
-
const dataUrl = await readDataUrl()
|
|
207
|
-
if (target.endsWith('||scene')) {
|
|
208
|
-
const sceneName = target.slice(0, -'||scene'.length)
|
|
209
|
-
setBusy(true); setError('')
|
|
210
|
-
try {
|
|
211
|
-
report('上传场景「' + sceneName + '」' + roleImageLabel + '…')
|
|
212
|
-
await api.scenes({ op: 'image', name: sceneName, dataUrl, label: roleImageLabel })
|
|
213
|
-
await refresh()
|
|
214
|
-
notify('已上传场景「' + sceneName + '」' + roleImageLabel)
|
|
215
|
-
report('已上传场景「' + sceneName + '」' + roleImageLabel, 'done')
|
|
216
|
-
} catch (err) {
|
|
217
|
-
const m = (err as Error).message
|
|
218
|
-
setError(m)
|
|
219
|
-
report('上传场景「' + sceneName + '」失败:' + m, 'error')
|
|
220
|
-
} finally {
|
|
221
|
-
setBusy(false)
|
|
222
|
-
setRoleImageTarget(null)
|
|
223
|
-
if (inputRef.current !== null) inputRef.current.value = ''
|
|
224
|
-
}
|
|
225
|
-
} else {
|
|
226
|
-
await handleImageUpload(target, file, roleImageLabel)
|
|
227
|
-
}
|
|
228
|
-
})()
|
|
229
|
-
}}
|
|
230
|
-
/>
|
|
231
|
-
|
|
232
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, flex: 1, minHeight: 0, overflow: 'auto' }}>
|
|
233
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 6, justifyContent: 'space-between' }}>
|
|
234
|
-
<span className={css.row} style={{ gap: 6 }}>
|
|
235
|
-
<button type='button' className={css.button + ' ' + css.buttonSmall} disabled={busy} onClick={() => { void extractRules() }}>⚠️ 提炼视觉规则</button>
|
|
236
|
-
<button type='button' className={css.button + ' ' + css.buttonSmall} onClick={() => { void refresh(); notify('角色库已刷新') }}>🔄 刷新角色库</button>
|
|
237
|
-
</span>
|
|
238
|
-
</div>
|
|
239
|
-
|
|
240
|
-
{(project.visualRules ?? []).length > 0 && (
|
|
241
|
-
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center', marginTop: 4 }}>
|
|
242
|
-
<span className={css.meta} style={{ fontSize: 11 }}>⚠️ 本书视觉规则:</span>
|
|
243
|
-
{(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>)}
|
|
244
|
-
</div>
|
|
245
|
-
)}
|
|
246
|
-
|
|
247
|
-
{error !== '' && <div className={css.importError}>{error}</div>}
|
|
248
|
-
{notice !== '' && <div className={css.importResult} style={{ padding: '6px 10px' }}>{notice}</div>}
|
|
249
|
-
|
|
250
|
-
{roles.length === 0 ? (
|
|
251
|
-
<div className={css.shelfEmpty} style={{ minHeight: 140, flex: 1 }}>
|
|
252
|
-
<span className={css.shelfEmptyIcon}>🎭</span>
|
|
253
|
-
<span className={css.shelfEmptyTitle}>角色库为空</span>
|
|
254
|
-
<span className={css.meta}>请先到「角色库」提炼角色并生成形象锚点</span>
|
|
255
|
-
{onGotoRoles !== undefined && <button type='button' className={css.button + ' ' + css.buttonSmall} onClick={onGotoRoles}>去角色库</button>}
|
|
256
|
-
</div>
|
|
257
|
-
) : (
|
|
258
|
-
<>
|
|
259
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 10 }}>
|
|
260
|
-
{roles.map(r => (
|
|
261
|
-
<div key={r.name} onClick={() => { openDetail(r.name) }} style={{ border: '1px solid var(--nf-border)', borderRadius: 12, overflow: 'hidden', cursor: 'pointer', background: 'var(--nf-bg-raise)' }}>
|
|
262
|
-
<div style={{ aspectRatio: '3/4', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', background: 'var(--nf-bg-inset)' }}>
|
|
263
|
-
{r.imageUrl !== undefined
|
|
264
|
-
? <img src={r.imageUrl} alt={r.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
265
|
-
: <span className={css.meta}>暂无形象</span>}
|
|
266
|
-
<span style={{ position: 'absolute', bottom: 4, right: 4, background: 'rgba(0,0,0,0.6)', color: '#fff', borderRadius: 999, padding: '0 6px', fontSize: 9 }}>{r.roleLabel}</span>
|
|
267
|
-
</div>
|
|
268
|
-
<div style={{ padding: 6, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
269
|
-
<b style={{ fontSize: 13 }}>{r.name}</b>
|
|
270
|
-
<div className={css.meta} style={{ fontSize: 10, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
271
|
-
{r.imagePrompt !== undefined ? '✓ 锚点' : '⚠ 无锚点'} · {(r.identity ?? '').slice(0, 14)}
|
|
272
|
-
</div>
|
|
273
|
-
</div>
|
|
274
|
-
</div>
|
|
275
|
-
))}
|
|
276
|
-
</div>
|
|
277
|
-
|
|
278
|
-
{detailRoleName !== null && (() => {
|
|
279
|
-
const detailRole = roles.find(r => r.name === detailRoleName)
|
|
280
|
-
if (detailRole === undefined) return null
|
|
281
|
-
const g = detailRole.gallery ?? []
|
|
282
|
-
const byLabel = (key: string) => g.filter(x => x.label === key)
|
|
283
|
-
const byPrefix = (pre: string) => g.filter(x => x.label.startsWith(pre))
|
|
284
|
-
const group = {
|
|
285
|
-
portrait: byLabel('立绘')[0],
|
|
286
|
-
sheet: byLabel('四视图')[0],
|
|
287
|
-
expressions: byPrefix('表情-'),
|
|
288
|
-
others: g.filter(x => x.label !== '立绘' && x.label !== '四视图' && !x.label.startsWith('表情-')),
|
|
289
|
-
}
|
|
290
|
-
const hasMain = group.portrait !== undefined || group.sheet !== undefined
|
|
291
|
-
const anchor = detailRole.imagePrompt
|
|
292
|
-
const expressions = detailRole.expressions ?? ['平静']
|
|
293
|
-
const kit = detailRole.promptKit
|
|
294
|
-
const blocks: Array<{ key: string; title: string; zh: string; en: string }> = []
|
|
295
|
-
if (kit !== undefined) {
|
|
296
|
-
blocks.push({ key: 'portrait', title: '立绘', zh: kit.portrait.zh, en: kit.portrait.en })
|
|
297
|
-
blocks.push({ key: 'sheet', title: '四视图', zh: kit.sheet.zh, en: kit.sheet.en })
|
|
298
|
-
for (const e of kit.expressions) blocks.push({ key: 'exp-' + e.name, title: '表情·' + e.name, zh: e.zh, en: e.en })
|
|
299
|
-
blocks.push({ key: 'details', title: '细节', zh: kit.details.zh, en: kit.details.en })
|
|
300
|
-
} else if (anchor !== undefined) {
|
|
301
|
-
const expName = (n: string) => n.replace(/^表情-/, '')
|
|
302
|
-
blocks.push({ key: 'portrait', title: '立绘', zh: anchor.zh, en: anchor.en })
|
|
303
|
-
for (const n of expressions) blocks.push({ key: 'exp-' + n, title: '表情·' + expName(n), zh: '脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(' + n + ')', en: 'face close-up, ' + anchor.en })
|
|
304
|
-
}
|
|
305
|
-
const blockCard = (b: { key: string; title: string; zh: string; en: string }) => (
|
|
306
|
-
<div key={b.key} style={{ border: '1px solid var(--nf-border)', borderRadius: 8, padding: 8 }}>
|
|
307
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
308
|
-
<b style={{ fontSize: 11 }}>{b.title}</b>
|
|
309
|
-
<button type='button' className={css.button + ' ' + css.buttonSmall} style={{ padding: '1px 6px', fontSize: 10 }} onClick={() => { void navigator.clipboard?.writeText(kitLang === 'zh' ? b.zh : b.en); notify('已复制「' + b.title + '」' + (kitLang === 'zh' ? '中文' : '英文')) }}>复制</button>
|
|
310
|
-
</div>
|
|
311
|
-
<div className={css.meta} style={{ fontSize: 11, lineHeight: 1.6, marginTop: 4 }}>{kitLang === 'zh' ? b.zh : b.en}</div>
|
|
312
|
-
</div>
|
|
313
|
-
)
|
|
314
|
-
return createPortal(
|
|
315
|
-
<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) setDetailRoleName(null) }}>
|
|
316
|
-
<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 }}>
|
|
317
|
-
<button type='button' className={css.iconButton} style={{ position: 'absolute', top: 10, right: 10 }} onClick={() => { setDetailRoleName(null) }}>✕</button>
|
|
318
|
-
<div style={{ marginBottom: 12 }}>
|
|
319
|
-
<div style={{ display: 'flex', alignItems: 'baseline', gap: 10, flexWrap: 'wrap' }}>
|
|
320
|
-
<b style={{ fontSize: 22 }}>{detailRole.name}</b>
|
|
321
|
-
<span className={css.badge} style={{ borderColor: 'var(--nf-accent)', color: 'var(--nf-accent)' }}>{detailRole.roleLabel}</span>
|
|
322
|
-
<span className={css.badge} style={{ borderColor: 'var(--nf-info)', color: 'var(--nf-info)' }}>🎨 提示词风格:{detailRole.promptStyleId ?? '未记录'}</span>
|
|
323
|
-
</div>
|
|
324
|
-
<div className={css.meta} style={{ marginTop: 4 }}>{detailRole.identity}</div>
|
|
325
|
-
</div>
|
|
326
|
-
|
|
327
|
-
{styleId !== undefined && detailRole.promptStyleId !== styleId && (
|
|
328
|
-
<div style={{ border: '1px solid var(--nf-warn, #b8860b)', borderRadius: 10, padding: '8px 10px', marginBottom: 10, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
|
329
|
-
<b style={{ fontSize: 12, color: 'var(--nf-warn, #b8860b)' }}>⚠️ 提示词风格与当前方案不一致(当前:{styleId},角色:{detailRole.promptStyleId ?? '未记录'})</b>
|
|
330
|
-
<button type='button' className={css.button + ' ' + css.buttonSmall} disabled={busy} onClick={() => { void handleVisual(detailRole.name) }}>✨ 按当前风格重生成锚点</button>
|
|
331
|
-
<button type='button' className={css.button + ' ' + css.buttonSmall} disabled={busy} onClick={() => { void refineKit(detailRole.name) }}>✨ 按当前风格重生成精修包</button>
|
|
332
|
-
</div>
|
|
333
|
-
)}
|
|
334
|
-
|
|
335
|
-
<details style={{ marginBottom: 10, fontSize: 12 }}>
|
|
336
|
-
<summary style={{ cursor: 'pointer', color: 'var(--nf-text-2)' }}>🖼 图集({g.length})</summary>
|
|
337
|
-
<div style={{ marginTop: 8 }}>
|
|
338
|
-
{hasMain && (
|
|
339
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 8, marginBottom: 12 }}>
|
|
340
|
-
{group.portrait !== undefined && (
|
|
341
|
-
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden' }}>
|
|
342
|
-
<img src={group.portrait.dataUrl} alt='立绘' style={{ width: '100%', objectFit: 'cover' }} />
|
|
343
|
-
<div className={css.meta} style={{ padding: '2px 8px', fontSize: 10 }}>立绘</div>
|
|
344
|
-
</div>
|
|
345
|
-
)}
|
|
346
|
-
{group.sheet !== undefined && (
|
|
347
|
-
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden' }}>
|
|
348
|
-
<img src={group.sheet.dataUrl} alt='四视图' style={{ width: '100%', objectFit: 'cover' }} />
|
|
349
|
-
<div className={css.meta} style={{ padding: '2px 8px', fontSize: 10 }}>四视图</div>
|
|
350
|
-
</div>
|
|
351
|
-
)}
|
|
352
|
-
</div>
|
|
353
|
-
)}
|
|
354
|
-
{group.expressions.length > 0 && (
|
|
355
|
-
<div style={{ marginBottom: 12 }}>
|
|
356
|
-
<b style={{ fontSize: 13 }}>表情设定</b>
|
|
357
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: 8, marginTop: 4 }}>
|
|
358
|
-
{group.expressions.map(img => (
|
|
359
|
-
<div key={img.label} style={{ position: 'relative', border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden' }}>
|
|
360
|
-
<img src={img.dataUrl} alt={img.label} style={{ width: '100%', objectFit: 'cover' }} />
|
|
361
|
-
<div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '2px 6px' }}>{img.label}</div>
|
|
362
|
-
<button type='button' className={css.iconButton} style={{ position: 'absolute', top: 4, right: 4 }} onClick={() => { void removeGallery(img.label) }}>×</button>
|
|
363
|
-
</div>
|
|
364
|
-
))}
|
|
365
|
-
</div>
|
|
366
|
-
</div>
|
|
367
|
-
)}
|
|
368
|
-
{group.others.length > 0 && (
|
|
369
|
-
<div style={{ marginBottom: 12 }}>
|
|
370
|
-
<b style={{ fontSize: 13 }}>场景 / 细节</b>
|
|
371
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: 8, marginTop: 4 }}>
|
|
372
|
-
{group.others.map(img => (
|
|
373
|
-
<div key={img.label} style={{ position: 'relative', border: '1px solid var(--nf-border)', borderRadius: 10, overflow: 'hidden' }}>
|
|
374
|
-
<img src={img.dataUrl} alt={img.label} style={{ width: '100%', objectFit: 'cover' }} />
|
|
375
|
-
<div style={{ background: 'rgba(0,0,0,0.55)', color: '#fff', fontSize: 10, padding: '2px 6px' }}>{img.label}</div>
|
|
376
|
-
<button type='button' className={css.iconButton} style={{ position: 'absolute', top: 4, right: 4 }} onClick={() => { void removeGallery(img.label) }}>×</button>
|
|
377
|
-
</div>
|
|
378
|
-
))}
|
|
379
|
-
</div>
|
|
380
|
-
</div>
|
|
381
|
-
)}
|
|
382
|
-
</div>
|
|
383
|
-
</details>
|
|
384
|
-
|
|
385
|
-
{/* 基本信息:紧凑卡片,不再与提示词包左右分栏 */}
|
|
386
|
-
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 10, padding: '8px 10px', marginBottom: 10, fontSize: 12 }}>
|
|
387
|
-
<b style={{ fontSize: 12 }}>基本信息</b>
|
|
388
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(170px, 1fr))', gap: '2px 14px', marginTop: 4, lineHeight: 1.7 }}>
|
|
389
|
-
<div><span className={css.meta}>性格:</span>{(detailRole.traits ?? []).join('、') || '—'}</div>
|
|
390
|
-
<div><span className={css.meta}>目标:</span>{detailRole.goals || '—'}</div>
|
|
391
|
-
<div><span className={css.meta}>关系:</span>{(detailRole.relations ?? []).join('、') || '—'}</div>
|
|
392
|
-
<div><span className={css.meta}>成长线:</span>{(detailRole.arc ?? []).join(';') || '—'}</div>
|
|
393
|
-
</div>
|
|
394
|
-
</div>
|
|
395
|
-
|
|
396
|
-
{/* 提示词包:全局中英切换 + 复制全部 + 分栏卡片 */}
|
|
397
|
-
<div>
|
|
398
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap', marginBottom: 8 }}>
|
|
399
|
-
<b style={{ fontSize: 13 }}>📄 提示词包</b>
|
|
400
|
-
<span className={css.row} style={{ gap: 6, flexWrap: 'wrap' }}>
|
|
401
|
-
<button type='button' className={css.button + ' ' + css.buttonSmall + (kitLang === 'zh' ? ' ' + css.buttonPrimary : '')} onClick={() => { setKitLang('zh') }}>🇨🇳 中文</button>
|
|
402
|
-
<button type='button' className={css.button + ' ' + css.buttonSmall + (kitLang === 'en' ? ' ' + css.buttonPrimary : '')} onClick={() => { setKitLang('en') }}>🇬🇧 English</button>
|
|
403
|
-
{blocks.length > 0 && (
|
|
404
|
-
<button type='button' className={css.button + ' ' + css.buttonSmall} onClick={() => {
|
|
405
|
-
const text = blocks.map(b => '【' + b.title + '】\n' + (kitLang === 'zh' ? b.zh : b.en)).join('\n\n')
|
|
406
|
-
void navigator.clipboard?.writeText(text).then(() => { notify('已复制全部提示词(' + (kitLang === 'zh' ? '中文' : '英文') + ')') }).catch(() => { /* ignore */ })
|
|
407
|
-
}}>📋 复制全部</button>
|
|
408
|
-
)}
|
|
409
|
-
{anchor !== undefined && kit === undefined && (
|
|
410
|
-
<button type='button' className={css.button + ' ' + css.buttonSmall} disabled={busy} onClick={() => { void refineKit(detailRole.name) }}>✨ 生成精修版</button>
|
|
411
|
-
)}
|
|
412
|
-
</span>
|
|
413
|
-
</div>
|
|
414
|
-
{anchor === undefined && <span className={css.meta}>未生成锚点——点下方「✨ 生成锚点」</span>}
|
|
415
|
-
{blocks.length > 0 && (
|
|
416
|
-
<>
|
|
417
|
-
{blocks.filter(b => b.key === 'portrait' || b.key === 'sheet').length > 0 && (
|
|
418
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 8 }}>
|
|
419
|
-
{blocks.filter(b => b.key === 'portrait' || b.key === 'sheet').map(b => blockCard(b))}
|
|
420
|
-
</div>
|
|
421
|
-
)}
|
|
422
|
-
{blocks.some(b => b.key.startsWith('exp-')) && (
|
|
423
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 8, marginTop: 8 }}>
|
|
424
|
-
{blocks.filter(b => b.key.startsWith('exp-')).map(b => blockCard(b))}
|
|
425
|
-
</div>
|
|
426
|
-
)}
|
|
427
|
-
{blocks.filter(b => b.key === 'details').length > 0 && (
|
|
428
|
-
<div style={{ marginTop: 8 }}>{blocks.filter(b => b.key === 'details').map(b => blockCard(b))}</div>
|
|
429
|
-
)}
|
|
430
|
-
</>
|
|
431
|
-
)}
|
|
432
|
-
{anchor !== undefined && (
|
|
433
|
-
<div className={css.meta} style={{ fontSize: 10, marginTop: 6 }}>关键词:{(anchor.tags ?? []).join('、')}</div>
|
|
434
|
-
)}
|
|
435
|
-
</div>
|
|
436
|
-
|
|
437
|
-
<div style={{ borderTop: '1px solid var(--nf-border)', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
|
438
|
-
{anchor === undefined && (
|
|
439
|
-
<button type='button' className={css.button + ' ' + css.buttonSmall} disabled={busy} onClick={() => { void handleVisual(detailRole.name) }}>✨ 生成锚点</button>
|
|
440
|
-
)}
|
|
441
|
-
{anchor !== undefined && imageApiEnabled === true && (
|
|
442
|
-
<span className={css.row} style={{ gap: 4 }}>
|
|
443
|
-
<select
|
|
444
|
-
className={css.input}
|
|
445
|
-
style={{ width: 150, fontSize: 11, padding: '3px 6px' }}
|
|
446
|
-
value={imageModelId}
|
|
447
|
-
onChange={e => { setImageModelId(e.target.value) }}
|
|
448
|
-
title="选择生图模型(缺省用设置中启用的模型)"
|
|
449
|
-
>
|
|
450
|
-
<option value="">默认(启用模型)</option>
|
|
451
|
-
{(imageModels ?? []).map(m => <option key={m.id} value={m.id}>{m.name !== '' ? m.name : (m.model !== '' ? m.model : m.id)}{m.enabled ? ' ✓' : ''}</option>)}
|
|
452
|
-
</select>
|
|
453
|
-
<button type='button' className={css.button + ' ' + css.buttonSmall} disabled={busy} onClick={() => { void handleImageGenerate(detailRole.name) }}>AI 生图</button>
|
|
454
|
-
</span>
|
|
455
|
-
)}
|
|
456
|
-
{anchor !== undefined && (
|
|
457
|
-
<button type='button' className={css.button + ' ' + css.buttonSmall} onClick={() => {
|
|
458
|
-
const text = [anchor.en, (anchor.tags ?? []).join(', ')].filter(Boolean).join('\n')
|
|
459
|
-
void navigator.clipboard?.writeText(text).then(() => { notify('已复制「' + detailRole.name + '」英文生图提示词') }).catch(() => { /* ignore */ })
|
|
460
|
-
}}>复制提示词</button>
|
|
461
|
-
)}
|
|
462
|
-
<input
|
|
463
|
-
className={css.input}
|
|
464
|
-
style={{ width: 140, fontSize: 12, padding: '3px 6px' }}
|
|
465
|
-
placeholder='图集标签'
|
|
466
|
-
value={detailUploadLabel}
|
|
467
|
-
onChange={e => { setDetailUploadLabel(e.target.value) }}
|
|
468
|
-
/>
|
|
469
|
-
<button type='button' className={css.button + ' ' + css.buttonSmall} disabled={busy} onClick={() => {
|
|
470
|
-
setRoleImageLabel(detailUploadLabel.trim() !== '' ? detailUploadLabel.trim() : '立绘')
|
|
471
|
-
setRoleImageTarget(detailRole.name)
|
|
472
|
-
inputRef.current?.click()
|
|
473
|
-
}}>📤 上传图</button>
|
|
474
|
-
</div>
|
|
475
|
-
</div>
|
|
476
|
-
</div>,
|
|
477
|
-
document.body,
|
|
478
|
-
)
|
|
479
|
-
})() }
|
|
480
|
-
</>
|
|
481
|
-
)}
|
|
482
|
-
</div>
|
|
483
|
-
</div>
|
|
484
|
-
)
|
|
485
|
-
}
|