fix: resolve confirmed character hidden bug, remove online font dependency, improve UI/UX experience

This commit is contained in:
saturn
2026-03-21 14:35:32 +08:00
parent f364bbc9e4
commit a6ad11b9c4
42 changed files with 1189 additions and 553 deletions

View File

@@ -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: Output format:
{ {
@@ -12,16 +12,38 @@ Output format:
] ]
} }
Rules: Key prop criteria:
1. Only include concrete reusable physical props that actually appear in the story. 1. It must be a real physical object that actually appears in the story.
2. Only output `name` and `summary`. 2. It must serve a clear story function rather than being background dressing.
3. `name` and `summary` must both be non-empty. 3. It must satisfy at least one of the following:
4. Do not repeat props that already exist in the prop library with the exact same name. - characters hold it, use it, fight over it, deliver it, hide it, lose it, or search for it
5. Exclude abstract concepts, powers, roles, places, creatures, outfits, and makeup. - it is a key tool, weapon, artifact, piece of evidence, token, key, or clue carrier
6. Keep names stable and short. - it is likely to reappear and therefore needs a consistent visual design
7. Keep summaries objective. - removing it would materially weaken plot comprehension or a key action
8. If none exist, return {"props": []}.
9. Replace raw quotation marks inside JSON string values with corner brackets「」. 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:
{input} {input}

View File

@@ -1,6 +1,6 @@
你是“故事道具资产分析师”。 你是“关键剧情道具资产分析师”。
任务:从输入文本中识别适合做成长期复用资产的道具,只返回 JSON不得包含任何额外解释或 markdown。 任务:从输入文本中识别【关键道具】,用于建立需要长期保持外观一致的资产库。宁缺毋滥。只返回 JSON不得包含任何额外解释或 markdown。
输出格式: 输出格式:
{ {
@@ -12,16 +12,38 @@
] ]
} }
规则 关键道具判定标准
1. 只保留在剧情中真实出现、可被反复引用、值得进入资产库的实体道具 1. 必须是剧情中真实出现的实体物件
2. 只输出两个字段name、summary 2. 必须在剧情中承担明确功能,而不只是背景摆设
3. name 不能为空summary 不能为空。 3. 必须至少满足以下一种情况:
4. 如果道具库里已经有完全同名道具,不要重复输出。 - 被角色持有、使用、争夺、交付、隐藏、丢失、寻找
5. 禁止输出抽象概念、情绪、能力、身份、地点、生物、服装妆容。 - 是推进情节的关键工具、武器、法器、证物、信物、钥匙、线索载体
6. 名称尽量简洁稳定,例如“青铜匕首”“录音笔”“红绳手链”。 - 后续大概率需要重复出镜,且需要保持外观一致
7. summary 只写客观描述,不写剧情推断。 - 去掉它会明显影响剧情理解或关键动作成立
8. 如果没有合适道具,返回 {"props": []}。
9. JSON 字符串值中的引号统一替换为「」。 严格不提取:
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} {input}

View File

@@ -12,6 +12,8 @@
"confirmProfiles": "Character Profiles to Confirm", "confirmProfiles": "Character Profiles to Confirm",
"confirmHint": "Please confirm these profiles before generating descriptions", "confirmHint": "Please confirm these profiles before generating descriptions",
"confirmAll": "Confirm All ({count})", "confirmAll": "Confirm All ({count})",
"pendingProfilesBanner": "AI Casting Complete",
"pendingProfilesHint": "Confirm profiles to auto-generate character visuals",
"assetsTitle": "Asset Analysis", "assetsTitle": "Asset Analysis",
"characterAssets": "Character Assets", "characterAssets": "Character Assets",
"locationAssets": "Location Assets", "locationAssets": "Location Assets",

View File

