@waterwx/dsh-novel-forge 2.1.2-alpha → 2.1.3
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 +278 -368
- package/lib/client.js +19568 -21381
- package/lib/index.js +5158 -3958
- package/lib/types/action-guard.d.ts +61 -0
- package/lib/types/ai-scan.d.ts +80 -12
- package/lib/types/assets.d.ts +7 -9
- package/lib/types/assistant.d.ts +25 -0
- package/lib/types/author-assets.d.ts +1 -3
- package/lib/types/bookshelf.d.ts +13 -3
- package/lib/types/client/api.d.ts +49 -29
- package/lib/types/client/chip-prefs.d.ts +36 -0
- package/lib/types/client/docx.d.ts +2 -2
- package/lib/types/client/llmLive.d.ts +12 -14
- package/lib/types/client/locales.d.ts +12 -67
- package/lib/types/client/mount.d.ts +0 -2
- package/lib/types/client/panel/AdaptModeView.d.ts +1 -1
- package/lib/types/client/panel/AiTag.d.ts +1 -0
- package/lib/types/client/panel/AssetsTab.d.ts +1 -1
- package/lib/types/client/panel/AssistantTab.d.ts +4 -1
- package/lib/types/client/panel/AuthorAssetsView.d.ts +1 -1
- package/lib/types/client/panel/AuthorHome.d.ts +12 -12
- package/lib/types/client/panel/CmdPalette.d.ts +12 -0
- package/lib/types/client/panel/CreateBookView.d.ts +1 -1
- package/lib/types/client/panel/GlobalAssetLibraryView.d.ts +1 -1
- package/lib/types/client/panel/ImportModal.d.ts +1 -1
- package/lib/types/client/panel/LiveFeedLog.d.ts +8 -0
- package/lib/types/client/panel/ModelManager.d.ts +2 -2
- package/lib/types/client/panel/NovelPanel.d.ts +7 -2
- package/lib/types/client/panel/PageHeader.d.ts +18 -0
- package/lib/types/client/panel/ProgressConsole.d.ts +1 -1
- package/lib/types/client/panel/PromptSlotsView.d.ts +11 -0
- package/lib/types/client/panel/ReaderView.d.ts +1 -1
- package/lib/types/client/panel/RunPanel.d.ts +1 -1
- package/lib/types/client/panel/SettingsView.d.ts +13 -10
- package/lib/types/client/panel/ShelfView.d.ts +11 -2
- package/lib/types/client/panel/SubPage.d.ts +27 -0
- package/lib/types/client/panel/TensionView.d.ts +18 -0
- package/lib/types/client/panel/TimelineView.d.ts +18 -0
- package/lib/types/client/panel/WorldTab.d.ts +1 -1
- package/lib/types/client/panel/helpers.d.ts +5 -3
- package/lib/types/client/panel/market-radar-state.d.ts +56 -0
- package/lib/types/client/panel/outline-markdown.d.ts +112 -0
- package/lib/types/client/panel/status-badge.d.ts +13 -0
- package/lib/types/client/panel/storage-parse.d.ts +46 -0
- package/lib/types/client/panel/toast.d.ts +2 -0
- package/lib/types/client/panel/usePersistentState.d.ts +17 -0
- package/lib/types/client/panel/views.d.ts +21 -35
- package/lib/types/client/run-status.d.ts +21 -0
- package/lib/types/client/sidebar-entry.d.ts +0 -2
- package/lib/types/digest-parse.d.ts +29 -0
- package/lib/types/docx-core.d.ts +16 -0
- package/lib/types/docx.d.ts +2 -3
- package/lib/types/engine.d.ts +122 -103
- package/lib/types/home.d.ts +11 -0
- package/lib/types/index.d.ts +21 -13
- package/lib/types/llm-live.d.ts +62 -36
- package/lib/types/llm-retry.d.ts +38 -0
- package/lib/types/market-radar-scan.d.ts +0 -1
- package/lib/types/market-radar-sources.d.ts +1 -1
- package/lib/types/migrate-output.d.ts +25 -0
- package/lib/types/model-capability.d.ts +119 -0
- package/lib/types/model-request.d.ts +36 -0
- package/lib/types/output-budget.d.ts +86 -0
- package/lib/types/plotline-align.d.ts +35 -0
- package/lib/types/prompt-slots.d.ts +57 -0
- package/lib/types/prompting.d.ts +2 -27
- package/lib/types/protocol.d.ts +479 -595
- package/lib/types/revision.d.ts +76 -0
- package/lib/types/routes.d.ts +9 -3
- package/lib/types/run-selection.d.ts +51 -0
- package/lib/types/run.d.ts +31 -0
- package/lib/types/settings-persist.d.ts +59 -0
- package/lib/types/snapshots.d.ts +46 -0
- package/lib/types/stage-contract.d.ts +58 -0
- package/lib/types/stream-fallback.d.ts +60 -0
- package/lib/types/strip-headings.d.ts +33 -0
- package/lib/types/style-fingerprint.d.ts +78 -0
- package/lib/types/tension.d.ts +53 -0
- package/lib/types/timeline.d.ts +37 -0
- package/package.json +41 -28
- package/assets/styles/arcane-thick-3d.webp +0 -0
- package/assets/styles/chinese-ink.webp +0 -0
- package/assets/styles/clay-stopmotion-3d.webp +0 -0
- package/assets/styles/dark-ghibli.webp +0 -0
- package/assets/styles/dark-gothic-3d.webp +0 -0
- package/assets/styles/film-photo.webp +0 -0
- package/assets/styles/guofeng-heavy.webp +0 -0
- package/assets/styles/guoman-2d-gufeng.webp +0 -0
- package/assets/styles/guoman-3d-tianyuan.webp +0 -0
- package/assets/styles/guoman-3d-xuanhuan.webp +0 -0
- package/assets/styles/horror-game-3d.webp +0 -0
- package/assets/styles/hyperreal-3d.webp +0 -0
- package/assets/styles/japan-cel-dark.webp +0 -0
- package/assets/styles/korean-webtoon.webp +0 -0
- package/assets/styles/low-cyberpunk-3d.webp +0 -0
- package/assets/styles/nextgen-cg.webp +0 -0
- package/assets/styles/noir-film.webp +0 -0
- package/assets/styles/openworld-survival.webp +0 -0
- package/assets/styles/paper-cut-3d.webp +0 -0
- package/assets/styles/pixar-adult-3d.webp +0 -0
- package/assets/styles/pop-marvel-2d.webp +0 -0
- package/assets/styles/sandiao-qban.webp +0 -0
- package/assets/styles/shinkai-anime.webp +0 -0
- package/assets/styles/tactical-stealth.webp +0 -0
- package/assets/styles/urban-lightreal-3d.webp +0 -0
- package/assets/styles/vaporwave-3d.webp +0 -0
- package/assets/styles/vhs90.webp +0 -0
- package/lib/types/client/panel/BookshelfBar.d.ts +0 -11
- package/lib/types/client/panel/FlowGuide.d.ts +0 -22
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +0 -20
- package/lib/types/client/panel/MangaWorkspace.d.ts +0 -12
- package/lib/types/client/panel/ModelPicker.d.ts +0 -11
- package/lib/types/client/panel/ProgressHomeView.d.ts +0 -5
- package/lib/types/client/panel/PropLibrary.d.ts +0 -8
- package/lib/types/client/panel/SceneLibrary.d.ts +0 -16
- package/lib/types/client/panel/StoryboardTab.d.ts +0 -19
- package/lib/types/client/panel/StyleCard.d.ts +0 -8
- package/lib/types/client/panel/StyleGallery.d.ts +0 -1
- package/lib/types/comic-presets.d.ts +0 -12
- package/lib/types/manga-genre-rules.d.ts +0 -22
- package/lib/types/prompt-utils.d.ts +0 -35
- package/lib/types/review-policy.d.ts +0 -97
- package/lib/types/shot-language.d.ts +0 -46
- package/lib/types/src/ai-scan.d.ts +0 -36
- package/lib/types/src/assets.d.ts +0 -37
- package/lib/types/src/assistant.d.ts +0 -45
- package/lib/types/src/author-assets.d.ts +0 -25
- package/lib/types/src/bookshelf.d.ts +0 -42
- package/lib/types/src/client/api.d.ts +0 -234
- package/lib/types/src/client/docx.d.ts +0 -15
- package/lib/types/src/client/index.d.ts +0 -14
- package/lib/types/src/client/locales.d.ts +0 -212
- package/lib/types/src/client/mount.d.ts +0 -9
- package/lib/types/src/client/panel/AdaptModeView.d.ts +0 -5
- package/lib/types/src/client/panel/AssetsTab.d.ts +0 -12
- package/lib/types/src/client/panel/AssistantTab.d.ts +0 -7
- package/lib/types/src/client/panel/AuthorAssetsView.d.ts +0 -4
- package/lib/types/src/client/panel/AuthorHome.d.ts +0 -32
- package/lib/types/src/client/panel/BookshelfBar.d.ts +0 -11
- package/lib/types/src/client/panel/CreateBookView.d.ts +0 -9
- package/lib/types/src/client/panel/FlowGuide.d.ts +0 -22
- package/lib/types/src/client/panel/GlobalAssetLibraryView.d.ts +0 -4
- package/lib/types/src/client/panel/ImportModal.d.ts +0 -7
- package/lib/types/src/client/panel/MangaRoleLibrary.d.ts +0 -20
- package/lib/types/src/client/panel/MangaWorkspace.d.ts +0 -12
- package/lib/types/src/client/panel/ModelManager.d.ts +0 -14
- package/lib/types/src/client/panel/NovelPanel.d.ts +0 -13
- package/lib/types/src/client/panel/ProgressConsole.d.ts +0 -17
- package/lib/types/src/client/panel/ProgressHomeView.d.ts +0 -5
- package/lib/types/src/client/panel/PropLibrary.d.ts +0 -8
- package/lib/types/src/client/panel/ReaderView.d.ts +0 -8
- package/lib/types/src/client/panel/ReasoningSection.d.ts +0 -10
- package/lib/types/src/client/panel/RunPanel.d.ts +0 -5
- package/lib/types/src/client/panel/SceneLibrary.d.ts +0 -16
- package/lib/types/src/client/panel/SettingsView.d.ts +0 -16
- package/lib/types/src/client/panel/ShelfView.d.ts +0 -15
- package/lib/types/src/client/panel/StoryboardTab.d.ts +0 -19
- package/lib/types/src/client/panel/StyleCard.d.ts +0 -8
- package/lib/types/src/client/panel/StyleGallery.d.ts +0 -1
- package/lib/types/src/client/panel/WorldTab.d.ts +0 -10
- package/lib/types/src/client/panel/controller.d.ts +0 -19
- package/lib/types/src/client/panel/helpers.d.ts +0 -18
- package/lib/types/src/client/panel/views.d.ts +0 -77
- package/lib/types/src/client/sidebar-entry.d.ts +0 -13
- package/lib/types/src/client/text.d.ts +0 -9
- package/lib/types/src/comic-presets.d.ts +0 -12
- package/lib/types/src/docx.d.ts +0 -19
- package/lib/types/src/engine.d.ts +0 -402
- package/lib/types/src/index.d.ts +0 -79
- package/lib/types/src/manga-genre-rules.d.ts +0 -22
- package/lib/types/src/novel-context.d.ts +0 -80
- package/lib/types/src/prompt-utils.d.ts +0 -35
- package/lib/types/src/protocol.d.ts +0 -2176
- package/lib/types/src/review-policy.d.ts +0 -97
- package/lib/types/src/routes.d.ts +0 -29
- package/lib/types/src/run.d.ts +0 -32
- package/lib/types/src/shot-language.d.ts +0 -46
- package/lib/types/src/story-beat-language.d.ts +0 -35
- package/lib/types/src/style-library.d.ts +0 -33
- package/lib/types/story-beat-language.d.ts +0 -35
- package/lib/types/style-library.d.ts +0 -33
- package/src/ai-scan.ts +0 -179
- package/src/assets.ts +0 -1774
- package/src/assistant.ts +0 -915
- package/src/author-assets.ts +0 -147
- package/src/bookshelf.ts +0 -167
- package/src/client/api.ts +0 -716
- package/src/client/css-modules.d.ts +0 -8
- package/src/client/docx.ts +0 -69
- package/src/client/index.ts +0 -34
- package/src/client/llmLive.ts +0 -101
- package/src/client/locales.ts +0 -418
- package/src/client/mount.tsx +0 -97
- package/src/client/mount.tsx._new +0 -97
- package/src/client/mount.tsx._nf +0 -97
- package/src/client/mount.tsx._tmp +0 -97
- package/src/client/panel/AdaptModeView.tsx +0 -535
- package/src/client/panel/AssetsTab.tsx +0 -458
- package/src/client/panel/AssistantTab.tsx +0 -339
- package/src/client/panel/AuthorAssetsView.tsx +0 -208
- package/src/client/panel/AuthorHome.tsx +0 -234
- package/src/client/panel/BookAnalysisView.tsx +0 -60
- package/src/client/panel/BookshelfBar.tsx +0 -170
- package/src/client/panel/CreateBookView.tsx +0 -371
- package/src/client/panel/DirectorView.tsx +0 -200
- package/src/client/panel/FlowGuide.tsx +0 -84
- package/src/client/panel/GlobalAssetLibraryView.tsx +0 -172
- package/src/client/panel/IdeaInspirationView.tsx +0 -71
- package/src/client/panel/ImportModal.tsx +0 -303
- package/src/client/panel/KnowledgeBaseView.tsx +0 -73
- package/src/client/panel/LiveFeedLog.tsx +0 -76
- package/src/client/panel/MangaRoleLibrary.tsx +0 -1002
- package/src/client/panel/MangaWorkspace.tsx +0 -715
- package/src/client/panel/MarketRadarView.tsx +0 -374
- package/src/client/panel/ModelManager.tsx +0 -205
- package/src/client/panel/NovelPanel.tsx +0 -4721
- package/src/client/panel/ProgressConsole.tsx +0 -63
- package/src/client/panel/ProgressHomeView.tsx +0 -87
- package/src/client/panel/PropLibrary.tsx +0 -175
- package/src/client/panel/ReaderView.tsx +0 -306
- package/src/client/panel/ReasoningSection.tsx +0 -31
- package/src/client/panel/RunPanel.tsx +0 -204
- package/src/client/panel/SceneLibrary.tsx +0 -453
- package/src/client/panel/SettingsView.tsx +0 -289
- package/src/client/panel/ShelfView.tsx +0 -242
- package/src/client/panel/StoryboardTab.tsx +0 -472
- package/src/client/panel/StyleCard.tsx +0 -90
- package/src/client/panel/StyleGallery.tsx +0 -58
- package/src/client/panel/WorldTab.tsx +0 -202
- package/src/client/panel/controller.ts +0 -45
- package/src/client/panel/helpers.ts +0 -67
- package/src/client/panel/panel.module.css +0 -4359
- package/src/client/panel/views.tsx +0 -314
- package/src/client/sidebar-entry.ts +0 -122
- package/src/client/text.ts +0 -28
- package/src/comic-presets.ts +0 -67
- package/src/docx.ts +0 -83
- package/src/engine.ts +0 -5636
- package/src/global-assets.ts +0 -59
- package/src/index.ts +0 -346
- package/src/llm-live.ts +0 -68
- package/src/manga-genre-rules.ts +0 -87
- package/src/market-radar-scan.ts +0 -41
- package/src/market-radar-sources.ts +0 -173
- package/src/novel-context.ts +0 -287
- package/src/prompt-utils.ts +0 -73
- package/src/prompting.ts +0 -159
- package/src/protocol.ts +0 -2565
- package/src/review-policy.ts +0 -161
- package/src/routes.ts +0 -3939
- package/src/run.ts +0 -338
- package/src/shot-language.ts +0 -181
- package/src/story-beat-language.ts +0 -130
- package/src/style-library.ts +0 -84
|
@@ -1,1002 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 漫剧角色库(制作角色投影):
|
|
3
|
-
* 从本集分镜提名 → 小说角色库匹配(规则过滤 + LLM 确认两段式)→ 导入建卡。
|
|
4
|
-
* 独立存储于 project.mangaRoles;sourceRoleName 只读追溯,禁止反向写回小说库。
|
|
5
|
-
*/
|
|
6
|
-
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
7
|
-
import type { NovelApi } from '../api.ts'
|
|
8
|
-
import type { ImageModelConfig, MangaRoleCandidate, MangaRoleCard, ProjectState } from '../../protocol.ts'
|
|
9
|
-
import css from './panel.module.css'
|
|
10
|
-
|
|
11
|
-
const CORE_FUNCTIONS: Array<{ id: MangaRoleCard['coreFunction']; label: string }> = [
|
|
12
|
-
{ id: 'protagonist', label: '主角' },
|
|
13
|
-
{ id: 'antagonist', label: '反派' },
|
|
14
|
-
{ id: 'love_interest', label: '感情线' },
|
|
15
|
-
{ id: 'mentor', label: '导师' },
|
|
16
|
-
{ id: 'sidekick', label: '搭档' },
|
|
17
|
-
{ id: 'informant', label: '线人' },
|
|
18
|
-
{ id: 'functional', label: '功能性' },
|
|
19
|
-
]
|
|
20
|
-
|
|
21
|
-
const RELATIONS: Array<{ id: MangaRoleCard['protagonistRelation']; label: string }> = [
|
|
22
|
-
{ id: 'enemy', label: '敌对' },
|
|
23
|
-
{ id: 'friend', label: '友方' },
|
|
24
|
-
{ id: 'mentor', label: '师徒' },
|
|
25
|
-
{ id: 'lover', label: '情感' },
|
|
26
|
-
{ id: 'exploit', label: '利用' },
|
|
27
|
-
{ id: 'neutral', label: '中立' },
|
|
28
|
-
]
|
|
29
|
-
|
|
30
|
-
const STATUS_LABEL: Record<MangaRoleCard['status'], string> = {
|
|
31
|
-
pending_match: '待匹配',
|
|
32
|
-
pending_confirm: '待确认',
|
|
33
|
-
imported: '已导入',
|
|
34
|
-
anchored: '已定妆',
|
|
35
|
-
archived: '已归档',
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const STATUS_CLASS: Record<MangaRoleCard['status'], string> = {
|
|
39
|
-
pending_match: css.badgePending,
|
|
40
|
-
pending_confirm: css.badgePending,
|
|
41
|
-
imported: css.badgeWritten,
|
|
42
|
-
anchored: css.badgeDone,
|
|
43
|
-
archived: css.badgePending,
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
interface Draft {
|
|
47
|
-
name: string
|
|
48
|
-
identity: string
|
|
49
|
-
coreFunction: MangaRoleCard['coreFunction']
|
|
50
|
-
protagonistRelation: MangaRoleCard['protagonistRelation']
|
|
51
|
-
speechStyle: string
|
|
52
|
-
traits: string
|
|
53
|
-
appearance: string
|
|
54
|
-
keyScenes: string
|
|
55
|
-
tier: 'protagonist' | 'supporting' | 'extra'
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const emptyDraft = (): Draft => ({
|
|
59
|
-
name: '',
|
|
60
|
-
identity: '',
|
|
61
|
-
coreFunction: 'functional',
|
|
62
|
-
protagonistRelation: 'neutral',
|
|
63
|
-
speechStyle: '',
|
|
64
|
-
traits: '',
|
|
65
|
-
appearance: '',
|
|
66
|
-
keyScenes: '',
|
|
67
|
-
tier: 'protagonist',
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
const draftFromSuggested = (s: MangaRoleCandidate['suggested'], tier?: MangaRoleCard['tier']): Draft => ({
|
|
71
|
-
name: s.name,
|
|
72
|
-
identity: s.identity,
|
|
73
|
-
coreFunction: s.coreFunction,
|
|
74
|
-
protagonistRelation: s.protagonistRelation,
|
|
75
|
-
speechStyle: s.speechStyle,
|
|
76
|
-
traits: s.traits.join('、'),
|
|
77
|
-
appearance: s.appearance,
|
|
78
|
-
keyScenes: s.keyScenes.join(';'),
|
|
79
|
-
tier: tier ?? 'protagonist',
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
const draftFromCard = (c: MangaRoleCard): Draft => ({
|
|
83
|
-
name: c.name,
|
|
84
|
-
identity: c.identity,
|
|
85
|
-
coreFunction: c.coreFunction,
|
|
86
|
-
protagonistRelation: c.protagonistRelation,
|
|
87
|
-
speechStyle: c.speechStyle,
|
|
88
|
-
traits: c.traits.join('、'),
|
|
89
|
-
appearance: c.appearance,
|
|
90
|
-
keyScenes: c.keyScenes.join(';'),
|
|
91
|
-
tier: c.tier ?? 'protagonist',
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
/** 草稿 → 漫剧卡(保留已有形象资产;新建时 id 为空由服务端分配)。 */
|
|
95
|
-
const draftToCard = (d: Draft, sourceRoleName: string | undefined, chapterNo: number | undefined, existing?: MangaRoleCard): MangaRoleCard => {
|
|
96
|
-
const now = new Date().toISOString()
|
|
97
|
-
const traits = d.traits.split(/[、,,]/).map(t => t.trim()).filter(t => t !== '').slice(0, 3)
|
|
98
|
-
const keyScenes = d.keyScenes.split(/[;;]/).map(k => k.trim()).filter(k => k !== '').slice(0, 5)
|
|
99
|
-
const episodes = existing !== undefined
|
|
100
|
-
? (chapterNo !== undefined && chapterNo > 0 && !existing.appearsInEpisodes.includes(chapterNo)
|
|
101
|
-
? [...existing.appearsInEpisodes, chapterNo].sort((a, b) => a - b)
|
|
102
|
-
: existing.appearsInEpisodes)
|
|
103
|
-
: (chapterNo !== undefined && chapterNo > 0 ? [chapterNo] : [])
|
|
104
|
-
return {
|
|
105
|
-
id: existing?.id ?? '',
|
|
106
|
-
sourceRoleName,
|
|
107
|
-
name: d.name.trim(),
|
|
108
|
-
identity: d.identity.trim(),
|
|
109
|
-
coreFunction: d.coreFunction,
|
|
110
|
-
protagonistRelation: d.protagonistRelation,
|
|
111
|
-
speechStyle: d.speechStyle.trim(),
|
|
112
|
-
traits,
|
|
113
|
-
appearance: d.appearance.trim(),
|
|
114
|
-
tier: d.tier,
|
|
115
|
-
keyScenes,
|
|
116
|
-
appearsInEpisodes: episodes,
|
|
117
|
-
status: existing?.status ?? 'imported',
|
|
118
|
-
imagePrompt: existing?.imagePrompt,
|
|
119
|
-
expressions: existing?.expressions,
|
|
120
|
-
promptKit: existing?.promptKit,
|
|
121
|
-
imageUrl: existing?.imageUrl,
|
|
122
|
-
gallery: existing?.gallery,
|
|
123
|
-
promptStyleId: existing?.promptStyleId,
|
|
124
|
-
createdAt: existing?.createdAt ?? now,
|
|
125
|
-
updatedAt: now,
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export function MangaRoleLibrary({
|
|
130
|
-
api,
|
|
131
|
-
project,
|
|
132
|
-
refresh,
|
|
133
|
-
styleId,
|
|
134
|
-
filterId,
|
|
135
|
-
focus,
|
|
136
|
-
showCards,
|
|
137
|
-
chapterNo: externalChapter,
|
|
138
|
-
onProgress,
|
|
139
|
-
}: {
|
|
140
|
-
api: NovelApi
|
|
141
|
-
project: ProjectState | null
|
|
142
|
-
refresh: () => void | Promise<void>
|
|
143
|
-
styleId?: string
|
|
144
|
-
filterId?: string
|
|
145
|
-
/** 是否启用生图(豆包等出定妆图)。 */
|
|
146
|
-
imageApiEnabled?: boolean
|
|
147
|
-
/** 生图模型库(出定妆图时可选择用哪条)。 */
|
|
148
|
-
imageModels?: ImageModelConfig[]
|
|
149
|
-
/** 步骤页聚焦:import=展开并滚动到导入区;cards=滚动到已建漫剧卡列表。 */
|
|
150
|
-
focus?: 'import' | 'cards'
|
|
151
|
-
/** 是否显示「已建漫剧卡」列表(第⑤步导入页可隐藏,只留导入区)。 */
|
|
152
|
-
showCards?: boolean
|
|
153
|
-
/** 全局当前章节(从工作台顶部导航条传入)。 */
|
|
154
|
-
chapterNo?: number | null
|
|
155
|
-
onProgress?: (text: string, kind?: 'info' | 'done' | 'error') => void
|
|
156
|
-
}) {
|
|
157
|
-
const cards = useMemo(() => project?.mangaRoles ?? [], [project?.mangaRoles])
|
|
158
|
-
/** 已生成过分镜的已写章节(提名入口按章选择)。 */
|
|
159
|
-
const sbChapters = useMemo(() => {
|
|
160
|
-
const withSb = new Set((project?.storyboards ?? []).map(e => e.chapterNo))
|
|
161
|
-
return (project?.chapters ?? [])
|
|
162
|
-
.filter(c => withSb.has(c.no) && c.status !== 'pending' && c.status !== 'generating' && c.status !== 'error')
|
|
163
|
-
.sort((a, b) => a.no - b.no)
|
|
164
|
-
}, [project?.storyboards, project?.chapters])
|
|
165
|
-
|
|
166
|
-
// 外部章节变化时同步内部状态
|
|
167
|
-
useEffect(() => {
|
|
168
|
-
if (externalChapter !== undefined && externalChapter !== null) {
|
|
169
|
-
setChapterNo(externalChapter)
|
|
170
|
-
}
|
|
171
|
-
}, [externalChapter])
|
|
172
|
-
|
|
173
|
-
const [busy, setBusy] = useState(false)
|
|
174
|
-
const [error, setError] = useState('')
|
|
175
|
-
const [notice, setNotice] = useState('')
|
|
176
|
-
const [importOpen, setImportOpen] = useState(false)
|
|
177
|
-
const [chapterNo, setChapterNo] = useState<number>(externalChapter ?? 0)
|
|
178
|
-
const [candidates, setCandidates] = useState<MangaRoleCandidate[] | null>(null)
|
|
179
|
-
const [showExtraCandidates, setShowExtraCandidates] = useState(false)
|
|
180
|
-
const [ignoredCandidates, setIgnoredCandidates] = useState<Set<string>>(new Set())
|
|
181
|
-
const [drafts, setDrafts] = useState<Record<string, Draft>>({})
|
|
182
|
-
const [chosen, setChosen] = useState<Record<string, string>>({})
|
|
183
|
-
const [createOpen, setCreateOpen] = useState(false)
|
|
184
|
-
const [createDraft, setCreateDraft] = useState<Draft>(emptyDraft())
|
|
185
|
-
const [editId, setEditId] = useState<string | null>(null)
|
|
186
|
-
const [editDraft, setEditDraft] = useState<Draft | null>(null)
|
|
187
|
-
/** 定妆图上传目标卡 id(null=未打开)。 */
|
|
188
|
-
const [uploadTarget, setUploadTarget] = useState<string | null>(null)
|
|
189
|
-
const [actingId, setActingId] = useState('')
|
|
190
|
-
const [uploadLabel, setUploadLabel] = useState('立绘')
|
|
191
|
-
/** 详情弹窗当前角色卡 id(null=未打开)。 */
|
|
192
|
-
const [detailCardId, setDetailCardId] = useState<string | null>(null)
|
|
193
|
-
/** tier 筛选:all/protagonist/supporting/extra。 */
|
|
194
|
-
const [tierFilter, setTierFilter] = useState<'all' | 'protagonist' | 'supporting' | 'extra'>('all')
|
|
195
|
-
const [showShortDramaCheck, setShowShortDramaCheck] = useState(false)
|
|
196
|
-
const [showAdvancedPrompts, setShowAdvancedPrompts] = useState(false)
|
|
197
|
-
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
|
198
|
-
/** 步骤页滚动目标(第⑤步导入区 / 第⑥步卡片列表)。 */
|
|
199
|
-
const headerCardRef = useRef<HTMLDivElement | null>(null)
|
|
200
|
-
const cardsCardRef = useRef<HTMLDivElement | null>(null)
|
|
201
|
-
|
|
202
|
-
// 步骤页聚焦:变化时展开/滚动到对应区域。
|
|
203
|
-
useEffect(() => {
|
|
204
|
-
if (focus === 'import') {
|
|
205
|
-
setImportOpen(true)
|
|
206
|
-
// 自动选中第一个有分镜的章节
|
|
207
|
-
if (chapterNo === 0 && sbChapters[0] !== undefined) setChapterNo(sbChapters[0].no)
|
|
208
|
-
window.setTimeout(() => { headerCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, 60)
|
|
209
|
-
} else if (focus === 'cards') {
|
|
210
|
-
setImportOpen(false)
|
|
211
|
-
window.setTimeout(() => { cardsCardRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }, 60)
|
|
212
|
-
}
|
|
213
|
-
}, [focus])
|
|
214
|
-
|
|
215
|
-
// 聚焦到导入页且章节已选、候选未加载时,自动提名(无需用户再点按钮)。
|
|
216
|
-
useEffect(() => {
|
|
217
|
-
if (focus === 'import' && chapterNo > 0 && candidates === null && !busy) {
|
|
218
|
-
void runNominate()
|
|
219
|
-
}
|
|
220
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
221
|
-
}, [focus, chapterNo])
|
|
222
|
-
|
|
223
|
-
const notify = (msg: string): void => { setNotice(msg); setTimeout(() => { setNotice('') }, 4000) }
|
|
224
|
-
const report = (text: string, kind: 'info' | 'done' | 'error' = 'info'): void => { onProgress?.(text, kind) }
|
|
225
|
-
const setDraft = (key: string, patch: Partial<Draft>): void => {
|
|
226
|
-
setDrafts(prev => ({ ...prev, [key]: { ...(prev[key] ?? emptyDraft()), ...patch } }))
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const runNominate = async (): Promise<void> => {
|
|
230
|
-
if (chapterNo <= 0) return
|
|
231
|
-
setBusy(true)
|
|
232
|
-
setError('')
|
|
233
|
-
report('第' + chapterNo + '章 漫剧角色提名中…')
|
|
234
|
-
try {
|
|
235
|
-
const res = await api.mangaRoles({ op: 'nominate', chapterNo })
|
|
236
|
-
setCandidates(res.candidates ?? null)
|
|
237
|
-
setIgnoredCandidates(new Set())
|
|
238
|
-
const d: Record<string, Draft> = {}
|
|
239
|
-
const c: Record<string, string> = {}
|
|
240
|
-
for (const cand of res.candidates ?? []) {
|
|
241
|
-
if (cand.verdict === 'already_imported') continue
|
|
242
|
-
d[cand.rawName] = draftFromSuggested(cand.suggested, cand.tier)
|
|
243
|
-
if (cand.matchedRoleName !== undefined) c[cand.rawName] = cand.matchedRoleName
|
|
244
|
-
}
|
|
245
|
-
setDrafts(d)
|
|
246
|
-
setChosen(c)
|
|
247
|
-
report('第' + chapterNo + '章 漫剧角色提名完成(' + (res.candidates ?? []).length + ' 个候选)', 'done')
|
|
248
|
-
} catch (err) {
|
|
249
|
-
const m = (err as Error).message
|
|
250
|
-
setError(m)
|
|
251
|
-
report('第' + chapterNo + '章 漫剧角色提名失败:' + m, 'error')
|
|
252
|
-
} finally {
|
|
253
|
-
setBusy(false)
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
const adoptCandidate = async (cand: MangaRoleCandidate): Promise<void> => {
|
|
258
|
-
const d = drafts[cand.rawName]
|
|
259
|
-
if (d === undefined || d.name.trim() === '') {
|
|
260
|
-
setError('角色名不能为空')
|
|
261
|
-
return
|
|
262
|
-
}
|
|
263
|
-
const source = cand.verdict === 'matched' ? (chosen[cand.rawName] ?? cand.matchedRoleName) : undefined
|
|
264
|
-
setBusy(true)
|
|
265
|
-
setError('')
|
|
266
|
-
report('导入漫剧角色「' + d.name.trim() + '」…')
|
|
267
|
-
try {
|
|
268
|
-
const card = draftToCard(d, source, chapterNo)
|
|
269
|
-
await api.mangaRoles({ op: 'adopt', card })
|
|
270
|
-
setCandidates(prev => (prev ?? []).filter(x => x.rawName !== cand.rawName))
|
|
271
|
-
await refresh()
|
|
272
|
-
report('已导入漫剧角色「' + d.name.trim() + '」', 'done')
|
|
273
|
-
notify('已导入「' + d.name.trim() + '」——可继续生成形象锚点')
|
|
274
|
-
} catch (err) {
|
|
275
|
-
const m = (err as Error).message
|
|
276
|
-
setError(m)
|
|
277
|
-
report('导入漫剧角色失败:' + m, 'error')
|
|
278
|
-
} finally {
|
|
279
|
-
setBusy(false)
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const genVisual = async (id: string, name: string): Promise<void> => {
|
|
284
|
-
setActingId(id)
|
|
285
|
-
setError('')
|
|
286
|
-
report('生成「' + name + '」形象锚点…')
|
|
287
|
-
try {
|
|
288
|
-
await api.mangaRoles({ op: 'visual', id, styleId, filterId })
|
|
289
|
-
await refresh()
|
|
290
|
-
report('已生成「' + name + '」形象锚点', 'done')
|
|
291
|
-
notify('「' + name + '」已定妆(锚点+精修包已写入)')
|
|
292
|
-
} catch (err) {
|
|
293
|
-
const m = (err as Error).message
|
|
294
|
-
setError(m)
|
|
295
|
-
report('「' + name + '」形象锚点生成失败:' + m, 'error')
|
|
296
|
-
} finally {
|
|
297
|
-
setActingId('')
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const genKit = async (id: string, name: string): Promise<void> => {
|
|
302
|
-
setActingId(id)
|
|
303
|
-
setError('')
|
|
304
|
-
report('精修「' + name + '」四类生图提示词…')
|
|
305
|
-
try {
|
|
306
|
-
await api.mangaRoles({ op: 'promptKit', id, styleId, filterId })
|
|
307
|
-
await refresh()
|
|
308
|
-
report('已精修「' + name + '」提示词包', 'done')
|
|
309
|
-
} catch (err) {
|
|
310
|
-
const m = (err as Error).message
|
|
311
|
-
setError(m)
|
|
312
|
-
report('「' + name + '」提示词精修失败:' + m, 'error')
|
|
313
|
-
} finally {
|
|
314
|
-
setActingId('')
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const removeCard = async (c: MangaRoleCard): Promise<void> => {
|
|
319
|
-
if (!window.confirm('删除漫剧角色「' + c.name + '」?只删漫剧卡,不影响小说角色库与分镜。')) return
|
|
320
|
-
setActingId(c.id)
|
|
321
|
-
setError('')
|
|
322
|
-
try {
|
|
323
|
-
await api.mangaRoles({ op: 'remove', id: c.id })
|
|
324
|
-
await refresh()
|
|
325
|
-
report('已删除漫剧角色「' + c.name + '」', 'done')
|
|
326
|
-
} catch (err) {
|
|
327
|
-
const m = (err as Error).message
|
|
328
|
-
setError(m)
|
|
329
|
-
report('删除漫剧角色失败:' + m, 'error')
|
|
330
|
-
} finally {
|
|
331
|
-
setActingId('')
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** 定妆图上传:文件 → dataURL → op=image(带标签进图集,立绘同步为参考图)。 */
|
|
336
|
-
const onUploadFile = async (cardId: string, file: File | null): Promise<void> => {
|
|
337
|
-
if (file === null) return
|
|
338
|
-
if (!file.type.startsWith('image/')) {
|
|
339
|
-
setError('请选择图片文件')
|
|
340
|
-
return
|
|
341
|
-
}
|
|
342
|
-
const dataUrl = await new Promise<string>((resolve, reject) => {
|
|
343
|
-
const reader = new FileReader()
|
|
344
|
-
reader.onload = () => resolve(String(reader.result ?? ''))
|
|
345
|
-
reader.onerror = () => reject(new Error('读取图片失败'))
|
|
346
|
-
reader.readAsDataURL(file)
|
|
347
|
-
})
|
|
348
|
-
setActingId(cardId)
|
|
349
|
-
setError('')
|
|
350
|
-
const label = uploadLabel.trim() !== '' ? uploadLabel.trim() : '立绘'
|
|
351
|
-
report('上传「' + label + '」定妆图…')
|
|
352
|
-
try {
|
|
353
|
-
await api.mangaRoles({ op: 'image', id: cardId, dataUrl, label })
|
|
354
|
-
setUploadTarget(null)
|
|
355
|
-
await refresh()
|
|
356
|
-
report('已上传「' + label + '」定妆图', 'done')
|
|
357
|
-
} catch (err) {
|
|
358
|
-
const m = (err as Error).message
|
|
359
|
-
setError(m)
|
|
360
|
-
report('定妆图上传失败:' + m, 'error')
|
|
361
|
-
} finally {
|
|
362
|
-
setActingId('')
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
const removeImage = async (c: MangaRoleCard, label: string): Promise<void> => {
|
|
367
|
-
setActingId(c.id)
|
|
368
|
-
setError('')
|
|
369
|
-
try {
|
|
370
|
-
await api.mangaRoles({ op: 'removeImage', id: c.id, label: label !== '' ? label : undefined })
|
|
371
|
-
await refresh()
|
|
372
|
-
report('已移除「' + c.name + '」的' + (label !== '' ? label + '图' : '参考图'), 'done')
|
|
373
|
-
} catch (err) {
|
|
374
|
-
const m = (err as Error).message
|
|
375
|
-
setError(m)
|
|
376
|
-
report('移除定妆图失败:' + m, 'error')
|
|
377
|
-
} finally {
|
|
378
|
-
setActingId('')
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
/** 一键复制所有角色提示词。 */
|
|
384
|
-
const copyAllPrompts = async (): Promise<void> => {
|
|
385
|
-
const lines: string[] = []
|
|
386
|
-
for (const c of cards) {
|
|
387
|
-
if (c.imagePrompt === undefined) continue
|
|
388
|
-
lines.push('=== ' + c.name + '(' + (c.tier === 'extra' ? '路人' : c.tier === 'supporting' ? '配角' : '主角') + ')===')
|
|
389
|
-
lines.push('正面:' + c.imagePrompt.zh)
|
|
390
|
-
if (c.imagePrompt.negativePrompt !== undefined && c.imagePrompt.negativePrompt !== '') {
|
|
391
|
-
lines.push('负面:' + c.imagePrompt.negativePrompt)
|
|
392
|
-
}
|
|
393
|
-
lines.push('')
|
|
394
|
-
}
|
|
395
|
-
if (lines.length === 0) {
|
|
396
|
-
notify('还没有角色生成提示词')
|
|
397
|
-
return
|
|
398
|
-
}
|
|
399
|
-
await navigator.clipboard?.writeText(lines.join('\n'))
|
|
400
|
-
notify('已复制 ' + cards.filter(c => c.imagePrompt !== undefined).length + ' 个角色的提示词')
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/** 短剧精简模式开关(5-8 上镜角色 / 功能标签 / 关系闭环 / 人设极致化)。 */
|
|
404
|
-
const toggleShortDrama = async (): Promise<void> => {
|
|
405
|
-
setBusy(true)
|
|
406
|
-
setError('')
|
|
407
|
-
const next = project?.shortDramaMode !== true
|
|
408
|
-
report(next ? '开启短剧精简模式…' : '关闭短剧精简模式…')
|
|
409
|
-
try {
|
|
410
|
-
await api.mangaRoles({ op: 'mode', shortDramaMode: next })
|
|
411
|
-
await refresh()
|
|
412
|
-
report(next ? '已开启短剧精简模式' : '已关闭短剧精简模式', 'done')
|
|
413
|
-
} catch (err) {
|
|
414
|
-
const m = (err as Error).message
|
|
415
|
-
setError(m)
|
|
416
|
-
report('切换短剧精简模式失败:' + m, 'error')
|
|
417
|
-
} finally {
|
|
418
|
-
setBusy(false)
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
const createCard = async (): Promise<void> => {
|
|
423
|
-
if (createDraft.name.trim() === '') {
|
|
424
|
-
setError('角色名不能为空')
|
|
425
|
-
return
|
|
426
|
-
}
|
|
427
|
-
setBusy(true)
|
|
428
|
-
setError('')
|
|
429
|
-
report('新建漫剧角色「' + createDraft.name.trim() + '」…')
|
|
430
|
-
try {
|
|
431
|
-
const card = draftToCard(createDraft, undefined, chapterNo > 0 ? chapterNo : undefined)
|
|
432
|
-
await api.mangaRoles({ op: 'adopt', card })
|
|
433
|
-
setCreateOpen(false)
|
|
434
|
-
setCreateDraft(emptyDraft())
|
|
435
|
-
await refresh()
|
|
436
|
-
report('已新建漫剧角色「' + card.name + '」', 'done')
|
|
437
|
-
} catch (err) {
|
|
438
|
-
const m = (err as Error).message
|
|
439
|
-
setError(m)
|
|
440
|
-
report('新建漫剧角色失败:' + m, 'error')
|
|
441
|
-
} finally {
|
|
442
|
-
setBusy(false)
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
const startEdit = (c: MangaRoleCard): void => {
|
|
447
|
-
setEditId(c.id)
|
|
448
|
-
setEditDraft(draftFromCard(c))
|
|
449
|
-
setError('')
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
const saveEdit = async (): Promise<void> => {
|
|
453
|
-
if (editId === null || editDraft === null) return
|
|
454
|
-
if (editDraft.name.trim() === '') {
|
|
455
|
-
setError('角色名不能为空')
|
|
456
|
-
return
|
|
457
|
-
}
|
|
458
|
-
const existing = cards.find(c => c.id === editId)
|
|
459
|
-
if (existing === undefined) return
|
|
460
|
-
setBusy(true)
|
|
461
|
-
setError('')
|
|
462
|
-
try {
|
|
463
|
-
const card = draftToCard(editDraft, existing.sourceRoleName, undefined, existing)
|
|
464
|
-
await api.mangaRoles({ op: 'update', card })
|
|
465
|
-
setEditId(null)
|
|
466
|
-
setEditDraft(null)
|
|
467
|
-
await refresh()
|
|
468
|
-
report('已更新漫剧角色「' + card.name + '」', 'done')
|
|
469
|
-
} catch (err) {
|
|
470
|
-
const m = (err as Error).message
|
|
471
|
-
setError(m)
|
|
472
|
-
report('更新漫剧角色失败:' + m, 'error')
|
|
473
|
-
} finally {
|
|
474
|
-
setBusy(false)
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
/** 漫剧卡通用编辑字段(提名候选 / 新建 / 编辑共用)。 */
|
|
479
|
-
const draftFields = (d: Draft, onChange: (patch: Partial<Draft>) => void): ReactNode => (
|
|
480
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))', gap: 'var(--nf-space-6)' }}>
|
|
481
|
-
<div className={css.field}>
|
|
482
|
-
<label className={css.fieldLabel}>漫剧用名</label>
|
|
483
|
-
<input className={css.input} value={d.name} onChange={e => onChange({ name: e.target.value })} placeholder="可短剧化改名" />
|
|
484
|
-
</div>
|
|
485
|
-
<div className={css.field}>
|
|
486
|
-
<label className={css.fieldLabel}>身份一句话</label>
|
|
487
|
-
<input className={css.input} value={d.identity} onChange={e => onChange({ identity: e.target.value })} placeholder="如:超市收银员" />
|
|
488
|
-
</div>
|
|
489
|
-
<div className={css.field}>
|
|
490
|
-
<label className={css.fieldLabel}>核心功能</label>
|
|
491
|
-
<select className={css.input} value={d.coreFunction} onChange={e => onChange({ coreFunction: e.target.value as MangaRoleCard['coreFunction'] })}>
|
|
492
|
-
{CORE_FUNCTIONS.map(f => <option key={f.id} value={f.id}>{f.label}</option>)}
|
|
493
|
-
</select>
|
|
494
|
-
</div>
|
|
495
|
-
<div className={css.field}>
|
|
496
|
-
<label className={css.fieldLabel}>与主角关系</label>
|
|
497
|
-
<select className={css.input} value={d.protagonistRelation} onChange={e => onChange({ protagonistRelation: e.target.value as MangaRoleCard['protagonistRelation'] })}>
|
|
498
|
-
{RELATIONS.map(r => <option key={r.id} value={r.id}>{r.label}</option>)}
|
|
499
|
-
</select>
|
|
500
|
-
</div>
|
|
501
|
-
<div className={css.field}>
|
|
502
|
-
<label className={css.fieldLabel}>性格标签(≤3,顿号分隔)</label>
|
|
503
|
-
<input className={css.input} value={d.traits} onChange={e => onChange({ traits: e.target.value })} />
|
|
504
|
-
</div>
|
|
505
|
-
<div className={css.field}>
|
|
506
|
-
<label className={css.fieldLabel}>辨识度外貌点</label>
|
|
507
|
-
<input className={css.input} value={d.appearance} onChange={e => onChange({ appearance: e.target.value })} />
|
|
508
|
-
</div>
|
|
509
|
-
<div className={css.field}>
|
|
510
|
-
<label className={css.fieldLabel}>定妆级别</label>
|
|
511
|
-
<select className={css.input} value={d.tier} onChange={e => onChange({ tier: e.target.value as Draft['tier'] })}>
|
|
512
|
-
<option value="protagonist">主角(完整四件套)</option>
|
|
513
|
-
<option value="supporting">配角(仅立绘)</option>
|
|
514
|
-
<option value="extra">路人(不做定妆)</option>
|
|
515
|
-
</select>
|
|
516
|
-
</div>
|
|
517
|
-
<div className={css.field}>
|
|
518
|
-
<label className={css.fieldLabel}>口头禅/说话方式</label>
|
|
519
|
-
<input className={css.input} value={d.speechStyle} onChange={e => onChange({ speechStyle: e.target.value })} />
|
|
520
|
-
</div>
|
|
521
|
-
<div className={css.field}>
|
|
522
|
-
<label className={css.fieldLabel}>关键剧情节点(;分隔)</label>
|
|
523
|
-
<input className={css.input} value={d.keyScenes} onChange={e => onChange({ keyScenes: e.target.value })} placeholder="第3章 对峙;第11章 真相" />
|
|
524
|
-
</div>
|
|
525
|
-
</div>
|
|
526
|
-
)
|
|
527
|
-
|
|
528
|
-
const shortDrama = project?.shortDramaMode === true
|
|
529
|
-
const presentFunctions = new Set(cards.map(c => c.coreFunction))
|
|
530
|
-
const presentRelations = new Set(cards.map(c => c.protagonistRelation))
|
|
531
|
-
|
|
532
|
-
if (project === null) {
|
|
533
|
-
return <div className={css.card}><span className={css.meta}>请先开书或选择一本书,再进入漫剧角色库。</span></div>
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
return (
|
|
537
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-10)' }}>
|
|
538
|
-
<div ref={headerCardRef} className={css.card}>
|
|
539
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
540
|
-
<span className={css.cardTitle}>🎭 漫剧角色库</span>
|
|
541
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
|
|
542
|
-
<button
|
|
543
|
-
type="button"
|
|
544
|
-
className={css.button + ' ' + css.buttonSmall + (shortDrama ? ' ' + css.buttonPrimary : '')}
|
|
545
|
-
disabled={busy}
|
|
546
|
-
onClick={() => { void toggleShortDrama() }}
|
|
547
|
-
title="短剧精简:漫剧角色库按 5-8 上镜角色 / 功能标签 / 关系闭环 / 人设极致化约束"
|
|
548
|
-
>
|
|
549
|
-
📺 短剧精简模式:{shortDrama ? '开' : '关'}
|
|
550
|
-
</button>
|
|
551
|
-
<button
|
|
552
|
-
type="button"
|
|
553
|
-
className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonPrimary}
|
|
554
|
-
disabled={busy}
|
|
555
|
-
onClick={() => {
|
|
556
|
-
setImportOpen(!importOpen)
|
|
557
|
-
if (chapterNo === 0 && sbChapters[0] !== undefined) setChapterNo(sbChapters[0].no)
|
|
558
|
-
setError('')
|
|
559
|
-
}}
|
|
560
|
-
>
|
|
561
|
-
📥 从本集分镜导入角色
|
|
562
|
-
</button>
|
|
563
|
-
<button
|
|
564
|
-
type="button"
|
|
565
|
-
className={css.button + ' ' + css.buttonSmall}
|
|
566
|
-
disabled={busy}
|
|
567
|
-
onClick={() => { setCreateOpen(!createOpen); setError('') }}
|
|
568
|
-
>
|
|
569
|
-
+ 直接新建漫剧卡
|
|
570
|
-
</button>
|
|
571
|
-
</div>
|
|
572
|
-
</div>
|
|
573
|
-
<span className={css.meta}>漫剧角色库 = 上镜角色(要出定妆图/锁一致性)。由分镜提名导入,独立于小说角色库;来源只读追溯、不回写。</span>
|
|
574
|
-
<span className={css.meta} style={{ display: 'block', marginTop: 'var(--nf-space-2)' }}>流程位置:第③步角色定妆 · 前置:一键生成已完成(分镜+提名+提示词已自动生成)· 下一步:第④步场景底图 → 第⑤步导出即梦脚本</span>
|
|
575
|
-
{styleId !== undefined && (
|
|
576
|
-
<span className={css.meta} style={{ display: 'block', marginTop: 'var(--nf-space-4)' }}>
|
|
577
|
-
🎨 当前方案:{styleId}(锚点/精修按此风格生成)
|
|
578
|
-
</span>
|
|
579
|
-
)}
|
|
580
|
-
{shortDrama && (
|
|
581
|
-
<div style={{ marginTop: 'var(--nf-space-8)', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', overflow: 'hidden' }}>
|
|
582
|
-
<button type="button" onClick={() => { setShowShortDramaCheck(prev => !prev) }} style={{ width: '100%', display: 'flex', alignItems: 'center', gap: 'var(--nf-space-6)', padding: 'var(--nf-space-6) var(--nf-space-10)', background: 'transparent', border: 'none', cursor: 'pointer', textAlign: 'left' }}>
|
|
583
|
-
<b>📺 短剧精简检查</b>
|
|
584
|
-
<span className={css.badge + (cards.length >= 5 && cards.length <= 8 ? ' ' + css.badgeDone : ' ' + css.badgePending)}>
|
|
585
|
-
上镜角色 {cards.length}/5-8
|
|
586
|
-
</span>
|
|
587
|
-
<span style={{ marginLeft: 'auto', fontSize: 'var(--nf-fs-12)', color: 'var(--nf-text-secondary)' }}>{showShortDramaCheck ? '▲ 收起' : '▼ 展开'}</span>
|
|
588
|
-
</button>
|
|
589
|
-
{showShortDramaCheck && (
|
|
590
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', padding: '0 var(--nf-space-10) var(--nf-space-8)', borderTop: '1px solid var(--nf-border)' }}>
|
|
591
|
-
{cards.length > 8 && <span className={css.meta}>⚠ 超员:建议把功能性/低戏份角色归档或删除</span>}
|
|
592
|
-
{cards.length < 5 && cards.length > 0 && <span className={css.meta}>提示:5 人以下张力偏弱,可补关键角色</span>}
|
|
593
|
-
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
594
|
-
<span className={css.meta}>功能覆盖:</span>
|
|
595
|
-
{CORE_FUNCTIONS.filter(f => f.id !== 'functional').map(f => (
|
|
596
|
-
<span key={f.id} className={css.badge + (presentFunctions.has(f.id) ? ' ' + css.badgeDone : ' ' + css.badgePending)}>
|
|
597
|
-
{f.label}{presentFunctions.has(f.id) ? '' : '(缺)'}
|
|
598
|
-
</span>
|
|
599
|
-
))}
|
|
600
|
-
</div>
|
|
601
|
-
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
602
|
-
<span className={css.meta}>关系闭环(与主角):</span>
|
|
603
|
-
{RELATIONS.map(r => (
|
|
604
|
-
<span key={r.id} className={css.badge + (presentRelations.has(r.id) ? ' ' + css.badgeDone : ' ' + css.badgePending)}>
|
|
605
|
-
{r.label}{presentRelations.has(r.id) ? '' : '(缺)'}
|
|
606
|
-
</span>
|
|
607
|
-
))}
|
|
608
|
-
</div>
|
|
609
|
-
<span className={css.meta}>人设极致化:提名只保留上镜角色;锚点/精修已注入「性格标签极致化、1-2 个最强辨识度外貌点」提示。</span>
|
|
610
|
-
</div>
|
|
611
|
-
)}
|
|
612
|
-
</div>
|
|
613
|
-
)}
|
|
614
|
-
{error !== '' && <div className={css.importError}>{error}</div>}
|
|
615
|
-
{notice !== '' && <span className={css.meta} style={{ color: 'var(--nf-accent)' }}>{notice}</span>}
|
|
616
|
-
|
|
617
|
-
{importOpen && (
|
|
618
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', marginTop: 'var(--nf-space-10)', borderTop: '1px solid var(--nf-border)', paddingTop: 'var(--nf-space-10)' }}>
|
|
619
|
-
<div className={css.row} style={{ flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
|
620
|
-
<div className={css.field} style={{ flex: 1, minWidth: 220 }}>
|
|
621
|
-
<label className={css.fieldLabel}>选择已生成分镜的章节</label>
|
|
622
|
-
<select
|
|
623
|
-
className={css.input}
|
|
624
|
-
value={chapterNo === 0 ? '' : chapterNo}
|
|
625
|
-
onChange={e => { setChapterNo(Number(e.target.value)); setCandidates(null); setError('') }}
|
|
626
|
-
>
|
|
627
|
-
{sbChapters.length === 0 && <option value="">(没有已生成分镜的章节)</option>}
|
|
628
|
-
{sbChapters.map(c => <option key={c.no} value={c.no}>第{c.no}章 {c.title}</option>)}
|
|
629
|
-
</select>
|
|
630
|
-
</div>
|
|
631
|
-
<button type="button" className={css.button + ' ' + css.buttonPrimary} disabled={busy || chapterNo <= 0} onClick={() => { void runNominate() }}>
|
|
632
|
-
{busy ? '提名中…' : '🔍 提名角色(规则过滤 + LLM 确认)'}
|
|
633
|
-
</button>
|
|
634
|
-
</div>
|
|
635
|
-
<span className={css.meta}>流程:从本章分镜的结构化 characters 提名 → 与小说角色库匹配 → 确认后导入为漫剧卡。</span>
|
|
636
|
-
|
|
637
|
-
{candidates !== null && (
|
|
638
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', marginTop: 'var(--nf-space-6)' }}>
|
|
639
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
640
|
-
<b>提名候选({candidates.filter(c => !ignoredCandidates.has(c.rawName)).length})· 主角 {candidates.filter(c => c.tier === 'protagonist' && !ignoredCandidates.has(c.rawName)).length} · 配角 {candidates.filter(c => c.tier === 'supporting' && !ignoredCandidates.has(c.rawName)).length} · 路人 {candidates.filter(c => (c.tier === 'extra' || c.tier === undefined) && !ignoredCandidates.has(c.rawName)).length}</b>
|
|
641
|
-
<div className={css.row} style={{ gap: 'var(--nf-space-4)' }}>
|
|
642
|
-
{candidates.some(c => (c.tier === 'extra' || c.tier === undefined) && !ignoredCandidates.has(c.rawName)) && (
|
|
643
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => {
|
|
644
|
-
const extraNames = candidates.filter(c => (c.tier === 'extra' || c.tier === undefined)).map(c => c.rawName)
|
|
645
|
-
setIgnoredCandidates(prev => new Set([...prev, ...extraNames]))
|
|
646
|
-
}}>全部忽略路人</button>
|
|
647
|
-
)}
|
|
648
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { setCandidates(null) }}>收起</button>
|
|
649
|
-
</div>
|
|
650
|
-
</div>
|
|
651
|
-
{candidates.length === 0 && <span className={css.meta}>该章分镜的角色已全部导入漫剧角色库。</span>}
|
|
652
|
-
{(() => {
|
|
653
|
-
const prot = candidates.filter(c => c.tier === 'protagonist' && !ignoredCandidates.has(c.rawName))
|
|
654
|
-
const supp = candidates.filter(c => c.tier === 'supporting' && !ignoredCandidates.has(c.rawName))
|
|
655
|
-
const extra = candidates.filter(c => (c.tier === 'extra' || c.tier === undefined) && !ignoredCandidates.has(c.rawName))
|
|
656
|
-
const renderCand = (cand: MangaRoleCandidate) => {
|
|
657
|
-
if (cand.verdict === 'already_imported') {
|
|
658
|
-
return (
|
|
659
|
-
<div key={cand.rawName} style={{ display: 'flex', gap: 'var(--nf-space-8)', alignItems: 'center', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-6) var(--nf-space-10)', opacity: 0.65 }}>
|
|
660
|
-
<b>{cand.rawName}</b>
|
|
661
|
-
<span className={css.badge + ' ' + css.badgeDone}>已导入</span>
|
|
662
|
-
</div>
|
|
663
|
-
)
|
|
664
|
-
}
|
|
665
|
-
const d = drafts[cand.rawName] ?? emptyDraft()
|
|
666
|
-
const isMatched = cand.verdict === 'matched'
|
|
667
|
-
const isAmbiguous = cand.verdict === 'ambiguous'
|
|
668
|
-
return (
|
|
669
|
-
<div key={cand.rawName} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)' }}>
|
|
670
|
-
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
671
|
-
<b>🎭 {cand.rawName}</b>
|
|
672
|
-
{cand.tier === 'protagonist' && <span className={css.badge + ' ' + css.badgePrimary}>主角</span>}
|
|
673
|
-
{cand.tier === 'supporting' && <span className={css.badge}>配角</span>}
|
|
674
|
-
{(cand.tier === 'extra' || cand.tier === undefined) && <span className={css.badge + ' ' + css.badgePending}>路人</span>}
|
|
675
|
-
{isMatched && <span className={css.badge + ' ' + css.badgeDone}>✓ 匹配:{chosen[cand.rawName] ?? cand.matchedRoleName ?? ''}</span>}
|
|
676
|
-
{isAmbiguous && <span className={css.badge + ' ' + css.badgePending}>?多个候选待定</span>}
|
|
677
|
-
{cand.verdict === 'not_in_library' && cand.novelHint === 'backfill' && (
|
|
678
|
-
<span className={css.badge + ' ' + css.badgePending}>⚠ 小说库未收录(正文有此称谓)</span>
|
|
679
|
-
)}
|
|
680
|
-
{cand.verdict === 'not_in_library' && cand.novelHint !== 'backfill' && (
|
|
681
|
-
<span className={css.badge}>+ 漫剧新增角色</span>
|
|
682
|
-
)}
|
|
683
|
-
</div>
|
|
684
|
-
{(isMatched || isAmbiguous) && cand.matches.length > 1 && (
|
|
685
|
-
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
686
|
-
<span className={css.meta}>命中角色:</span>
|
|
687
|
-
<select
|
|
688
|
-
className={css.input}
|
|
689
|
-
style={{ width: 'auto', padding: 'var(--nf-space-4) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)' }}
|
|
690
|
-
value={chosen[cand.rawName] ?? ''}
|
|
691
|
-
onChange={e => { setChosen(prev => ({ ...prev, [cand.rawName]: e.target.value })) }}
|
|
692
|
-
>
|
|
693
|
-
{cand.matches.map(m => <option key={m.roleName} value={m.roleName}>{m.roleName}({m.reason})</option>)}
|
|
694
|
-
</select>
|
|
695
|
-
</div>
|
|
696
|
-
)}
|
|
697
|
-
{cand.verdict === 'not_in_library' && cand.novelHint === 'backfill' && (
|
|
698
|
-
<span className={css.meta}>
|
|
699
|
-
提示:正文出现过「{cand.rawName}」但小说角色库没提炼到——可先去左侧「角色库」补提炼再重新提名;或直接以漫剧新增角色建卡。
|
|
700
|
-
</span>
|
|
701
|
-
)}
|
|
702
|
-
{draftFields(d, patch => { setDraft(cand.rawName, patch) })}
|
|
703
|
-
<div className={css.row}>
|
|
704
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void adoptCandidate(cand) }}>
|
|
705
|
-
📥 导入为漫剧卡
|
|
706
|
-
</button>
|
|
707
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} disabled={busy} onClick={() => { setIgnoredCandidates(prev => new Set(prev).add(cand.rawName)) }}>
|
|
708
|
-
忽略
|
|
709
|
-
</button>
|
|
710
|
-
<span className={css.meta}>导入后可在下方卡片继续生成形象锚点</span>
|
|
711
|
-
</div>
|
|
712
|
-
</div>
|
|
713
|
-
)
|
|
714
|
-
}
|
|
715
|
-
return (
|
|
716
|
-
<>
|
|
717
|
-
{prot.length > 0 && <div style={{ fontWeight: 600, fontSize: 'var(--nf-fs-13)', marginTop: 'var(--nf-space-4)' }}>⭐ 主角({prot.length})</div>}
|
|
718
|
-
{prot.map(renderCand)}
|
|
719
|
-
{supp.length > 0 && <div style={{ fontWeight: 600, fontSize: 'var(--nf-fs-13)', marginTop: 'var(--nf-space-4)' }}>👥 配角({supp.length})</div>}
|
|
720
|
-
{supp.map(renderCand)}
|
|
721
|
-
{extra.length > 0 && (
|
|
722
|
-
<div style={{ marginTop: 'var(--nf-space-4)' }}>
|
|
723
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { setShowExtraCandidates(prev => !prev) }}>
|
|
724
|
-
{showExtraCandidates ? '▼ 收起路人' : '▶ 展开路人(' + extra.length + ')'}
|
|
725
|
-
</button>
|
|
726
|
-
{showExtraCandidates && extra.map(renderCand)}
|
|
727
|
-
</div>
|
|
728
|
-
)}
|
|
729
|
-
</>
|
|
730
|
-
)
|
|
731
|
-
})()}
|
|
732
|
-
</div>
|
|
733
|
-
)}
|
|
734
|
-
</div>
|
|
735
|
-
)}
|
|
736
|
-
|
|
737
|
-
{createOpen && (
|
|
738
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-8)', marginTop: 'var(--nf-space-10)', borderTop: '1px solid var(--nf-border)', paddingTop: 'var(--nf-space-10)' }}>
|
|
739
|
-
<b>+ 新建漫剧卡(不关联小说角色库)</b>
|
|
740
|
-
{draftFields(createDraft, patch => { setCreateDraft(prev => ({ ...prev, ...patch })) })}
|
|
741
|
-
<div className={css.row}>
|
|
742
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonPrimary} disabled={busy} onClick={() => { void createCard() }}>
|
|
743
|
-
💾 保存漫剧卡
|
|
744
|
-
</button>
|
|
745
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { setCreateOpen(false) }}>取消</button>
|
|
746
|
-
</div>
|
|
747
|
-
</div>
|
|
748
|
-
)}
|
|
749
|
-
</div>
|
|
750
|
-
|
|
751
|
-
{showCards !== false && (
|
|
752
|
-
<div ref={cardsCardRef} className={css.card}>
|
|
753
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
754
|
-
<b>已建漫剧卡({cards.length})· {cards.filter(c => c.status === 'anchored').length} 张已定妆</b>
|
|
755
|
-
<div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-6)' }}>
|
|
756
|
-
<select
|
|
757
|
-
className={css.input}
|
|
758
|
-
style={{ width: 'auto', padding: 'var(--nf-space-4) var(--nf-space-10)', fontSize: 'var(--nf-fs-12)' }}
|
|
759
|
-
value={tierFilter}
|
|
760
|
-
onChange={e => { setTierFilter(e.target.value as typeof tierFilter) }}
|
|
761
|
-
>
|
|
762
|
-
<option value="all">全部角色</option>
|
|
763
|
-
<option value="protagonist">主角</option>
|
|
764
|
-
<option value="supporting">配角</option>
|
|
765
|
-
<option value="extra">路人</option>
|
|
766
|
-
</select>
|
|
767
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { void copyAllPrompts() }}>
|
|
768
|
-
📋 复制全部提示词
|
|
769
|
-
</button>
|
|
770
|
-
</div>
|
|
771
|
-
</div>
|
|
772
|
-
<span className={css.meta} style={{ display: 'block', marginTop: 'var(--nf-space-4)' }}>点击卡片查看详情和各图说明;路人角色不做定妆,仅保留提示词。</span>
|
|
773
|
-
|
|
774
|
-
{(() => {
|
|
775
|
-
const filtered = tierFilter === 'all' ? cards : cards.filter(c => (c.tier ?? 'protagonist') === tierFilter)
|
|
776
|
-
if (filtered.length === 0) {
|
|
777
|
-
return <div className={css.meta} style={{ marginTop: 'var(--nf-space-8)' }}>该分类下暂无角色。</div>
|
|
778
|
-
}
|
|
779
|
-
return (
|
|
780
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(340px, 1fr))', gap: 'var(--nf-space-8)', marginTop: 'var(--nf-space-10)' }}>
|
|
781
|
-
{filtered.map(c => {
|
|
782
|
-
const isExtra = c.tier === 'extra'
|
|
783
|
-
const hasImage = c.imageUrl !== undefined
|
|
784
|
-
const tierLabel = c.tier === 'supporting' ? '配角' : c.tier === 'extra' ? '路人' : '主角'
|
|
785
|
-
const tierColor = c.tier === 'extra' ? '#9ca3af' : c.tier === 'supporting' ? '#3b82f6' : '#ef4444'
|
|
786
|
-
const funcLabel = CORE_FUNCTIONS.find(f => f.id === c.coreFunction)?.label ?? c.coreFunction
|
|
787
|
-
const relLabel = RELATIONS.find(r => r.id === c.protagonistRelation)?.label ?? c.protagonistRelation
|
|
788
|
-
return (
|
|
789
|
-
<div
|
|
790
|
-
key={c.id}
|
|
791
|
-
onClick={() => { setDetailCardId(c.id) }}
|
|
792
|
-
style={{
|
|
793
|
-
cursor: 'pointer',
|
|
794
|
-
border: '1px solid var(--nf-border)',
|
|
795
|
-
borderRadius: 'var(--nf-radius-10)',
|
|
796
|
-
padding: 'var(--nf-space-8)',
|
|
797
|
-
display: 'flex',
|
|
798
|
-
flexDirection: 'row',
|
|
799
|
-
gap: 'var(--nf-space-8)',
|
|
800
|
-
opacity: isExtra ? 0.55 : 1,
|
|
801
|
-
background: hasImage ? 'rgba(82,196,26,0.04)' : 'transparent',
|
|
802
|
-
transition: 'box-shadow 0.15s',
|
|
803
|
-
alignItems: 'flex-start',
|
|
804
|
-
}}
|
|
805
|
-
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)' }}
|
|
806
|
-
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.boxShadow = 'none' }}
|
|
807
|
-
>
|
|
808
|
-
<div style={{ width: 120, minWidth: 120, aspectRatio: '3/4', borderRadius: 'var(--nf-radius-8)', overflow: 'hidden', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
809
|
-
{hasImage ? (
|
|
810
|
-
<img src={c.imageUrl} alt={c.name} style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
|
|
811
|
-
) : isExtra ? (
|
|
812
|
-
<span style={{ fontSize: 'var(--nf-fs-11)', color: '#9ca3af', textAlign: 'center' }}>路人·不做图</span>
|
|
813
|
-
) : (
|
|
814
|
-
<span style={{ fontSize: 'var(--nf-fs-11)', color: '#9ca3af', textAlign: 'center' }}>待定妆</span>
|
|
815
|
-
)}
|
|
816
|
-
</div>
|
|
817
|
-
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-2)' }}>
|
|
818
|
-
<div className={css.row} style={{ gap: 'var(--nf-space-4)', flexWrap: 'wrap' }}>
|
|
819
|
-
<span style={{ fontWeight: 600, fontSize: 'var(--nf-fs-14)' }}>{c.name}</span>
|
|
820
|
-
<span style={{ fontSize: 'var(--nf-fs-11)', color: tierColor, border: '1px solid ' + tierColor + '40', borderRadius: 'var(--nf-radius-999)', padding: '0 var(--nf-space-6)' }}>{tierLabel}</span>
|
|
821
|
-
</div>
|
|
822
|
-
{c.identity !== '' && <span style={{ fontSize: 'var(--nf-fs-12)', color: 'var(--nf-text-secondary)' }}>{c.identity}</span>}
|
|
823
|
-
<span style={{ fontSize: 'var(--nf-fs-12)', color: 'var(--nf-text-secondary)' }}>功能:{funcLabel} · 关系:{relLabel}</span>
|
|
824
|
-
{c.traits.length > 0 && <span style={{ fontSize: 'var(--nf-fs-12)', color: 'var(--nf-text-secondary)' }}>性格:{c.traits.join('、')}</span>}
|
|
825
|
-
{c.appearance !== '' && <span style={{ fontSize: 'var(--nf-fs-12)', color: 'var(--nf-text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>外貌:{c.appearance}</span>}
|
|
826
|
-
<span style={{ fontSize: 'var(--nf-fs-12)', color: hasImage ? '#52C41A' : '#9ca3af', marginTop: 'auto' }}>{hasImage ? '✅ 已定妆' : isExtra ? '— 跳过' : '⏳ 待生成'}</span>
|
|
827
|
-
</div>
|
|
828
|
-
</div>
|
|
829
|
-
)
|
|
830
|
-
})}
|
|
831
|
-
</div>
|
|
832
|
-
)
|
|
833
|
-
})()}
|
|
834
|
-
</div>
|
|
835
|
-
)}
|
|
836
|
-
|
|
837
|
-
{/* 角色详情弹窗 */}
|
|
838
|
-
{detailCardId !== null && (() => {
|
|
839
|
-
const c = cards.find(x => x.id === detailCardId)
|
|
840
|
-
if (c === undefined) return null
|
|
841
|
-
const isExtra = c.tier === 'extra'
|
|
842
|
-
const tierLabel = c.tier === 'supporting' ? '配角' : c.tier === 'extra' ? '路人' : '主角'
|
|
843
|
-
const tierColor = c.tier === 'extra' ? 'var(--nf-text-secondary)' : c.tier === 'supporting' ? '#3b82f6' : '#ef4444'
|
|
844
|
-
return (
|
|
845
|
-
<div
|
|
846
|
-
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 'var(--nf-space-10)' }}
|
|
847
|
-
onClick={() => { setDetailCardId(null) }}
|
|
848
|
-
>
|
|
849
|
-
<div
|
|
850
|
-
className={css.card}
|
|
851
|
-
style={{ maxWidth: 680, width: '100%', maxHeight: '85vh', overflowY: 'auto', margin: 0 }}
|
|
852
|
-
onClick={e => { e.stopPropagation() }}
|
|
853
|
-
>
|
|
854
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
855
|
-
<div>
|
|
856
|
-
<span className={css.cardTitle}>🎭 {c.name}</span>
|
|
857
|
-
<span style={{ marginLeft: 'var(--nf-space-6)', fontSize: 'var(--nf-fs-12)', color: tierColor }}>[{tierLabel}]</span>
|
|
858
|
-
{c.identity !== '' && <span className={css.meta} style={{ marginLeft: 'var(--nf-space-6)' }}>{c.identity}</span>}
|
|
859
|
-
</div>
|
|
860
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { setDetailCardId(null) }}>✕ 关闭</button>
|
|
861
|
-
</div>
|
|
862
|
-
|
|
863
|
-
{/* 基本信息 */}
|
|
864
|
-
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))', gap: 'var(--nf-space-4)', marginTop: 'var(--nf-space-8)' }}>
|
|
865
|
-
<span className={css.meta}>功能:{CORE_FUNCTIONS.find(f => f.id === c.coreFunction)?.label ?? c.coreFunction}</span>
|
|
866
|
-
<span className={css.meta}>与主角:{RELATIONS.find(r => r.id === c.protagonistRelation)?.label ?? c.protagonistRelation}</span>
|
|
867
|
-
{c.traits.length > 0 && <span className={css.meta}>性格:{c.traits.join('、')}</span>}
|
|
868
|
-
{c.appearance !== '' && <span className={css.meta}>外貌:{c.appearance}</span>}
|
|
869
|
-
{c.speechStyle !== '' && <span className={css.meta}>说话:{c.speechStyle}</span>}
|
|
870
|
-
</div>
|
|
871
|
-
|
|
872
|
-
{/* 立绘 */}
|
|
873
|
-
{!isExtra && (
|
|
874
|
-
<div style={{ marginTop: 'var(--nf-space-10)', borderTop: '1px solid var(--nf-border)', paddingTop: 'var(--nf-space-8)' }}>
|
|
875
|
-
<div className={css.row} style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
|
|
876
|
-
<span style={{ fontWeight: 600 }}>📌 立绘(即梦智能角色参考图)</span>
|
|
877
|
-
<span className={css.meta}>上传到即梦「智能角色」,全片保持脸盲一致</span>
|
|
878
|
-
</div>
|
|
879
|
-
<div className={css.row} style={{ marginTop: 'var(--nf-space-6)', alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
|
880
|
-
{c.imageUrl !== undefined ? (
|
|
881
|
-
<img src={c.imageUrl} alt={c.name + ' 立绘'} style={{ maxHeight: 180, borderRadius: 'var(--nf-radius-8)', border: '1px solid var(--nf-border)' }} />
|
|
882
|
-
) : (
|
|
883
|
-
<div style={{ width: 120, height: 160, background: 'var(--nf-bg)', borderRadius: 'var(--nf-radius-8)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
884
|
-
<span className={css.meta}>待定妆</span>
|
|
885
|
-
</div>
|
|
886
|
-
)}
|
|
887
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)' }}>
|
|
888
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { setUploadTarget(c.id); setUploadLabel('立绘') }}>🖼 上传立绘</button>
|
|
889
|
-
{c.imageUrl !== undefined && <button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { void removeImage(c, '') }}>移除立绘</button>}
|
|
890
|
-
</div>
|
|
891
|
-
</div>
|
|
892
|
-
</div>
|
|
893
|
-
)}
|
|
894
|
-
|
|
895
|
-
{/* 上传区 */}
|
|
896
|
-
{uploadTarget === c.id && !isExtra && (
|
|
897
|
-
<div style={{ marginTop: 'var(--nf-space-8)', border: '1px dashed var(--nf-border)', borderRadius: 'var(--nf-radius-8)', padding: 'var(--nf-space-8)' }}>
|
|
898
|
-
<div className={css.row} style={{ flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
|
899
|
-
<div className={css.field} style={{ minWidth: 140 }}>
|
|
900
|
-
<label className={css.fieldLabel}>用途标签</label>
|
|
901
|
-
<input className={css.input} value={uploadLabel} onChange={e => { setUploadLabel(e.target.value) }} placeholder="立绘/四视图/表情-xx" />
|
|
902
|
-
</div>
|
|
903
|
-
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={e => { void onUploadFile(c.id, e.target.files?.[0] ?? null); e.target.value = '' }} />
|
|
904
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} disabled={actingId === c.id} onClick={() => { fileInputRef.current?.click() }}>{actingId === c.id ? '上传中…' : '📤 选择图片'}</button>
|
|
905
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { setUploadTarget(null) }}>取消</button>
|
|
906
|
-
</div>
|
|
907
|
-
</div>
|
|
908
|
-
)}
|
|
909
|
-
|
|
910
|
-
{/* 精修提示词(立绘+负面词+表情图高级可选) */}
|
|
911
|
-
{!isExtra && c.promptKit !== undefined && (
|
|
912
|
-
<div style={{ marginTop: 'var(--nf-space-10)', borderTop: '1px solid var(--nf-border)', paddingTop: 'var(--nf-space-8)' }}>
|
|
913
|
-
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
914
|
-
<span style={{ fontWeight: 600 }}>🎨 精修提示词</span>
|
|
915
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => {
|
|
916
|
-
const kit = c.promptKit!
|
|
917
|
-
const neg = c.imagePrompt?.negativePrompt ?? '低质量,模糊,变形,多指,断肢,文字,水印,丑陋,比例失调'
|
|
918
|
-
const parts: string[] = []
|
|
919
|
-
if (kit.portrait) parts.push('【立绘】\n' + kit.portrait.zh)
|
|
920
|
-
parts.push('【负面词】\n' + neg)
|
|
921
|
-
if (showAdvancedPrompts && kit.expressions) for (const e of kit.expressions) parts.push('【表情-' + e.name + '】\n' + e.zh)
|
|
922
|
-
void navigator.clipboard?.writeText(parts.join('\n\n'))
|
|
923
|
-
}}>📋 复制全部</button>
|
|
924
|
-
</div>
|
|
925
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)', marginTop: 'var(--nf-space-4)' }}>
|
|
926
|
-
{c.promptKit.portrait !== undefined && (
|
|
927
|
-
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-8)', padding: 'var(--nf-space-8)' }}>
|
|
928
|
-
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
929
|
-
<b>📌 立绘(智能角色参考图)</b>
|
|
930
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { void navigator.clipboard?.writeText(c.promptKit!.portrait!.zh) }}>复制</button>
|
|
931
|
-
</div>
|
|
932
|
-
<div style={{ fontSize: 'var(--nf-fs-12)', marginTop: 'var(--nf-space-4)' }}>{c.promptKit.portrait.zh}</div>
|
|
933
|
-
</div>
|
|
934
|
-
)}
|
|
935
|
-
{c.imagePrompt?.negativePrompt !== undefined && c.imagePrompt.negativePrompt !== '' ? (
|
|
936
|
-
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-8)', padding: 'var(--nf-space-8)' }}>
|
|
937
|
-
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
938
|
-
<b>🚫 负面词(复制到即梦反向提示词框)</b>
|
|
939
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { void navigator.clipboard?.writeText(c.imagePrompt!.negativePrompt!) }}>复制</button>
|
|
940
|
-
</div>
|
|
941
|
-
<div style={{ fontSize: 'var(--nf-fs-12)', marginTop: 'var(--nf-space-4)', color: 'var(--nf-text-secondary)' }}>{c.imagePrompt.negativePrompt}</div>
|
|
942
|
-
</div>
|
|
943
|
-
) : (
|
|
944
|
-
<div style={{ border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-8)', padding: 'var(--nf-space-8)' }}>
|
|
945
|
-
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
946
|
-
<b>🚫 负面词(复制到即梦反向提示词框)</b>
|
|
947
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { void navigator.clipboard?.writeText('低质量,模糊,变形,多指,断肢,文字,水印,丑陋,比例失调') }}>复制</button>
|
|
948
|
-
</div>
|
|
949
|
-
<div style={{ fontSize: 'var(--nf-fs-12)', marginTop: 'var(--nf-space-4)', color: 'var(--nf-text-secondary)' }}>低质量,模糊,变形,多指,断肢,文字,水印,丑陋,比例失调</div>
|
|
950
|
-
</div>
|
|
951
|
-
)}
|
|
952
|
-
{c.promptKit.expressions !== undefined && c.promptKit.expressions.length > 0 && (
|
|
953
|
-
<div style={{ border: '1px dashed var(--nf-border)', borderRadius: 'var(--nf-radius-8)', padding: 'var(--nf-space-8)' }}>
|
|
954
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { setShowAdvancedPrompts(prev => !prev) }} style={{ width: '100%', textAlign: 'left' }}>
|
|
955
|
-
{showAdvancedPrompts ? '▼ 高级可选:表情图(点击收起)' : '▶ 高级可选:表情图(' + c.promptKit.expressions.length + '个,点击展开)'}
|
|
956
|
-
</button>
|
|
957
|
-
{showAdvancedPrompts && (
|
|
958
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', marginTop: 'var(--nf-space-4)' }}>
|
|
959
|
-
{c.promptKit.expressions.map((e, i) => (
|
|
960
|
-
<div key={i} style={{ fontSize: 'var(--nf-fs-12)' }}>
|
|
961
|
-
<div className={css.row} style={{ justifyContent: 'space-between' }}>
|
|
962
|
-
<span style={{ fontWeight: 600 }}>😊 {e.name}</span>
|
|
963
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { void navigator.clipboard?.writeText(e.zh) }}>复制</button>
|
|
964
|
-
</div>
|
|
965
|
-
<div style={{ marginTop: 'var(--nf-space-2)' }}>{e.zh}</div>
|
|
966
|
-
</div>
|
|
967
|
-
))}
|
|
968
|
-
</div>
|
|
969
|
-
)}
|
|
970
|
-
</div>
|
|
971
|
-
)}
|
|
972
|
-
</div>
|
|
973
|
-
</div>
|
|
974
|
-
)}
|
|
975
|
-
|
|
976
|
-
{/* 即梦使用指引 */}
|
|
977
|
-
{!isExtra && c.promptKit !== undefined && (
|
|
978
|
-
<div style={{ marginTop: 'var(--nf-space-10)', borderTop: '1px solid var(--nf-border)', paddingTop: 'var(--nf-space-8)' }}>
|
|
979
|
-
<span style={{ fontWeight: 600 }}>💡 即梦使用指引</span>
|
|
980
|
-
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-2)', marginTop: 'var(--nf-space-4)' }}>
|
|
981
|
-
<span className={css.meta}>• <b>立绘</b> → 即梦「智能角色」上传(正面全身纯白背景,全片锁脸锁服装)</span>
|
|
982
|
-
<span className={css.meta}>• <b>多角度</b> → 立绘上传即梦画布,用「多角度编辑」生成侧面/背面(无需单独生四视图)</span>
|
|
983
|
-
<span className={css.meta}>• <b>负面词</b> → 复制到即梦「反向提示词」框(生图生视频都有),每次都粘贴</span>
|
|
984
|
-
<span className={css.meta}>• <b>表情图</b>(高级可选)→ 特定情绪镜头时投喂对应表情图,锁脸部</span>
|
|
985
|
-
</div>
|
|
986
|
-
</div>
|
|
987
|
-
)}
|
|
988
|
-
|
|
989
|
-
{/* 底部操作 */}
|
|
990
|
-
<div style={{ marginTop: 'var(--nf-space-10)', borderTop: '1px solid var(--nf-border)', paddingTop: 'var(--nf-space-8)', display: 'flex', gap: 'var(--nf-space-6)', flexWrap: 'wrap' }}>
|
|
991
|
-
{!isExtra && <button type="button" className={css.button + ' ' + css.buttonSmall} disabled={actingId === c.id} onClick={() => { void genVisual(c.id, c.name) }}>{actingId === c.id ? '生成中…' : '🎨 生成形象锚点'}</button>}
|
|
992
|
-
{c.tier === 'protagonist' && <button type="button" className={css.button + ' ' + css.buttonSmall} disabled={actingId === c.id || c.imagePrompt === undefined} onClick={() => { void genKit(c.id, c.name) }}>✨ 精修提示词</button>}
|
|
993
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall} onClick={() => { startEdit(c); setDetailCardId(null) }}>✏️ 编辑</button>
|
|
994
|
-
<button type="button" className={css.button + ' ' + css.buttonSmall + ' ' + css.buttonDanger} disabled={actingId === c.id} onClick={() => { void removeCard(c); setDetailCardId(null) }}>🗑 删除</button>
|
|
995
|
-
</div>
|
|
996
|
-
</div>
|
|
997
|
-
</div>
|
|
998
|
-
)
|
|
999
|
-
})()}
|
|
1000
|
-
</div>
|
|
1001
|
-
)
|
|
1002
|
-
}
|