fix: resolve confirmed character hidden bug, remove online font dependency, improve UI/UX experience
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
You are a prop asset extractor.
|
||||
You are a key story prop extractor.
|
||||
|
||||
Task: identify reusable physical props from the input text and return JSON only.
|
||||
Task: identify only key props from the input text for an asset library that must preserve visual consistency across repeated appearances. Be conservative. Return JSON only.
|
||||
|
||||
Output format:
|
||||
{
|
||||
@@ -12,16 +12,38 @@ Output format:
|
||||
]
|
||||
}
|
||||
|
||||
Rules:
|
||||
1. Only include concrete reusable physical props that actually appear in the story.
|
||||
2. Only output `name` and `summary`.
|
||||
3. `name` and `summary` must both be non-empty.
|
||||
4. Do not repeat props that already exist in the prop library with the exact same name.
|
||||
5. Exclude abstract concepts, powers, roles, places, creatures, outfits, and makeup.
|
||||
6. Keep names stable and short.
|
||||
7. Keep summaries objective.
|
||||
8. If none exist, return {"props": []}.
|
||||
9. Replace raw quotation marks inside JSON string values with corner brackets「」.
|
||||
Key prop criteria:
|
||||
1. It must be a real physical object that actually appears in the story.
|
||||
2. It must serve a clear story function rather than being background dressing.
|
||||
3. It must satisfy at least one of the following:
|
||||
- characters hold it, use it, fight over it, deliver it, hide it, lose it, or search for it
|
||||
- it is a key tool, weapon, artifact, piece of evidence, token, key, or clue carrier
|
||||
- it is likely to reappear and therefore needs a consistent visual design
|
||||
- removing it would materially weaken plot comprehension or a key action
|
||||
|
||||
Strictly exclude:
|
||||
1. Ordinary background items, furniture, tableware, food, drinks, daily necessities, and decorations.
|
||||
2. Objects that are only mentioned in passing and have no story function.
|
||||
3. Environmental elements that belong to the scene unless they are explicitly used as key props.
|
||||
4. Ordinary clothing, makeup, and accessories unless they are themselves key clues or tokens.
|
||||
5. Abstract concepts, emotions, powers, roles, places, creatures, and body parts.
|
||||
|
||||
Decision bias:
|
||||
1. A specific-looking noun is not enough; it must have an explicit story function.
|
||||
2. If an object could be either a background item or a prop, treat it as background and do not output it.
|
||||
3. If it merely appears but is not used, emphasized, or plot-relevant, do not output it.
|
||||
4. If you are unsure whether it deserves an asset entry, do not output it.
|
||||
5. Prefer under-extraction. Never output props just to increase the count.
|
||||
|
||||
Output rules:
|
||||
1. Only output `name` and `summary`.
|
||||
2. `name` and `summary` must both be non-empty.
|
||||
3. Do not repeat props that already exist in the prop library with the exact same name.
|
||||
4. Keep names stable and short.
|
||||
5. Keep summaries objective.
|
||||
6. Usually output no more than 3-5 props unless more are clearly all key props.
|
||||
7. If none exist, return {"props": []}.
|
||||
8. Replace raw quotation marks inside JSON string values with corner brackets「」.
|
||||
|
||||
Input:
|
||||
{input}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
你是“故事道具资产分析师”。
|
||||
你是“关键剧情道具资产分析师”。
|
||||
|
||||
任务:从输入文本中识别适合做成长期复用资产的道具,只返回 JSON,不得包含任何额外解释或 markdown。
|
||||
任务:从输入文本中只识别【关键道具】,用于建立需要长期保持外观一致的资产库。宁缺毋滥。只返回 JSON,不得包含任何额外解释或 markdown。
|
||||
|
||||
输出格式:
|
||||
{
|
||||
@@ -12,16 +12,38 @@
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
1. 只保留在剧情中真实出现、可被反复引用、值得进入资产库的实体道具。
|
||||
2. 只输出两个字段:name、summary。
|
||||
3. name 不能为空;summary 不能为空。
|
||||
4. 如果道具库里已经有完全同名道具,不要重复输出。
|
||||
5. 禁止输出抽象概念、情绪、能力、身份、地点、生物、服装妆容。
|
||||
6. 名称尽量简洁稳定,例如“青铜匕首”“录音笔”“红绳手链”。
|
||||
7. summary 只写客观描述,不写剧情推断。
|
||||
8. 如果没有合适道具,返回 {"props": []}。
|
||||
9. JSON 字符串值中的引号统一替换为「」。
|
||||
关键道具判定标准:
|
||||
1. 必须是剧情中真实出现的实体物件。
|
||||
2. 必须在剧情中承担明确功能,而不只是背景摆设。
|
||||
3. 必须至少满足以下一种情况:
|
||||
- 被角色持有、使用、争夺、交付、隐藏、丢失、寻找
|
||||
- 是推进情节的关键工具、武器、法器、证物、信物、钥匙、线索载体
|
||||
- 后续大概率需要重复出镜,且需要保持外观一致
|
||||
- 去掉它会明显影响剧情理解或关键动作成立
|
||||
|
||||
严格不提取:
|
||||
1. 普通背景陈设、家具、餐具、食物、饮料、日用品、装饰物。
|
||||
2. 仅被顺带提及、没有剧情功能的物件。
|
||||
3. 场景自带的环境元素,除非它被明确当作关键道具使用。
|
||||
4. 普通服装、妆容、饰品,除非它本身就是关键线索或关键信物。
|
||||
5. 抽象概念、情绪、能力、身份、地点、生物、身体部位。
|
||||
|
||||
判断倾向:
|
||||
1. 仅因外观具体、名词明确,不足以成为关键道具;必须有明确剧情作用。
|
||||
2. 如果一个物件既可能是背景物,也可能是道具,默认按背景物处理,不输出。
|
||||
3. 如果只是“出现过”,但没有“被使用/被强调/影响剧情”,不输出。
|
||||
4. 如果不确定它是否值得进入资产库,直接不输出。
|
||||
5. 优先少报,禁止为了凑数量而输出。
|
||||
|
||||
输出要求:
|
||||
1. 只输出两个字段:name、summary。
|
||||
2. name 不能为空;summary 不能为空。
|
||||
3. 如果道具库里已经有完全同名道具,不要重复输出。
|
||||
4. 名称尽量简洁稳定,例如“青铜匕首”“录音笔”“红绳手链”。
|
||||
5. summary 只写客观描述,不写剧情推断。
|
||||
6. 通常不超过 3-5 个;只有确实都是关键道具时才可更多。
|
||||
7. 如果没有合适道具,返回 {"props": []}。
|
||||
8. JSON 字符串值中的引号统一替换为「」。
|
||||
|
||||
输入文本:
|
||||
{input}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"confirmProfiles": "Character Profiles to Confirm",
|
||||
"confirmHint": "Please confirm these profiles before generating descriptions",
|
||||
"confirmAll": "Confirm All ({count})",
|
||||
"pendingProfilesBanner": "AI Casting Complete",
|
||||
"pendingProfilesHint": "Confirm profiles to auto-generate character visuals",
|
||||
"assetsTitle": "Asset Analysis",
|
||||
"characterAssets": "Character Assets",
|
||||
"locationAssets": "Location Assets",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"streamStep": {
|
||||
"analyzeCharacters": "Analyze characters",
|
||||
"analyzeLocations": "Analyze locations",
|
||||
"analyzeProps": "Analyze props",
|
||||
"splitClips": "Split clips",
|
||||
"screenplayConversion": "Convert screenplay",
|
||||
"storyboardPlan": "Plan storyboard",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"confirmProfiles": "角色档案待确认",
|
||||
"confirmHint": "请确认以下角色档案后生成外貌描述",
|
||||
"confirmAll": "全部确认 ({count})",
|
||||
"pendingProfilesBanner": "AI 选角完成",
|
||||
"pendingProfilesHint": "确认档案后自动生成角色形象",
|
||||
"assetsTitle": "资产分析",
|
||||
"characterAssets": "角色资产",
|
||||
"locationAssets": "场景资产",
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
"streamStep": {
|
||||
"analyzeCharacters": "角色分析",
|
||||
"analyzeLocations": "场景分析",
|
||||
"analyzeProps": "道具分析",
|
||||
"splitClips": "片段切分",
|
||||
"screenplayConversion": "剧本转换",
|
||||
"storyboardPlan": "分镜规划",
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -37,6 +37,7 @@
|
||||
"cos-nodejs-sdk-v5": "^2.15.4",
|
||||
"express": "^5.2.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"geist": "^1.7.0",
|
||||
"ioredis": "^5.9.2",
|
||||
"jsonrepair": "^3.13.2",
|
||||
"jszip": "^3.10.1",
|
||||
@@ -10207,6 +10208,15 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/geist": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/geist/-/geist-1.7.0.tgz",
|
||||
"integrity": "sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==",
|
||||
"license": "SIL OPEN FONT LICENSE",
|
||||
"peerDependencies": {
|
||||
"next": ">=13.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/generate-function": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz",
|
||||
|
||||
@@ -138,6 +138,7 @@
|
||||
"cos-nodejs-sdk-v5": "^2.15.4",
|
||||
"express": "^5.2.1",
|
||||
"file-saver": "^2.0.5",
|
||||
"geist": "^1.7.0",
|
||||
"ioredis": "^5.9.2",
|
||||
"jsonrepair": "^3.13.2",
|
||||
"jszip": "^3.10.1",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import Script from "next/script";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import { GeistMono } from 'geist/font/mono';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages, getTranslations } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
@@ -9,16 +10,6 @@ import { Providers } from "./providers";
|
||||
|
||||
import { locales } from '@/i18n/routing';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
|
||||
|
||||
type SupportedLocale = (typeof locales)[number]
|
||||
@@ -72,7 +63,7 @@ export default async function LocaleLayout({
|
||||
)}
|
||||
</head>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${GeistSans.variable} ${GeistMono.variable} antialiased`}
|
||||
>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<Providers>
|
||||
|
||||
@@ -49,7 +49,6 @@ import LocationSection from './assets/LocationSection'
|
||||
import AssetToolbar from './assets/AssetToolbar'
|
||||
import AssetFilterBar, { type AssetKindFilter } from './assets/AssetFilterBar'
|
||||
import AssetsStageStatusOverlays from './assets/AssetsStageStatusOverlays'
|
||||
import UnconfirmedProfilesSection from './assets/UnconfirmedProfilesSection'
|
||||
import AssetsStageModals from './assets/AssetsStageModals'
|
||||
|
||||
interface AssetsStageProps {
|
||||
@@ -207,12 +206,9 @@ export default function AssetsStage({
|
||||
// 批量生成
|
||||
const {
|
||||
isBatchSubmitting,
|
||||
batchProgress,
|
||||
activeTaskKeys,
|
||||
registerTransientTaskKey,
|
||||
clearTransientTaskKey,
|
||||
handleGenerateAllImages,
|
||||
handleRegenerateAllImages
|
||||
} = useBatchGeneration({
|
||||
projectId,
|
||||
handleGenerateImage
|
||||
@@ -391,9 +387,6 @@ export default function AssetsStage({
|
||||
isBatchSubmitting={isBatchSubmitting}
|
||||
isAnalyzingAssets={isAnalyzingAssets}
|
||||
isGlobalAnalyzing={isGlobalAnalyzing}
|
||||
batchProgress={batchProgress}
|
||||
onGenerateAll={handleGenerateAllImages}
|
||||
onRegenerateAll={handleRegenerateAllImages}
|
||||
onGlobalAnalyze={handleGlobalAnalyze}
|
||||
episodeId={episodeFilter}
|
||||
onEpisodeChange={setEpisodeFilter}
|
||||
@@ -412,22 +405,6 @@ export default function AssetsStage({
|
||||
}}
|
||||
/>
|
||||
|
||||
<UnconfirmedProfilesSection
|
||||
unconfirmedCharacters={unconfirmedCharacters}
|
||||
confirmTitle={t('stage.confirmProfiles')}
|
||||
confirmHint={t('stage.confirmHint')}
|
||||
confirmAllLabel={t('stage.confirmAll', { count: unconfirmedCharacters.length })}
|
||||
batchConfirming={batchConfirming}
|
||||
batchConfirmingState={batchConfirmingState}
|
||||
deletingCharacterId={deletingCharacterId}
|
||||
isConfirmingCharacter={isConfirmingCharacter}
|
||||
onBatchConfirm={handleBatchConfirm}
|
||||
onEditProfile={handleEditProfile}
|
||||
onConfirmProfile={handleConfirmProfile}
|
||||
onUseExistingProfile={handleCopyFromGlobal}
|
||||
onDeleteProfile={handleDeleteProfile}
|
||||
/>
|
||||
|
||||
{(kindFilter === 'all' || kindFilter === 'character') && (
|
||||
<CharacterSection
|
||||
key="character"
|
||||
@@ -456,6 +433,17 @@ export default function AssetsStage({
|
||||
onCopyFromGlobal={handleCopyFromGlobal}
|
||||
getAppearances={getAppearances}
|
||||
filterIds={episodeAssetIds?.charIds ?? null}
|
||||
// 🔥 V7:待确认角色档案内嵌到 CharacterSection
|
||||
unconfirmedCharacters={unconfirmedCharacters}
|
||||
isConfirmingCharacter={isConfirmingCharacter}
|
||||
deletingCharacterId={deletingCharacterId}
|
||||
batchConfirming={batchConfirming}
|
||||
batchConfirmingState={batchConfirmingState}
|
||||
onBatchConfirm={handleBatchConfirm}
|
||||
onEditProfile={handleEditProfile}
|
||||
onConfirmProfile={handleConfirmProfile}
|
||||
onUseExistingProfile={handleCopyFromGlobal}
|
||||
onDeleteProfile={handleDeleteProfile}
|
||||
/>
|
||||
)}
|
||||
{(kindFilter === 'all' || kindFilter === 'location') && (
|
||||
|
||||
@@ -38,11 +38,15 @@ export default function AssetFilterBar({
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 glass-surface rounded-xl">
|
||||
<SegmentedControl
|
||||
options={segmentOptions}
|
||||
value={kindFilter}
|
||||
onChange={onKindFilterChange}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<SegmentedControl
|
||||
options={segmentOptions}
|
||||
value={kindFilter}
|
||||
onChange={onKindFilterChange}
|
||||
layout="compact"
|
||||
className="min-w-max"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRefreshProjectAssets, useProjectAssets, useProjectData } from '@/lib/query/hooks'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { useProjectAssets, useProjectData } from '@/lib/query/hooks'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import JSZip from 'jszip'
|
||||
import { logError as _logError } from '@/lib/logging/core'
|
||||
|
||||
/**
|
||||
* AssetToolbar - 资产管理工具栏组件
|
||||
* 从 AssetsStage.tsx 提取,负责批量操作和刷新按钮
|
||||
* 从 AssetsStage.tsx 提取,负责资产统计与顶部操作
|
||||
*/
|
||||
|
||||
interface EpisodeOption {
|
||||
@@ -29,9 +27,6 @@ interface AssetToolbarProps {
|
||||
isBatchSubmitting: boolean
|
||||
isAnalyzingAssets: boolean
|
||||
isGlobalAnalyzing?: boolean
|
||||
batchProgress: { current: number; total: number }
|
||||
onGenerateAll: () => void
|
||||
onRegenerateAll: () => void
|
||||
onGlobalAnalyze?: () => void
|
||||
/** Episode filter */
|
||||
episodeId: string | null
|
||||
@@ -166,30 +161,17 @@ export default function AssetToolbar({
|
||||
isBatchSubmitting,
|
||||
isAnalyzingAssets,
|
||||
isGlobalAnalyzing = false,
|
||||
batchProgress,
|
||||
onGenerateAll,
|
||||
onRegenerateAll,
|
||||
onGlobalAnalyze,
|
||||
episodeId,
|
||||
onEpisodeChange,
|
||||
episodes,
|
||||
}: AssetToolbarProps) {
|
||||
const onRefresh = useRefreshProjectAssets(projectId)
|
||||
const t = useTranslations('assets')
|
||||
const { data: assets } = useProjectAssets(projectId)
|
||||
const { data: projectData } = useProjectData(projectId)
|
||||
const projectName = projectData?.name
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
const assetTaskRunningState = isBatchSubmitting
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'generate',
|
||||
resource: 'image',
|
||||
hasOutput: true,
|
||||
})
|
||||
: null
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
const characters = assets?.characters ?? []
|
||||
const locations = assets?.locations ?? []
|
||||
@@ -297,39 +279,6 @@ export default function AssetToolbar({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onGenerateAll}
|
||||
disabled={isBatchSubmitting || isAnalyzingAssets || isGlobalAnalyzing}
|
||||
className="glass-btn-base glass-btn-tone-success flex items-center gap-2 px-4 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isBatchSubmitting ? (
|
||||
<>
|
||||
<TaskStatusInline state={assetTaskRunningState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
<span className="text-xs text-white/90">({batchProgress.current}/{batchProgress.total})</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AppIcon name="image" className="w-4 h-4" />
|
||||
<span>{t("toolbar.generateAll")}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onRegenerateAll}
|
||||
disabled={isBatchSubmitting || isAnalyzingAssets || isGlobalAnalyzing}
|
||||
className="glass-btn-base glass-btn-tone-warning flex items-center gap-2 px-4 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={t("toolbar.regenerateAllHint")}
|
||||
>
|
||||
<AppIcon name="refresh" className="w-4 h-4" />
|
||||
<span>{t("toolbar.regenerateAll")}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRefresh()}
|
||||
className="glass-btn-base glass-btn-secondary flex items-center gap-2 px-4 py-2 text-sm font-medium border border-[var(--glass-stroke-base)]"
|
||||
>
|
||||
<AppIcon name="refresh" className="w-4 h-4" />
|
||||
<span>{t("common.refresh")}</span>
|
||||
</button>
|
||||
{/* 打包下载按钮 */}
|
||||
<button
|
||||
onClick={handleDownloadAll}
|
||||
|
||||
@@ -23,13 +23,24 @@ interface CharacterProfileCardProps {
|
||||
isDeleting?: boolean
|
||||
}
|
||||
|
||||
const ROLE_LEVEL_COLORS = {
|
||||
S: 'bg-[var(--glass-tone-warning-bg)] text-[var(--glass-tone-warning-fg)]',
|
||||
A: 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]',
|
||||
B: 'bg-[var(--glass-tone-neutral-bg)] text-[var(--glass-tone-neutral-fg)]',
|
||||
C: 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-primary)]',
|
||||
D: 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'
|
||||
/**
|
||||
* 游戏品质分级颜色系统
|
||||
* S金橙 / A史诗紫 / B稀有蓝 / C精良绿 / D普通灰
|
||||
*/
|
||||
interface TierStyle {
|
||||
gradient: string
|
||||
glow: string
|
||||
accent: string
|
||||
}
|
||||
|
||||
const TIER_STYLES: Record<string, TierStyle> = {
|
||||
S: { gradient: 'linear-gradient(135deg, #f59e0b, #ef4444)', glow: '0 2px 8px rgba(245,158,11,0.35)', accent: '#b45309' },
|
||||
A: { gradient: 'linear-gradient(135deg, #a855f7, #6366f1)', glow: '0 2px 8px rgba(168,85,247,0.3)', accent: '#7c3aed' },
|
||||
B: { gradient: 'linear-gradient(135deg, #3b82f6, #06b6d4)', glow: '0 2px 8px rgba(59,130,246,0.3)', accent: '#2563eb' },
|
||||
C: { gradient: 'linear-gradient(135deg, #22c55e, #10b981)', glow: '0 2px 8px rgba(34,197,94,0.25)', accent: '#16a34a' },
|
||||
D: { gradient: 'linear-gradient(135deg, #9ca3af, #6b7280)', glow: '0 2px 6px rgba(156,163,175,0.2)', accent: '#6b7280' },
|
||||
}
|
||||
|
||||
const ROLE_LEVELS = ['S', 'A', 'B', 'C', 'D'] as const
|
||||
type RoleLevel = (typeof ROLE_LEVELS)[number]
|
||||
|
||||
@@ -68,117 +79,124 @@ export default function CharacterProfileCard({
|
||||
const roleLevelLabel = roleLevel
|
||||
? t(`characterProfile.importance.${roleLevel}`)
|
||||
: profileData.role_level
|
||||
const roleLevelColor = roleLevel
|
||||
? ROLE_LEVEL_COLORS[roleLevel]
|
||||
: 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-primary)]'
|
||||
const tierStyle = roleLevel ? TIER_STYLES[roleLevel] : null
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--glass-bg-surface)] rounded-xl border border-[var(--glass-stroke-base)] p-4 hover:shadow-md transition-shadow">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-[var(--glass-text-primary)] mb-1">{name}</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${roleLevelColor}`}>
|
||||
{roleLevelLabel}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--glass-text-tertiary)]">{profileData.archetype}</span>
|
||||
<div className="glass-surface overflow-hidden hover:shadow-md transition-shadow">
|
||||
<div className="p-5">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-bold text-[var(--glass-text-primary)] mb-1.5">{name}</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-1 rounded-full text-[11px] font-black text-white tracking-wide"
|
||||
style={{
|
||||
background: tierStyle?.gradient ?? 'var(--glass-bg-muted)',
|
||||
boxShadow: tierStyle?.glow ?? 'none',
|
||||
...(!tierStyle ? { color: 'var(--glass-text-primary)' } : {}),
|
||||
}}
|
||||
>
|
||||
{roleLevelLabel}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--glass-text-tertiary)]">{profileData.archetype}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* 删除按钮 */}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={isConfirming || isDeleting}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-danger-fg)] hover:bg-[var(--glass-tone-danger-bg)] transition-colors disabled:opacity-50 shrink-0"
|
||||
title={t('characterProfile.delete')}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<TaskStatusInline state={deletingState} className="[&_span]:sr-only [&_svg]:text-current" />
|
||||
) : (
|
||||
<AppIcon name="trash" className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 删除按钮 */}
|
||||
{onDelete && (
|
||||
|
||||
{/* 档案摘要 */}
|
||||
<div className="space-y-1.5 mb-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.gender')}</span>
|
||||
<span className="text-[var(--glass-text-primary)]">{profileData.gender}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.age')}</span>
|
||||
<span className="text-[var(--glass-text-primary)]">{profileData.age_range}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.era')}</span>
|
||||
<span className="text-[var(--glass-text-primary)]">{profileData.era_period}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.class')}</span>
|
||||
<span className="text-[var(--glass-text-primary)]">{profileData.social_class}</span>
|
||||
</div>
|
||||
{profileData.occupation && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.occupation')}</span>
|
||||
<span className="text-[var(--glass-text-primary)]">{profileData.occupation}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.personality')}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{profileData.personality_tags.map((tag, i) => (
|
||||
<span key={i} className="px-1.5 py-0.5 bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] rounded text-xs font-medium">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.costume')}</span>
|
||||
<span className="text-[var(--glass-text-primary)]">
|
||||
{'●'.repeat(profileData.costume_tier)}{'○'.repeat(5 - profileData.costume_tier)}
|
||||
</span>
|
||||
</div>
|
||||
{profileData.primary_identifier && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.identifier')}</span>
|
||||
<span className="font-medium" style={{ color: tierStyle?.accent ?? 'var(--glass-tone-warning-fg)' }}>{profileData.primary_identifier}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2 pt-3 border-t border-[var(--glass-stroke-base)]">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={isConfirming || isDeleting}
|
||||
className="p-1.5 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-danger-fg)] hover:bg-[var(--glass-tone-danger-bg)] rounded-lg transition-colors disabled:opacity-50"
|
||||
title={t('characterProfile.delete')}
|
||||
onClick={onEdit}
|
||||
disabled={isConfirming}
|
||||
className="glass-btn-base glass-btn-secondary flex-1 px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<TaskStatusInline state={deletingState} className="[&_span]:sr-only [&_svg]:text-current" />
|
||||
{t('characterProfile.editProfile')}
|
||||
</button>
|
||||
{onUseExisting && (
|
||||
<button
|
||||
onClick={onUseExisting}
|
||||
disabled={isConfirming}
|
||||
className="glass-btn-base glass-btn-tone-info flex-1 px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{t('characterProfile.useExisting')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isConfirming}
|
||||
className="glass-btn-base glass-btn-primary flex-1 px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
|
||||
>
|
||||
{isConfirming ? (
|
||||
<TaskStatusInline state={confirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<AppIcon name="trash" className="w-4 h-4" />
|
||||
t('characterProfile.confirmAndGenerate')
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 档案摘要 */}
|
||||
<div className="space-y-1.5 mb-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.gender')}</span>
|
||||
<span className="text-[var(--glass-text-primary)]">{profileData.gender}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.age')}</span>
|
||||
<span className="text-[var(--glass-text-primary)]">{profileData.age_range}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.era')}</span>
|
||||
<span className="text-[var(--glass-text-primary)]">{profileData.era_period}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.class')}</span>
|
||||
<span className="text-[var(--glass-text-primary)]">{profileData.social_class}</span>
|
||||
</div>
|
||||
{profileData.occupation && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.occupation')}</span>
|
||||
<span className="text-[var(--glass-text-primary)]">{profileData.occupation}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.personality')}</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{profileData.personality_tags.map((tag, i) => (
|
||||
<span key={i} className="px-1.5 py-0.5 bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] rounded text-xs">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.costume')}</span>
|
||||
<span className="text-[var(--glass-text-primary)]">
|
||||
{'●'.repeat(profileData.costume_tier)}
|
||||
</span>
|
||||
</div>
|
||||
{profileData.primary_identifier && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.identifier')}</span>
|
||||
<span className="text-[var(--glass-tone-warning-fg)] font-medium">{profileData.primary_identifier}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
disabled={isConfirming}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-[var(--glass-stroke-strong)] rounded-lg hover:bg-[var(--glass-bg-muted)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{t('characterProfile.editProfile')}
|
||||
</button>
|
||||
{onUseExisting && (
|
||||
<button
|
||||
onClick={onUseExisting}
|
||||
disabled={isConfirming}
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-[var(--glass-stroke-focus)] text-[var(--glass-tone-info-fg)] rounded-lg hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50 flex items-center justify-center gap-1"
|
||||
>
|
||||
{t('characterProfile.useExisting')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isConfirming}
|
||||
className="flex-1 px-3 py-1.5 text-sm bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)] transition-colors disabled:opacity-50 flex items-center justify-center gap-1"
|
||||
>
|
||||
{isConfirming ? (
|
||||
<TaskStatusInline state={confirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
t('characterProfile.confirmAndGenerate')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import type { TaskPresentationState } from '@/lib/task/presentation'
|
||||
import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
|
||||
|
||||
/**
|
||||
@@ -11,11 +12,14 @@ import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
|
||||
* 从 AssetsStage.tsx 提取,负责角色列表的展示和操作
|
||||
*
|
||||
* 🔥 V6.5 重构:内部直接订阅 useProjectAssets,消除 props drilling
|
||||
* 🔥 V7 重构:待确认角色档案内嵌显示,不再使用独立 Section
|
||||
*/
|
||||
|
||||
import { Character, CharacterAppearance } from '@/types/project'
|
||||
import { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'
|
||||
import CharacterCard from './CharacterCard'
|
||||
import CharacterProfileCard from './CharacterProfileCard'
|
||||
import { parseProfileData } from '@/types/character-profile'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface CharacterSectionProps {
|
||||
@@ -49,6 +53,17 @@ interface CharacterSectionProps {
|
||||
getAppearances: (character: Character) => CharacterAppearance[]
|
||||
/** 分集筛选:仅显示指定 ID 的角色,null 表示显示全部 */
|
||||
filterIds?: Set<string> | null
|
||||
// 🔥 V7:待确认角色档案(内嵌到 CharacterSection)
|
||||
unconfirmedCharacters: Character[]
|
||||
isConfirmingCharacter: (characterId: string) => boolean
|
||||
deletingCharacterId: string | null
|
||||
batchConfirming: boolean
|
||||
batchConfirmingState: TaskPresentationState | null
|
||||
onBatchConfirm: () => void
|
||||
onEditProfile: (characterId: string, characterName: string) => void
|
||||
onConfirmProfile: (characterId: string) => void
|
||||
onUseExistingProfile: (characterId: string) => void
|
||||
onDeleteProfile: (characterId: string) => void
|
||||
}
|
||||
|
||||
export default function CharacterSection({
|
||||
@@ -78,6 +93,17 @@ export default function CharacterSection({
|
||||
onCopyFromGlobal,
|
||||
getAppearances,
|
||||
filterIds = null,
|
||||
// 🔥 V7:待确认角色
|
||||
unconfirmedCharacters,
|
||||
isConfirmingCharacter,
|
||||
deletingCharacterId,
|
||||
batchConfirming,
|
||||
batchConfirmingState,
|
||||
onBatchConfirm,
|
||||
onEditProfile,
|
||||
onConfirmProfile,
|
||||
onUseExistingProfile,
|
||||
onDeleteProfile,
|
||||
}: CharacterSectionProps) {
|
||||
const t = useTranslations('assets')
|
||||
const analyzingAssetsState = isAnalyzingAssets
|
||||
@@ -91,9 +117,17 @@ export default function CharacterSection({
|
||||
|
||||
const { data: assets } = useProjectAssets(projectId)
|
||||
const allCharacters: Character[] = useMemo(() => assets?.characters ?? [], [assets?.characters])
|
||||
// 🔥 V7:排除待确认角色,避免同一角色在待确认区与已确认网格中重复出现
|
||||
const unconfirmedIds = useMemo(
|
||||
() => new Set(unconfirmedCharacters.map((c) => c.id)),
|
||||
[unconfirmedCharacters],
|
||||
)
|
||||
const characters: Character[] = useMemo(
|
||||
() => filterIds ? allCharacters.filter((c) => filterIds.has(c.id)) : allCharacters,
|
||||
[allCharacters, filterIds],
|
||||
() => {
|
||||
const base = filterIds ? allCharacters.filter((c) => filterIds.has(c.id)) : allCharacters
|
||||
return base.filter((c) => !unconfirmedIds.has(c.id))
|
||||
},
|
||||
[allCharacters, filterIds, unconfirmedIds],
|
||||
)
|
||||
const [highlightedCharacterId, setHighlightedCharacterId] = useState<string | null>(null)
|
||||
const scrollAnimationRef = useRef<number | null>(null)
|
||||
@@ -179,6 +213,54 @@ export default function CharacterSection({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 🔥 V7:待确认角色档案 - 内嵌引导横幅 */}
|
||||
{unconfirmedCharacters.length > 0 && (
|
||||
<div className="mb-6">
|
||||
{/* 引导横幅 */}
|
||||
<div className="flex items-center justify-between mb-3 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-md bg-[var(--glass-tone-info-bg)]">
|
||||
<AppIcon name="sparkles" className="h-3 w-3 text-[var(--glass-tone-info-fg)]" />
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-[var(--glass-text-primary)]">{t('stage.pendingProfilesBanner')}</span>
|
||||
<span className="text-xs text-[var(--glass-text-tertiary)]">{t('stage.pendingProfilesHint')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onBatchConfirm}
|
||||
disabled={batchConfirming}
|
||||
className="glass-btn-base glass-btn-primary px-3 py-1.5 text-sm disabled:opacity-50 flex items-center gap-1.5"
|
||||
>
|
||||
{batchConfirming ? (
|
||||
<TaskStatusInline state={batchConfirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
t('stage.confirmAll', { count: unconfirmedCharacters.length })
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* 待确认卡片网格 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{unconfirmedCharacters.map((character) => {
|
||||
const profileData = parseProfileData(character.profileData!)
|
||||
if (!profileData) return null
|
||||
return (
|
||||
<CharacterProfileCard
|
||||
key={character.id}
|
||||
characterId={character.id}
|
||||
name={character.name}
|
||||
profileData={profileData}
|
||||
onEdit={() => onEditProfile(character.id, character.name)}
|
||||
onConfirm={() => onConfirmProfile(character.id)}
|
||||
onUseExisting={() => onUseExistingProfile(character.id)}
|
||||
onDelete={() => onDeleteProfile(character.id)}
|
||||
isConfirming={isConfirmingCharacter(character.id)}
|
||||
isDeleting={deletingCharacterId === character.id}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 按角色分组显示:外层 grid 让多角色并排 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{characters.map(character => {
|
||||
@@ -198,7 +280,7 @@ export default function CharacterSection({
|
||||
className={`glass-surface rounded-xl p-4 scroll-mt-24 transition-all duration-700 ${highlightedCharacterId === character.id ? 'ring-2 ring-[var(--glass-focus-ring)] bg-[var(--glass-tone-info-bg)]/40' : ''}`}
|
||||
>
|
||||
{/* 角色标题 */}
|
||||
<div className="flex items-center justify-between border-b border-[var(--glass-stroke-base)] pb-2">
|
||||
<div className="flex items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-base font-semibold text-[var(--glass-text-primary)]">{character.name}</h3>
|
||||
<span className="text-xs text-[var(--glass-text-tertiary)]">
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
|
||||
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
|
||||
import { countGeneratedImageSlots, resolveDisplayImageSlots } from '@/lib/image-generation/slot-state'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { canGenerateLocationBackedAsset } from './location-backed-asset'
|
||||
|
||||
interface LocationCardProps {
|
||||
location: Location
|
||||
@@ -358,7 +359,7 @@ export default function LocationCard({
|
||||
)
|
||||
|
||||
const firstImage = location.images?.[0]
|
||||
const hasDescription = !!firstImage?.description
|
||||
const canGenerate = canGenerateLocationBackedAsset(location)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 glass-surface-elevated p-3">
|
||||
@@ -395,7 +396,7 @@ export default function LocationCard({
|
||||
mode="compact"
|
||||
currentImageUrl={currentImageUrl}
|
||||
isTaskRunning={isTaskRunning}
|
||||
hasDescription={hasDescription}
|
||||
canGenerate={canGenerate}
|
||||
generationCount={generationCount}
|
||||
onGenerationCountChange={setGenerationCount}
|
||||
onGenerate={onGenerate}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Location, Prop } from '@/types/project'
|
||||
import { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'
|
||||
import LocationCard from './LocationCard'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { resolveLocationBackedGenerateType } from './location-backed-asset'
|
||||
|
||||
interface LocationSectionProps {
|
||||
// 🔥 V6.5 删除:locations prop - 现在内部直接订阅
|
||||
@@ -26,7 +27,7 @@ interface LocationSectionProps {
|
||||
onDeleteLocation: (locationId: string) => void
|
||||
onEditLocation: (location: Location | Prop) => void
|
||||
// 🔥 V6.6 重构:重命名为 handleGenerateImage
|
||||
handleGenerateImage: (type: 'character' | 'location', id: string, appearanceId?: string, count?: number) => Promise<void>
|
||||
handleGenerateImage: (type: 'character' | 'location' | 'prop', id: string, appearanceId?: string, count?: number) => Promise<void>
|
||||
onSelectImage: (locationId: string, imageIndex: number | null) => void
|
||||
onConfirmSelection: (locationId: string) => void
|
||||
onRegenerateSingle: (locationId: string, imageIndex: number) => Promise<void>
|
||||
@@ -68,6 +69,7 @@ export default function LocationSection({
|
||||
: assets?.locations ?? []
|
||||
const locations = filterIds ? allLocations.filter((l) => filterIds.has(l.id)) : allLocations
|
||||
const assetKey = assetType === 'prop' ? 'prop' : 'location'
|
||||
const generateType = resolveLocationBackedGenerateType(assetType)
|
||||
|
||||
return (
|
||||
<div className="glass-surface p-6">
|
||||
@@ -135,7 +137,7 @@ export default function LocationSection({
|
||||
onGenerate={(count) => {
|
||||
const taskKey = `location-${location.id}-group`
|
||||
onRegisterTransientTaskKey(taskKey)
|
||||
void handleGenerateImage('location', location.id, undefined, count).catch(() => {
|
||||
void handleGenerateImage(generateType, location.id, undefined, count).catch(() => {
|
||||
onClearTaskKey(taskKey)
|
||||
})
|
||||
}}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import CharacterProfileCard from './CharacterProfileCard'
|
||||
import { parseProfileData } from '@/types/character-profile'
|
||||
import type { Character } from '@/types/project'
|
||||
import type { TaskPresentationState } from '@/lib/task/presentation'
|
||||
|
||||
interface UnconfirmedProfilesSectionProps {
|
||||
unconfirmedCharacters: Character[]
|
||||
confirmTitle: string
|
||||
confirmHint: string
|
||||
confirmAllLabel: string
|
||||
batchConfirming: boolean
|
||||
batchConfirmingState: TaskPresentationState | null
|
||||
deletingCharacterId: string | null
|
||||
isConfirmingCharacter: (characterId: string) => boolean
|
||||
onBatchConfirm: () => void
|
||||
onEditProfile: (characterId: string, characterName: string) => void
|
||||
onConfirmProfile: (characterId: string) => void
|
||||
onUseExistingProfile: (characterId: string) => void
|
||||
onDeleteProfile: (characterId: string) => void
|
||||
}
|
||||
|
||||
export default function UnconfirmedProfilesSection({
|
||||
unconfirmedCharacters,
|
||||
confirmTitle,
|
||||
confirmHint,
|
||||
confirmAllLabel,
|
||||
batchConfirming,
|
||||
batchConfirmingState,
|
||||
deletingCharacterId,
|
||||
isConfirmingCharacter,
|
||||
onBatchConfirm,
|
||||
onEditProfile,
|
||||
onConfirmProfile,
|
||||
onUseExistingProfile,
|
||||
onDeleteProfile,
|
||||
}: UnconfirmedProfilesSectionProps) {
|
||||
if (unconfirmedCharacters.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--glass-tone-warning-bg)] border border-[var(--glass-stroke-warning)] rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">{confirmTitle}</h3>
|
||||
<p className="text-sm text-[var(--glass-text-secondary)]">{confirmHint}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onBatchConfirm}
|
||||
disabled={batchConfirming}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{batchConfirming ? (
|
||||
<TaskStatusInline state={batchConfirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
confirmAllLabel
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{unconfirmedCharacters.map((character) => {
|
||||
const profileData = parseProfileData(character.profileData!)
|
||||
if (!profileData) return null
|
||||
return (
|
||||
<CharacterProfileCard
|
||||
key={character.id}
|
||||
characterId={character.id}
|
||||
name={character.name}
|
||||
profileData={profileData}
|
||||
onEdit={() => onEditProfile(character.id, character.name)}
|
||||
onConfirm={() => onConfirmProfile(character.id)}
|
||||
onUseExisting={() => onUseExistingProfile(character.id)}
|
||||
onDelete={() => onDeleteProfile(character.id)}
|
||||
isConfirming={isConfirmingCharacter(character.id)}
|
||||
isDeleting={deletingCharacterId === character.id}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -114,94 +114,110 @@ export default function VoiceSettings({
|
||||
? 'border border-[var(--glass-stroke-base)] rounded-xl p-3 bg-[var(--glass-bg-surface-strong)]'
|
||||
: 'mt-4 border border-[var(--glass-stroke-base)] rounded-xl p-4 bg-[var(--glass-bg-surface-strong)]'
|
||||
|
||||
const headerClass = compact
|
||||
? 'flex items-center gap-2 mb-2 pb-2 border-b'
|
||||
: 'flex items-center gap-2 mb-3 pb-2 border-b'
|
||||
|
||||
const iconSize = compact ? 'w-5 h-5' : 'w-6 h-6'
|
||||
const innerIconSize = compact ? 'w-3 h-3' : 'w-3.5 h-3.5'
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={containerClass}>
|
||||
<div className={`${headerClass} ${hasCustomVoice ? 'border-[var(--glass-stroke-base)]' : 'border-[var(--glass-stroke-warning)]'}`}>
|
||||
<div className={`${iconSize} rounded-full flex items-center justify-center ${hasCustomVoice ? 'bg-[var(--glass-bg-muted)]' : 'bg-[var(--glass-tone-warning-bg)]'}`}>
|
||||
<AppIcon name="mic" className={`${innerIconSize} ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`} />
|
||||
{/* 折叠标题行 - 点击展开/收起 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-between cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`${iconSize} rounded-full flex items-center justify-center ${hasCustomVoice ? 'bg-[var(--glass-bg-muted)]' : 'bg-[var(--glass-tone-warning-bg)]'}`}>
|
||||
<AppIcon name="mic" className={`${innerIconSize} ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`} />
|
||||
</div>
|
||||
<span className={`text-${compact ? 'xs' : 'sm'} font-medium text-[var(--glass-text-secondary)]`}>
|
||||
{t('tts.title')}
|
||||
</span>
|
||||
<span className={`w-2 h-2 rounded-full ${hasCustomVoice ? 'bg-[var(--glass-tone-success-fg)]' : 'bg-[var(--glass-tone-warning-fg)]'}`} />
|
||||
</div>
|
||||
<span className={`text-${compact ? 'xs' : 'sm'} font-medium ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`}>
|
||||
{t('tts.title')}{!hasCustomVoice && <span className="text-[var(--glass-tone-warning-fg)]">({t('tts.noVoice')})</span>}
|
||||
</span>
|
||||
</div>
|
||||
<AppIcon
|
||||
name="chevronDown"
|
||||
className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 隐藏的音频文件输入 */}
|
||||
<input
|
||||
ref={voiceFileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleUploadVoice}
|
||||
className="hidden"
|
||||
/>
|
||||
{/* 展开内容 */}
|
||||
{isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--glass-stroke-base)]">
|
||||
{/* 隐藏的音频文件输入 */}
|
||||
<input
|
||||
ref={voiceFileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleUploadVoice}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 w-full justify-center">
|
||||
{/* 上传音频按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!confirmUploadVoice()) return
|
||||
voiceFileInputRef.current?.click()
|
||||
}}
|
||||
disabled={uploadVoice.isPending}
|
||||
className="flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] rounded-lg text-xs text-[var(--glass-text-secondary)] font-medium hover:border-[var(--glass-stroke-success)] hover:bg-[var(--glass-tone-success-bg)] hover:text-[var(--glass-tone-success-fg)] transition-all relative group whitespace-nowrap"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{hasCustomVoice && <div className="w-1.5 h-1.5 bg-[var(--glass-tone-success-fg)] rounded-full flex-shrink-0"></div>}
|
||||
<span>{uploadVoice.isPending ? t('tts.uploading') : hasCustomVoice ? t('tts.uploaded') : t('tts.uploadAudio')}</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-2 w-full justify-center">
|
||||
{/* 上传音频按钮 */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!confirmUploadVoice()) return
|
||||
voiceFileInputRef.current?.click()
|
||||
}}
|
||||
disabled={uploadVoice.isPending}
|
||||
className="flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] rounded-lg text-xs text-[var(--glass-text-secondary)] font-medium hover:border-[var(--glass-stroke-success)] hover:bg-[var(--glass-tone-success-bg)] hover:text-[var(--glass-tone-success-fg)] transition-all relative group whitespace-nowrap"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{hasCustomVoice && <div className="w-1.5 h-1.5 bg-[var(--glass-tone-success-fg)] rounded-full flex-shrink-0"></div>}
|
||||
<span>{uploadVoice.isPending ? t('tts.uploading') : hasCustomVoice ? t('tts.uploaded') : t('tts.uploadAudio')}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 从资产中心选择按钮 */}
|
||||
{onSelectFromHub && (
|
||||
<button
|
||||
onClick={() => onSelectFromHub(characterId)}
|
||||
className="flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-focus)] rounded-lg text-xs text-[var(--glass-tone-info-fg)] font-medium hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-tone-info-bg)] transition-all whitespace-nowrap"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<AppIcon name="copy" className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{t('assetLibrary.button')}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* AI设计按钮 */}
|
||||
{onVoiceDesign && (
|
||||
<button
|
||||
onClick={() => onVoiceDesign(characterId, characterName)}
|
||||
className="glass-btn-base glass-btn-primary flex-1 min-w-[80px] px-2 py-1.5 text-xs font-medium whitespace-nowrap"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<AppIcon name="bolt" className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{t('modal.aiDesign')}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 试听按钮 - 仅在有音频时显示 */}
|
||||
{hasCustomVoice && (
|
||||
<button
|
||||
onClick={handlePreviewVoice}
|
||||
className={`w-full mt-2 px-3 py-2 border rounded-lg text-sm font-medium transition-all ${isPreviewingVoice
|
||||
? 'bg-[var(--glass-accent-from)] border-[var(--glass-stroke-focus)] text-white hover:bg-[var(--glass-accent-to)]'
|
||||
: 'bg-[var(--glass-tone-info-bg)] border-[var(--glass-stroke-focus)] text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isPreviewingVoice ? (
|
||||
<AppIcon name="pause" className="w-4 h-4" />
|
||||
) : (
|
||||
<AppIcon name="play" className="w-4 h-4" />
|
||||
{/* 从资产中心选择按钮 */}
|
||||
{onSelectFromHub && (
|
||||
<button
|
||||
onClick={() => onSelectFromHub(characterId)}
|
||||
className="flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-focus)] rounded-lg text-xs text-[var(--glass-tone-info-fg)] font-medium hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-tone-info-bg)] transition-all whitespace-nowrap"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<AppIcon name="copy" className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{t('assetLibrary.button')}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* AI设计按钮 */}
|
||||
{onVoiceDesign && (
|
||||
<button
|
||||
onClick={() => onVoiceDesign(characterId, characterName)}
|
||||
className="glass-btn-base glass-btn-primary flex-1 min-w-[80px] px-2 py-1.5 text-xs font-medium whitespace-nowrap"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<AppIcon name="bolt" className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{t('modal.aiDesign')}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
{isPreviewingVoice ? t('tts.pause') : t('tts.preview')}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 试听按钮 - 仅在有音频时显示 */}
|
||||
{hasCustomVoice && (
|
||||
<button
|
||||
onClick={handlePreviewVoice}
|
||||
className={`w-full mt-2 px-3 py-2 border rounded-lg text-sm font-medium transition-all ${isPreviewingVoice
|
||||
? 'bg-[var(--glass-accent-from)] border-[var(--glass-stroke-focus)] text-white hover:bg-[var(--glass-accent-to)]'
|
||||
: 'bg-[var(--glass-tone-info-bg)] border-[var(--glass-stroke-focus)] text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{isPreviewingVoice ? (
|
||||
<AppIcon name="pause" className="w-4 h-4" />
|
||||
) : (
|
||||
<AppIcon name="play" className="w-4 h-4" />
|
||||
)}
|
||||
{isPreviewingVoice ? t('tts.pause') : t('tts.preview')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Location, Prop } from '@/types/project'
|
||||
|
||||
export function canGenerateLocationBackedAsset(asset: Location | Prop): boolean {
|
||||
if (asset.summary && asset.summary.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (asset.images ?? []).some((image) =>
|
||||
typeof image.description === 'string' && image.description.trim().length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveLocationBackedGenerateType(
|
||||
assetType: 'location' | 'prop',
|
||||
): 'location' | 'prop' {
|
||||
return assetType
|
||||
}
|
||||
@@ -19,7 +19,7 @@ type LocationCardActionsProps =
|
||||
mode: 'compact'
|
||||
currentImageUrl: string | null | undefined
|
||||
isTaskRunning: boolean
|
||||
hasDescription: boolean
|
||||
canGenerate: boolean
|
||||
generationCount: number
|
||||
onGenerationCountChange: (value: number) => void
|
||||
onGenerate: (count?: number) => void
|
||||
@@ -67,7 +67,7 @@ export default function LocationCardActions(props: LocationCardActionsProps) {
|
||||
options={getImageGenerationCountOptions('location')}
|
||||
onValueChange={props.onGenerationCountChange}
|
||||
onClick={() => props.onGenerate(props.generationCount)}
|
||||
disabled={!props.hasDescription}
|
||||
disabled={!props.canGenerate}
|
||||
ariaLabel={t('image.selectCount')}
|
||||
className="glass-btn-base glass-btn-primary flex w-full items-center justify-center gap-1 py-1 text-xs disabled:opacity-50"
|
||||
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-xs font-semibold text-current outline-none cursor-pointer leading-none transition-colors"
|
||||
|
||||
@@ -321,6 +321,7 @@ export default function ScriptViewAssetsPanel({
|
||||
const hasCharacterSelectionChanges = !setsEqual(initialAppearanceKeys, pendingAppearanceKeys) || hasCharacterLabelChanges
|
||||
const hasLocationSelectionChanges = !setsEqual(new Set(activeLocationIds), pendingLocationIds) || hasLocationLabelChanges
|
||||
const hasPropSelectionChanges = !setsEqual(new Set(activePropIds), pendingPropIds)
|
||||
const hasProjectProps = props.length > 0
|
||||
|
||||
const handleConfirmCharacterSelection = async () => {
|
||||
if (isSavingCharacterSelection) return
|
||||
@@ -754,6 +755,7 @@ export default function ScriptViewAssetsPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasProjectProps ? (
|
||||
<div className="relative">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h3 className="text-sm font-bold text-[var(--glass-text-secondary)]">道具 ({activePropIds.length})</h3>
|
||||
@@ -855,6 +857,7 @@ export default function ScriptViewAssetsPanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -874,7 +877,7 @@ export default function ScriptViewAssetsPanel({
|
||||
<button
|
||||
onClick={onGenerateStoryboard}
|
||||
disabled={isSubmittingStoryboardBuild || clips.length === 0 || !allAssetsHaveImages}
|
||||
className="w-full py-4 text-lg font-bold bg-[var(--glass-accent-from)] text-white rounded-2xl"
|
||||
className="glass-btn-base glass-btn-primary w-full py-4 text-lg font-bold disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{isSubmittingStoryboardBuild ? tScript('generate.generating') : tScript('generate.startGenerate')}
|
||||
</button>
|
||||
|
||||
@@ -9,10 +9,14 @@ export interface SegmentedControlOption<T extends string = string> {
|
||||
label: ReactNode
|
||||
}
|
||||
|
||||
type SegmentedControlLayout = 'fill' | 'compact'
|
||||
|
||||
interface SegmentedControlProps<T extends string = string> {
|
||||
options: SegmentedControlOption<T>[]
|
||||
value: T
|
||||
onChange: (value: T) => void
|
||||
/** Layout mode: stretch to container or keep a compact left-aligned width */
|
||||
layout?: SegmentedControlLayout
|
||||
/** Extra className on the outer container */
|
||||
className?: string
|
||||
}
|
||||
@@ -31,10 +35,12 @@ export function SegmentedControl<T extends string = string>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
layout = 'fill',
|
||||
className = '',
|
||||
}: SegmentedControlProps<T>) {
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const [indicator, setIndicator] = useState<{ left: number; width: number }>({ left: 0, width: 0 })
|
||||
const isCompact = layout === 'compact'
|
||||
|
||||
useEffect(() => {
|
||||
if (!gridRef.current) return
|
||||
@@ -47,11 +53,13 @@ export function SegmentedControl<T extends string = string>({
|
||||
}, [value, options])
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl p-[3px] bg-[#e8e8ed] dark:bg-[#1c1c1e] ${className}`}>
|
||||
<div
|
||||
className={`rounded-xl p-[3px] bg-[#e8e8ed] dark:bg-[#1c1c1e] ${isCompact ? 'inline-block max-w-full' : 'block w-full'} ${className}`}
|
||||
>
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="relative grid"
|
||||
style={{ gridTemplateColumns: `repeat(${Math.max(1, options.length)}, minmax(0, 1fr))` }}
|
||||
className={isCompact ? 'relative inline-grid grid-flow-col auto-cols-[minmax(96px,max-content)]' : 'relative grid'}
|
||||
style={isCompact ? undefined : { gridTemplateColumns: `repeat(${Math.max(1, options.length)}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{/* Sliding pill indicator */}
|
||||
<div
|
||||
|
||||
@@ -74,6 +74,7 @@ export type CharacterAssetSummary = BaseAssetSummary & {
|
||||
family: 'visual'
|
||||
variants: AssetVariantSummary[]
|
||||
introduction: string | null
|
||||
profileData: string | null
|
||||
profileConfirmed: boolean | null
|
||||
profileTaskRefs: AssetTaskRef[]
|
||||
profileTaskState: AssetTaskState
|
||||
|
||||
@@ -32,6 +32,7 @@ type ProjectCharacterRecord = {
|
||||
id: string
|
||||
name: string
|
||||
introduction?: string | null
|
||||
profileData?: string | null
|
||||
voiceType?: 'custom' | 'qwen-designed' | 'uploaded' | null
|
||||
voiceId?: string | null
|
||||
customVoiceUrl?: string | null
|
||||
@@ -214,6 +215,7 @@ export function mapProjectCharacterToAsset(character: ProjectCharacterRecord): C
|
||||
taskState: createIdleTaskState(),
|
||||
variants,
|
||||
introduction: character.introduction ?? null,
|
||||
profileData: character.profileData ?? null,
|
||||
profileConfirmed: character.profileConfirmed ?? null,
|
||||
profileTaskRefs: [
|
||||
{
|
||||
@@ -290,6 +292,7 @@ export function mapGlobalCharacterToAsset(character: GlobalCharacterRecord): Cha
|
||||
taskState: createIdleTaskState(),
|
||||
variants,
|
||||
introduction: null,
|
||||
profileData: null,
|
||||
profileConfirmed: null,
|
||||
profileTaskRefs: [],
|
||||
profileTaskState: createIdleTaskState(),
|
||||
|
||||
@@ -63,6 +63,22 @@ function buildImageGroups(
|
||||
return groups
|
||||
}
|
||||
|
||||
function normalizeSeedDescriptions(input: {
|
||||
descriptions?: string[]
|
||||
fallbackDescription: string
|
||||
}): string[] {
|
||||
const normalized = (input.descriptions ?? [])
|
||||
.map((description) => description.trim())
|
||||
.filter((description) => description.length > 0)
|
||||
|
||||
if (normalized.length > 0) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const fallbackDescription = input.fallbackDescription.trim()
|
||||
return fallbackDescription.length > 0 ? [fallbackDescription] : []
|
||||
}
|
||||
|
||||
async function readProjectLocationBackedImages(locationIds: string[]): Promise<Map<string, LocationBackedImageRow[]>> {
|
||||
if (locationIds.length === 0) {
|
||||
return new Map()
|
||||
@@ -194,6 +210,11 @@ export async function createProjectLocationBackedAsset(input: {
|
||||
NOW()
|
||||
)
|
||||
`)
|
||||
await seedProjectLocationBackedImageSlots({
|
||||
locationId: id,
|
||||
fallbackDescription: input.summary,
|
||||
descriptions: [input.summary],
|
||||
})
|
||||
return { id }
|
||||
}
|
||||
|
||||
@@ -229,9 +250,52 @@ export async function createGlobalLocationBackedAsset(input: {
|
||||
NOW()
|
||||
)
|
||||
`)
|
||||
await seedGlobalLocationBackedImageSlots({
|
||||
locationId: id,
|
||||
fallbackDescription: input.summary,
|
||||
descriptions: [input.summary],
|
||||
})
|
||||
return { id }
|
||||
}
|
||||
|
||||
export async function seedProjectLocationBackedImageSlots(input: {
|
||||
locationId: string
|
||||
fallbackDescription: string
|
||||
descriptions?: string[]
|
||||
}): Promise<void> {
|
||||
const descriptions = normalizeSeedDescriptions(input)
|
||||
if (descriptions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await prisma.locationImage.createMany({
|
||||
data: descriptions.map((description, imageIndex) => ({
|
||||
locationId: input.locationId,
|
||||
imageIndex,
|
||||
description,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
export async function seedGlobalLocationBackedImageSlots(input: {
|
||||
locationId: string
|
||||
fallbackDescription: string
|
||||
descriptions?: string[]
|
||||
}): Promise<void> {
|
||||
const descriptions = normalizeSeedDescriptions(input)
|
||||
if (descriptions.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await prisma.globalLocationImage.createMany({
|
||||
data: descriptions.map((description, imageIndex) => ({
|
||||
locationId: input.locationId,
|
||||
imageIndex,
|
||||
description,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteProjectLocationBackedAsset(assetId: string): Promise<void> {
|
||||
await prisma.$transaction([
|
||||
prisma.$executeRaw(Prisma.sql`DELETE FROM location_images WHERE locationId = ${assetId}`),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { queryKeys } from '../keys'
|
||||
import type { Character, Location, MediaRef, Prop } from '@/types/project'
|
||||
import { useAssets } from './useAssets'
|
||||
import type { AssetGroupMap } from '@/lib/assets/grouping'
|
||||
import { groupAssetsByKind } from '@/lib/assets/grouping'
|
||||
|
||||
// ============ 类型定义 ============
|
||||
@@ -14,6 +15,96 @@ export interface ProjectAssetsData {
|
||||
props: Prop[]
|
||||
}
|
||||
|
||||
function mapCharacterAssetToProjectCharacter(asset: AssetGroupMap['character'][number]): Character {
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
aliases: null,
|
||||
introduction: asset.introduction,
|
||||
appearances: asset.variants.map((variant) => ({
|
||||
id: variant.id,
|
||||
appearanceIndex: variant.index,
|
||||
changeReason: variant.label,
|
||||
description: variant.description,
|
||||
descriptions: null,
|
||||
imageUrl: variant.renders.find((render) => render.isSelected)?.imageUrl
|
||||
?? variant.renders[0]?.imageUrl
|
||||
?? null,
|
||||
media: variant.renders.find((render) => render.isSelected)?.media
|
||||
?? variant.renders[0]?.media
|
||||
?? null,
|
||||
imageUrls: variant.renders.map((render) => render.imageUrl ?? '').filter((value) => value.length > 0),
|
||||
imageMedias: variant.renders.map((render) => render.media).filter((media): media is MediaRef => !!media),
|
||||
previousImageUrl: variant.renders[0]?.previousImageUrl ?? null,
|
||||
previousMedia: variant.renders[0]?.previousMedia ?? null,
|
||||
previousImageUrls: variant.renders.map((render) => render.previousImageUrl ?? '').filter((value) => value.length > 0),
|
||||
previousImageMedias: variant.renders.map((render) => render.previousMedia).filter((media): media is MediaRef => !!media),
|
||||
previousDescription: null,
|
||||
previousDescriptions: null,
|
||||
selectedIndex: variant.selectionState.selectedRenderIndex,
|
||||
imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning,
|
||||
imageErrorMessage: variant.taskState.lastError?.message ?? null,
|
||||
lastError: variant.taskState.lastError ?? asset.taskState.lastError,
|
||||
})),
|
||||
voiceType: asset.voice.voiceType,
|
||||
voiceId: asset.voice.voiceId,
|
||||
customVoiceUrl: asset.voice.customVoiceUrl,
|
||||
media: asset.voice.media,
|
||||
profileData: asset.profileData,
|
||||
profileConfirmed: asset.profileConfirmed ?? undefined,
|
||||
profileConfirmTaskRunning: asset.profileTaskState.isRunning,
|
||||
}
|
||||
}
|
||||
|
||||
function mapLocationVariantToProjectImage(
|
||||
asset: AssetGroupMap['location'][number] | AssetGroupMap['prop'][number],
|
||||
variant: AssetGroupMap['location'][number]['variants'][number],
|
||||
) {
|
||||
const render = variant.renders[0] ?? null
|
||||
return {
|
||||
id: variant.id,
|
||||
imageIndex: variant.index,
|
||||
description: variant.description,
|
||||
imageUrl: render?.imageUrl ?? null,
|
||||
media: render?.media ?? null,
|
||||
previousImageUrl: render?.previousImageUrl ?? null,
|
||||
previousMedia: render?.previousMedia ?? null,
|
||||
previousDescription: null,
|
||||
isSelected: render?.isSelected ?? false,
|
||||
imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning,
|
||||
imageErrorMessage: variant.taskState.lastError?.message ?? null,
|
||||
lastError: variant.taskState.lastError ?? asset.taskState.lastError,
|
||||
}
|
||||
}
|
||||
|
||||
function mapLocationAssetToProjectLocation(asset: AssetGroupMap['location'][number]): Location {
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
summary: asset.summary,
|
||||
selectedImageId: asset.selectedVariantId,
|
||||
images: asset.variants.map((variant) => mapLocationVariantToProjectImage(asset, variant)),
|
||||
}
|
||||
}
|
||||
|
||||
function mapPropAssetToProjectProp(asset: AssetGroupMap['prop'][number]): Prop {
|
||||
return {
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
summary: asset.summary,
|
||||
selectedImageId: asset.selectedVariantId,
|
||||
images: asset.variants.map((variant) => mapLocationVariantToProjectImage(asset, variant)),
|
||||
}
|
||||
}
|
||||
|
||||
export function mapAssetGroupsToProjectAssetsData(groups: AssetGroupMap): ProjectAssetsData {
|
||||
return {
|
||||
characters: groups.character.map(mapCharacterAssetToProjectCharacter),
|
||||
locations: groups.location.map(mapLocationAssetToProjectLocation),
|
||||
props: groups.prop.map(mapPropAssetToProjectProp),
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 查询 Hooks ============
|
||||
|
||||
/**
|
||||
@@ -25,92 +116,7 @@ export function useProjectAssets(projectId: string | null) {
|
||||
projectId,
|
||||
})
|
||||
const groups = groupAssetsByKind(assetsQuery.data)
|
||||
const data: ProjectAssetsData = {
|
||||
characters: groups.character.map((asset) => ({
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
aliases: null,
|
||||
introduction: asset.introduction,
|
||||
appearances: asset.variants.map((variant) => ({
|
||||
id: variant.id,
|
||||
appearanceIndex: variant.index,
|
||||
changeReason: variant.label,
|
||||
description: variant.description,
|
||||
descriptions: null,
|
||||
imageUrl: variant.renders.find((render) => render.isSelected)?.imageUrl
|
||||
?? variant.renders[0]?.imageUrl
|
||||
?? null,
|
||||
media: variant.renders.find((render) => render.isSelected)?.media
|
||||
?? variant.renders[0]?.media
|
||||
?? null,
|
||||
imageUrls: variant.renders.map((render) => render.imageUrl ?? '').filter((value) => value.length > 0),
|
||||
imageMedias: variant.renders.map((render) => render.media).filter((media): media is MediaRef => !!media),
|
||||
previousImageUrl: variant.renders[0]?.previousImageUrl ?? null,
|
||||
previousMedia: variant.renders[0]?.previousMedia ?? null,
|
||||
previousImageUrls: variant.renders.map((render) => render.previousImageUrl ?? '').filter((value) => value.length > 0),
|
||||
previousImageMedias: variant.renders.map((render) => render.previousMedia).filter((media): media is MediaRef => !!media),
|
||||
previousDescription: null,
|
||||
previousDescriptions: null,
|
||||
selectedIndex: variant.selectionState.selectedRenderIndex,
|
||||
imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning,
|
||||
imageErrorMessage: variant.taskState.lastError?.message ?? null,
|
||||
lastError: variant.taskState.lastError ?? asset.taskState.lastError,
|
||||
})),
|
||||
voiceType: asset.voice.voiceType,
|
||||
voiceId: asset.voice.voiceId,
|
||||
customVoiceUrl: asset.voice.customVoiceUrl,
|
||||
media: asset.voice.media,
|
||||
profileData: null,
|
||||
profileConfirmed: asset.profileConfirmed ?? undefined,
|
||||
profileConfirmTaskRunning: asset.profileTaskState.isRunning,
|
||||
})),
|
||||
locations: groups.location.map((asset) => ({
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
summary: asset.summary,
|
||||
selectedImageId: asset.selectedVariantId,
|
||||
images: asset.variants.map((variant) => {
|
||||
const render = variant.renders[0] ?? null
|
||||
return {
|
||||
id: variant.id,
|
||||
imageIndex: variant.index,
|
||||
description: variant.description,
|
||||
imageUrl: render?.imageUrl ?? null,
|
||||
media: render?.media ?? null,
|
||||
previousImageUrl: render?.previousImageUrl ?? null,
|
||||
previousMedia: render?.previousMedia ?? null,
|
||||
previousDescription: null,
|
||||
isSelected: render?.isSelected ?? false,
|
||||
imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning,
|
||||
imageErrorMessage: variant.taskState.lastError?.message ?? null,
|
||||
lastError: variant.taskState.lastError ?? asset.taskState.lastError,
|
||||
}
|
||||
}),
|
||||
})),
|
||||
props: groups.prop.map((asset) => ({
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
summary: asset.summary,
|
||||
selectedImageId: asset.selectedVariantId,
|
||||
images: asset.variants.map((variant) => {
|
||||
const render = variant.renders[0] ?? null
|
||||
return {
|
||||
id: variant.id,
|
||||
imageIndex: variant.index,
|
||||
description: variant.description,
|
||||
imageUrl: render?.imageUrl ?? null,
|
||||
media: render?.media ?? null,
|
||||
previousImageUrl: render?.previousImageUrl ?? null,
|
||||
previousMedia: render?.previousMedia ?? null,
|
||||
previousDescription: null,
|
||||
isSelected: render?.isSelected ?? false,
|
||||
imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning,
|
||||
imageErrorMessage: variant.taskState.lastError?.message ?? null,
|
||||
lastError: variant.taskState.lastError ?? asset.taskState.lastError,
|
||||
}
|
||||
}),
|
||||
})),
|
||||
}
|
||||
const data = mapAssetGroupsToProjectAssetsData(groups)
|
||||
|
||||
return {
|
||||
...assetsQuery,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type AnalyzeGlobalPropsData,
|
||||
type CharacterBrief,
|
||||
} from './analyze-global-parse'
|
||||
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
|
||||
|
||||
export type AnalyzeGlobalStats = {
|
||||
totalChunks: number
|
||||
@@ -48,10 +49,6 @@ export async function persistAnalyzeGlobalChunk(params: {
|
||||
existingPropNames: string[]
|
||||
stats: AnalyzeGlobalStats
|
||||
}) {
|
||||
const locationModel = prisma.novelPromotionLocation as unknown as {
|
||||
create: (args: { data: Record<string, unknown>; select?: Record<string, boolean> }) => Promise<{ id: string }>
|
||||
}
|
||||
|
||||
for (const char of params.charactersData.new_characters || []) {
|
||||
const name = readText(char.name).trim()
|
||||
const aliases = toStringArray(char.aliases)
|
||||
@@ -181,15 +178,11 @@ export async function persistAnalyzeGlobalChunk(params: {
|
||||
},
|
||||
})
|
||||
|
||||
for (let j = 0; j < cleanDescriptions.length; j += 1) {
|
||||
await prisma.locationImage.create({
|
||||
data: {
|
||||
locationId: created.id,
|
||||
imageIndex: j,
|
||||
description: cleanDescriptions[j],
|
||||
},
|
||||
})
|
||||
}
|
||||
await seedProjectLocationBackedImageSlots({
|
||||
locationId: created.id,
|
||||
descriptions: cleanDescriptions,
|
||||
fallbackDescription: summary || name,
|
||||
})
|
||||
|
||||
params.existingLocationNames.push(name)
|
||||
params.existingLocationInfo.push(summary ? `${name}(${summary})` : name)
|
||||
@@ -214,7 +207,7 @@ export async function persistAnalyzeGlobalChunk(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
await locationModel.create({
|
||||
const created = await prisma.novelPromotionLocation.create({
|
||||
data: {
|
||||
novelPromotionProjectId: params.projectInternalId,
|
||||
name,
|
||||
@@ -222,6 +215,11 @@ export async function persistAnalyzeGlobalChunk(params: {
|
||||
assetKind: 'prop',
|
||||
},
|
||||
})
|
||||
await seedProjectLocationBackedImageSlots({
|
||||
locationId: created.id,
|
||||
descriptions: [summary],
|
||||
fallbackDescription: summary,
|
||||
})
|
||||
params.existingPropNames.push(name)
|
||||
params.stats.newProps += 1
|
||||
} catch {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './
|
||||
import type { TaskJobData } from '@/lib/task/types'
|
||||
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
|
||||
import { resolveAnalysisModel } from './resolve-analysis-model'
|
||||
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
|
||||
|
||||
function readAssetKind(value: Record<string, unknown>): string {
|
||||
return typeof value.assetKind === 'string' ? value.assetKind : 'location'
|
||||
@@ -43,9 +44,6 @@ function parseJsonResponse(responseText: string): Record<string, unknown> {
|
||||
export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
|
||||
const payload = (job.data.payload || {}) as Record<string, unknown>
|
||||
const projectId = job.data.projectId
|
||||
const locationModel = prisma.novelPromotionLocation as unknown as {
|
||||
create: (args: { data: Record<string, unknown>; select?: Record<string, boolean> }) => Promise<{ id: string }>
|
||||
}
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: projectId },
|
||||
@@ -312,7 +310,7 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
|
||||
)
|
||||
if (existsInLibrary) continue
|
||||
|
||||
const created = await locationModel.create({
|
||||
const created = await prisma.novelPromotionLocation.create({
|
||||
data: {
|
||||
novelPromotionProjectId: novelData.id,
|
||||
name,
|
||||
@@ -322,15 +320,11 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
|
||||
})
|
||||
|
||||
const cleanDescriptions = descriptions.map((value) => removeLocationPromptSuffix(value || ''))
|
||||
for (let i = 0; i < cleanDescriptions.length; i += 1) {
|
||||
await prisma.locationImage.create({
|
||||
data: {
|
||||
locationId: created.id,
|
||||
imageIndex: i,
|
||||
description: cleanDescriptions[i],
|
||||
},
|
||||
})
|
||||
}
|
||||
await seedProjectLocationBackedImageSlots({
|
||||
locationId: created.id,
|
||||
descriptions: cleanDescriptions,
|
||||
fallbackDescription: readText(item.summary) || name,
|
||||
})
|
||||
|
||||
createdLocations.push(created)
|
||||
}
|
||||
@@ -349,7 +343,7 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
|
||||
const normalizedName = name.toLowerCase()
|
||||
if (existingPropNameSet.has(normalizedName)) continue
|
||||
|
||||
const created = await locationModel.create({
|
||||
const created = await prisma.novelPromotionLocation.create({
|
||||
data: {
|
||||
novelPromotionProjectId: novelData.id,
|
||||
name,
|
||||
@@ -358,6 +352,11 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
await seedProjectLocationBackedImageSlots({
|
||||
locationId: created.id,
|
||||
descriptions: [summary],
|
||||
fallbackDescription: summary,
|
||||
})
|
||||
existingPropNameSet.add(normalizedName)
|
||||
createdProps.push(created)
|
||||
}
|
||||
|
||||
@@ -129,27 +129,50 @@ async function handleConfirmProfile(
|
||||
}
|
||||
await assertTaskActive(job, 'character_profile_confirm_persist')
|
||||
|
||||
const appearanceRows: Array<{
|
||||
characterId: string
|
||||
appearanceIndex: number
|
||||
changeReason: string
|
||||
description: string
|
||||
descriptions: string
|
||||
imageUrls: string
|
||||
previousImageUrls: string
|
||||
}> = []
|
||||
|
||||
for (let appIndex = 0; appIndex < appearances.length; appIndex++) {
|
||||
const app = appearances[appIndex]
|
||||
await assertTaskActive(job, 'character_profile_confirm_create_appearance')
|
||||
const descriptions = Array.isArray(app.descriptions) ? app.descriptions : []
|
||||
const normalizedDescriptions = descriptions.map((item) => readText(item)).filter(Boolean)
|
||||
await prisma.characterAppearance.create({
|
||||
data: {
|
||||
characterId: character.id,
|
||||
appearanceIndex: appIndex,
|
||||
changeReason: readText(app.change_reason) || '初始形象',
|
||||
description: normalizedDescriptions[0] || '',
|
||||
descriptions: JSON.stringify(normalizedDescriptions),
|
||||
imageUrls: encodeImageUrls([]),
|
||||
previousImageUrls: encodeImageUrls([]),
|
||||
},
|
||||
appearanceRows.push({
|
||||
characterId: character.id,
|
||||
appearanceIndex: appIndex,
|
||||
changeReason: readText(app.change_reason) || '初始形象',
|
||||
description: normalizedDescriptions[0] || '',
|
||||
descriptions: JSON.stringify(normalizedDescriptions),
|
||||
imageUrls: encodeImageUrls([]),
|
||||
previousImageUrls: encodeImageUrls([]),
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.novelPromotionCharacter.update({
|
||||
where: { id: characterId },
|
||||
data: { profileConfirmed: true },
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.characterAppearance.deleteMany({
|
||||
where: { characterId: character.id },
|
||||
})
|
||||
|
||||
for (const appearanceRow of appearanceRows) {
|
||||
await tx.characterAppearance.create({
|
||||
data: appearanceRow,
|
||||
})
|
||||
}
|
||||
|
||||
await tx.novelPromotionCharacter.update({
|
||||
where: { id: characterId },
|
||||
data: {
|
||||
profileData: finalProfileData,
|
||||
profileConfirmed: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
if (!suppressProgress) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { removeLocationPromptSuffix } from '@/lib/constants'
|
||||
import type { StoryToScriptClipCandidate } from '@/lib/novel-promotion/story-to-script/orchestrator'
|
||||
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
|
||||
|
||||
export type AnyObj = Record<string, unknown>
|
||||
|
||||
@@ -118,15 +119,11 @@ export async function persistAnalyzedLocations(params: {
|
||||
})
|
||||
|
||||
const cleanDescriptions = mergedDescriptions.map((desc) => removeLocationPromptSuffix(desc || ''))
|
||||
for (let i = 0; i < cleanDescriptions.length; i += 1) {
|
||||
await prisma.locationImage.create({
|
||||
data: {
|
||||
locationId: location.id,
|
||||
imageIndex: i,
|
||||
description: cleanDescriptions[i],
|
||||
},
|
||||
})
|
||||
}
|
||||
await seedProjectLocationBackedImageSlots({
|
||||
locationId: location.id,
|
||||
descriptions: cleanDescriptions,
|
||||
fallbackDescription: asString(item.summary) || name,
|
||||
})
|
||||
|
||||
params.existingNames.add(key)
|
||||
created.push(location)
|
||||
@@ -141,9 +138,6 @@ export async function persistAnalyzedProps(params: {
|
||||
analyzedProps: Record<string, unknown>[]
|
||||
}) {
|
||||
const created: Array<{ id: string; name: string }> = []
|
||||
const locationModel = prisma.novelPromotionLocation as unknown as {
|
||||
create: (args: { data: Record<string, unknown>; select: { id: true; name: true } }) => Promise<{ id: string; name: string }>
|
||||
}
|
||||
|
||||
for (const item of params.analyzedProps) {
|
||||
const name = asString(item.name).trim()
|
||||
@@ -153,7 +147,7 @@ export async function persistAnalyzedProps(params: {
|
||||
const key = name.toLowerCase()
|
||||
if (params.existingNames.has(key)) continue
|
||||
|
||||
const prop = await locationModel.create({
|
||||
const prop = await prisma.novelPromotionLocation.create({
|
||||
data: {
|
||||
novelPromotionProjectId: params.projectInternalId,
|
||||
name,
|
||||
@@ -165,6 +159,11 @@ export async function persistAnalyzedProps(params: {
|
||||
name: true,
|
||||
},
|
||||
})
|
||||
await seedProjectLocationBackedImageSlots({
|
||||
locationId: prop.id,
|
||||
descriptions: [summary],
|
||||
fallbackDescription: summary,
|
||||
})
|
||||
|
||||
params.existingNames.add(key)
|
||||
created.push(prop)
|
||||
|
||||
@@ -139,27 +139,25 @@
|
||||
}
|
||||
|
||||
/* Page Transition Animation - 页面切换动画 */
|
||||
/* 注意:不可使用 transform,因为子组件中有 fixed 定位元素(如 StoryboardStageShell 的悬浮按钮),
|
||||
transform 会创建新的 containing block 导致 fixed 失效并产生跳动 */
|
||||
@keyframes pageSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pageSlideOut {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ const prismaMock = vi.hoisted(() => ({
|
||||
$queryRaw: vi.fn(),
|
||||
$executeRaw: vi.fn(),
|
||||
$transaction: vi.fn(),
|
||||
locationImage: { createMany: vi.fn() },
|
||||
globalLocationImage: { createMany: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/prisma', () => ({
|
||||
@@ -44,4 +46,50 @@ describe('location-backed assets service', () => {
|
||||
expect(imageSql).toContain('FROM location_images')
|
||||
expect(imageSql).toContain('NULL AS previousImageMediaId')
|
||||
})
|
||||
|
||||
it('seeds an initial project image slot when creating a prop asset', async () => {
|
||||
const mod = await import('@/lib/assets/services/location-backed-assets')
|
||||
|
||||
const result = await mod.createProjectLocationBackedAsset({
|
||||
novelPromotionProjectId: 'novel-project-1',
|
||||
name: 'Bronze Dagger',
|
||||
summary: 'Old bronze dagger',
|
||||
kind: 'prop',
|
||||
})
|
||||
|
||||
expect(prismaMock.locationImage.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
locationId: result.id,
|
||||
imageIndex: 0,
|
||||
description: 'Old bronze dagger',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('seeds multiple project image slots when explicit descriptions are provided', async () => {
|
||||
const mod = await import('@/lib/assets/services/location-backed-assets')
|
||||
|
||||
await mod.seedProjectLocationBackedImageSlots({
|
||||
locationId: 'location-1',
|
||||
descriptions: ['Night street', 'Rainy alley'],
|
||||
fallbackDescription: 'Night street',
|
||||
})
|
||||
|
||||
expect(prismaMock.locationImage.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{
|
||||
locationId: 'location-1',
|
||||
imageIndex: 0,
|
||||
description: 'Night street',
|
||||
},
|
||||
{
|
||||
locationId: 'location-1',
|
||||
imageIndex: 1,
|
||||
description: 'Rainy alley',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
37
tests/unit/assets/location-backed-generation.test.ts
Normal file
37
tests/unit/assets/location-backed-generation.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canGenerateLocationBackedAsset, resolveLocationBackedGenerateType } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-backed-asset'
|
||||
|
||||
describe('location-backed asset generation rules', () => {
|
||||
it('allows props to generate from summary even before any image slot exists', () => {
|
||||
expect(canGenerateLocationBackedAsset({
|
||||
id: 'prop-1',
|
||||
name: '金箍棒',
|
||||
summary: '一根两头包裹金片的黑铁长棍',
|
||||
images: [],
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('allows locations to generate from seeded image descriptions', () => {
|
||||
expect(canGenerateLocationBackedAsset({
|
||||
id: 'location-1',
|
||||
name: '雨夜街道',
|
||||
summary: null,
|
||||
images: [
|
||||
{
|
||||
id: 'image-1',
|
||||
imageIndex: 0,
|
||||
description: '潮湿反光的老街',
|
||||
imageUrl: null,
|
||||
previousImageUrl: null,
|
||||
previousDescription: null,
|
||||
isSelected: false,
|
||||
},
|
||||
],
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
it('routes prop generation through the prop branch', () => {
|
||||
expect(resolveLocationBackedGenerateType('prop')).toBe('prop')
|
||||
expect(resolveLocationBackedGenerateType('location')).toBe('location')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ describe('asset mappers', () => {
|
||||
id: 'character-1',
|
||||
name: '林夏',
|
||||
introduction: '主角',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
voiceType: 'custom',
|
||||
voiceId: 'voice-1',
|
||||
customVoiceUrl: 'https://example.com/voice.mp3',
|
||||
@@ -37,6 +38,7 @@ describe('asset mappers', () => {
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
introduction: '主角',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
profileConfirmed: true,
|
||||
voice: expect.objectContaining({
|
||||
voiceType: 'custom',
|
||||
|
||||
80
tests/unit/components/asset-toolbar.test.ts
Normal file
80
tests/unit/components/asset-toolbar.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
import AssetToolbar from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar'
|
||||
|
||||
vi.mock('@/lib/query/hooks', () => ({
|
||||
useProjectAssets: vi.fn(() => ({ data: { characters: [], locations: [], props: [] } })),
|
||||
useProjectData: vi.fn(() => ({ data: { name: '项目A' } })),
|
||||
}))
|
||||
|
||||
const messages = {
|
||||
assets: {
|
||||
common: {
|
||||
refresh: '刷新',
|
||||
},
|
||||
filterBar: {
|
||||
allEpisodes: '全部集数',
|
||||
},
|
||||
toolbar: {
|
||||
assetManagement: '资产管理',
|
||||
assetCount: '共 {total} 个资产({appearances} 角色形象 + {locations} 场景 + {props} 道具)',
|
||||
globalAnalyze: '全局分析',
|
||||
globalAnalyzeHint: '分析所有资产',
|
||||
downloadAll: '下载全部',
|
||||
generateAll: '生成全部图片',
|
||||
regenerateAll: '重新生成全部',
|
||||
regenerateAllHint: '重新生成所有图片',
|
||||
},
|
||||
assetLibrary: {
|
||||
downloadEmpty: '没有可下载图片',
|
||||
downloadFailed: '下载失败',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
const renderWithIntl = (node: ReactElement) => {
|
||||
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
children: node,
|
||||
}
|
||||
|
||||
return renderToStaticMarkup(
|
||||
createElement(NextIntlClientProvider, providerProps),
|
||||
)
|
||||
}
|
||||
|
||||
describe('AssetToolbar', () => {
|
||||
it('删除批量生成与刷新按钮 -> 仅保留全局分析和下载入口', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const html = renderWithIntl(
|
||||
createElement(AssetToolbar, {
|
||||
projectId: 'project-1',
|
||||
totalAssets: 24,
|
||||
totalAppearances: 11,
|
||||
totalLocations: 13,
|
||||
totalProps: 0,
|
||||
isBatchSubmitting: false,
|
||||
isAnalyzingAssets: false,
|
||||
isGlobalAnalyzing: false,
|
||||
onGlobalAnalyze: () => undefined,
|
||||
episodeId: null,
|
||||
onEpisodeChange: () => undefined,
|
||||
episodes: [],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('全局分析')
|
||||
expect(html).toContain('title="下载全部"')
|
||||
expect(html).not.toContain('生成全部图片')
|
||||
expect(html).not.toContain('重新生成全部')
|
||||
expect(html).not.toContain('>刷新<')
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,9 @@ const messages = {
|
||||
waitingModelOutput: '等待模型输出...',
|
||||
reasoningNotProvided: '该步骤未返回思考过程',
|
||||
},
|
||||
streamStep: {
|
||||
analyzeProps: '道具分析',
|
||||
},
|
||||
runtime: {
|
||||
llm: {
|
||||
processing: '模型处理中...',
|
||||
@@ -69,4 +72,27 @@ describe('LLMStageStreamCard error rendering', () => {
|
||||
expect(html).not.toContain('Copy error detail')
|
||||
expect(html).not.toContain('Open feedback form')
|
||||
})
|
||||
|
||||
it('resolves analyze props progress keys without missing message errors', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
const html = renderWithIntl(
|
||||
createElement(LLMStageStreamCard, {
|
||||
title: 'progress.streamStep.analyzeProps',
|
||||
stages: [{
|
||||
id: 'analyze_props',
|
||||
title: 'progress.streamStep.analyzeProps',
|
||||
subtitle: 'progress.streamStep.analyzeProps',
|
||||
status: 'processing',
|
||||
progress: 35,
|
||||
}],
|
||||
activeStageId: 'analyze_props',
|
||||
activeMessage: 'progress.streamStep.analyzeProps',
|
||||
outputText: '',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('道具分析')
|
||||
expect(html).not.toContain('progress.streamStep.analyzeProps')
|
||||
expect(html).not.toContain('MISSING_MESSAGE')
|
||||
})
|
||||
})
|
||||
|
||||
29
tests/unit/components/segmented-control.test.ts
Normal file
29
tests/unit/components/segmented-control.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { SegmentedControl } from '@/components/ui/SegmentedControl'
|
||||
|
||||
describe('SegmentedControl', () => {
|
||||
it('compact 布局 -> 输出左对齐的非拉伸结构', () => {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(SegmentedControl, {
|
||||
options: [
|
||||
{ value: 'all', label: '全部 (24)' },
|
||||
{ value: 'character', label: '角色 (11)' },
|
||||
{ value: 'location', label: '场景 (13)' },
|
||||
{ value: 'prop', label: '道具 (0)' },
|
||||
],
|
||||
value: 'all',
|
||||
onChange: () => undefined,
|
||||
layout: 'compact',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(html).toContain('inline-block max-w-full')
|
||||
expect(html).toContain('inline-grid grid-flow-col auto-cols-[minmax(96px,max-content)]')
|
||||
expect(html).not.toContain('grid-template-columns:repeat(4,minmax(0,1fr))')
|
||||
})
|
||||
})
|
||||
24
tests/unit/prompt-i18n/select-prop-template.test.ts
Normal file
24
tests/unit/prompt-i18n/select-prop-template.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getPromptTemplate, PROMPT_IDS } from '@/lib/prompt-i18n'
|
||||
|
||||
describe('select prop prompt template', () => {
|
||||
it('zh template restricts extraction to key story props and prefers omission when uncertain', () => {
|
||||
const template = getPromptTemplate(PROMPT_IDS.NP_SELECT_PROP, 'zh')
|
||||
|
||||
expect(template).toContain('关键剧情道具资产分析师')
|
||||
expect(template).toContain('宁缺毋滥')
|
||||
expect(template).toContain('必须在剧情中承担明确功能')
|
||||
expect(template).toContain('如果不确定它是否值得进入资产库,直接不输出')
|
||||
expect(template).toContain('仅因外观具体、名词明确,不足以成为关键道具')
|
||||
})
|
||||
|
||||
it('en template restricts extraction to key story props and prefers omission when uncertain', () => {
|
||||
const template = getPromptTemplate(PROMPT_IDS.NP_SELECT_PROP, 'en')
|
||||
|
||||
expect(template).toContain('key story prop extractor')
|
||||
expect(template).toContain('Be conservative')
|
||||
expect(template).toContain('clear story function')
|
||||
expect(template).toContain('If you are unsure whether it deserves an asset entry, do not output it')
|
||||
expect(template).toContain('A specific-looking noun is not enough')
|
||||
})
|
||||
})
|
||||
55
tests/unit/query/use-project-assets.test.ts
Normal file
55
tests/unit/query/use-project-assets.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createEmptyAssetGroupMap } from '@/lib/assets/grouping'
|
||||
import { mapAssetGroupsToProjectAssetsData } from '@/lib/query/hooks/useProjectAssets'
|
||||
|
||||
describe('useProjectAssets adapters', () => {
|
||||
it('preserves profileData for unconfirmed character profiles', () => {
|
||||
const groups = createEmptyAssetGroupMap()
|
||||
groups.character.push({
|
||||
id: 'character-1',
|
||||
scope: 'project',
|
||||
kind: 'character',
|
||||
family: 'visual',
|
||||
name: '林夏',
|
||||
folderId: null,
|
||||
capabilities: {
|
||||
canGenerate: true,
|
||||
canSelectRender: true,
|
||||
canRevertRender: true,
|
||||
canModifyRender: true,
|
||||
canUploadRender: true,
|
||||
canBindVoice: true,
|
||||
canCopyFromGlobal: true,
|
||||
},
|
||||
taskRefs: [],
|
||||
taskState: {
|
||||
isRunning: false,
|
||||
lastError: null,
|
||||
},
|
||||
variants: [],
|
||||
introduction: '主角',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
profileConfirmed: false,
|
||||
profileTaskRefs: [],
|
||||
profileTaskState: {
|
||||
isRunning: false,
|
||||
lastError: null,
|
||||
},
|
||||
voice: {
|
||||
voiceType: null,
|
||||
voiceId: null,
|
||||
customVoiceUrl: null,
|
||||
media: null,
|
||||
},
|
||||
})
|
||||
|
||||
const data = mapAssetGroupsToProjectAssetsData(groups)
|
||||
|
||||
expect(data.characters).toHaveLength(1)
|
||||
expect(data.characters[0]).toEqual(expect.objectContaining({
|
||||
id: 'character-1',
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
profileConfirmed: false,
|
||||
}))
|
||||
})
|
||||
})
|
||||
136
tests/unit/script-view/script-view-assets-panel.test.ts
Normal file
136
tests/unit/script-view/script-view-assets-panel.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import * as React from 'react'
|
||||
import { createElement } from 'react'
|
||||
import type { ComponentProps, ReactElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import type { AbstractIntlMessages } from 'next-intl'
|
||||
import ScriptViewAssetsPanel from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewAssetsPanel'
|
||||
|
||||
const messages = {
|
||||
scriptView: {
|
||||
inSceneAssets: '出场资产',
|
||||
assetView: {
|
||||
allClips: '全部片段',
|
||||
},
|
||||
segment: {
|
||||
title: '片段 {index}',
|
||||
},
|
||||
asset: {
|
||||
activeCharacters: '出场角色',
|
||||
activeLocations: '出场场景',
|
||||
defaultAppearance: '默认形象',
|
||||
},
|
||||
screenplay: {
|
||||
noCharacter: '当前片段未选择角色',
|
||||
noLocation: '当前片段未选择场景',
|
||||
},
|
||||
generate: {
|
||||
startGenerate: '开始生成',
|
||||
},
|
||||
},
|
||||
assets: {
|
||||
character: {
|
||||
primary: '初始形象',
|
||||
},
|
||||
},
|
||||
novelPromotion: {
|
||||
buttons: {
|
||||
assetLibrary: '资产库',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
edit: '编辑',
|
||||
cancel: '取消',
|
||||
confirm: '确定',
|
||||
},
|
||||
} as const
|
||||
|
||||
function renderWithIntl(node: ReactElement) {
|
||||
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
|
||||
locale: 'zh',
|
||||
messages: messages as unknown as AbstractIntlMessages,
|
||||
timeZone: 'Asia/Shanghai',
|
||||
children: node,
|
||||
}
|
||||
|
||||
return renderToStaticMarkup(
|
||||
createElement(NextIntlClientProvider, providerProps),
|
||||
)
|
||||
}
|
||||
|
||||
function renderPanel(propsCount: number) {
|
||||
Reflect.set(globalThis, 'React', React)
|
||||
|
||||
const props = Array.from({ length: propsCount }, (_, index) => ({
|
||||
id: `prop-${index + 1}`,
|
||||
name: `道具${index + 1}`,
|
||||
summary: `道具描述${index + 1}`,
|
||||
selectedImageId: null,
|
||||
images: [],
|
||||
}))
|
||||
|
||||
return renderWithIntl(
|
||||
createElement(ScriptViewAssetsPanel, {
|
||||
clips: [{ id: 'clip-1', location: null, props: null }],
|
||||
assetViewMode: 'all',
|
||||
setAssetViewMode: () => undefined,
|
||||
setSelectedClipId: () => undefined,
|
||||
characters: [],
|
||||
locations: [],
|
||||
props,
|
||||
activeCharIds: [],
|
||||
activeLocationIds: [],
|
||||
activePropIds: [],
|
||||
selectedAppearanceKeys: new Set<string>(),
|
||||
onUpdateClipAssets: async () => undefined,
|
||||
onOpenAssetLibrary: () => undefined,
|
||||
assetsLoading: false,
|
||||
assetsLoadingState: null,
|
||||
allAssetsHaveImages: true,
|
||||
globalCharIds: [],
|
||||
globalLocationIds: [],
|
||||
globalPropIds: [],
|
||||
missingAssetsCount: 0,
|
||||
onGenerateStoryboard: () => undefined,
|
||||
isSubmittingStoryboardBuild: false,
|
||||
getSelectedAppearances: () => [],
|
||||
tScript: (key: string, values?: Record<string, unknown>) => {
|
||||
if (key === 'inSceneAssets') return '出场资产'
|
||||
if (key === 'assetView.allClips') return '全部片段'
|
||||
if (key === 'segment.title') return `片段 ${String(values?.index ?? '')}`
|
||||
if (key === 'asset.activeCharacters') return '出场角色'
|
||||
if (key === 'asset.activeLocations') return '出场场景'
|
||||
if (key === 'screenplay.noCharacter') return '当前片段未选择角色'
|
||||
if (key === 'screenplay.noLocation') return '当前片段未选择场景'
|
||||
if (key === 'generate.startGenerate') return '开始生成'
|
||||
if (key === 'asset.defaultAppearance') return '默认形象'
|
||||
return key
|
||||
},
|
||||
tAssets: (key: string) => (key === 'character.primary' ? '初始形象' : key),
|
||||
tNP: (key: string) => (key === 'buttons.assetLibrary' ? '资产库' : key),
|
||||
tCommon: (key: string) => {
|
||||
if (key === 'edit') return '编辑'
|
||||
if (key === 'cancel') return '取消'
|
||||
if (key === 'confirm') return '确定'
|
||||
return key
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('ScriptViewAssetsPanel', () => {
|
||||
it('hides the prop section when the project has no prop assets', () => {
|
||||
const html = renderPanel(0)
|
||||
|
||||
expect(html).not.toContain('道具 (0)')
|
||||
expect(html).not.toContain('当前片段未选择道具')
|
||||
})
|
||||
|
||||
it('keeps the prop section visible when the project has prop assets even if none are selected in the current clip', () => {
|
||||
const html = renderPanel(1)
|
||||
|
||||
expect(html).toContain('道具 (0)')
|
||||
expect(html).toContain('当前片段未选择道具')
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,10 @@ const prismaMock = vi.hoisted(() => ({
|
||||
novelPromotionEpisode: { findFirst: vi.fn() },
|
||||
novelPromotionCharacter: { create: vi.fn(async () => ({ id: 'char-new-1' })) },
|
||||
novelPromotionLocation: { create: vi.fn(async () => ({ id: 'loc-new-1' })) },
|
||||
locationImage: { create: vi.fn(async () => ({})) },
|
||||
locationImage: {
|
||||
create: vi.fn(async () => ({})),
|
||||
createMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
}))
|
||||
|
||||
const llmMock = vi.hoisted(() => ({
|
||||
@@ -49,6 +52,7 @@ vi.mock('@/lib/prompt-i18n', () => ({
|
||||
PROMPT_IDS: {
|
||||
NP_AGENT_CHARACTER_PROFILE: 'char',
|
||||
NP_SELECT_LOCATION: 'loc',
|
||||
NP_SELECT_PROP: 'prop',
|
||||
},
|
||||
buildPrompt: vi.fn(() => 'analysis-prompt'),
|
||||
}))
|
||||
@@ -75,6 +79,10 @@ describe('worker analyze-novel behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
prismaMock.novelPromotionLocation.create
|
||||
.mockResolvedValueOnce({ id: 'loc-new-1' })
|
||||
.mockResolvedValueOnce({ id: 'prop-new-1' })
|
||||
|
||||
prismaMock.project.findUnique.mockResolvedValue({
|
||||
id: 'project-1',
|
||||
mode: 'novel-promotion',
|
||||
@@ -114,6 +122,14 @@ describe('worker analyze-novel behavior', () => {
|
||||
},
|
||||
],
|
||||
}))
|
||||
.mockReturnValueOnce(JSON.stringify({
|
||||
props: [
|
||||
{
|
||||
name: '金箍棒',
|
||||
summary: '一根两头包裹金片的黑铁长棍',
|
||||
},
|
||||
],
|
||||
}))
|
||||
})
|
||||
|
||||
it('no global text and no episode text -> explicit error', async () => {
|
||||
@@ -137,10 +153,10 @@ describe('worker analyze-novel behavior', () => {
|
||||
success: true,
|
||||
characters: [{ id: 'char-new-1' }],
|
||||
locations: [{ id: 'loc-new-1' }],
|
||||
props: [],
|
||||
props: [{ id: 'prop-new-1' }],
|
||||
characterCount: 1,
|
||||
locationCount: 1,
|
||||
propCount: 0,
|
||||
propCount: 1,
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionCharacter.create).toHaveBeenCalledWith(
|
||||
@@ -163,12 +179,24 @@ describe('worker analyze-novel behavior', () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prismaMock.locationImage.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
locationId: 'loc-new-1',
|
||||
imageIndex: 0,
|
||||
description: '雨夜街道',
|
||||
},
|
||||
expect(prismaMock.locationImage.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.locationImage.createMany).toHaveBeenNthCalledWith(1, {
|
||||
data: [
|
||||
{
|
||||
locationId: 'loc-new-1',
|
||||
imageIndex: 0,
|
||||
description: '雨夜街道',
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(prismaMock.locationImage.createMany).toHaveBeenNthCalledWith(2, {
|
||||
data: [
|
||||
{
|
||||
locationId: 'prop-new-1',
|
||||
imageIndex: 0,
|
||||
description: '一根两头包裹金片的黑铁长棍',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
||||
|
||||
const prismaMock = vi.hoisted(() => ({
|
||||
$transaction: vi.fn(),
|
||||
novelPromotionCharacter: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
@@ -10,6 +11,7 @@ const prismaMock = vi.hoisted(() => ({
|
||||
},
|
||||
characterAppearance: {
|
||||
create: vi.fn(async () => ({})),
|
||||
deleteMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -89,6 +91,9 @@ function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>):
|
||||
describe('worker character-profile behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise<unknown>) => {
|
||||
return await callback(prismaMock)
|
||||
})
|
||||
|
||||
llmMock.getCompletionContent.mockReturnValue(
|
||||
JSON.stringify({
|
||||
@@ -134,10 +139,13 @@ describe('worker character-profile behavior', () => {
|
||||
await expect(handleCharacterProfileTask(job)).rejects.toThrow('Unsupported character profile task type')
|
||||
})
|
||||
|
||||
it('confirm profile success -> creates appearance and marks profileConfirmed', async () => {
|
||||
it('confirm profile success -> rebuilds appearances and marks profileConfirmed', async () => {
|
||||
const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' })
|
||||
const result = await handleCharacterProfileTask(job)
|
||||
|
||||
expect(prismaMock.characterAppearance.deleteMany).toHaveBeenCalledWith({
|
||||
where: { characterId: 'character-1' },
|
||||
})
|
||||
expect(prismaMock.characterAppearance.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
characterId: 'character-1',
|
||||
@@ -149,7 +157,10 @@ describe('worker character-profile behavior', () => {
|
||||
|
||||
expect(prismaMock.novelPromotionCharacter.update).toHaveBeenCalledWith({
|
||||
where: { id: 'character-1' },
|
||||
data: { profileConfirmed: true },
|
||||
data: {
|
||||
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||
profileConfirmed: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({
|
||||
@@ -171,4 +182,18 @@ describe('worker character-profile behavior', () => {
|
||||
})
|
||||
expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reconfirm with existing appearances -> replaces old rows instead of colliding on unique index', async () => {
|
||||
const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' })
|
||||
|
||||
await expect(handleCharacterProfileTask(job)).resolves.toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
}))
|
||||
|
||||
expect(prismaMock.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.characterAppearance.deleteMany).toHaveBeenCalledWith({
|
||||
where: { characterId: 'character-1' },
|
||||
})
|
||||
expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user