From a6ad11b9c4e91377264e6bd0e13db8359a25d8a0 Mon Sep 17 00:00:00 2001 From: saturn Date: Sat, 21 Mar 2026 14:35:32 +0800 Subject: [PATCH] fix: resolve confirmed character hidden bug, remove online font dependency, improve UI/UX experience --- .../novel-promotion/select_prop.en.txt | 46 +++- .../novel-promotion/select_prop.zh.txt | 46 +++- messages/en/assets.json | 2 + messages/en/progress.json | 1 + messages/zh/assets.json | 2 + messages/zh/progress.json | 1 + package-lock.json | 10 + package.json | 1 + src/app/[locale]/layout.tsx | 15 +- .../components/AssetsStage.tsx | 34 +-- .../components/assets/AssetFilterBar.tsx | 14 +- .../components/assets/AssetToolbar.tsx | 55 +---- .../assets/CharacterProfileCard.tsx | 232 ++++++++++-------- .../components/assets/CharacterSection.tsx | 88 ++++++- .../components/assets/LocationCard.tsx | 5 +- .../components/assets/LocationSection.tsx | 6 +- .../assets/UnconfirmedProfilesSection.tsx | 85 ------- .../components/assets/VoiceSettings.tsx | 168 +++++++------ .../assets/location-backed-asset.ts | 17 ++ .../location-card/LocationCardActions.tsx | 4 +- .../script-view/ScriptViewAssetsPanel.tsx | 5 +- src/components/ui/SegmentedControl.tsx | 14 +- src/lib/assets/contracts.ts | 1 + src/lib/assets/mappers.ts | 3 + .../assets/services/location-backed-assets.ts | 64 +++++ src/lib/query/hooks/useProjectAssets.ts | 178 +++++++------- .../handlers/analyze-global-persist.ts | 26 +- src/lib/workers/handlers/analyze-novel.ts | 27 +- src/lib/workers/handlers/character-profile.ts | 49 +++- .../handlers/story-to-script-helpers.ts | 25 +- src/styles/animations.css | 6 +- .../assets/location-backed-assets.test.ts | 48 ++++ .../assets/location-backed-generation.test.ts | 37 +++ tests/unit/assets/mappers.test.ts | 2 + tests/unit/components/asset-toolbar.test.ts | 80 ++++++ .../llm-stage-stream-card-error.test.ts | 26 ++ .../unit/components/segmented-control.test.ts | 29 +++ .../prompt-i18n/select-prop-template.test.ts | 24 ++ tests/unit/query/use-project-assets.test.ts | 55 +++++ .../script-view-assets-panel.test.ts | 136 ++++++++++ tests/unit/worker/analyze-novel.test.ts | 46 +++- tests/unit/worker/character-profile.test.ts | 29 ++- 42 files changed, 1189 insertions(+), 553 deletions(-) delete mode 100644 src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/UnconfirmedProfilesSection.tsx create mode 100644 src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-backed-asset.ts create mode 100644 tests/unit/assets/location-backed-generation.test.ts create mode 100644 tests/unit/components/asset-toolbar.test.ts create mode 100644 tests/unit/components/segmented-control.test.ts create mode 100644 tests/unit/prompt-i18n/select-prop-template.test.ts create mode 100644 tests/unit/query/use-project-assets.test.ts create mode 100644 tests/unit/script-view/script-view-assets-panel.test.ts diff --git a/lib/prompts/novel-promotion/select_prop.en.txt b/lib/prompts/novel-promotion/select_prop.en.txt index de3d6c7..c7a6434 100644 --- a/lib/prompts/novel-promotion/select_prop.en.txt +++ b/lib/prompts/novel-promotion/select_prop.en.txt @@ -1,6 +1,6 @@ -You are a prop asset extractor. +You are a key story prop extractor. -Task: identify reusable physical props from the input text and return JSON only. +Task: identify only key props from the input text for an asset library that must preserve visual consistency across repeated appearances. Be conservative. Return JSON only. Output format: { @@ -12,16 +12,38 @@ Output format: ] } -Rules: -1. Only include concrete reusable physical props that actually appear in the story. -2. Only output `name` and `summary`. -3. `name` and `summary` must both be non-empty. -4. Do not repeat props that already exist in the prop library with the exact same name. -5. Exclude abstract concepts, powers, roles, places, creatures, outfits, and makeup. -6. Keep names stable and short. -7. Keep summaries objective. -8. If none exist, return {"props": []}. -9. Replace raw quotation marks inside JSON string values with corner brackets「」. +Key prop criteria: +1. It must be a real physical object that actually appears in the story. +2. It must serve a clear story function rather than being background dressing. +3. It must satisfy at least one of the following: + - characters hold it, use it, fight over it, deliver it, hide it, lose it, or search for it + - it is a key tool, weapon, artifact, piece of evidence, token, key, or clue carrier + - it is likely to reappear and therefore needs a consistent visual design + - removing it would materially weaken plot comprehension or a key action + +Strictly exclude: +1. Ordinary background items, furniture, tableware, food, drinks, daily necessities, and decorations. +2. Objects that are only mentioned in passing and have no story function. +3. Environmental elements that belong to the scene unless they are explicitly used as key props. +4. Ordinary clothing, makeup, and accessories unless they are themselves key clues or tokens. +5. Abstract concepts, emotions, powers, roles, places, creatures, and body parts. + +Decision bias: +1. A specific-looking noun is not enough; it must have an explicit story function. +2. If an object could be either a background item or a prop, treat it as background and do not output it. +3. If it merely appears but is not used, emphasized, or plot-relevant, do not output it. +4. If you are unsure whether it deserves an asset entry, do not output it. +5. Prefer under-extraction. Never output props just to increase the count. + +Output rules: +1. Only output `name` and `summary`. +2. `name` and `summary` must both be non-empty. +3. Do not repeat props that already exist in the prop library with the exact same name. +4. Keep names stable and short. +5. Keep summaries objective. +6. Usually output no more than 3-5 props unless more are clearly all key props. +7. If none exist, return {"props": []}. +8. Replace raw quotation marks inside JSON string values with corner brackets「」. Input: {input} diff --git a/lib/prompts/novel-promotion/select_prop.zh.txt b/lib/prompts/novel-promotion/select_prop.zh.txt index 90032c9..157c92f 100644 --- a/lib/prompts/novel-promotion/select_prop.zh.txt +++ b/lib/prompts/novel-promotion/select_prop.zh.txt @@ -1,6 +1,6 @@ -你是“故事道具资产分析师”。 +你是“关键剧情道具资产分析师”。 -任务:从输入文本中识别适合做成长期复用资产的道具,只返回 JSON,不得包含任何额外解释或 markdown。 +任务:从输入文本中只识别【关键道具】,用于建立需要长期保持外观一致的资产库。宁缺毋滥。只返回 JSON,不得包含任何额外解释或 markdown。 输出格式: { @@ -12,16 +12,38 @@ ] } -规则: -1. 只保留在剧情中真实出现、可被反复引用、值得进入资产库的实体道具。 -2. 只输出两个字段:name、summary。 -3. name 不能为空;summary 不能为空。 -4. 如果道具库里已经有完全同名道具,不要重复输出。 -5. 禁止输出抽象概念、情绪、能力、身份、地点、生物、服装妆容。 -6. 名称尽量简洁稳定,例如“青铜匕首”“录音笔”“红绳手链”。 -7. summary 只写客观描述,不写剧情推断。 -8. 如果没有合适道具,返回 {"props": []}。 -9. JSON 字符串值中的引号统一替换为「」。 +关键道具判定标准: +1. 必须是剧情中真实出现的实体物件。 +2. 必须在剧情中承担明确功能,而不只是背景摆设。 +3. 必须至少满足以下一种情况: + - 被角色持有、使用、争夺、交付、隐藏、丢失、寻找 + - 是推进情节的关键工具、武器、法器、证物、信物、钥匙、线索载体 + - 后续大概率需要重复出镜,且需要保持外观一致 + - 去掉它会明显影响剧情理解或关键动作成立 + +严格不提取: +1. 普通背景陈设、家具、餐具、食物、饮料、日用品、装饰物。 +2. 仅被顺带提及、没有剧情功能的物件。 +3. 场景自带的环境元素,除非它被明确当作关键道具使用。 +4. 普通服装、妆容、饰品,除非它本身就是关键线索或关键信物。 +5. 抽象概念、情绪、能力、身份、地点、生物、身体部位。 + +判断倾向: +1. 仅因外观具体、名词明确,不足以成为关键道具;必须有明确剧情作用。 +2. 如果一个物件既可能是背景物,也可能是道具,默认按背景物处理,不输出。 +3. 如果只是“出现过”,但没有“被使用/被强调/影响剧情”,不输出。 +4. 如果不确定它是否值得进入资产库,直接不输出。 +5. 优先少报,禁止为了凑数量而输出。 + +输出要求: +1. 只输出两个字段:name、summary。 +2. name 不能为空;summary 不能为空。 +3. 如果道具库里已经有完全同名道具,不要重复输出。 +4. 名称尽量简洁稳定,例如“青铜匕首”“录音笔”“红绳手链”。 +5. summary 只写客观描述,不写剧情推断。 +6. 通常不超过 3-5 个;只有确实都是关键道具时才可更多。 +7. 如果没有合适道具,返回 {"props": []}。 +8. JSON 字符串值中的引号统一替换为「」。 输入文本: {input} diff --git a/messages/en/assets.json b/messages/en/assets.json index d170ed3..af780ec 100644 --- a/messages/en/assets.json +++ b/messages/en/assets.json @@ -12,6 +12,8 @@ "confirmProfiles": "Character Profiles to Confirm", "confirmHint": "Please confirm these profiles before generating descriptions", "confirmAll": "Confirm All ({count})", + "pendingProfilesBanner": "AI Casting Complete", + "pendingProfilesHint": "Confirm profiles to auto-generate character visuals", "assetsTitle": "Asset Analysis", "characterAssets": "Character Assets", "locationAssets": "Location Assets", diff --git a/messages/en/progress.json b/messages/en/progress.json index 53a9353..0b2e2ed 100644 --- a/messages/en/progress.json +++ b/messages/en/progress.json @@ -125,6 +125,7 @@ "streamStep": { "analyzeCharacters": "Analyze characters", "analyzeLocations": "Analyze locations", + "analyzeProps": "Analyze props", "splitClips": "Split clips", "screenplayConversion": "Convert screenplay", "storyboardPlan": "Plan storyboard", diff --git a/messages/zh/assets.json b/messages/zh/assets.json index 576ed60..a6a7748 100644 --- a/messages/zh/assets.json +++ b/messages/zh/assets.json @@ -12,6 +12,8 @@ "confirmProfiles": "角色档案待确认", "confirmHint": "请确认以下角色档案后生成外貌描述", "confirmAll": "全部确认 ({count})", + "pendingProfilesBanner": "AI 选角完成", + "pendingProfilesHint": "确认档案后自动生成角色形象", "assetsTitle": "资产分析", "characterAssets": "角色资产", "locationAssets": "场景资产", diff --git a/messages/zh/progress.json b/messages/zh/progress.json index 7898230..5010522 100644 --- a/messages/zh/progress.json +++ b/messages/zh/progress.json @@ -125,6 +125,7 @@ "streamStep": { "analyzeCharacters": "角色分析", "analyzeLocations": "场景分析", + "analyzeProps": "道具分析", "splitClips": "片段切分", "screenplayConversion": "剧本转换", "storyboardPlan": "分镜规划", diff --git a/package-lock.json b/package-lock.json index d91ee0e..80ae2fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "cos-nodejs-sdk-v5": "^2.15.4", "express": "^5.2.1", "file-saver": "^2.0.5", + "geist": "^1.7.0", "ioredis": "^5.9.2", "jsonrepair": "^3.13.2", "jszip": "^3.10.1", @@ -10207,6 +10208,15 @@ "node": ">=18" } }, + "node_modules/geist": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/geist/-/geist-1.7.0.tgz", + "integrity": "sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==", + "license": "SIL OPEN FONT LICENSE", + "peerDependencies": { + "next": ">=13.2.0" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz", diff --git a/package.json b/package.json index af8f596..c2d46b0 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "cos-nodejs-sdk-v5": "^2.15.4", "express": "^5.2.1", "file-saver": "^2.0.5", + "geist": "^1.7.0", "ioredis": "^5.9.2", "jsonrepair": "^3.13.2", "jszip": "^3.10.1", diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 7feb586..d106be3 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Script from "next/script"; -import { Geist, Geist_Mono } from "next/font/google"; +import { GeistSans } from 'geist/font/sans'; +import { GeistMono } from 'geist/font/mono'; import { NextIntlClientProvider } from 'next-intl'; import { getMessages, getTranslations } from 'next-intl/server'; import { notFound } from 'next/navigation'; @@ -9,16 +10,6 @@ import { Providers } from "./providers"; import { locales } from '@/i18n/routing'; -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); - type SupportedLocale = (typeof locales)[number] @@ -72,7 +63,7 @@ export default async function LocaleLayout({ )} diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetsStage.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetsStage.tsx index f960322..bf59db5 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetsStage.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/AssetsStage.tsx @@ -49,7 +49,6 @@ import LocationSection from './assets/LocationSection' import AssetToolbar from './assets/AssetToolbar' import AssetFilterBar, { type AssetKindFilter } from './assets/AssetFilterBar' import AssetsStageStatusOverlays from './assets/AssetsStageStatusOverlays' -import UnconfirmedProfilesSection from './assets/UnconfirmedProfilesSection' import AssetsStageModals from './assets/AssetsStageModals' interface AssetsStageProps { @@ -207,12 +206,9 @@ export default function AssetsStage({ // 批量生成 const { isBatchSubmitting, - batchProgress, activeTaskKeys, registerTransientTaskKey, clearTransientTaskKey, - handleGenerateAllImages, - handleRegenerateAllImages } = useBatchGeneration({ projectId, handleGenerateImage @@ -391,9 +387,6 @@ export default function AssetsStage({ isBatchSubmitting={isBatchSubmitting} isAnalyzingAssets={isAnalyzingAssets} isGlobalAnalyzing={isGlobalAnalyzing} - batchProgress={batchProgress} - onGenerateAll={handleGenerateAllImages} - onRegenerateAll={handleRegenerateAllImages} onGlobalAnalyze={handleGlobalAnalyze} episodeId={episodeFilter} onEpisodeChange={setEpisodeFilter} @@ -412,22 +405,6 @@ export default function AssetsStage({ }} /> - - {(kindFilter === 'all' || kindFilter === 'character') && ( )} {(kindFilter === 'all' || kindFilter === 'location') && ( diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetFilterBar.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetFilterBar.tsx index cdef4de..0a97072 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetFilterBar.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetFilterBar.tsx @@ -38,11 +38,15 @@ export default function AssetFilterBar({ return (
- +
+ +
) } diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar.tsx index f929967..1f59856 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar.tsx @@ -2,16 +2,14 @@ import { useState, useRef, useEffect, useCallback } from 'react' import { createPortal } from 'react-dom' import { useTranslations } from 'next-intl' -import { useRefreshProjectAssets, useProjectAssets, useProjectData } from '@/lib/query/hooks' -import TaskStatusInline from '@/components/task/TaskStatusInline' -import { resolveTaskPresentationState } from '@/lib/task/presentation' +import { useProjectAssets, useProjectData } from '@/lib/query/hooks' import { AppIcon } from '@/components/ui/icons' import JSZip from 'jszip' import { logError as _logError } from '@/lib/logging/core' /** * AssetToolbar - 资产管理工具栏组件 - * 从 AssetsStage.tsx 提取,负责批量操作和刷新按钮 + * 从 AssetsStage.tsx 提取,负责资产统计与顶部操作 */ interface EpisodeOption { @@ -29,9 +27,6 @@ interface AssetToolbarProps { isBatchSubmitting: boolean isAnalyzingAssets: boolean isGlobalAnalyzing?: boolean - batchProgress: { current: number; total: number } - onGenerateAll: () => void - onRegenerateAll: () => void onGlobalAnalyze?: () => void /** Episode filter */ episodeId: string | null @@ -166,30 +161,17 @@ export default function AssetToolbar({ isBatchSubmitting, isAnalyzingAssets, isGlobalAnalyzing = false, - batchProgress, - onGenerateAll, - onRegenerateAll, onGlobalAnalyze, episodeId, onEpisodeChange, episodes, }: AssetToolbarProps) { - const onRefresh = useRefreshProjectAssets(projectId) const t = useTranslations('assets') const { data: assets } = useProjectAssets(projectId) const { data: projectData } = useProjectData(projectId) const projectName = projectData?.name const [isDownloading, setIsDownloading] = useState(false) - const assetTaskRunningState = isBatchSubmitting - ? resolveTaskPresentationState({ - phase: 'processing', - intent: 'generate', - resource: 'image', - hasOutput: true, - }) - : null - const handleDownloadAll = async () => { const characters = assets?.characters ?? [] const locations = assets?.locations ?? [] @@ -297,39 +279,6 @@ export default function AssetToolbar({ )}
- - - {/* 打包下载按钮 */} + )}
- {/* 删除按钮 */} - {onDelete && ( + + {/* 档案摘要 */} +
+
+ {t('characterProfile.summary.gender')} + {profileData.gender} +
+
+ {t('characterProfile.summary.age')} + {profileData.age_range} +
+
+ {t('characterProfile.summary.era')} + {profileData.era_period} +
+
+ {t('characterProfile.summary.class')} + {profileData.social_class} +
+ {profileData.occupation && ( +
+ {t('characterProfile.summary.occupation')} + {profileData.occupation} +
+ )} +
+ {t('characterProfile.summary.personality')} +
+ {profileData.personality_tags.map((tag, i) => ( + + {tag} + + ))} +
+
+
+ {t('characterProfile.summary.costume')} + + {'●'.repeat(profileData.costume_tier)}{'○'.repeat(5 - profileData.costume_tier)} + +
+ {profileData.primary_identifier && ( +
+ {t('characterProfile.summary.identifier')} + {profileData.primary_identifier} +
+ )} +
+ + {/* 操作按钮 */} +
+ {onUseExisting && ( + + )} + - )} -
- - {/* 档案摘要 */} -
-
- {t('characterProfile.summary.gender')} - {profileData.gender}
-
- {t('characterProfile.summary.age')} - {profileData.age_range} -
-
- {t('characterProfile.summary.era')} - {profileData.era_period} -
-
- {t('characterProfile.summary.class')} - {profileData.social_class} -
- {profileData.occupation && ( -
- {t('characterProfile.summary.occupation')} - {profileData.occupation} -
- )} -
- {t('characterProfile.summary.personality')} -
- {profileData.personality_tags.map((tag, i) => ( - - {tag} - - ))} -
-
-
- {t('characterProfile.summary.costume')} - - {'●'.repeat(profileData.costume_tier)} - -
- {profileData.primary_identifier && ( -
- {t('characterProfile.summary.identifier')} - {profileData.primary_identifier} -
- )} -
- - {/* 操作按钮 */} -
- - {onUseExisting && ( - - )} -
) diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterSection.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterSection.tsx index 6de0af2..79049f6 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterSection.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/CharacterSection.tsx @@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl' import { useEffect, useMemo, useRef, useState } from 'react' import TaskStatusInline from '@/components/task/TaskStatusInline' import { resolveTaskPresentationState } from '@/lib/task/presentation' +import type { TaskPresentationState } from '@/lib/task/presentation' import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants' /** @@ -11,11 +12,14 @@ import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants' * 从 AssetsStage.tsx 提取,负责角色列表的展示和操作 * * 🔥 V6.5 重构:内部直接订阅 useProjectAssets,消除 props drilling + * 🔥 V7 重构:待确认角色档案内嵌显示,不再使用独立 Section */ import { Character, CharacterAppearance } from '@/types/project' import { useProjectAssets } from '@/lib/query/hooks/useProjectAssets' import CharacterCard from './CharacterCard' +import CharacterProfileCard from './CharacterProfileCard' +import { parseProfileData } from '@/types/character-profile' import { AppIcon } from '@/components/ui/icons' interface CharacterSectionProps { @@ -49,6 +53,17 @@ interface CharacterSectionProps { getAppearances: (character: Character) => CharacterAppearance[] /** 分集筛选:仅显示指定 ID 的角色,null 表示显示全部 */ filterIds?: Set | null + // 🔥 V7:待确认角色档案(内嵌到 CharacterSection) + unconfirmedCharacters: Character[] + isConfirmingCharacter: (characterId: string) => boolean + deletingCharacterId: string | null + batchConfirming: boolean + batchConfirmingState: TaskPresentationState | null + onBatchConfirm: () => void + onEditProfile: (characterId: string, characterName: string) => void + onConfirmProfile: (characterId: string) => void + onUseExistingProfile: (characterId: string) => void + onDeleteProfile: (characterId: string) => void } export default function CharacterSection({ @@ -78,6 +93,17 @@ export default function CharacterSection({ onCopyFromGlobal, getAppearances, filterIds = null, + // 🔥 V7:待确认角色 + unconfirmedCharacters, + isConfirmingCharacter, + deletingCharacterId, + batchConfirming, + batchConfirmingState, + onBatchConfirm, + onEditProfile, + onConfirmProfile, + onUseExistingProfile, + onDeleteProfile, }: CharacterSectionProps) { const t = useTranslations('assets') const analyzingAssetsState = isAnalyzingAssets @@ -91,9 +117,17 @@ export default function CharacterSection({ const { data: assets } = useProjectAssets(projectId) const allCharacters: Character[] = useMemo(() => assets?.characters ?? [], [assets?.characters]) + // 🔥 V7:排除待确认角色,避免同一角色在待确认区与已确认网格中重复出现 + const unconfirmedIds = useMemo( + () => new Set(unconfirmedCharacters.map((c) => c.id)), + [unconfirmedCharacters], + ) const characters: Character[] = useMemo( - () => filterIds ? allCharacters.filter((c) => filterIds.has(c.id)) : allCharacters, - [allCharacters, filterIds], + () => { + const base = filterIds ? allCharacters.filter((c) => filterIds.has(c.id)) : allCharacters + return base.filter((c) => !unconfirmedIds.has(c.id)) + }, + [allCharacters, filterIds, unconfirmedIds], ) const [highlightedCharacterId, setHighlightedCharacterId] = useState(null) const scrollAnimationRef = useRef(null) @@ -179,6 +213,54 @@ export default function CharacterSection({ + {/* 🔥 V7:待确认角色档案 - 内嵌引导横幅 */} + {unconfirmedCharacters.length > 0 && ( +
+ {/* 引导横幅 */} +
+
+ + + + {t('stage.pendingProfilesBanner')} + {t('stage.pendingProfilesHint')} +
+ +
+ {/* 待确认卡片网格 */} +
+ {unconfirmedCharacters.map((character) => { + const profileData = parseProfileData(character.profileData!) + if (!profileData) return null + return ( + 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} + /> + ) + })} +
+
+ )} + {/* 按角色分组显示:外层 grid 让多角色并排 */}
{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' : ''}`} > {/* 角色标题 */} -
+

{character.name}

diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard.tsx index acf501b..c1a14e3 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationCard.tsx @@ -20,6 +20,7 @@ import { getImageGenerationCountOptions } from '@/lib/image-generation/count' import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count' import { countGeneratedImageSlots, resolveDisplayImageSlots } from '@/lib/image-generation/slot-state' import { AppIcon } from '@/components/ui/icons' +import { canGenerateLocationBackedAsset } from './location-backed-asset' interface LocationCardProps { location: Location @@ -358,7 +359,7 @@ export default function LocationCard({ ) const firstImage = location.images?.[0] - const hasDescription = !!firstImage?.description + const canGenerate = canGenerateLocationBackedAsset(location) return (
@@ -395,7 +396,7 @@ export default function LocationCard({ mode="compact" currentImageUrl={currentImageUrl} isTaskRunning={isTaskRunning} - hasDescription={hasDescription} + canGenerate={canGenerate} generationCount={generationCount} onGenerationCountChange={setGenerationCount} onGenerate={onGenerate} diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationSection.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationSection.tsx index 7ed4eed..cc1bc3b 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationSection.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/LocationSection.tsx @@ -13,6 +13,7 @@ import { Location, Prop } from '@/types/project' import { useProjectAssets } from '@/lib/query/hooks/useProjectAssets' import LocationCard from './LocationCard' import { AppIcon } from '@/components/ui/icons' +import { resolveLocationBackedGenerateType } from './location-backed-asset' interface LocationSectionProps { // 🔥 V6.5 删除:locations prop - 现在内部直接订阅 @@ -26,7 +27,7 @@ interface LocationSectionProps { onDeleteLocation: (locationId: string) => void onEditLocation: (location: Location | Prop) => void // 🔥 V6.6 重构:重命名为 handleGenerateImage - handleGenerateImage: (type: 'character' | 'location', id: string, appearanceId?: string, count?: number) => Promise + handleGenerateImage: (type: 'character' | 'location' | 'prop', id: string, appearanceId?: string, count?: number) => Promise onSelectImage: (locationId: string, imageIndex: number | null) => void onConfirmSelection: (locationId: string) => void onRegenerateSingle: (locationId: string, imageIndex: number) => Promise @@ -68,6 +69,7 @@ export default function LocationSection({ : assets?.locations ?? [] const locations = filterIds ? allLocations.filter((l) => filterIds.has(l.id)) : allLocations const assetKey = assetType === 'prop' ? 'prop' : 'location' + const generateType = resolveLocationBackedGenerateType(assetType) return (
@@ -135,7 +137,7 @@ export default function LocationSection({ onGenerate={(count) => { const taskKey = `location-${location.id}-group` onRegisterTransientTaskKey(taskKey) - void handleGenerateImage('location', location.id, undefined, count).catch(() => { + void handleGenerateImage(generateType, location.id, undefined, count).catch(() => { onClearTaskKey(taskKey) }) }} diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/UnconfirmedProfilesSection.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/UnconfirmedProfilesSection.tsx deleted file mode 100644 index ad8401d..0000000 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/UnconfirmedProfilesSection.tsx +++ /dev/null @@ -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 ( -
-
-
-

{confirmTitle}

-

{confirmHint}

-
- -
-
- {unconfirmedCharacters.map((character) => { - const profileData = parseProfileData(character.profileData!) - if (!profileData) return null - return ( - 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} - /> - ) - })} -
-
- ) -} diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/VoiceSettings.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/VoiceSettings.tsx index f6246a9..8af9dfc 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/VoiceSettings.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/VoiceSettings.tsx @@ -114,94 +114,110 @@ export default function VoiceSettings({ ? 'border border-[var(--glass-stroke-base)] rounded-xl p-3 bg-[var(--glass-bg-surface-strong)]' : 'mt-4 border border-[var(--glass-stroke-base)] rounded-xl p-4 bg-[var(--glass-bg-surface-strong)]' - const headerClass = compact - ? 'flex items-center gap-2 mb-2 pb-2 border-b' - : 'flex items-center gap-2 mb-3 pb-2 border-b' const iconSize = compact ? 'w-5 h-5' : 'w-6 h-6' const innerIconSize = compact ? 'w-3 h-3' : 'w-3.5 h-3.5' + const [isExpanded, setIsExpanded] = useState(false) + return (
-
-
- + {/* 折叠标题行 - 点击展开/收起 */} +
+ + - {/* 隐藏的音频文件输入 */} - + {/* 展开内容 */} + {isExpanded && ( +
+ {/* 隐藏的音频文件输入 */} + -
- {/* 上传音频按钮 */} - +
+ {/* 上传音频按钮 */} + - {/* 从资产中心选择按钮 */} - {onSelectFromHub && ( - - )} - - {/* AI设计按钮 */} - {onVoiceDesign && ( - - )} -
- - {/* 试听按钮 - 仅在有音频时显示 */} - {hasCustomVoice && ( - + )} + + {/* AI设计按钮 */} + {onVoiceDesign && ( + )} - {isPreviewingVoice ? t('tts.pause') : t('tts.preview')}
- + + {/* 试听按钮 - 仅在有音频时显示 */} + {hasCustomVoice && ( + + )} +
)}
) diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-backed-asset.ts b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-backed-asset.ts new file mode 100644 index 0000000..7fc1b70 --- /dev/null +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-backed-asset.ts @@ -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 +} diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-card/LocationCardActions.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-card/LocationCardActions.tsx index 22b25ff..62f46a5 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-card/LocationCardActions.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-card/LocationCardActions.tsx @@ -19,7 +19,7 @@ type LocationCardActionsProps = mode: 'compact' currentImageUrl: string | null | undefined isTaskRunning: boolean - hasDescription: boolean + canGenerate: boolean generationCount: number onGenerationCountChange: (value: number) => void onGenerate: (count?: number) => void @@ -67,7 +67,7 @@ export default function LocationCardActions(props: LocationCardActionsProps) { options={getImageGenerationCountOptions('location')} onValueChange={props.onGenerationCountChange} onClick={() => props.onGenerate(props.generationCount)} - disabled={!props.hasDescription} + disabled={!props.canGenerate} ariaLabel={t('image.selectCount')} className="glass-btn-base glass-btn-primary flex w-full items-center justify-center gap-1 py-1 text-xs disabled:opacity-50" selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-xs font-semibold text-current outline-none cursor-pointer leading-none transition-colors" diff --git a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewAssetsPanel.tsx b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewAssetsPanel.tsx index be7eef0..cd5d397 100644 --- a/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewAssetsPanel.tsx +++ b/src/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewAssetsPanel.tsx @@ -321,6 +321,7 @@ export default function ScriptViewAssetsPanel({ const hasCharacterSelectionChanges = !setsEqual(initialAppearanceKeys, pendingAppearanceKeys) || hasCharacterLabelChanges const hasLocationSelectionChanges = !setsEqual(new Set(activeLocationIds), pendingLocationIds) || hasLocationLabelChanges const hasPropSelectionChanges = !setsEqual(new Set(activePropIds), pendingPropIds) + const hasProjectProps = props.length > 0 const handleConfirmCharacterSelection = async () => { if (isSavingCharacterSelection) return @@ -754,6 +755,7 @@ export default function ScriptViewAssetsPanel({ )}
+ {hasProjectProps ? (

道具 ({activePropIds.length})

@@ -855,6 +857,7 @@ export default function ScriptViewAssetsPanel({
)}
+ ) : null}
@@ -874,7 +877,7 @@ export default function ScriptViewAssetsPanel({ diff --git a/src/components/ui/SegmentedControl.tsx b/src/components/ui/SegmentedControl.tsx index 59c476b..f2f06d6 100644 --- a/src/components/ui/SegmentedControl.tsx +++ b/src/components/ui/SegmentedControl.tsx @@ -9,10 +9,14 @@ export interface SegmentedControlOption { label: ReactNode } +type SegmentedControlLayout = 'fill' | 'compact' + interface SegmentedControlProps { options: SegmentedControlOption[] value: T onChange: (value: T) => void + /** Layout mode: stretch to container or keep a compact left-aligned width */ + layout?: SegmentedControlLayout /** Extra className on the outer container */ className?: string } @@ -31,10 +35,12 @@ export function SegmentedControl({ options, value, onChange, + layout = 'fill', className = '', }: SegmentedControlProps) { const gridRef = useRef(null) const [indicator, setIndicator] = useState<{ left: number; width: number }>({ left: 0, width: 0 }) + const isCompact = layout === 'compact' useEffect(() => { if (!gridRef.current) return @@ -47,11 +53,13 @@ export function SegmentedControl({ }, [value, options]) return ( -
+
{/* Sliding pill indicator */}
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> { if (locationIds.length === 0) { return new Map() @@ -194,6 +210,11 @@ export async function createProjectLocationBackedAsset(input: { NOW() ) `) + await seedProjectLocationBackedImageSlots({ + locationId: id, + fallbackDescription: input.summary, + descriptions: [input.summary], + }) return { id } } @@ -229,9 +250,52 @@ export async function createGlobalLocationBackedAsset(input: { NOW() ) `) + await seedGlobalLocationBackedImageSlots({ + locationId: id, + fallbackDescription: input.summary, + descriptions: [input.summary], + }) return { id } } +export async function seedProjectLocationBackedImageSlots(input: { + locationId: string + fallbackDescription: string + descriptions?: string[] +}): Promise { + 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 { + 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 { await prisma.$transaction([ prisma.$executeRaw(Prisma.sql`DELETE FROM location_images WHERE locationId = ${assetId}`), diff --git a/src/lib/query/hooks/useProjectAssets.ts b/src/lib/query/hooks/useProjectAssets.ts index e13fb4a..4a87d5e 100644 --- a/src/lib/query/hooks/useProjectAssets.ts +++ b/src/lib/query/hooks/useProjectAssets.ts @@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query' import { queryKeys } from '../keys' import type { Character, Location, MediaRef, Prop } from '@/types/project' import { useAssets } from './useAssets' +import type { AssetGroupMap } from '@/lib/assets/grouping' import { groupAssetsByKind } from '@/lib/assets/grouping' // ============ 类型定义 ============ @@ -14,6 +15,96 @@ export interface ProjectAssetsData { props: Prop[] } +function mapCharacterAssetToProjectCharacter(asset: AssetGroupMap['character'][number]): Character { + return { + id: asset.id, + name: asset.name, + aliases: null, + introduction: asset.introduction, + appearances: asset.variants.map((variant) => ({ + id: variant.id, + appearanceIndex: variant.index, + changeReason: variant.label, + description: variant.description, + descriptions: null, + imageUrl: variant.renders.find((render) => render.isSelected)?.imageUrl + ?? variant.renders[0]?.imageUrl + ?? null, + media: variant.renders.find((render) => render.isSelected)?.media + ?? variant.renders[0]?.media + ?? null, + imageUrls: variant.renders.map((render) => render.imageUrl ?? '').filter((value) => value.length > 0), + imageMedias: variant.renders.map((render) => render.media).filter((media): media is MediaRef => !!media), + previousImageUrl: variant.renders[0]?.previousImageUrl ?? null, + previousMedia: variant.renders[0]?.previousMedia ?? null, + previousImageUrls: variant.renders.map((render) => render.previousImageUrl ?? '').filter((value) => value.length > 0), + previousImageMedias: variant.renders.map((render) => render.previousMedia).filter((media): media is MediaRef => !!media), + previousDescription: null, + previousDescriptions: null, + selectedIndex: variant.selectionState.selectedRenderIndex, + imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning, + imageErrorMessage: variant.taskState.lastError?.message ?? null, + lastError: variant.taskState.lastError ?? asset.taskState.lastError, + })), + voiceType: asset.voice.voiceType, + voiceId: asset.voice.voiceId, + customVoiceUrl: asset.voice.customVoiceUrl, + media: asset.voice.media, + profileData: asset.profileData, + profileConfirmed: asset.profileConfirmed ?? undefined, + profileConfirmTaskRunning: asset.profileTaskState.isRunning, + } +} + +function mapLocationVariantToProjectImage( + asset: AssetGroupMap['location'][number] | AssetGroupMap['prop'][number], + variant: AssetGroupMap['location'][number]['variants'][number], +) { + const render = variant.renders[0] ?? null + return { + id: variant.id, + imageIndex: variant.index, + description: variant.description, + imageUrl: render?.imageUrl ?? null, + media: render?.media ?? null, + previousImageUrl: render?.previousImageUrl ?? null, + previousMedia: render?.previousMedia ?? null, + previousDescription: null, + isSelected: render?.isSelected ?? false, + imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning, + imageErrorMessage: variant.taskState.lastError?.message ?? null, + lastError: variant.taskState.lastError ?? asset.taskState.lastError, + } +} + +function mapLocationAssetToProjectLocation(asset: AssetGroupMap['location'][number]): Location { + return { + id: asset.id, + name: asset.name, + summary: asset.summary, + selectedImageId: asset.selectedVariantId, + images: asset.variants.map((variant) => mapLocationVariantToProjectImage(asset, variant)), + } +} + +function mapPropAssetToProjectProp(asset: AssetGroupMap['prop'][number]): Prop { + return { + id: asset.id, + name: asset.name, + summary: asset.summary, + selectedImageId: asset.selectedVariantId, + images: asset.variants.map((variant) => mapLocationVariantToProjectImage(asset, variant)), + } +} + +export function mapAssetGroupsToProjectAssetsData(groups: AssetGroupMap): ProjectAssetsData { + return { + characters: groups.character.map(mapCharacterAssetToProjectCharacter), + locations: groups.location.map(mapLocationAssetToProjectLocation), + props: groups.prop.map(mapPropAssetToProjectProp), + } +} + // ============ 查询 Hooks ============ /** @@ -25,92 +116,7 @@ export function useProjectAssets(projectId: string | null) { projectId, }) const groups = groupAssetsByKind(assetsQuery.data) - const data: ProjectAssetsData = { - characters: groups.character.map((asset) => ({ - id: asset.id, - name: asset.name, - aliases: null, - introduction: asset.introduction, - appearances: asset.variants.map((variant) => ({ - id: variant.id, - appearanceIndex: variant.index, - changeReason: variant.label, - description: variant.description, - descriptions: null, - imageUrl: variant.renders.find((render) => render.isSelected)?.imageUrl - ?? variant.renders[0]?.imageUrl - ?? null, - media: variant.renders.find((render) => render.isSelected)?.media - ?? variant.renders[0]?.media - ?? null, - imageUrls: variant.renders.map((render) => render.imageUrl ?? '').filter((value) => value.length > 0), - imageMedias: variant.renders.map((render) => render.media).filter((media): media is MediaRef => !!media), - previousImageUrl: variant.renders[0]?.previousImageUrl ?? null, - previousMedia: variant.renders[0]?.previousMedia ?? null, - previousImageUrls: variant.renders.map((render) => render.previousImageUrl ?? '').filter((value) => value.length > 0), - previousImageMedias: variant.renders.map((render) => render.previousMedia).filter((media): media is MediaRef => !!media), - previousDescription: null, - previousDescriptions: null, - selectedIndex: variant.selectionState.selectedRenderIndex, - imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning, - imageErrorMessage: variant.taskState.lastError?.message ?? null, - lastError: variant.taskState.lastError ?? asset.taskState.lastError, - })), - voiceType: asset.voice.voiceType, - voiceId: asset.voice.voiceId, - customVoiceUrl: asset.voice.customVoiceUrl, - media: asset.voice.media, - profileData: null, - profileConfirmed: asset.profileConfirmed ?? undefined, - profileConfirmTaskRunning: asset.profileTaskState.isRunning, - })), - locations: groups.location.map((asset) => ({ - id: asset.id, - name: asset.name, - summary: asset.summary, - selectedImageId: asset.selectedVariantId, - images: asset.variants.map((variant) => { - const render = variant.renders[0] ?? null - return { - id: variant.id, - imageIndex: variant.index, - description: variant.description, - imageUrl: render?.imageUrl ?? null, - media: render?.media ?? null, - previousImageUrl: render?.previousImageUrl ?? null, - previousMedia: render?.previousMedia ?? null, - previousDescription: null, - isSelected: render?.isSelected ?? false, - imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning, - imageErrorMessage: variant.taskState.lastError?.message ?? null, - lastError: variant.taskState.lastError ?? asset.taskState.lastError, - } - }), - })), - props: groups.prop.map((asset) => ({ - id: asset.id, - name: asset.name, - summary: asset.summary, - selectedImageId: asset.selectedVariantId, - images: asset.variants.map((variant) => { - const render = variant.renders[0] ?? null - return { - id: variant.id, - imageIndex: variant.index, - description: variant.description, - imageUrl: render?.imageUrl ?? null, - media: render?.media ?? null, - previousImageUrl: render?.previousImageUrl ?? null, - previousMedia: render?.previousMedia ?? null, - previousDescription: null, - isSelected: render?.isSelected ?? false, - imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning, - imageErrorMessage: variant.taskState.lastError?.message ?? null, - lastError: variant.taskState.lastError ?? asset.taskState.lastError, - } - }), - })), - } + const data = mapAssetGroupsToProjectAssetsData(groups) return { ...assetsQuery, diff --git a/src/lib/workers/handlers/analyze-global-persist.ts b/src/lib/workers/handlers/analyze-global-persist.ts index cf729bc..d3dfa70 100644 --- a/src/lib/workers/handlers/analyze-global-persist.ts +++ b/src/lib/workers/handlers/analyze-global-persist.ts @@ -9,6 +9,7 @@ import { type AnalyzeGlobalPropsData, type CharacterBrief, } from './analyze-global-parse' +import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets' export type AnalyzeGlobalStats = { totalChunks: number @@ -48,10 +49,6 @@ export async function persistAnalyzeGlobalChunk(params: { existingPropNames: string[] stats: AnalyzeGlobalStats }) { - const locationModel = prisma.novelPromotionLocation as unknown as { - create: (args: { data: Record; select?: Record }) => Promise<{ id: string }> - } - for (const char of params.charactersData.new_characters || []) { const name = readText(char.name).trim() const aliases = toStringArray(char.aliases) @@ -181,15 +178,11 @@ export async function persistAnalyzeGlobalChunk(params: { }, }) - for (let j = 0; j < cleanDescriptions.length; j += 1) { - await prisma.locationImage.create({ - data: { - locationId: created.id, - imageIndex: j, - description: cleanDescriptions[j], - }, - }) - } + await seedProjectLocationBackedImageSlots({ + locationId: created.id, + descriptions: cleanDescriptions, + fallbackDescription: summary || name, + }) params.existingLocationNames.push(name) params.existingLocationInfo.push(summary ? `${name}(${summary})` : name) @@ -214,7 +207,7 @@ export async function persistAnalyzeGlobalChunk(params: { } try { - await locationModel.create({ + const created = await prisma.novelPromotionLocation.create({ data: { novelPromotionProjectId: params.projectInternalId, name, @@ -222,6 +215,11 @@ export async function persistAnalyzeGlobalChunk(params: { assetKind: 'prop', }, }) + await seedProjectLocationBackedImageSlots({ + locationId: created.id, + descriptions: [summary], + fallbackDescription: summary, + }) params.existingPropNames.push(name) params.stats.newProps += 1 } catch { diff --git a/src/lib/workers/handlers/analyze-novel.ts b/src/lib/workers/handlers/analyze-novel.ts index a05ddeb..6f21972 100644 --- a/src/lib/workers/handlers/analyze-novel.ts +++ b/src/lib/workers/handlers/analyze-novel.ts @@ -10,6 +10,7 @@ import { createWorkerLLMStreamCallbacks, createWorkerLLMStreamContext } from './ import type { TaskJobData } from '@/lib/task/types' import { buildPrompt, PROMPT_IDS } from '@/lib/prompt-i18n' import { resolveAnalysisModel } from './resolve-analysis-model' +import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets' function readAssetKind(value: Record): string { return typeof value.assetKind === 'string' ? value.assetKind : 'location' @@ -43,9 +44,6 @@ function parseJsonResponse(responseText: string): Record { export async function handleAnalyzeNovelTask(job: Job) { const payload = (job.data.payload || {}) as Record const projectId = job.data.projectId - const locationModel = prisma.novelPromotionLocation as unknown as { - create: (args: { data: Record; select?: Record }) => Promise<{ id: string }> - } const project = await prisma.project.findUnique({ where: { id: projectId }, @@ -312,7 +310,7 @@ export async function handleAnalyzeNovelTask(job: Job) { ) if (existsInLibrary) continue - const created = await locationModel.create({ + const created = await prisma.novelPromotionLocation.create({ data: { novelPromotionProjectId: novelData.id, name, @@ -322,15 +320,11 @@ export async function handleAnalyzeNovelTask(job: Job) { }) const cleanDescriptions = descriptions.map((value) => removeLocationPromptSuffix(value || '')) - for (let i = 0; i < cleanDescriptions.length; i += 1) { - await prisma.locationImage.create({ - data: { - locationId: created.id, - imageIndex: i, - description: cleanDescriptions[i], - }, - }) - } + await seedProjectLocationBackedImageSlots({ + locationId: created.id, + descriptions: cleanDescriptions, + fallbackDescription: readText(item.summary) || name, + }) createdLocations.push(created) } @@ -349,7 +343,7 @@ export async function handleAnalyzeNovelTask(job: Job) { const normalizedName = name.toLowerCase() if (existingPropNameSet.has(normalizedName)) continue - const created = await locationModel.create({ + const created = await prisma.novelPromotionLocation.create({ data: { novelPromotionProjectId: novelData.id, name, @@ -358,6 +352,11 @@ export async function handleAnalyzeNovelTask(job: Job) { }, select: { id: true }, }) + await seedProjectLocationBackedImageSlots({ + locationId: created.id, + descriptions: [summary], + fallbackDescription: summary, + }) existingPropNameSet.add(normalizedName) createdProps.push(created) } diff --git a/src/lib/workers/handlers/character-profile.ts b/src/lib/workers/handlers/character-profile.ts index 405cda7..cab8e5b 100644 --- a/src/lib/workers/handlers/character-profile.ts +++ b/src/lib/workers/handlers/character-profile.ts @@ -129,27 +129,50 @@ async function handleConfirmProfile( } await assertTaskActive(job, 'character_profile_confirm_persist') + const appearanceRows: Array<{ + characterId: string + appearanceIndex: number + changeReason: string + description: string + descriptions: string + imageUrls: string + previousImageUrls: string + }> = [] + for (let appIndex = 0; appIndex < appearances.length; appIndex++) { const app = appearances[appIndex] await assertTaskActive(job, 'character_profile_confirm_create_appearance') const descriptions = Array.isArray(app.descriptions) ? app.descriptions : [] const normalizedDescriptions = descriptions.map((item) => readText(item)).filter(Boolean) - await prisma.characterAppearance.create({ - data: { - characterId: character.id, - appearanceIndex: appIndex, - changeReason: readText(app.change_reason) || '初始形象', - description: normalizedDescriptions[0] || '', - descriptions: JSON.stringify(normalizedDescriptions), - imageUrls: encodeImageUrls([]), - previousImageUrls: encodeImageUrls([]), - }, + appearanceRows.push({ + characterId: character.id, + appearanceIndex: appIndex, + changeReason: readText(app.change_reason) || '初始形象', + description: normalizedDescriptions[0] || '', + descriptions: JSON.stringify(normalizedDescriptions), + imageUrls: encodeImageUrls([]), + previousImageUrls: encodeImageUrls([]), }) } - await prisma.novelPromotionCharacter.update({ - where: { id: characterId }, - data: { profileConfirmed: true }, + await prisma.$transaction(async (tx) => { + await tx.characterAppearance.deleteMany({ + where: { characterId: character.id }, + }) + + for (const appearanceRow of appearanceRows) { + await tx.characterAppearance.create({ + data: appearanceRow, + }) + } + + await tx.novelPromotionCharacter.update({ + where: { id: characterId }, + data: { + profileData: finalProfileData, + profileConfirmed: true, + }, + }) }) if (!suppressProgress) { diff --git a/src/lib/workers/handlers/story-to-script-helpers.ts b/src/lib/workers/handlers/story-to-script-helpers.ts index 16db894..a56b103 100644 --- a/src/lib/workers/handlers/story-to-script-helpers.ts +++ b/src/lib/workers/handlers/story-to-script-helpers.ts @@ -1,6 +1,7 @@ import { prisma } from '@/lib/prisma' import { removeLocationPromptSuffix } from '@/lib/constants' import type { StoryToScriptClipCandidate } from '@/lib/novel-promotion/story-to-script/orchestrator' +import { seedProjectLocationBackedImageSlots } from '@/lib/assets/services/location-backed-assets' export type AnyObj = Record @@ -118,15 +119,11 @@ export async function persistAnalyzedLocations(params: { }) const cleanDescriptions = mergedDescriptions.map((desc) => removeLocationPromptSuffix(desc || '')) - for (let i = 0; i < cleanDescriptions.length; i += 1) { - await prisma.locationImage.create({ - data: { - locationId: location.id, - imageIndex: i, - description: cleanDescriptions[i], - }, - }) - } + await seedProjectLocationBackedImageSlots({ + locationId: location.id, + descriptions: cleanDescriptions, + fallbackDescription: asString(item.summary) || name, + }) params.existingNames.add(key) created.push(location) @@ -141,9 +138,6 @@ export async function persistAnalyzedProps(params: { analyzedProps: Record[] }) { const created: Array<{ id: string; name: string }> = [] - const locationModel = prisma.novelPromotionLocation as unknown as { - create: (args: { data: Record; select: { id: true; name: true } }) => Promise<{ id: string; name: string }> - } for (const item of params.analyzedProps) { const name = asString(item.name).trim() @@ -153,7 +147,7 @@ export async function persistAnalyzedProps(params: { const key = name.toLowerCase() if (params.existingNames.has(key)) continue - const prop = await locationModel.create({ + const prop = await prisma.novelPromotionLocation.create({ data: { novelPromotionProjectId: params.projectInternalId, name, @@ -165,6 +159,11 @@ export async function persistAnalyzedProps(params: { name: true, }, }) + await seedProjectLocationBackedImageSlots({ + locationId: prop.id, + descriptions: [summary], + fallbackDescription: summary, + }) params.existingNames.add(key) created.push(prop) diff --git a/src/styles/animations.css b/src/styles/animations.css index ca85f1b..4b70112 100644 --- a/src/styles/animations.css +++ b/src/styles/animations.css @@ -139,27 +139,25 @@ } /* Page Transition Animation - 页面切换动画 */ +/* 注意:不可使用 transform,因为子组件中有 fixed 定位元素(如 StoryboardStageShell 的悬浮按钮), + transform 会创建新的 containing block 导致 fixed 失效并产生跳动 */ @keyframes pageSlideIn { from { opacity: 0; - transform: translateY(20px); } to { opacity: 1; - transform: translateY(0); } } @keyframes pageSlideOut { from { opacity: 1; - transform: translateY(0); } to { opacity: 0; - transform: translateY(-20px); } } diff --git a/tests/unit/assets/location-backed-assets.test.ts b/tests/unit/assets/location-backed-assets.test.ts index adac22b..9c3bfc3 100644 --- a/tests/unit/assets/location-backed-assets.test.ts +++ b/tests/unit/assets/location-backed-assets.test.ts @@ -4,6 +4,8 @@ const prismaMock = vi.hoisted(() => ({ $queryRaw: vi.fn(), $executeRaw: vi.fn(), $transaction: vi.fn(), + locationImage: { createMany: vi.fn() }, + globalLocationImage: { createMany: vi.fn() }, })) vi.mock('@/lib/prisma', () => ({ @@ -44,4 +46,50 @@ describe('location-backed assets service', () => { expect(imageSql).toContain('FROM location_images') expect(imageSql).toContain('NULL AS previousImageMediaId') }) + + it('seeds an initial project image slot when creating a prop asset', async () => { + const mod = await import('@/lib/assets/services/location-backed-assets') + + const result = await mod.createProjectLocationBackedAsset({ + novelPromotionProjectId: 'novel-project-1', + name: 'Bronze Dagger', + summary: 'Old bronze dagger', + kind: 'prop', + }) + + expect(prismaMock.locationImage.createMany).toHaveBeenCalledWith({ + data: [ + { + locationId: result.id, + imageIndex: 0, + description: 'Old bronze dagger', + }, + ], + }) + }) + + it('seeds multiple project image slots when explicit descriptions are provided', async () => { + const mod = await import('@/lib/assets/services/location-backed-assets') + + await mod.seedProjectLocationBackedImageSlots({ + locationId: 'location-1', + descriptions: ['Night street', 'Rainy alley'], + fallbackDescription: 'Night street', + }) + + expect(prismaMock.locationImage.createMany).toHaveBeenCalledWith({ + data: [ + { + locationId: 'location-1', + imageIndex: 0, + description: 'Night street', + }, + { + locationId: 'location-1', + imageIndex: 1, + description: 'Rainy alley', + }, + ], + }) + }) }) diff --git a/tests/unit/assets/location-backed-generation.test.ts b/tests/unit/assets/location-backed-generation.test.ts new file mode 100644 index 0000000..7273c4f --- /dev/null +++ b/tests/unit/assets/location-backed-generation.test.ts @@ -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') + }) +}) diff --git a/tests/unit/assets/mappers.test.ts b/tests/unit/assets/mappers.test.ts index 5cad6d2..53fde09 100644 --- a/tests/unit/assets/mappers.test.ts +++ b/tests/unit/assets/mappers.test.ts @@ -8,6 +8,7 @@ describe('asset mappers', () => { id: 'character-1', name: '林夏', introduction: '主角', + profileData: JSON.stringify({ archetype: 'lead' }), voiceType: 'custom', voiceId: 'voice-1', customVoiceUrl: 'https://example.com/voice.mp3', @@ -37,6 +38,7 @@ describe('asset mappers', () => { scope: 'project', kind: 'character', introduction: '主角', + profileData: JSON.stringify({ archetype: 'lead' }), profileConfirmed: true, voice: expect.objectContaining({ voiceType: 'custom', diff --git a/tests/unit/components/asset-toolbar.test.ts b/tests/unit/components/asset-toolbar.test.ts new file mode 100644 index 0000000..99a3e9e --- /dev/null +++ b/tests/unit/components/asset-toolbar.test.ts @@ -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 = { + 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('>刷新<') + }) +}) diff --git a/tests/unit/components/llm-stage-stream-card-error.test.ts b/tests/unit/components/llm-stage-stream-card-error.test.ts index 16a56b7..dc1ef52 100644 --- a/tests/unit/components/llm-stage-stream-card-error.test.ts +++ b/tests/unit/components/llm-stage-stream-card-error.test.ts @@ -24,6 +24,9 @@ const messages = { waitingModelOutput: '等待模型输出...', reasoningNotProvided: '该步骤未返回思考过程', }, + streamStep: { + analyzeProps: '道具分析', + }, runtime: { llm: { processing: '模型处理中...', @@ -69,4 +72,27 @@ describe('LLMStageStreamCard error rendering', () => { expect(html).not.toContain('Copy error detail') expect(html).not.toContain('Open feedback form') }) + + it('resolves analyze props progress keys without missing message errors', () => { + Reflect.set(globalThis, 'React', React) + const html = renderWithIntl( + createElement(LLMStageStreamCard, { + title: 'progress.streamStep.analyzeProps', + stages: [{ + id: 'analyze_props', + title: 'progress.streamStep.analyzeProps', + subtitle: 'progress.streamStep.analyzeProps', + status: 'processing', + progress: 35, + }], + activeStageId: 'analyze_props', + activeMessage: 'progress.streamStep.analyzeProps', + outputText: '', + }), + ) + + expect(html).toContain('道具分析') + expect(html).not.toContain('progress.streamStep.analyzeProps') + expect(html).not.toContain('MISSING_MESSAGE') + }) }) diff --git a/tests/unit/components/segmented-control.test.ts b/tests/unit/components/segmented-control.test.ts new file mode 100644 index 0000000..f00c0c9 --- /dev/null +++ b/tests/unit/components/segmented-control.test.ts @@ -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))') + }) +}) diff --git a/tests/unit/prompt-i18n/select-prop-template.test.ts b/tests/unit/prompt-i18n/select-prop-template.test.ts new file mode 100644 index 0000000..6a0147f --- /dev/null +++ b/tests/unit/prompt-i18n/select-prop-template.test.ts @@ -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') + }) +}) diff --git a/tests/unit/query/use-project-assets.test.ts b/tests/unit/query/use-project-assets.test.ts new file mode 100644 index 0000000..49e1b0c --- /dev/null +++ b/tests/unit/query/use-project-assets.test.ts @@ -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, + })) + }) +}) diff --git a/tests/unit/script-view/script-view-assets-panel.test.ts b/tests/unit/script-view/script-view-assets-panel.test.ts new file mode 100644 index 0000000..a057a22 --- /dev/null +++ b/tests/unit/script-view/script-view-assets-panel.test.ts @@ -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 = { + 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(), + 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) => { + 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('当前片段未选择道具') + }) +}) diff --git a/tests/unit/worker/analyze-novel.test.ts b/tests/unit/worker/analyze-novel.test.ts index 64a7059..cf8ac01 100644 --- a/tests/unit/worker/analyze-novel.test.ts +++ b/tests/unit/worker/analyze-novel.test.ts @@ -11,7 +11,10 @@ const prismaMock = vi.hoisted(() => ({ novelPromotionEpisode: { findFirst: vi.fn() }, novelPromotionCharacter: { create: vi.fn(async () => ({ id: 'char-new-1' })) }, novelPromotionLocation: { create: vi.fn(async () => ({ id: 'loc-new-1' })) }, - locationImage: { create: vi.fn(async () => ({})) }, + locationImage: { + create: vi.fn(async () => ({})), + createMany: vi.fn(async () => ({ count: 1 })), + }, })) const llmMock = vi.hoisted(() => ({ @@ -49,6 +52,7 @@ vi.mock('@/lib/prompt-i18n', () => ({ PROMPT_IDS: { NP_AGENT_CHARACTER_PROFILE: 'char', NP_SELECT_LOCATION: 'loc', + NP_SELECT_PROP: 'prop', }, buildPrompt: vi.fn(() => 'analysis-prompt'), })) @@ -75,6 +79,10 @@ describe('worker analyze-novel behavior', () => { beforeEach(() => { vi.clearAllMocks() + prismaMock.novelPromotionLocation.create + .mockResolvedValueOnce({ id: 'loc-new-1' }) + .mockResolvedValueOnce({ id: 'prop-new-1' }) + prismaMock.project.findUnique.mockResolvedValue({ id: 'project-1', mode: 'novel-promotion', @@ -114,6 +122,14 @@ describe('worker analyze-novel behavior', () => { }, ], })) + .mockReturnValueOnce(JSON.stringify({ + props: [ + { + name: '金箍棒', + summary: '一根两头包裹金片的黑铁长棍', + }, + ], + })) }) it('no global text and no episode text -> explicit error', async () => { @@ -137,10 +153,10 @@ describe('worker analyze-novel behavior', () => { success: true, characters: [{ id: 'char-new-1' }], locations: [{ id: 'loc-new-1' }], - props: [], + props: [{ id: 'prop-new-1' }], characterCount: 1, locationCount: 1, - propCount: 0, + propCount: 1, }) expect(prismaMock.novelPromotionCharacter.create).toHaveBeenCalledWith( @@ -163,12 +179,24 @@ describe('worker analyze-novel behavior', () => { }), ) - expect(prismaMock.locationImage.create).toHaveBeenCalledWith({ - data: { - locationId: 'loc-new-1', - imageIndex: 0, - description: '雨夜街道', - }, + expect(prismaMock.locationImage.create).not.toHaveBeenCalled() + expect(prismaMock.locationImage.createMany).toHaveBeenNthCalledWith(1, { + data: [ + { + locationId: 'loc-new-1', + imageIndex: 0, + description: '雨夜街道', + }, + ], + }) + expect(prismaMock.locationImage.createMany).toHaveBeenNthCalledWith(2, { + data: [ + { + locationId: 'prop-new-1', + imageIndex: 0, + description: '一根两头包裹金片的黑铁长棍', + }, + ], }) expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith({ diff --git a/tests/unit/worker/character-profile.test.ts b/tests/unit/worker/character-profile.test.ts index 5a3958d..e5b354b 100644 --- a/tests/unit/worker/character-profile.test.ts +++ b/tests/unit/worker/character-profile.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { TASK_TYPE, type TaskJobData } from '@/lib/task/types' const prismaMock = vi.hoisted(() => ({ + $transaction: vi.fn(), novelPromotionCharacter: { findFirst: vi.fn(), findMany: vi.fn(), @@ -10,6 +11,7 @@ const prismaMock = vi.hoisted(() => ({ }, characterAppearance: { create: vi.fn(async () => ({})), + deleteMany: vi.fn(async () => ({ count: 1 })), }, })) @@ -89,6 +91,9 @@ function buildJob(type: TaskJobData['type'], payload: Record): describe('worker character-profile behavior', () => { beforeEach(() => { vi.clearAllMocks() + prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise) => { + return await callback(prismaMock) + }) llmMock.getCompletionContent.mockReturnValue( JSON.stringify({ @@ -134,10 +139,13 @@ describe('worker character-profile behavior', () => { await expect(handleCharacterProfileTask(job)).rejects.toThrow('Unsupported character profile task type') }) - it('confirm profile success -> creates appearance and marks profileConfirmed', async () => { + it('confirm profile success -> rebuilds appearances and marks profileConfirmed', async () => { const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' }) const result = await handleCharacterProfileTask(job) + expect(prismaMock.characterAppearance.deleteMany).toHaveBeenCalledWith({ + where: { characterId: 'character-1' }, + }) expect(prismaMock.characterAppearance.create).toHaveBeenCalledWith({ data: expect.objectContaining({ characterId: 'character-1', @@ -149,7 +157,10 @@ describe('worker character-profile behavior', () => { expect(prismaMock.novelPromotionCharacter.update).toHaveBeenCalledWith({ where: { id: 'character-1' }, - data: { profileConfirmed: true }, + data: { + profileData: JSON.stringify({ archetype: 'lead' }), + profileConfirmed: true, + }, }) expect(result).toEqual(expect.objectContaining({ @@ -171,4 +182,18 @@ describe('worker character-profile behavior', () => { }) expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(2) }) + + it('reconfirm with existing appearances -> replaces old rows instead of colliding on unique index', async () => { + const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' }) + + await expect(handleCharacterProfileTask(job)).resolves.toEqual(expect.objectContaining({ + success: true, + })) + + expect(prismaMock.$transaction).toHaveBeenCalledTimes(1) + expect(prismaMock.characterAppearance.deleteMany).toHaveBeenCalledWith({ + where: { characterId: 'character-1' }, + }) + expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(1) + }) })