@@ -125,6 +125,7 @@
"streamStep": { "streamStep": {
"analyzeCharacters": "Analyze characters", "analyzeCharacters": "Analyze characters",
"analyzeLocations": "Analyze locations", "analyzeLocations": "Analyze locations",
"analyzeProps": "Analyze props",
"splitClips": "Split clips", "splitClips": "Split clips",
"screenplayConversion": "Convert screenplay", "screenplayConversion": "Convert screenplay",
"storyboardPlan": "Plan storyboard", "storyboardPlan": "Plan storyboard",

View File

@@ -12,6 +12,8 @@
"confirmProfiles": "角色档案待确认", "confirmProfiles": "角色档案待确认",
"confirmHint": "请确认以下角色档案后生成外貌描述", "confirmHint": "请确认以下角色档案后生成外貌描述",
"confirmAll": "全部确认 ({count})", "confirmAll": "全部确认 ({count})",
"pendingProfilesBanner": "AI 选角完成",
"pendingProfilesHint": "确认档案后自动生成角色形象",
"assetsTitle": "资产分析", "assetsTitle": "资产分析",
"characterAssets": "角色资产", "characterAssets": "角色资产",
"locationAssets": "场景资产", "locationAssets": "场景资产",

View File

@@ -125,6 +125,7 @@
"streamStep": { "streamStep": {
"analyzeCharacters": "角色分析", "analyzeCharacters": "角色分析",
"analyzeLocations": "场景分析", "analyzeLocations": "场景分析",
"analyzeProps": "道具分析",
"splitClips": "片段切分", "splitClips": "片段切分",
"screenplayConversion": "剧本转换", "screenplayConversion": "剧本转换",
"storyboardPlan": "分镜规划", "storyboardPlan": "分镜规划",

10
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"cos-nodejs-sdk-v5": "^2.15.4", "cos-nodejs-sdk-v5": "^2.15.4",
"express": "^5.2.1", "express": "^5.2.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"geist": "^1.7.0",
"ioredis": "^5.9.2", "ioredis": "^5.9.2",
"jsonrepair": "^3.13.2", "jsonrepair": "^3.13.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",
@@ -10207,6 +10208,15 @@
"node": ">=18" "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": { "node_modules/generate-function": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz", "resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz",

View File

@@ -138,6 +138,7 @@
"cos-nodejs-sdk-v5": "^2.15.4", "cos-nodejs-sdk-v5": "^2.15.4",
"express": "^5.2.1", "express": "^5.2.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"geist": "^1.7.0",
"ioredis": "^5.9.2", "ioredis": "^5.9.2",
"jsonrepair": "^3.13.2", "jsonrepair": "^3.13.2",
"jszip": "^3.10.1", "jszip": "^3.10.1",

View File

@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Script from "next/script"; 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 { NextIntlClientProvider } from 'next-intl';
import { getMessages, getTranslations } from 'next-intl/server'; import { getMessages, getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
@@ -9,16 +10,6 @@ import { Providers } from "./providers";
import { locales } from '@/i18n/routing'; 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] type SupportedLocale = (typeof locales)[number]
@@ -72,7 +63,7 @@ export default async function LocaleLayout({
)} )}
</head> </head>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${GeistSans.variable} ${GeistMono.variable} antialiased`}
> >
<NextIntlClientProvider messages={messages}> <NextIntlClientProvider messages={messages}>
<Providers> <Providers>

View File

@@ -49,7 +49,6 @@ import LocationSection from './assets/LocationSection'
import AssetToolbar from './assets/AssetToolbar' import AssetToolbar from './assets/AssetToolbar'
import AssetFilterBar, { type AssetKindFilter } from './assets/AssetFilterBar' import AssetFilterBar, { type AssetKindFilter } from './assets/AssetFilterBar'
import AssetsStageStatusOverlays from './assets/AssetsStageStatusOverlays' import AssetsStageStatusOverlays from './assets/AssetsStageStatusOverlays'
import UnconfirmedProfilesSection from './assets/UnconfirmedProfilesSection'
import AssetsStageModals from './assets/AssetsStageModals' import AssetsStageModals from './assets/AssetsStageModals'
interface AssetsStageProps { interface AssetsStageProps {
@@ -207,12 +206,9 @@ export default function AssetsStage({
// 批量生成 // 批量生成
const { const {
isBatchSubmitting, isBatchSubmitting,
batchProgress,
activeTaskKeys, activeTaskKeys,
registerTransientTaskKey, registerTransientTaskKey,
clearTransientTaskKey, clearTransientTaskKey,
handleGenerateAllImages,
handleRegenerateAllImages
} = useBatchGeneration({ } = useBatchGeneration({
projectId, projectId,
handleGenerateImage handleGenerateImage
@@ -391,9 +387,6 @@ export default function AssetsStage({
isBatchSubmitting={isBatchSubmitting} isBatchSubmitting={isBatchSubmitting}
isAnalyzingAssets={isAnalyzingAssets} isAnalyzingAssets={isAnalyzingAssets}
isGlobalAnalyzing={isGlobalAnalyzing} isGlobalAnalyzing={isGlobalAnalyzing}
batchProgress={batchProgress}
onGenerateAll={handleGenerateAllImages}
onRegenerateAll={handleRegenerateAllImages}
onGlobalAnalyze={handleGlobalAnalyze} onGlobalAnalyze={handleGlobalAnalyze}
episodeId={episodeFilter} episodeId={episodeFilter}
onEpisodeChange={setEpisodeFilter} 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') && ( {(kindFilter === 'all' || kindFilter === 'character') && (
<CharacterSection <CharacterSection
key="character" key="character"
@@ -456,6 +433,17 @@ export default function AssetsStage({
onCopyFromGlobal={handleCopyFromGlobal} onCopyFromGlobal={handleCopyFromGlobal}
getAppearances={getAppearances} getAppearances={getAppearances}
filterIds={episodeAssetIds?.charIds ?? null} 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') && ( {(kindFilter === 'all' || kindFilter === 'location') && (

View File

@@ -38,11 +38,15 @@ export default function AssetFilterBar({
return ( return (
<div className="px-4 py-3 glass-surface rounded-xl"> <div className="px-4 py-3 glass-surface rounded-xl">
<div className="overflow-x-auto">
<SegmentedControl <SegmentedControl
options={segmentOptions} options={segmentOptions}
value={kindFilter} value={kindFilter}
onChange={onKindFilterChange} onChange={onKindFilterChange}
layout="compact"
className="min-w-max"
/> />
</div> </div>
</div>
) )
} }

View File

@@ -2,16 +2,14 @@
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useRefreshProjectAssets, useProjectAssets, useProjectData } from '@/lib/query/hooks' import { useProjectAssets, useProjectData } from '@/lib/query/hooks'
import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { AppIcon } from '@/components/ui/icons' import { AppIcon } from '@/components/ui/icons'
import JSZip from 'jszip' import JSZip from 'jszip'
import { logError as _logError } from '@/lib/logging/core' import { logError as _logError } from '@/lib/logging/core'
/** /**
* AssetToolbar - 资产管理工具栏组件 * AssetToolbar - 资产管理工具栏组件
* 从 AssetsStage.tsx 提取,负责批量操作和刷新按钮 * 从 AssetsStage.tsx 提取,负责资产统计与顶部操作
*/ */
interface EpisodeOption { interface EpisodeOption {
@@ -29,9 +27,6 @@ interface AssetToolbarProps {
isBatchSubmitting: boolean isBatchSubmitting: boolean
isAnalyzingAssets: boolean isAnalyzingAssets: boolean
isGlobalAnalyzing?: boolean isGlobalAnalyzing?: boolean
batchProgress: { current: number; total: number }
onGenerateAll: () => void
onRegenerateAll: () => void
onGlobalAnalyze?: () => void onGlobalAnalyze?: () => void
/** Episode filter */ /** Episode filter */
episodeId: string | null episodeId: string | null
@@ -166,30 +161,17 @@ export default function AssetToolbar({
isBatchSubmitting, isBatchSubmitting,
isAnalyzingAssets, isAnalyzingAssets,
isGlobalAnalyzing = false, isGlobalAnalyzing = false,
batchProgress,
onGenerateAll,
onRegenerateAll,
onGlobalAnalyze, onGlobalAnalyze,
episodeId, episodeId,
onEpisodeChange, onEpisodeChange,
episodes, episodes,
}: AssetToolbarProps) { }: AssetToolbarProps) {
const onRefresh = useRefreshProjectAssets(projectId)
const t = useTranslations('assets') const t = useTranslations('assets')
const { data: assets } = useProjectAssets(projectId) const { data: assets } = useProjectAssets(projectId)
const { data: projectData } = useProjectData(projectId) const { data: projectData } = useProjectData(projectId)
const projectName = projectData?.name const projectName = projectData?.name
const [isDownloading, setIsDownloading] = useState(false) const [isDownloading, setIsDownloading] = useState(false)
const assetTaskRunningState = isBatchSubmitting
? resolveTaskPresentationState({
phase: 'processing',
intent: 'generate',
resource: 'image',
hasOutput: true,
})
: null
const handleDownloadAll = async () => { const handleDownloadAll = async () => {
const characters = assets?.characters ?? [] const characters = assets?.characters ?? []
const locations = assets?.locations ?? [] const locations = assets?.locations ?? []
@@ -297,39 +279,6 @@ export default function AssetToolbar({
)} )}
</div> </div>
<div className="flex items-center gap-2"> <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 <button
onClick={handleDownloadAll} onClick={handleDownloadAll}

View File

@@ -23,13 +23,24 @@ interface CharacterProfileCardProps {
isDeleting?: boolean 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)]', * S金橙 / A史诗紫 / B稀有蓝 / C精良绿 / D普通灰
B: 'bg-[var(--glass-tone-neutral-bg)] text-[var(--glass-tone-neutral-fg)]', */
C: 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-primary)]', interface TierStyle {
D: 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]' 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 const ROLE_LEVELS = ['S', 'A', 'B', 'C', 'D'] as const
type RoleLevel = (typeof ROLE_LEVELS)[number] type RoleLevel = (typeof ROLE_LEVELS)[number]
@@ -68,18 +79,24 @@ export default function CharacterProfileCard({
const roleLevelLabel = roleLevel const roleLevelLabel = roleLevel
? t(`characterProfile.importance.${roleLevel}`) ? t(`characterProfile.importance.${roleLevel}`)
: profileData.role_level : profileData.role_level
const roleLevelColor = roleLevel const tierStyle = roleLevel ? TIER_STYLES[roleLevel] : null
? ROLE_LEVEL_COLORS[roleLevel]
: 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-primary)]'
return ( 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="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 items-start justify-between mb-3">
<div className="flex-1"> <div className="flex-1 min-w-0">
<h3 className="font-semibold text-[var(--glass-text-primary)] mb-1">{name}</h3> <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"> <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}`}> <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} {roleLevelLabel}
</span> </span>
<span className="text-xs text-[var(--glass-text-tertiary)]">{profileData.archetype}</span> <span className="text-xs text-[var(--glass-text-tertiary)]">{profileData.archetype}</span>
@@ -90,7 +107,7 @@ export default function CharacterProfileCard({
<button <button
onClick={onDelete} onClick={onDelete}
disabled={isConfirming || isDeleting} 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" 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')} title={t('characterProfile.delete')}
> >
{isDeleting ? ( {isDeleting ? (
@@ -105,57 +122,57 @@ export default function CharacterProfileCard({
{/* 档案摘要 */} {/* 档案摘要 */}
<div className="space-y-1.5 mb-3"> <div className="space-y-1.5 mb-3">
<div className="flex items-center gap-2 text-sm"> <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-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.gender')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.gender}</span> <span className="text-[var(--glass-text-primary)]">{profileData.gender}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm"> <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-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.age')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.age_range}</span> <span className="text-[var(--glass-text-primary)]">{profileData.age_range}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm"> <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-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.era')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.era_period}</span> <span className="text-[var(--glass-text-primary)]">{profileData.era_period}</span>
</div> </div>
<div className="flex items-center gap-2 text-sm"> <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-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.class')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.social_class}</span> <span className="text-[var(--glass-text-primary)]">{profileData.social_class}</span>
</div> </div>
{profileData.occupation && ( {profileData.occupation && (
<div className="flex items-center gap-2 text-sm"> <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-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.occupation')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.occupation}</span> <span className="text-[var(--glass-text-primary)]">{profileData.occupation}</span>
</div> </div>
)} )}
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.personality')}</span> <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"> <div className="flex flex-wrap gap-1">
{profileData.personality_tags.map((tag, i) => ( {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"> <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} {tag}
</span> </span>
))} ))}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 text-sm"> <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-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.costume')}</span>
<span className="text-[var(--glass-text-primary)]"> <span className="text-[var(--glass-text-primary)]">
{'●'.repeat(profileData.costume_tier)} {'●'.repeat(profileData.costume_tier)}{'○'.repeat(5 - profileData.costume_tier)}
</span> </span>
</div> </div>
{profileData.primary_identifier && ( {profileData.primary_identifier && (
<div className="flex items-center gap-2 text-sm"> <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-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.identifier')}</span>
<span className="text-[var(--glass-tone-warning-fg)] font-medium">{profileData.primary_identifier}</span> <span className="font-medium" style={{ color: tierStyle?.accent ?? 'var(--glass-tone-warning-fg)' }}>{profileData.primary_identifier}</span>
</div> </div>
)} )}
</div> </div>
{/* 操作按钮 */} {/* 操作按钮 */}
<div className="flex gap-2"> <div className="flex gap-2 pt-3 border-t border-[var(--glass-stroke-base)]">
<button <button
onClick={onEdit} onClick={onEdit}
disabled={isConfirming} 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" className="glass-btn-base glass-btn-secondary flex-1 px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
> >
{t('characterProfile.editProfile')} {t('characterProfile.editProfile')}
</button> </button>
@@ -163,7 +180,7 @@ export default function CharacterProfileCard({
<button <button
onClick={onUseExisting} onClick={onUseExisting}
disabled={isConfirming} 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" 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')} {t('characterProfile.useExisting')}
</button> </button>
@@ -171,7 +188,7 @@ export default function CharacterProfileCard({
<button <button
onClick={onConfirm} onClick={onConfirm}
disabled={isConfirming} 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" className="glass-btn-base glass-btn-primary flex-1 px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
> >
{isConfirming ? ( {isConfirming ? (
<TaskStatusInline state={confirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" /> <TaskStatusInline state={confirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
@@ -181,5 +198,6 @@ export default function CharacterProfileCard({
</button> </button>
</div> </div>
</div> </div>
</div>
) )
} }

View File

@@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import TaskStatusInline from '@/components/task/TaskStatusInline' import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation' import { resolveTaskPresentationState } from '@/lib/task/presentation'
import type { TaskPresentationState } from '@/lib/task/presentation'
import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants' import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
/** /**
@@ -11,11 +12,14 @@ import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
* 从 AssetsStage.tsx 提取,负责角色列表的展示和操作 * 从 AssetsStage.tsx 提取,负责角色列表的展示和操作
* *
* 🔥 V6.5 重构:内部直接订阅 useProjectAssets消除 props drilling * 🔥 V6.5 重构:内部直接订阅 useProjectAssets消除 props drilling
* 🔥 V7 重构:待确认角色档案内嵌显示,不再使用独立 Section
*/ */
import { Character, CharacterAppearance } from '@/types/project' import { Character, CharacterAppearance } from '@/types/project'
import { useProjectAssets } from '@/lib/query/hooks/useProjectAssets' import { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'
import CharacterCard from './CharacterCard' import CharacterCard from './CharacterCard'
import CharacterProfileCard from './CharacterProfileCard'
import { parseProfileData } from '@/types/character-profile'
import { AppIcon } from '@/components/ui/icons' import { AppIcon } from '@/components/ui/icons'
interface CharacterSectionProps { interface CharacterSectionProps {
@@ -49,6 +53,17 @@ interface CharacterSectionProps {
getAppearances: (character: Character) => CharacterAppearance[] getAppearances: (character: Character) => CharacterAppearance[]
/** 分集筛选:仅显示指定 ID 的角色null 表示显示全部 */ /** 分集筛选:仅显示指定 ID 的角色null 表示显示全部 */
filterIds?: Set<string> | 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({ export default function CharacterSection({
@@ -78,6 +93,17 @@ export default function CharacterSection({
onCopyFromGlobal, onCopyFromGlobal,
getAppearances, getAppearances,
filterIds = null, filterIds = null,
// 🔥 V7待确认角色
unconfirmedCharacters,
isConfirmingCharacter,
deletingCharacterId,
batchConfirming,
batchConfirmingState,
onBatchConfirm,
onEditProfile,
onConfirmProfile,
onUseExistingProfile,
onDeleteProfile,
}: CharacterSectionProps) { }: CharacterSectionProps) {
const t = useTranslations('assets') const t = useTranslations('assets')
const analyzingAssetsState = isAnalyzingAssets const analyzingAssetsState = isAnalyzingAssets
@@ -91,9 +117,17 @@ export default function CharacterSection({
const { data: assets } = useProjectAssets(projectId) const { data: assets } = useProjectAssets(projectId)
const allCharacters: Character[] = useMemo(() => assets?.characters ?? [], [assets?.characters]) const allCharacters: Character[] = useMemo(() => assets?.characters ?? [], [assets?.characters])
// 🔥 V7排除待确认角色避免同一角色在待确认区与已确认网格中重复出现
const unconfirmedIds = useMemo(
() => new Set(unconfirmedCharacters.map((c) => c.id)),
[unconfirmedCharacters],
)
const characters: Character[] = useMemo( 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 [highlightedCharacterId, setHighlightedCharacterId] = useState<string | null>(null)
const scrollAnimationRef = useRef<number | null>(null) const scrollAnimationRef = useRef<number | null>(null)
@@ -179,6 +213,54 @@ export default function CharacterSection({
</button> </button>
</div> </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 让多角色并排 */} {/* 按角色分组显示:外层 grid 让多角色并排 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{characters.map(character => { {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' : ''}`} 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"> <div className="flex items-center gap-3">
<h3 className="text-base font-semibold text-[var(--glass-text-primary)]">{character.name}</h3> <h3 className="text-base font-semibold text-[var(--glass-text-primary)]">{character.name}</h3>
<span className="text-xs text-[var(--glass-text-tertiary)]"> <span className="text-xs text-[var(--glass-text-tertiary)]">

View File

@@ -20,6 +20,7 @@ import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count' import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
import { countGeneratedImageSlots, resolveDisplayImageSlots } from '@/lib/image-generation/slot-state' import { countGeneratedImageSlots, resolveDisplayImageSlots } from '@/lib/image-generation/slot-state'
import { AppIcon } from '@/components/ui/icons' import { AppIcon } from '@/components/ui/icons'
import { canGenerateLocationBackedAsset } from './location-backed-asset'
interface LocationCardProps { interface LocationCardProps {
location: Location location: Location
@@ -358,7 +359,7 @@ export default function LocationCard({
) )
const firstImage = location.images?.[0] const firstImage = location.images?.[0]
const hasDescription = !!firstImage?.description const canGenerate = canGenerateLocationBackedAsset(location)
return ( return (
<div className="flex flex-col gap-2 glass-surface-elevated p-3"> <div className="flex flex-col gap-2 glass-surface-elevated p-3">
@@ -395,7 +396,7 @@ export default function LocationCard({
mode="compact" mode="compact"
currentImageUrl={currentImageUrl} currentImageUrl={currentImageUrl}
isTaskRunning={isTaskRunning} isTaskRunning={isTaskRunning}
hasDescription={hasDescription} canGenerate={canGenerate}
generationCount={generationCount} generationCount={generationCount}
onGenerationCountChange={setGenerationCount} onGenerationCountChange={setGenerationCount}
onGenerate={onGenerate} onGenerate={onGenerate}

View File

@@ -13,6 +13,7 @@ import { Location, Prop } from '@/types/project'
import { useProjectAssets } from '@/lib/query/hooks/useProjectAssets' import { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'
import LocationCard from './LocationCard' import LocationCard from './LocationCard'
import { AppIcon } from '@/components/ui/icons' import { AppIcon } from '@/components/ui/icons'
import { resolveLocationBackedGenerateType } from './location-backed-asset'
interface LocationSectionProps { interface LocationSectionProps {
// 🔥 V6.5 删除locations prop - 现在内部直接订阅 // 🔥 V6.5 删除locations prop - 现在内部直接订阅
@@ -26,7 +27,7 @@ interface LocationSectionProps {
onDeleteLocation: (locationId: string) => void onDeleteLocation: (locationId: string) => void
onEditLocation: (location: Location | Prop) => void onEditLocation: (location: Location | Prop) => void
// 🔥 V6.6 重构:重命名为 handleGenerateImage // 🔥 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 onSelectImage: (locationId: string, imageIndex: number | null) => void
onConfirmSelection: (locationId: string) => void onConfirmSelection: (locationId: string) => void
onRegenerateSingle: (locationId: string, imageIndex: number) => Promise<void> onRegenerateSingle: (locationId: string, imageIndex: number) => Promise<void>
@@ -68,6 +69,7 @@ export default function LocationSection({
: assets?.locations ?? [] : assets?.locations ?? []
const locations = filterIds ? allLocations.filter((l) => filterIds.has(l.id)) : allLocations const locations = filterIds ? allLocations.filter((l) => filterIds.has(l.id)) : allLocations
const assetKey = assetType === 'prop' ? 'prop' : 'location' const assetKey = assetType === 'prop' ? 'prop' : 'location'
const generateType = resolveLocationBackedGenerateType(assetType)
return ( return (
<div className="glass-surface p-6"> <div className="glass-surface p-6">
@@ -135,7 +137,7 @@ export default function LocationSection({
onGenerate={(count) => { onGenerate={(count) => {
const taskKey = `location-${location.id}-group` const taskKey = `location-${location.id}-group`
onRegisterTransientTaskKey(taskKey) onRegisterTransientTaskKey(taskKey)
void handleGenerateImage('location', location.id, undefined, count).catch(() => { void handleGenerateImage(generateType, location.id, undefined, count).catch(() => {
onClearTaskKey(taskKey) onClearTaskKey(taskKey)
}) })
}} }}

View File

@@ -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>
)
}

View File

@@ -114,24 +114,38 @@ export default function VoiceSettings({
? 'border border-[var(--glass-stroke-base)] rounded-xl p-3 bg-[var(--glass-bg-surface-strong)]' ? '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)]' : '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 iconSize = compact ? 'w-5 h-5' : 'w-6 h-6'
const innerIconSize = compact ? 'w-3 h-3' : 'w-3.5 h-3.5' const innerIconSize = compact ? 'w-3 h-3' : 'w-3.5 h-3.5'
const [isExpanded, setIsExpanded] = useState(false)
return ( return (
<div className={containerClass}> <div className={containerClass}>
<div className={`${headerClass} ${hasCustomVoice ? 'border-[var(--glass-stroke-base)]' : 'border-[var(--glass-stroke-warning)]'}`}> {/* 折叠标题行 - 点击展开/收起 */}
<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)]'}`}> <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)]'}`} /> <AppIcon name="mic" className={`${innerIconSize} ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`} />
</div> </div>
<span className={`text-${compact ? 'xs' : 'sm'} font-medium ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`}> <span className={`text-${compact ? 'xs' : 'sm'} font-medium text-[var(--glass-text-secondary)]`}>
{t('tts.title')}{!hasCustomVoice && <span className="text-[var(--glass-tone-warning-fg)]">({t('tts.noVoice')})</span>} {t('tts.title')}
</span> </span>
<span className={`w-2 h-2 rounded-full ${hasCustomVoice ? 'bg-[var(--glass-tone-success-fg)]' : 'bg-[var(--glass-tone-warning-fg)]'}`} />
</div> </div>
<AppIcon
name="chevronDown"
className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
/>
</button>
{/* 展开内容 */}
{isExpanded && (
<div className="mt-3 pt-3 border-t border-[var(--glass-stroke-base)]">
{/* 隐藏的音频文件输入 */} {/* 隐藏的音频文件输入 */}
<input <input
ref={voiceFileInputRef} ref={voiceFileInputRef}
@@ -204,6 +218,8 @@ export default function VoiceSettings({
</button> </button>
)} )}
</div> </div>
)}
</div>
) )
} }
type UploadedVoiceResult = { audioUrl?: string } type UploadedVoiceResult = { audioUrl?: string }

View File

@@ -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
}

View File

@@ -19,7 +19,7 @@ type LocationCardActionsProps =
mode: 'compact' mode: 'compact'
currentImageUrl: string | null | undefined currentImageUrl: string | null | undefined
isTaskRunning: boolean isTaskRunning: boolean
hasDescription: boolean canGenerate: boolean
generationCount: number generationCount: number
onGenerationCountChange: (value: number) => void onGenerationCountChange: (value: number) => void
onGenerate: (count?: number) => void onGenerate: (count?: number) => void
@@ -67,7 +67,7 @@ export default function LocationCardActions(props: LocationCardActionsProps) {
options={getImageGenerationCountOptions('location')} options={getImageGenerationCountOptions('location')}
onValueChange={props.onGenerationCountChange} onValueChange={props.onGenerationCountChange}
onClick={() => props.onGenerate(props.generationCount)} onClick={() => props.onGenerate(props.generationCount)}
disabled={!props.hasDescription} disabled={!props.canGenerate}
ariaLabel={t('image.selectCount')} 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" 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" 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"

View File

@@ -321,6 +321,7 @@ export default function ScriptViewAssetsPanel({
const hasCharacterSelectionChanges = !setsEqual(initialAppearanceKeys, pendingAppearanceKeys) || hasCharacterLabelChanges const hasCharacterSelectionChanges = !setsEqual(initialAppearanceKeys, pendingAppearanceKeys) || hasCharacterLabelChanges
const hasLocationSelectionChanges = !setsEqual(new Set(activeLocationIds), pendingLocationIds) || hasLocationLabelChanges const hasLocationSelectionChanges = !setsEqual(new Set(activeLocationIds), pendingLocationIds) || hasLocationLabelChanges
const hasPropSelectionChanges = !setsEqual(new Set(activePropIds), pendingPropIds) const hasPropSelectionChanges = !setsEqual(new Set(activePropIds), pendingPropIds)
const hasProjectProps = props.length > 0
const handleConfirmCharacterSelection = async () => { const handleConfirmCharacterSelection = async () => {
if (isSavingCharacterSelection) return if (isSavingCharacterSelection) return
@@ -754,6 +755,7 @@ export default function ScriptViewAssetsPanel({
)} )}
</div> </div>
{hasProjectProps ? (
<div className="relative"> <div className="relative">
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<h3 className="text-sm font-bold text-[var(--glass-text-secondary)]"> ({activePropIds.length})</h3> <h3 className="text-sm font-bold text-[var(--glass-text-secondary)]"> ({activePropIds.length})</h3>
@@ -855,6 +857,7 @@ export default function ScriptViewAssetsPanel({
</div> </div>
)} )}
</div> </div>
) : null}
</div> </div>
</div> </div>
@@ -874,7 +877,7 @@ export default function ScriptViewAssetsPanel({
<button <button
onClick={onGenerateStoryboard} onClick={onGenerateStoryboard}
disabled={isSubmittingStoryboardBuild || clips.length === 0 || !allAssetsHaveImages} 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')} {isSubmittingStoryboardBuild ? tScript('generate.generating') : tScript('generate.startGenerate')}
</button> </button>

View File

@@ -9,10 +9,14 @@ export interface SegmentedControlOption<T extends string = string> {
label: ReactNode label: ReactNode
} }
type SegmentedControlLayout = 'fill' | 'compact'
interface SegmentedControlProps<T extends string = string> { interface SegmentedControlProps<T extends string = string> {
options: SegmentedControlOption<T>[] options: SegmentedControlOption<T>[]
value: T value: T
onChange: (value: T) => void onChange: (value: T) => void
/** Layout mode: stretch to container or keep a compact left-aligned width */
layout?: SegmentedControlLayout
/** Extra className on the outer container */ /** Extra className on the outer container */
className?: string className?: string
} }
@@ -31,10 +35,12 @@ export function SegmentedControl<T extends string = string>({
options, options,
value, value,
onChange, onChange,
layout = 'fill',
className = '', className = '',
}: SegmentedControlProps<T>) { }: SegmentedControlProps<T>) {
const gridRef = useRef<HTMLDivElement>(null) const gridRef = useRef<HTMLDivElement>(null)
const [indicator, setIndicator] = useState<{ left: number; width: number }>({ left: 0, width: 0 }) const [indicator, setIndicator] = useState<{ left: number; width: number }>({ left: 0, width: 0 })
const isCompact = layout === 'compact'
useEffect(() => { useEffect(() => {
if (!gridRef.current) return if (!gridRef.current) return
@@ -47,11 +53,13 @@ export function SegmentedControl<T extends string = string>({
}, [value, options]) }, [value, options])
return ( 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 <div
ref={gridRef} ref={gridRef}
className="relative grid" className={isCompact ? 'relative inline-grid grid-flow-col auto-cols-[minmax(96px,max-content)]' : 'relative grid'}
style={{ gridTemplateColumns: `repeat(${Math.max(1, options.length)}, minmax(0, 1fr))` }} style={isCompact ? undefined : { gridTemplateColumns: `repeat(${Math.max(1, options.length)}, minmax(0, 1fr))` }}
> >
{/* Sliding pill indicator */} {/* Sliding pill indicator */}
<div <div

View File

@@ -74,6 +74,7 @@ export type CharacterAssetSummary = BaseAssetSummary & {
family: 'visual' family: 'visual'
variants: AssetVariantSummary[] variants: AssetVariantSummary[]
introduction: string | null introduction: string | null
profileData: string | null
profileConfirmed: boolean | null profileConfirmed: boolean | null
profileTaskRefs: AssetTaskRef[] profileTaskRefs: AssetTaskRef[]
profileTaskState: AssetTaskState profileTaskState: AssetTaskState

View File

@@ -32,6 +32,7 @@ type ProjectCharacterRecord = {
id: string id: string
name: string name: string
introduction?: string | null introduction?: string | null
profileData?: string | null
voiceType?: 'custom' | 'qwen-designed' | 'uploaded' | null voiceType?: 'custom' | 'qwen-designed' | 'uploaded' | null
voiceId?: string | null voiceId?: string | null
customVoiceUrl?: string | null customVoiceUrl?: string | null
@@ -214,6 +215,7 @@ export function mapProjectCharacterToAsset(character: ProjectCharacterRecord): C
taskState: createIdleTaskState(), taskState: createIdleTaskState(),
variants, variants,
introduction: character.introduction ?? null, introduction: character.introduction ?? null,
profileData: character.profileData ?? null,
profileConfirmed: character.profileConfirmed ?? null, profileConfirmed: character.profileConfirmed ?? null,
profileTaskRefs: [ profileTaskRefs: [
{ {
@@ -290,6 +292,7 @@ export function mapGlobalCharacterToAsset(character: GlobalCharacterRecord): Cha
taskState: createIdleTaskState(), taskState: createIdleTaskState(),
variants, variants,
introduction: null, introduction: null,
profileData: null,
profileConfirmed: null, profileConfirmed: null,
profileTaskRefs: [], profileTaskRefs: [],
profileTaskState: createIdleTaskState(), profileTaskState: createIdleTaskState(),

View File

@@ -63,6 +63,22 @@ function buildImageGroups(
return groups 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[]>> { async function readProjectLocationBackedImages(locationIds: string[]): Promise<Map<string, LocationBackedImageRow[]>> {
if (locationIds.length === 0) { if (locationIds.length === 0) {
return new Map() return new Map()
@@ -194,6 +210,11 @@ export async function createProjectLocationBackedAsset(input: {
NOW() NOW()
) )
`) `)
await seedProjectLocationBackedImageSlots({
locationId: id,
fallbackDescription: input.summary,
descriptions: [input.summary],
})
return { id } return { id }
} }
@@ -229,9 +250,52 @@ export async function createGlobalLocationBackedAsset(input: {
NOW() NOW()
) )
`) `)
await seedGlobalLocationBackedImageSlots({
locationId: id,
fallbackDescription: input.summary,
descriptions: [input.summary],
})
return { id } 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> { export async function deleteProjectLocationBackedAsset(assetId: string): Promise<void> {
await prisma.$transaction([ await prisma.$transaction([
prisma.$executeRaw(Prisma.sql`DELETE FROM location_images WHERE locationId = ${assetId}`), prisma.$executeRaw(Prisma.sql`DELETE FROM location_images WHERE locationId = ${assetId}`),

View File

@@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { queryKeys } from '../keys' import { queryKeys } from '../keys'
import type { Character, Location, MediaRef, Prop } from '@/types/project' import type { Character, Location, MediaRef, Prop } from '@/types/project'
import { useAssets } from './useAssets' import { useAssets } from './useAssets'
import type { AssetGroupMap } from '@/lib/assets/grouping'
import { groupAssetsByKind } from '@/lib/assets/grouping' import { groupAssetsByKind } from '@/lib/assets/grouping'
// ============ 类型定义 ============ // ============ 类型定义 ============
@@ -14,19 +15,8 @@ export interface ProjectAssetsData {
props: Prop[] props: Prop[]
} }
// ============ 查询 Hooks ============ function mapCharacterAssetToProjectCharacter(asset: AssetGroupMap['character'][number]): Character {
return {
/**
* 获取项目资产(角色 + 场景)
*/
export function useProjectAssets(projectId: string | null) {
const assetsQuery = useAssets({
scope: 'project',
projectId,
})
const groups = groupAssetsByKind(assetsQuery.data)
const data: ProjectAssetsData = {
characters: groups.character.map((asset) => ({
id: asset.id, id: asset.id,
name: asset.name, name: asset.name,
aliases: null, aliases: null,
@@ -60,16 +50,16 @@ export function useProjectAssets(projectId: string | null) {
voiceId: asset.voice.voiceId, voiceId: asset.voice.voiceId,
customVoiceUrl: asset.voice.customVoiceUrl, customVoiceUrl: asset.voice.customVoiceUrl,
media: asset.voice.media, media: asset.voice.media,
profileData: null, profileData: asset.profileData,
profileConfirmed: asset.profileConfirmed ?? undefined, profileConfirmed: asset.profileConfirmed ?? undefined,
profileConfirmTaskRunning: asset.profileTaskState.isRunning, profileConfirmTaskRunning: asset.profileTaskState.isRunning,
})), }
locations: groups.location.map((asset) => ({ }
id: asset.id,
name: asset.name, function mapLocationVariantToProjectImage(
summary: asset.summary, asset: AssetGroupMap['location'][number] | AssetGroupMap['prop'][number],
selectedImageId: asset.selectedVariantId, variant: AssetGroupMap['location'][number]['variants'][number],
images: asset.variants.map((variant) => { ) {
const render = variant.renders[0] ?? null const render = variant.renders[0] ?? null
return { return {
id: variant.id, id: variant.id,
@@ -85,32 +75,48 @@ export function useProjectAssets(projectId: string | null) {
imageErrorMessage: variant.taskState.lastError?.message ?? null, imageErrorMessage: variant.taskState.lastError?.message ?? null,
lastError: variant.taskState.lastError ?? asset.taskState.lastError, lastError: variant.taskState.lastError ?? asset.taskState.lastError,
} }
}), }
})),
props: groups.prop.map((asset) => ({ function mapLocationAssetToProjectLocation(asset: AssetGroupMap['location'][number]): Location {
return {
id: asset.id, id: asset.id,
name: asset.name, name: asset.name,
summary: asset.summary, summary: asset.summary,
selectedImageId: asset.selectedVariantId, selectedImageId: asset.selectedVariantId,
images: asset.variants.map((variant) => { images: asset.variants.map((variant) => mapLocationVariantToProjectImage(asset, variant)),
const render = variant.renders[0] ?? null }
}
function mapPropAssetToProjectProp(asset: AssetGroupMap['prop'][number]): Prop {
return { return {
id: variant.id, id: asset.id,
imageIndex: variant.index, name: asset.name,
description: variant.description, summary: asset.summary,
imageUrl: render?.imageUrl ?? null, selectedImageId: asset.selectedVariantId,
media: render?.media ?? null, images: asset.variants.map((variant) => mapLocationVariantToProjectImage(asset, variant)),
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,
} }
}), }
})),
export function mapAssetGroupsToProjectAssetsData(groups: AssetGroupMap): ProjectAssetsData {
return {
characters: groups.character.map(mapCharacterAssetToProjectCharacter),
locations: groups.location.map(mapLocationAssetToProjectLocation),
props: groups.prop.map(mapPropAssetToProjectProp),
} }
}
// ============ 查询 Hooks ============
/**
* 获取项目资产(角色 + 场景)
*/
export function useProjectAssets(projectId: string | null) {
const assetsQuery = useAssets({
scope: 'project',
projectId,
})
const groups = groupAssetsByKind(assetsQuery.data)
const data = mapAssetGroupsToProjectAssetsData(groups)
return { return {
...assetsQuery, ...assetsQuery,

View File

@@ -9,6 +9,7 @@ import {
type AnalyzeGlobalPropsData, type AnalyzeGlobalPropsData,
type CharacterBrief, type CharacterBrief,
} from './analyze-global-parse' } from './analyze-global-parse'
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
export type AnalyzeGlobalStats = { export type AnalyzeGlobalStats = {
totalChunks: number totalChunks: number
@@ -48,10 +49,6 @@ export async function persistAnalyzeGlobalChunk(params: {
existingPropNames: string[] existingPropNames: string[]
stats: AnalyzeGlobalStats 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 || []) { for (const char of params.charactersData.new_characters || []) {
const name = readText(char.name).trim() const name = readText(char.name).trim()
const aliases = toStringArray(char.aliases) const aliases = toStringArray(char.aliases)
@@ -181,15 +178,11 @@ export async function persistAnalyzeGlobalChunk(params: {
}, },
}) })
for (let j = 0; j < cleanDescriptions.length; j += 1) { await seedProjectLocationBackedImageSlots({
await prisma.locationImage.create({
data: {
locationId: created.id, locationId: created.id,
imageIndex: j, descriptions: cleanDescriptions,
description: cleanDescriptions[j], fallbackDescription: summary || name,
},
}) })
}
params.existingLocationNames.push(name) params.existingLocationNames.push(name)
params.existingLocationInfo.push(summary ? `${name}(${summary})` : name) params.existingLocationInfo.push(summary ? `${name}(${summary})` : name)
@@ -214,7 +207,7 @@ export async function persistAnalyzeGlobalChunk(params: {
} }
try { try {
await locationModel.create({ const created = await prisma.novelPromotionLocation.create({
data: { data: {
novelPromotionProjectId: params.projectInternalId, novelPromotionProjectId: params.projectInternalId,
name, name,
@@ -222,6 +215,11 @@ export async function persistAnalyzeGlobalChunk(params: {
assetKind: 'prop', assetKind: 'prop',
}, },
}) })
await seedProjectLocationBackedImageSlots({
locationId: created.id,
descriptions: [summary],
fallbackDescription: summary,
})
params.existingPropNames.push(name) params.existingPropNames.push(name)
params.stats.newProps += 1 params.stats.newProps += 1
} catch { } catch {

View File

@@ -10,6 +10,7 @@ import { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './
import type { TaskJobData } from '@/lib/task/types' import type { TaskJobData } from '@/lib/task/types'
import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n' import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n'
import { resolveAnalysisModel } from './resolve-analysis-model' import { resolveAnalysisModel } from './resolve-analysis-model'
import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets'
function readAssetKind(value: Record<string, unknown>): string { function readAssetKind(value: Record<string, unknown>): string {
return typeof value.assetKind === 'string' ? value.assetKind : 'location' 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>) { export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
const payload = (job.data.payload || {}) as Record<string, unknown> const payload = (job.data.payload || {}) as Record<string, unknown>
const projectId = job.data.projectId 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({ const project = await prisma.project.findUnique({
where: { id: projectId }, where: { id: projectId },
@@ -312,7 +310,7 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
) )
if (existsInLibrary) continue if (existsInLibrary) continue
const created = await locationModel.create({ const created = await prisma.novelPromotionLocation.create({
data: { data: {
novelPromotionProjectId: novelData.id, novelPromotionProjectId: novelData.id,
name, name,
@@ -322,15 +320,11 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
}) })
const cleanDescriptions = descriptions.map((value) => removeLocationPromptSuffix(value || '')) const cleanDescriptions = descriptions.map((value) => removeLocationPromptSuffix(value || ''))
for (let i = 0; i < cleanDescriptions.length; i += 1) { await seedProjectLocationBackedImageSlots({
await prisma.locationImage.create({
data: {
locationId: created.id, locationId: created.id,
imageIndex: i, descriptions: cleanDescriptions,
description: cleanDescriptions[i], fallbackDescription: readText(item.summary) || name,
},
}) })
}
createdLocations.push(created) createdLocations.push(created)
} }
@@ -349,7 +343,7 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
const normalizedName = name.toLowerCase() const normalizedName = name.toLowerCase()
if (existingPropNameSet.has(normalizedName)) continue if (existingPropNameSet.has(normalizedName)) continue
const created = await locationModel.create({ const created = await prisma.novelPromotionLocation.create({
data: { data: {
novelPromotionProjectId: novelData.id, novelPromotionProjectId: novelData.id,
name, name,
@@ -358,6 +352,11 @@ export async function handleAnalyzeNovelTask(job: Job<TaskJobData>) {
}, },
select: { id: true }, select: { id: true },
}) })
await seedProjectLocationBackedImageSlots({
locationId: created.id,
descriptions: [summary],
fallbackDescription: summary,
})
existingPropNameSet.add(normalizedName) existingPropNameSet.add(normalizedName)
createdProps.push(created) createdProps.push(created)
} }

View File

@@ -129,13 +129,22 @@ async function handleConfirmProfile(
} }
await assertTaskActive(job, 'character_profile_confirm_persist') 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++) { for (let appIndex = 0; appIndex < appearances.length; appIndex++) {
const app = appearances[appIndex] const app = appearances[appIndex]
await assertTaskActive(job, 'character_profile_confirm_create_appearance') await assertTaskActive(job, 'character_profile_confirm_create_appearance')
const descriptions = Array.isArray(app.descriptions) ? app.descriptions : [] const descriptions = Array.isArray(app.descriptions) ? app.descriptions : []
const normalizedDescriptions = descriptions.map((item) => readText(item)).filter(Boolean) const normalizedDescriptions = descriptions.map((item) => readText(item)).filter(Boolean)
await prisma.characterAppearance.create({ appearanceRows.push({
data: {
characterId: character.id, characterId: character.id,
appearanceIndex: appIndex, appearanceIndex: appIndex,
changeReason: readText(app.change_reason) || '初始形象', changeReason: readText(app.change_reason) || '初始形象',
@@ -143,13 +152,27 @@ async function handleConfirmProfile(
descriptions: JSON.stringify(normalizedDescriptions), descriptions: JSON.stringify(normalizedDescriptions),
imageUrls: encodeImageUrls([]), imageUrls: encodeImageUrls([]),
previousImageUrls: encodeImageUrls([]), previousImageUrls: encodeImageUrls([]),
},
}) })
} }
await prisma.novelPromotionCharacter.update({ 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 }, where: { id: characterId },
data: { profileConfirmed: true }, data: {
profileData: finalProfileData,
profileConfirmed: true,
},
})
}) })
if (!suppressProgress) { if (!suppressProgress) {

View File

@@ -1,6 +1,7 @@
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { removeLocationPromptSuffix } from '@/lib/constants' import { removeLocationPromptSuffix } from '@/lib/constants'
import type { StoryToScriptClipCandidate } from '@/lib/novel-promotion/story-to-script/orchestrator' 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> export type AnyObj = Record<string, unknown>
@@ -118,15 +119,11 @@ export async function persistAnalyzedLocations(params: {
}) })
const cleanDescriptions = mergedDescriptions.map((desc) => removeLocationPromptSuffix(desc || '')) const cleanDescriptions = mergedDescriptions.map((desc) => removeLocationPromptSuffix(desc || ''))
for (let i = 0; i < cleanDescriptions.length; i += 1) { await seedProjectLocationBackedImageSlots({
await prisma.locationImage.create({
data: {
locationId: location.id, locationId: location.id,
imageIndex: i, descriptions: cleanDescriptions,
description: cleanDescriptions[i], fallbackDescription: asString(item.summary) || name,
},
}) })
}
params.existingNames.add(key) params.existingNames.add(key)
created.push(location) created.push(location)
@@ -141,9 +138,6 @@ export async function persistAnalyzedProps(params: {
analyzedProps: Record<string, unknown>[] analyzedProps: Record<string, unknown>[]
}) { }) {
const created: Array<{ id: string; name: string }> = [] 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) { for (const item of params.analyzedProps) {
const name = asString(item.name).trim() const name = asString(item.name).trim()
@@ -153,7 +147,7 @@ export async function persistAnalyzedProps(params: {
const key = name.toLowerCase() const key = name.toLowerCase()
if (params.existingNames.has(key)) continue if (params.existingNames.has(key)) continue
const prop = await locationModel.create({ const prop = await prisma.novelPromotionLocation.create({
data: { data: {
novelPromotionProjectId: params.projectInternalId, novelPromotionProjectId: params.projectInternalId,
name, name,
@@ -165,6 +159,11 @@ export async function persistAnalyzedProps(params: {
name: true, name: true,
}, },
}) })
await seedProjectLocationBackedImageSlots({
locationId: prop.id,
descriptions: [summary],
fallbackDescription: summary,
})
params.existingNames.add(key) params.existingNames.add(key)
created.push(prop) created.push(prop)

View File

@@ -139,27 +139,25 @@
} }
/* Page Transition Animation - 页面切换动画 */ /* Page Transition Animation - 页面切换动画 */
/* 注意:不可使用 transform因为子组件中有 fixed 定位元素(如 StoryboardStageShell 的悬浮按钮),
transform 会创建新的 containing block 导致 fixed 失效并产生跳动 */
@keyframes pageSlideIn { @keyframes pageSlideIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(20px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0);
} }
} }
@keyframes pageSlideOut { @keyframes pageSlideOut {
from { from {
opacity: 1; opacity: 1;
transform: translateY(0);
} }
to { to {
opacity: 0; opacity: 0;
transform: translateY(-20px);
} }
} }

View File

@@ -4,6 +4,8 @@ const prismaMock = vi.hoisted(() => ({
$queryRaw: vi.fn(), $queryRaw: vi.fn(),
$executeRaw: vi.fn(), $executeRaw: vi.fn(),
$transaction: vi.fn(), $transaction: vi.fn(),
locationImage: { createMany: vi.fn() },
globalLocationImage: { createMany: vi.fn() },
})) }))
vi.mock('@/lib/prisma', () => ({ vi.mock('@/lib/prisma', () => ({
@@ -44,4 +46,50 @@ describe('location-backed assets service', () => {
expect(imageSql).toContain('FROM location_images') expect(imageSql).toContain('FROM location_images')
expect(imageSql).toContain('NULL AS previousImageMediaId') 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',
},
],
})
})
}) })

View 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')
})
})

View File

@@ -8,6 +8,7 @@ describe('asset mappers', () => {
id: 'character-1', id: 'character-1',
name: '林夏', name: '林夏',
introduction: '主角', introduction: '主角',
profileData: JSON.stringify({ archetype: 'lead' }),
voiceType: 'custom', voiceType: 'custom',
voiceId: 'voice-1', voiceId: 'voice-1',
customVoiceUrl: 'https://example.com/voice.mp3', customVoiceUrl: 'https://example.com/voice.mp3',
@@ -37,6 +38,7 @@ describe('asset mappers', () => {
scope: 'project', scope: 'project',
kind: 'character', kind: 'character',
introduction: '主角', introduction: '主角',
profileData: JSON.stringify({ archetype: 'lead' }),
profileConfirmed: true, profileConfirmed: true,
voice: expect.objectContaining({ voice: expect.objectContaining({
voiceType: 'custom', voiceType: 'custom',

View 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('>刷新<')
})
})

View File

@@ -24,6 +24,9 @@ const messages = {
waitingModelOutput: '等待模型输出...', waitingModelOutput: '等待模型输出...',
reasoningNotProvided: '该步骤未返回思考过程', reasoningNotProvided: '该步骤未返回思考过程',
}, },
streamStep: {
analyzeProps: '道具分析',
},
runtime: { runtime: {
llm: { llm: {
processing: '模型处理中...', processing: '模型处理中...',
@@ -69,4 +72,27 @@ describe('LLMStageStreamCard error rendering', () => {
expect(html).not.toContain('Copy error detail') expect(html).not.toContain('Copy error detail')
expect(html).not.toContain('Open feedback form') 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')
})
}) })

View 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))')
})
})

View 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')
})
})

View 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,
}))
})
})

View 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('当前片段未选择道具')
})
})

View File

@@ -11,7 +11,10 @@ const prismaMock = vi.hoisted(() => ({
novelPromotionEpisode: { findFirst: vi.fn() }, novelPromotionEpisode: { findFirst: vi.fn() },
novelPromotionCharacter: { create: vi.fn(async () => ({ id: 'char-new-1' })) }, novelPromotionCharacter: { create: vi.fn(async () => ({ id: 'char-new-1' })) },
novelPromotionLocation: { create: vi.fn(async () => ({ id: 'loc-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(() => ({ const llmMock = vi.hoisted(() => ({
@@ -49,6 +52,7 @@ vi.mock('@/lib/prompt-i18n', () => ({
PROMPT_IDS: { PROMPT_IDS: {
NP_AGENT_CHARACTER_PROFILE: 'char', NP_AGENT_CHARACTER_PROFILE: 'char',
NP_SELECT_LOCATION: 'loc', NP_SELECT_LOCATION: 'loc',
NP_SELECT_PROP: 'prop',
}, },
buildPrompt: vi.fn(() => 'analysis-prompt'), buildPrompt: vi.fn(() => 'analysis-prompt'),
})) }))
@@ -75,6 +79,10 @@ describe('worker analyze-novel behavior', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
prismaMock.novelPromotionLocation.create
.mockResolvedValueOnce({ id: 'loc-new-1' })
.mockResolvedValueOnce({ id: 'prop-new-1' })
prismaMock.project.findUnique.mockResolvedValue({ prismaMock.project.findUnique.mockResolvedValue({
id: 'project-1', id: 'project-1',
mode: 'novel-promotion', 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 () => { it('no global text and no episode text -> explicit error', async () => {
@@ -137,10 +153,10 @@ describe('worker analyze-novel behavior', () => {
success: true, success: true,
characters: [{ id: 'char-new-1' }], characters: [{ id: 'char-new-1' }],
locations: [{ id: 'loc-new-1' }], locations: [{ id: 'loc-new-1' }],
props: [], props: [{ id: 'prop-new-1' }],
characterCount: 1, characterCount: 1,
locationCount: 1, locationCount: 1,
propCount: 0, propCount: 1,
}) })
expect(prismaMock.novelPromotionCharacter.create).toHaveBeenCalledWith( expect(prismaMock.novelPromotionCharacter.create).toHaveBeenCalledWith(
@@ -163,12 +179,24 @@ describe('worker analyze-novel behavior', () => {
}), }),
) )
expect(prismaMock.locationImage.create).toHaveBeenCalledWith({ expect(prismaMock.locationImage.create).not.toHaveBeenCalled()
data: { expect(prismaMock.locationImage.createMany).toHaveBeenNthCalledWith(1, {
data: [
{
locationId: 'loc-new-1', locationId: 'loc-new-1',
imageIndex: 0, imageIndex: 0,
description: '雨夜街道', description: '雨夜街道',
}, },
],
})
expect(prismaMock.locationImage.createMany).toHaveBeenNthCalledWith(2, {
data: [
{
locationId: 'prop-new-1',
imageIndex: 0,
description: '一根两头包裹金片的黑铁长棍',
},
],
}) })
expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith({ expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith({

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types' import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const prismaMock = vi.hoisted(() => ({ const prismaMock = vi.hoisted(() => ({
$transaction: vi.fn(),
novelPromotionCharacter: { novelPromotionCharacter: {
findFirst: vi.fn(), findFirst: vi.fn(),
findMany: vi.fn(), findMany: vi.fn(),
@@ -10,6 +11,7 @@ const prismaMock = vi.hoisted(() => ({
}, },
characterAppearance: { characterAppearance: {
create: vi.fn(async () => ({})), 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', () => { describe('worker character-profile behavior', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise<unknown>) => {
return await callback(prismaMock)
})
llmMock.getCompletionContent.mockReturnValue( llmMock.getCompletionContent.mockReturnValue(
JSON.stringify({ JSON.stringify({
@@ -134,10 +139,13 @@ describe('worker character-profile behavior', () => {
await expect(handleCharacterProfileTask(job)).rejects.toThrow('Unsupported character profile task type') 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 job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' })
const result = await handleCharacterProfileTask(job) const result = await handleCharacterProfileTask(job)
expect(prismaMock.characterAppearance.deleteMany).toHaveBeenCalledWith({
where: { characterId: 'character-1' },
})
expect(prismaMock.characterAppearance.create).toHaveBeenCalledWith({ expect(prismaMock.characterAppearance.create).toHaveBeenCalledWith({
data: expect.objectContaining({ data: expect.objectContaining({
characterId: 'character-1', characterId: 'character-1',
@@ -149,7 +157,10 @@ describe('worker character-profile behavior', () => {
expect(prismaMock.novelPromotionCharacter.update).toHaveBeenCalledWith({ expect(prismaMock.novelPromotionCharacter.update).toHaveBeenCalledWith({
where: { id: 'character-1' }, where: { id: 'character-1' },
data: { profileConfirmed: true }, data: {
profileData: JSON.stringify({ archetype: 'lead' }),
profileConfirmed: true,
},
}) })
expect(result).toEqual(expect.objectContaining({ expect(result).toEqual(expect.objectContaining({
@@ -171,4 +182,18 @@ describe('worker character-profile behavior', () => {
}) })
expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(2) 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)
})
}) })