fix: resolve confirmed character hidden bug, remove online font dependency, improve UI/UX experience
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
You are a prop asset extractor.
|
You are a key story prop extractor.
|
||||||
|
|
||||||
Task: identify reusable physical props from the input text and return JSON only.
|
Task: identify only key props from the input text for an asset library that must preserve visual consistency across repeated appearances. Be conservative. Return JSON only.
|
||||||
|
|
||||||
Output format:
|
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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
"confirmProfiles": "角色档案待确认",
|
"confirmProfiles": "角色档案待确认",
|
||||||
"confirmHint": "请确认以下角色档案后生成外貌描述",
|
"confirmHint": "请确认以下角色档案后生成外貌描述",
|
||||||
"confirmAll": "全部确认 ({count})",
|
"confirmAll": "全部确认 ({count})",
|
||||||
|
"pendingProfilesBanner": "AI 选角完成",
|
||||||
|
"pendingProfilesHint": "确认档案后自动生成角色形象",
|
||||||
"assetsTitle": "资产分析",
|
"assetsTitle": "资产分析",
|
||||||
"characterAssets": "角色资产",
|
"characterAssets": "角色资产",
|
||||||
"locationAssets": "场景资产",
|
"locationAssets": "场景资产",
|
||||||
|
|||||||
@@ -125,6 +125,7 @@
|
|||||||
"streamStep": {
|
"streamStep": {
|
||||||
"analyzeCharacters": "角色分析",
|
"analyzeCharacters": "角色分析",
|
||||||
"analyzeLocations": "场景分析",
|
"analyzeLocations": "场景分析",
|
||||||
|
"analyzeProps": "道具分析",
|
||||||
"splitClips": "片段切分",
|
"splitClips": "片段切分",
|
||||||
"screenplayConversion": "剧本转换",
|
"screenplayConversion": "剧本转换",
|
||||||
"storyboardPlan": "分镜规划",
|
"storyboardPlan": "分镜规划",
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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') && (
|
||||||
|
|||||||
@@ -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">
|
||||||
<SegmentedControl
|
<div className="overflow-x-auto">
|
||||||
options={segmentOptions}
|
<SegmentedControl
|
||||||
value={kindFilter}
|
options={segmentOptions}
|
||||||
onChange={onKindFilterChange}
|
value={kindFilter}
|
||||||
/>
|
onChange={onKindFilterChange}
|
||||||
|
layout="compact"
|
||||||
|
className="min-w-max"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,117 +79,124 @@ 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-1">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="font-semibold text-[var(--glass-text-primary)] mb-1">{name}</h3>
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<h3 className="text-base font-bold text-[var(--glass-text-primary)] mb-1.5">{name}</h3>
|
||||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${roleLevelColor}`}>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{roleLevelLabel}
|
<span
|
||||||
</span>
|
className="inline-flex items-center px-2.5 py-1 rounded-full text-[11px] font-black text-white tracking-wide"
|
||||||
<span className="text-xs text-[var(--glass-text-tertiary)]">{profileData.archetype}</span>
|
style={{
|
||||||
|
background: tierStyle?.gradient ?? 'var(--glass-bg-muted)',
|
||||||
|
boxShadow: tierStyle?.glow ?? 'none',
|
||||||
|
...(!tierStyle ? { color: 'var(--glass-text-primary)' } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{roleLevelLabel}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--glass-text-tertiary)]">{profileData.archetype}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 删除按钮 */}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={isConfirming || isDeleting}
|
||||||
|
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-danger-fg)] hover:bg-[var(--glass-tone-danger-bg)] transition-colors disabled:opacity-50 shrink-0"
|
||||||
|
title={t('characterProfile.delete')}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<TaskStatusInline state={deletingState} className="[&_span]:sr-only [&_svg]:text-current" />
|
||||||
|
) : (
|
||||||
|
<AppIcon name="trash" className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* 删除按钮 */}
|
|
||||||
{onDelete && (
|
{/* 档案摘要 */}
|
||||||
|
<div className="space-y-1.5 mb-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.gender')}</span>
|
||||||
|
<span className="text-[var(--glass-text-primary)]">{profileData.gender}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.age')}</span>
|
||||||
|
<span className="text-[var(--glass-text-primary)]">{profileData.age_range}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.era')}</span>
|
||||||
|
<span className="text-[var(--glass-text-primary)]">{profileData.era_period}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.class')}</span>
|
||||||
|
<span className="text-[var(--glass-text-primary)]">{profileData.social_class}</span>
|
||||||
|
</div>
|
||||||
|
{profileData.occupation && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.occupation')}</span>
|
||||||
|
<span className="text-[var(--glass-text-primary)]">{profileData.occupation}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.personality')}</span>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{profileData.personality_tags.map((tag, i) => (
|
||||||
|
<span key={i} className="px-1.5 py-0.5 bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] rounded text-xs font-medium">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.costume')}</span>
|
||||||
|
<span className="text-[var(--glass-text-primary)]">
|
||||||
|
{'●'.repeat(profileData.costume_tier)}{'○'.repeat(5 - profileData.costume_tier)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{profileData.primary_identifier && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.identifier')}</span>
|
||||||
|
<span className="font-medium" style={{ color: tierStyle?.accent ?? 'var(--glass-tone-warning-fg)' }}>{profileData.primary_identifier}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex gap-2 pt-3 border-t border-[var(--glass-stroke-base)]">
|
||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onEdit}
|
||||||
disabled={isConfirming || isDeleting}
|
disabled={isConfirming}
|
||||||
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="glass-btn-base glass-btn-secondary flex-1 px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
|
||||||
title={t('characterProfile.delete')}
|
|
||||||
>
|
>
|
||||||
{isDeleting ? (
|
{t('characterProfile.editProfile')}
|
||||||
<TaskStatusInline state={deletingState} className="[&_span]:sr-only [&_svg]:text-current" />
|
</button>
|
||||||
|
{onUseExisting && (
|
||||||
|
<button
|
||||||
|
onClick={onUseExisting}
|
||||||
|
disabled={isConfirming}
|
||||||
|
className="glass-btn-base glass-btn-tone-info flex-1 px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('characterProfile.useExisting')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isConfirming}
|
||||||
|
className="glass-btn-base glass-btn-primary flex-1 px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isConfirming ? (
|
||||||
|
<TaskStatusInline state={confirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||||
) : (
|
) : (
|
||||||
<AppIcon name="trash" className="w-4 h-4" />
|
t('characterProfile.confirmAndGenerate')
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 档案摘要 */}
|
|
||||||
<div className="space-y-1.5 mb-3">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.gender')}</span>
|
|
||||||
<span className="text-[var(--glass-text-primary)]">{profileData.gender}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.age')}</span>
|
|
||||||
<span className="text-[var(--glass-text-primary)]">{profileData.age_range}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.era')}</span>
|
|
||||||
<span className="text-[var(--glass-text-primary)]">{profileData.era_period}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.class')}</span>
|
|
||||||
<span className="text-[var(--glass-text-primary)]">{profileData.social_class}</span>
|
|
||||||
</div>
|
|
||||||
{profileData.occupation && (
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.occupation')}</span>
|
|
||||||
<span className="text-[var(--glass-text-primary)]">{profileData.occupation}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.personality')}</span>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{profileData.personality_tags.map((tag, i) => (
|
|
||||||
<span key={i} className="px-1.5 py-0.5 bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] rounded text-xs">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.costume')}</span>
|
|
||||||
<span className="text-[var(--glass-text-primary)]">
|
|
||||||
{'●'.repeat(profileData.costume_tier)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{profileData.primary_identifier && (
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.identifier')}</span>
|
|
||||||
<span className="text-[var(--glass-tone-warning-fg)] font-medium">{profileData.primary_identifier}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onEdit}
|
|
||||||
disabled={isConfirming}
|
|
||||||
className="flex-1 px-3 py-1.5 text-sm border border-[var(--glass-stroke-strong)] rounded-lg hover:bg-[var(--glass-bg-muted)] transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t('characterProfile.editProfile')}
|
|
||||||
</button>
|
|
||||||
{onUseExisting && (
|
|
||||||
<button
|
|
||||||
onClick={onUseExisting}
|
|
||||||
disabled={isConfirming}
|
|
||||||
className="flex-1 px-3 py-1.5 text-sm border border-[var(--glass-stroke-focus)] text-[var(--glass-tone-info-fg)] rounded-lg hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50 flex items-center justify-center gap-1"
|
|
||||||
>
|
|
||||||
{t('characterProfile.useExisting')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={isConfirming}
|
|
||||||
className="flex-1 px-3 py-1.5 text-sm bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)] transition-colors disabled:opacity-50 flex items-center justify-center gap-1"
|
|
||||||
>
|
|
||||||
{isConfirming ? (
|
|
||||||
<TaskStatusInline state={confirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
|
||||||
) : (
|
|
||||||
t('characterProfile.confirmAndGenerate')
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)]">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
|
||||||
import CharacterProfileCard from './CharacterProfileCard'
|
|
||||||
import { parseProfileData } from '@/types/character-profile'
|
|
||||||
import type { Character } from '@/types/project'
|
|
||||||
import type { TaskPresentationState } from '@/lib/task/presentation'
|
|
||||||
|
|
||||||
interface UnconfirmedProfilesSectionProps {
|
|
||||||
unconfirmedCharacters: Character[]
|
|
||||||
confirmTitle: string
|
|
||||||
confirmHint: string
|
|
||||||
confirmAllLabel: string
|
|
||||||
batchConfirming: boolean
|
|
||||||
batchConfirmingState: TaskPresentationState | null
|
|
||||||
deletingCharacterId: string | null
|
|
||||||
isConfirmingCharacter: (characterId: string) => boolean
|
|
||||||
onBatchConfirm: () => void
|
|
||||||
onEditProfile: (characterId: string, characterName: string) => void
|
|
||||||
onConfirmProfile: (characterId: string) => void
|
|
||||||
onUseExistingProfile: (characterId: string) => void
|
|
||||||
onDeleteProfile: (characterId: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UnconfirmedProfilesSection({
|
|
||||||
unconfirmedCharacters,
|
|
||||||
confirmTitle,
|
|
||||||
confirmHint,
|
|
||||||
confirmAllLabel,
|
|
||||||
batchConfirming,
|
|
||||||
batchConfirmingState,
|
|
||||||
deletingCharacterId,
|
|
||||||
isConfirmingCharacter,
|
|
||||||
onBatchConfirm,
|
|
||||||
onEditProfile,
|
|
||||||
onConfirmProfile,
|
|
||||||
onUseExistingProfile,
|
|
||||||
onDeleteProfile,
|
|
||||||
}: UnconfirmedProfilesSectionProps) {
|
|
||||||
if (unconfirmedCharacters.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--glass-tone-warning-bg)] border border-[var(--glass-stroke-warning)] rounded-xl p-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">{confirmTitle}</h3>
|
|
||||||
<p className="text-sm text-[var(--glass-text-secondary)]">{confirmHint}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onBatchConfirm}
|
|
||||||
disabled={batchConfirming}
|
|
||||||
className="glass-btn-base glass-btn-primary px-4 py-2 disabled:opacity-50 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{batchConfirming ? (
|
|
||||||
<TaskStatusInline state={batchConfirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
|
||||||
) : (
|
|
||||||
confirmAllLabel
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{unconfirmedCharacters.map((character) => {
|
|
||||||
const profileData = parseProfileData(character.profileData!)
|
|
||||||
if (!profileData) return null
|
|
||||||
return (
|
|
||||||
<CharacterProfileCard
|
|
||||||
key={character.id}
|
|
||||||
characterId={character.id}
|
|
||||||
name={character.name}
|
|
||||||
profileData={profileData}
|
|
||||||
onEdit={() => onEditProfile(character.id, character.name)}
|
|
||||||
onConfirm={() => onConfirmProfile(character.id)}
|
|
||||||
onUseExisting={() => onUseExistingProfile(character.id)}
|
|
||||||
onDelete={() => onDeleteProfile(character.id)}
|
|
||||||
isConfirming={isConfirmingCharacter(character.id)}
|
|
||||||
isDeleting={deletingCharacterId === character.id}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -114,94 +114,110 @@ export default function VoiceSettings({
|
|||||||
? 'border border-[var(--glass-stroke-base)] rounded-xl p-3 bg-[var(--glass-bg-surface-strong)]'
|
? '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)]'}`}>
|
{/* 折叠标题行 - 点击展开/收起 */}
|
||||||
<div className={`${iconSize} rounded-full flex items-center justify-center ${hasCustomVoice ? 'bg-[var(--glass-bg-muted)]' : 'bg-[var(--glass-tone-warning-bg)]'}`}>
|
<button
|
||||||
<AppIcon name="mic" className={`${innerIconSize} ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`} />
|
type="button"
|
||||||
|
onClick={() => setIsExpanded((v) => !v)}
|
||||||
|
className="w-full flex items-center justify-between cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`${iconSize} rounded-full flex items-center justify-center ${hasCustomVoice ? 'bg-[var(--glass-bg-muted)]' : 'bg-[var(--glass-tone-warning-bg)]'}`}>
|
||||||
|
<AppIcon name="mic" className={`${innerIconSize} ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-${compact ? 'xs' : 'sm'} font-medium text-[var(--glass-text-secondary)]`}>
|
||||||
|
{t('tts.title')}
|
||||||
|
</span>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${hasCustomVoice ? 'bg-[var(--glass-tone-success-fg)]' : 'bg-[var(--glass-tone-warning-fg)]'}`} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-${compact ? 'xs' : 'sm'} font-medium ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`}>
|
<AppIcon
|
||||||
{t('tts.title')}{!hasCustomVoice && <span className="text-[var(--glass-tone-warning-fg)]">({t('tts.noVoice')})</span>}
|
name="chevronDown"
|
||||||
</span>
|
className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
||||||
</div>
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* 隐藏的音频文件输入 */}
|
{/* 展开内容 */}
|
||||||
<input
|
{isExpanded && (
|
||||||
ref={voiceFileInputRef}
|
<div className="mt-3 pt-3 border-t border-[var(--glass-stroke-base)]">
|
||||||
type="file"
|
{/* 隐藏的音频文件输入 */}
|
||||||
accept="audio/*"
|
<input
|
||||||
onChange={handleUploadVoice}
|
ref={voiceFileInputRef}
|
||||||
className="hidden"
|
type="file"
|
||||||
/>
|
accept="audio/*"
|
||||||
|
onChange={handleUploadVoice}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 w-full justify-center">
|
<div className="flex flex-wrap gap-2 w-full justify-center">
|
||||||
{/* 上传音频按钮 */}
|
{/* 上传音频按钮 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!confirmUploadVoice()) return
|
if (!confirmUploadVoice()) return
|
||||||
voiceFileInputRef.current?.click()
|
voiceFileInputRef.current?.click()
|
||||||
}}
|
}}
|
||||||
disabled={uploadVoice.isPending}
|
disabled={uploadVoice.isPending}
|
||||||
className="flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] rounded-lg text-xs text-[var(--glass-text-secondary)] font-medium hover:border-[var(--glass-stroke-success)] hover:bg-[var(--glass-tone-success-bg)] hover:text-[var(--glass-tone-success-fg)] transition-all relative group whitespace-nowrap"
|
className="flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] rounded-lg text-xs text-[var(--glass-text-secondary)] font-medium hover:border-[var(--glass-stroke-success)] hover:bg-[var(--glass-tone-success-bg)] hover:text-[var(--glass-tone-success-fg)] transition-all relative group whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
{hasCustomVoice && <div className="w-1.5 h-1.5 bg-[var(--glass-tone-success-fg)] rounded-full flex-shrink-0"></div>}
|
{hasCustomVoice && <div className="w-1.5 h-1.5 bg-[var(--glass-tone-success-fg)] rounded-full flex-shrink-0"></div>}
|
||||||
<span>{uploadVoice.isPending ? t('tts.uploading') : hasCustomVoice ? t('tts.uploaded') : t('tts.uploadAudio')}</span>
|
<span>{uploadVoice.isPending ? t('tts.uploading') : hasCustomVoice ? t('tts.uploaded') : t('tts.uploadAudio')}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 从资产中心选择按钮 */}
|
{/* 从资产中心选择按钮 */}
|
||||||
{onSelectFromHub && (
|
{onSelectFromHub && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onSelectFromHub(characterId)}
|
onClick={() => onSelectFromHub(characterId)}
|
||||||
className="flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-focus)] rounded-lg text-xs text-[var(--glass-tone-info-fg)] font-medium hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-tone-info-bg)] transition-all whitespace-nowrap"
|
className="flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-focus)] rounded-lg text-xs text-[var(--glass-tone-info-fg)] font-medium hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-tone-info-bg)] transition-all whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<AppIcon name="copy" className="w-3.5 h-3.5 flex-shrink-0" />
|
<AppIcon name="copy" className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<span>{t('assetLibrary.button')}</span>
|
<span>{t('assetLibrary.button')}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI设计按钮 */}
|
{/* AI设计按钮 */}
|
||||||
{onVoiceDesign && (
|
{onVoiceDesign && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onVoiceDesign(characterId, characterName)}
|
onClick={() => onVoiceDesign(characterId, characterName)}
|
||||||
className="glass-btn-base glass-btn-primary flex-1 min-w-[80px] px-2 py-1.5 text-xs font-medium whitespace-nowrap"
|
className="glass-btn-base glass-btn-primary flex-1 min-w-[80px] px-2 py-1.5 text-xs font-medium whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<AppIcon name="bolt" className="w-3.5 h-3.5 flex-shrink-0" />
|
<AppIcon name="bolt" className="w-3.5 h-3.5 flex-shrink-0" />
|
||||||
<span>{t('modal.aiDesign')}</span>
|
<span>{t('modal.aiDesign')}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 试听按钮 - 仅在有音频时显示 */}
|
|
||||||
{hasCustomVoice && (
|
|
||||||
<button
|
|
||||||
onClick={handlePreviewVoice}
|
|
||||||
className={`w-full mt-2 px-3 py-2 border rounded-lg text-sm font-medium transition-all ${isPreviewingVoice
|
|
||||||
? 'bg-[var(--glass-accent-from)] border-[var(--glass-stroke-focus)] text-white hover:bg-[var(--glass-accent-to)]'
|
|
||||||
: 'bg-[var(--glass-tone-info-bg)] border-[var(--glass-stroke-focus)] text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
{isPreviewingVoice ? (
|
|
||||||
<AppIcon name="pause" className="w-4 h-4" />
|
|
||||||
) : (
|
|
||||||
<AppIcon name="play" className="w-4 h-4" />
|
|
||||||
)}
|
)}
|
||||||
{isPreviewingVoice ? t('tts.pause') : t('tts.preview')}
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
|
{/* 试听按钮 - 仅在有音频时显示 */}
|
||||||
|
{hasCustomVoice && (
|
||||||
|
<button
|
||||||
|
onClick={handlePreviewVoice}
|
||||||
|
className={`w-full mt-2 px-3 py-2 border rounded-lg text-sm font-medium transition-all ${isPreviewingVoice
|
||||||
|
? 'bg-[var(--glass-accent-from)] border-[var(--glass-stroke-focus)] text-white hover:bg-[var(--glass-accent-to)]'
|
||||||
|
: 'bg-[var(--glass-tone-info-bg)] border-[var(--glass-stroke-focus)] text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
{isPreviewingVoice ? (
|
||||||
|
<AppIcon name="pause" className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<AppIcon name="play" className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{isPreviewingVoice ? t('tts.pause') : t('tts.preview')}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Location, Prop } from '@/types/project'
|
||||||
|
|
||||||
|
export function canGenerateLocationBackedAsset(asset: Location | Prop): boolean {
|
||||||
|
if (asset.summary && asset.summary.trim().length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return (asset.images ?? []).some((image) =>
|
||||||
|
typeof image.description === 'string' && image.description.trim().length > 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLocationBackedGenerateType(
|
||||||
|
assetType: 'location' | 'prop',
|
||||||
|
): 'location' | 'prop' {
|
||||||
|
return assetType
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ type LocationCardActionsProps =
|
|||||||
mode: 'compact'
|
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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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,6 +15,96 @@ export interface ProjectAssetsData {
|
|||||||
props: Prop[]
|
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 ============
|
// ============ 查询 Hooks ============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,92 +116,7 @@ export function useProjectAssets(projectId: string | null) {
|
|||||||
projectId,
|
projectId,
|
||||||
})
|
})
|
||||||
const groups = groupAssetsByKind(assetsQuery.data)
|
const groups = groupAssetsByKind(assetsQuery.data)
|
||||||
const data: ProjectAssetsData = {
|
const data = mapAssetGroupsToProjectAssetsData(groups)
|
||||||
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,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...assetsQuery,
|
...assetsQuery,
|
||||||
|
|||||||
@@ -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({
|
locationId: created.id,
|
||||||
data: {
|
descriptions: cleanDescriptions,
|
||||||
locationId: created.id,
|
fallbackDescription: summary || name,
|
||||||
imageIndex: j,
|
})
|
||||||
description: cleanDescriptions[j],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@@ -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({
|
locationId: created.id,
|
||||||
data: {
|
descriptions: cleanDescriptions,
|
||||||
locationId: created.id,
|
fallbackDescription: readText(item.summary) || name,
|
||||||
imageIndex: i,
|
})
|
||||||
description: cleanDescriptions[i],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,27 +129,50 @@ 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) || '初始形象',
|
description: normalizedDescriptions[0] || '',
|
||||||
description: normalizedDescriptions[0] || '',
|
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) => {
|
||||||
where: { id: characterId },
|
await tx.characterAppearance.deleteMany({
|
||||||
data: { profileConfirmed: true },
|
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) {
|
if (!suppressProgress) {
|
||||||
|
|||||||
@@ -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({
|
locationId: location.id,
|
||||||
data: {
|
descriptions: cleanDescriptions,
|
||||||
locationId: location.id,
|
fallbackDescription: asString(item.summary) || name,
|
||||||
imageIndex: i,
|
})
|
||||||
description: cleanDescriptions[i],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
37
tests/unit/assets/location-backed-generation.test.ts
Normal file
37
tests/unit/assets/location-backed-generation.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { canGenerateLocationBackedAsset, resolveLocationBackedGenerateType } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-backed-asset'
|
||||||
|
|
||||||
|
describe('location-backed asset generation rules', () => {
|
||||||
|
it('allows props to generate from summary even before any image slot exists', () => {
|
||||||
|
expect(canGenerateLocationBackedAsset({
|
||||||
|
id: 'prop-1',
|
||||||
|
name: '金箍棒',
|
||||||
|
summary: '一根两头包裹金片的黑铁长棍',
|
||||||
|
images: [],
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows locations to generate from seeded image descriptions', () => {
|
||||||
|
expect(canGenerateLocationBackedAsset({
|
||||||
|
id: 'location-1',
|
||||||
|
name: '雨夜街道',
|
||||||
|
summary: null,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
id: 'image-1',
|
||||||
|
imageIndex: 0,
|
||||||
|
description: '潮湿反光的老街',
|
||||||
|
imageUrl: null,
|
||||||
|
previousImageUrl: null,
|
||||||
|
previousDescription: null,
|
||||||
|
isSelected: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes prop generation through the prop branch', () => {
|
||||||
|
expect(resolveLocationBackedGenerateType('prop')).toBe('prop')
|
||||||
|
expect(resolveLocationBackedGenerateType('location')).toBe('location')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ describe('asset mappers', () => {
|
|||||||
id: 'character-1',
|
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',
|
||||||
|
|||||||
80
tests/unit/components/asset-toolbar.test.ts
Normal file
80
tests/unit/components/asset-toolbar.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import type { ComponentProps, ReactElement } from 'react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import type { AbstractIntlMessages } from 'next-intl'
|
||||||
|
import AssetToolbar from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar'
|
||||||
|
|
||||||
|
vi.mock('@/lib/query/hooks', () => ({
|
||||||
|
useProjectAssets: vi.fn(() => ({ data: { characters: [], locations: [], props: [] } })),
|
||||||
|
useProjectData: vi.fn(() => ({ data: { name: '项目A' } })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
assets: {
|
||||||
|
common: {
|
||||||
|
refresh: '刷新',
|
||||||
|
},
|
||||||
|
filterBar: {
|
||||||
|
allEpisodes: '全部集数',
|
||||||
|
},
|
||||||
|
toolbar: {
|
||||||
|
assetManagement: '资产管理',
|
||||||
|
assetCount: '共 {total} 个资产({appearances} 角色形象 + {locations} 场景 + {props} 道具)',
|
||||||
|
globalAnalyze: '全局分析',
|
||||||
|
globalAnalyzeHint: '分析所有资产',
|
||||||
|
downloadAll: '下载全部',
|
||||||
|
generateAll: '生成全部图片',
|
||||||
|
regenerateAll: '重新生成全部',
|
||||||
|
regenerateAllHint: '重新生成所有图片',
|
||||||
|
},
|
||||||
|
assetLibrary: {
|
||||||
|
downloadEmpty: '没有可下载图片',
|
||||||
|
downloadFailed: '下载失败',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const renderWithIntl = (node: ReactElement) => {
|
||||||
|
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
|
||||||
|
locale: 'zh',
|
||||||
|
messages: messages as unknown as AbstractIntlMessages,
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
children: node,
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
createElement(NextIntlClientProvider, providerProps),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AssetToolbar', () => {
|
||||||
|
it('删除批量生成与刷新按钮 -> 仅保留全局分析和下载入口', () => {
|
||||||
|
Reflect.set(globalThis, 'React', React)
|
||||||
|
|
||||||
|
const html = renderWithIntl(
|
||||||
|
createElement(AssetToolbar, {
|
||||||
|
projectId: 'project-1',
|
||||||
|
totalAssets: 24,
|
||||||
|
totalAppearances: 11,
|
||||||
|
totalLocations: 13,
|
||||||
|
totalProps: 0,
|
||||||
|
isBatchSubmitting: false,
|
||||||
|
isAnalyzingAssets: false,
|
||||||
|
isGlobalAnalyzing: false,
|
||||||
|
onGlobalAnalyze: () => undefined,
|
||||||
|
episodeId: null,
|
||||||
|
onEpisodeChange: () => undefined,
|
||||||
|
episodes: [],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain('全局分析')
|
||||||
|
expect(html).toContain('title="下载全部"')
|
||||||
|
expect(html).not.toContain('生成全部图片')
|
||||||
|
expect(html).not.toContain('重新生成全部')
|
||||||
|
expect(html).not.toContain('>刷新<')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -24,6 +24,9 @@ const messages = {
|
|||||||
waitingModelOutput: '等待模型输出...',
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
29
tests/unit/components/segmented-control.test.ts
Normal file
29
tests/unit/components/segmented-control.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { SegmentedControl } from '@/components/ui/SegmentedControl'
|
||||||
|
|
||||||
|
describe('SegmentedControl', () => {
|
||||||
|
it('compact 布局 -> 输出左对齐的非拉伸结构', () => {
|
||||||
|
Reflect.set(globalThis, 'React', React)
|
||||||
|
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
createElement(SegmentedControl, {
|
||||||
|
options: [
|
||||||
|
{ value: 'all', label: '全部 (24)' },
|
||||||
|
{ value: 'character', label: '角色 (11)' },
|
||||||
|
{ value: 'location', label: '场景 (13)' },
|
||||||
|
{ value: 'prop', label: '道具 (0)' },
|
||||||
|
],
|
||||||
|
value: 'all',
|
||||||
|
onChange: () => undefined,
|
||||||
|
layout: 'compact',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(html).toContain('inline-block max-w-full')
|
||||||
|
expect(html).toContain('inline-grid grid-flow-col auto-cols-[minmax(96px,max-content)]')
|
||||||
|
expect(html).not.toContain('grid-template-columns:repeat(4,minmax(0,1fr))')
|
||||||
|
})
|
||||||
|
})
|
||||||
24
tests/unit/prompt-i18n/select-prop-template.test.ts
Normal file
24
tests/unit/prompt-i18n/select-prop-template.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { getPromptTemplate, PROMPT_IDS } from '@/lib/prompt-i18n'
|
||||||
|
|
||||||
|
describe('select prop prompt template', () => {
|
||||||
|
it('zh template restricts extraction to key story props and prefers omission when uncertain', () => {
|
||||||
|
const template = getPromptTemplate(PROMPT_IDS.NP_SELECT_PROP, 'zh')
|
||||||
|
|
||||||
|
expect(template).toContain('关键剧情道具资产分析师')
|
||||||
|
expect(template).toContain('宁缺毋滥')
|
||||||
|
expect(template).toContain('必须在剧情中承担明确功能')
|
||||||
|
expect(template).toContain('如果不确定它是否值得进入资产库,直接不输出')
|
||||||
|
expect(template).toContain('仅因外观具体、名词明确,不足以成为关键道具')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('en template restricts extraction to key story props and prefers omission when uncertain', () => {
|
||||||
|
const template = getPromptTemplate(PROMPT_IDS.NP_SELECT_PROP, 'en')
|
||||||
|
|
||||||
|
expect(template).toContain('key story prop extractor')
|
||||||
|
expect(template).toContain('Be conservative')
|
||||||
|
expect(template).toContain('clear story function')
|
||||||
|
expect(template).toContain('If you are unsure whether it deserves an asset entry, do not output it')
|
||||||
|
expect(template).toContain('A specific-looking noun is not enough')
|
||||||
|
})
|
||||||
|
})
|
||||||
55
tests/unit/query/use-project-assets.test.ts
Normal file
55
tests/unit/query/use-project-assets.test.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createEmptyAssetGroupMap } from '@/lib/assets/grouping'
|
||||||
|
import { mapAssetGroupsToProjectAssetsData } from '@/lib/query/hooks/useProjectAssets'
|
||||||
|
|
||||||
|
describe('useProjectAssets adapters', () => {
|
||||||
|
it('preserves profileData for unconfirmed character profiles', () => {
|
||||||
|
const groups = createEmptyAssetGroupMap()
|
||||||
|
groups.character.push({
|
||||||
|
id: 'character-1',
|
||||||
|
scope: 'project',
|
||||||
|
kind: 'character',
|
||||||
|
family: 'visual',
|
||||||
|
name: '林夏',
|
||||||
|
folderId: null,
|
||||||
|
capabilities: {
|
||||||
|
canGenerate: true,
|
||||||
|
canSelectRender: true,
|
||||||
|
canRevertRender: true,
|
||||||
|
canModifyRender: true,
|
||||||
|
canUploadRender: true,
|
||||||
|
canBindVoice: true,
|
||||||
|
canCopyFromGlobal: true,
|
||||||
|
},
|
||||||
|
taskRefs: [],
|
||||||
|
taskState: {
|
||||||
|
isRunning: false,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
variants: [],
|
||||||
|
introduction: '主角',
|
||||||
|
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||||
|
profileConfirmed: false,
|
||||||
|
profileTaskRefs: [],
|
||||||
|
profileTaskState: {
|
||||||
|
isRunning: false,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
voice: {
|
||||||
|
voiceType: null,
|
||||||
|
voiceId: null,
|
||||||
|
customVoiceUrl: null,
|
||||||
|
media: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = mapAssetGroupsToProjectAssetsData(groups)
|
||||||
|
|
||||||
|
expect(data.characters).toHaveLength(1)
|
||||||
|
expect(data.characters[0]).toEqual(expect.objectContaining({
|
||||||
|
id: 'character-1',
|
||||||
|
profileData: JSON.stringify({ archetype: 'lead' }),
|
||||||
|
profileConfirmed: false,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
136
tests/unit/script-view/script-view-assets-panel.test.ts
Normal file
136
tests/unit/script-view/script-view-assets-panel.test.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { createElement } from 'react'
|
||||||
|
import type { ComponentProps, ReactElement } from 'react'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
import { NextIntlClientProvider } from 'next-intl'
|
||||||
|
import type { AbstractIntlMessages } from 'next-intl'
|
||||||
|
import ScriptViewAssetsPanel from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewAssetsPanel'
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
scriptView: {
|
||||||
|
inSceneAssets: '出场资产',
|
||||||
|
assetView: {
|
||||||
|
allClips: '全部片段',
|
||||||
|
},
|
||||||
|
segment: {
|
||||||
|
title: '片段 {index}',
|
||||||
|
},
|
||||||
|
asset: {
|
||||||
|
activeCharacters: '出场角色',
|
||||||
|
activeLocations: '出场场景',
|
||||||
|
defaultAppearance: '默认形象',
|
||||||
|
},
|
||||||
|
screenplay: {
|
||||||
|
noCharacter: '当前片段未选择角色',
|
||||||
|
noLocation: '当前片段未选择场景',
|
||||||
|
},
|
||||||
|
generate: {
|
||||||
|
startGenerate: '开始生成',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
character: {
|
||||||
|
primary: '初始形象',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
novelPromotion: {
|
||||||
|
buttons: {
|
||||||
|
assetLibrary: '资产库',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
edit: '编辑',
|
||||||
|
cancel: '取消',
|
||||||
|
confirm: '确定',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function renderWithIntl(node: ReactElement) {
|
||||||
|
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
|
||||||
|
locale: 'zh',
|
||||||
|
messages: messages as unknown as AbstractIntlMessages,
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
children: node,
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
createElement(NextIntlClientProvider, providerProps),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPanel(propsCount: number) {
|
||||||
|
Reflect.set(globalThis, 'React', React)
|
||||||
|
|
||||||
|
const props = Array.from({ length: propsCount }, (_, index) => ({
|
||||||
|
id: `prop-${index + 1}`,
|
||||||
|
name: `道具${index + 1}`,
|
||||||
|
summary: `道具描述${index + 1}`,
|
||||||
|
selectedImageId: null,
|
||||||
|
images: [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
return renderWithIntl(
|
||||||
|
createElement(ScriptViewAssetsPanel, {
|
||||||
|
clips: [{ id: 'clip-1', location: null, props: null }],
|
||||||
|
assetViewMode: 'all',
|
||||||
|
setAssetViewMode: () => undefined,
|
||||||
|
setSelectedClipId: () => undefined,
|
||||||
|
characters: [],
|
||||||
|
locations: [],
|
||||||
|
props,
|
||||||
|
activeCharIds: [],
|
||||||
|
activeLocationIds: [],
|
||||||
|
activePropIds: [],
|
||||||
|
selectedAppearanceKeys: new Set<string>(),
|
||||||
|
onUpdateClipAssets: async () => undefined,
|
||||||
|
onOpenAssetLibrary: () => undefined,
|
||||||
|
assetsLoading: false,
|
||||||
|
assetsLoadingState: null,
|
||||||
|
allAssetsHaveImages: true,
|
||||||
|
globalCharIds: [],
|
||||||
|
globalLocationIds: [],
|
||||||
|
globalPropIds: [],
|
||||||
|
missingAssetsCount: 0,
|
||||||
|
onGenerateStoryboard: () => undefined,
|
||||||
|
isSubmittingStoryboardBuild: false,
|
||||||
|
getSelectedAppearances: () => [],
|
||||||
|
tScript: (key: string, values?: Record<string, unknown>) => {
|
||||||
|
if (key === 'inSceneAssets') return '出场资产'
|
||||||
|
if (key === 'assetView.allClips') return '全部片段'
|
||||||
|
if (key === 'segment.title') return `片段 ${String(values?.index ?? '')}`
|
||||||
|
if (key === 'asset.activeCharacters') return '出场角色'
|
||||||
|
if (key === 'asset.activeLocations') return '出场场景'
|
||||||
|
if (key === 'screenplay.noCharacter') return '当前片段未选择角色'
|
||||||
|
if (key === 'screenplay.noLocation') return '当前片段未选择场景'
|
||||||
|
if (key === 'generate.startGenerate') return '开始生成'
|
||||||
|
if (key === 'asset.defaultAppearance') return '默认形象'
|
||||||
|
return key
|
||||||
|
},
|
||||||
|
tAssets: (key: string) => (key === 'character.primary' ? '初始形象' : key),
|
||||||
|
tNP: (key: string) => (key === 'buttons.assetLibrary' ? '资产库' : key),
|
||||||
|
tCommon: (key: string) => {
|
||||||
|
if (key === 'edit') return '编辑'
|
||||||
|
if (key === 'cancel') return '取消'
|
||||||
|
if (key === 'confirm') return '确定'
|
||||||
|
return key
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScriptViewAssetsPanel', () => {
|
||||||
|
it('hides the prop section when the project has no prop assets', () => {
|
||||||
|
const html = renderPanel(0)
|
||||||
|
|
||||||
|
expect(html).not.toContain('道具 (0)')
|
||||||
|
expect(html).not.toContain('当前片段未选择道具')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the prop section visible when the project has prop assets even if none are selected in the current clip', () => {
|
||||||
|
const html = renderPanel(1)
|
||||||
|
|
||||||
|
expect(html).toContain('道具 (0)')
|
||||||
|
expect(html).toContain('当前片段未选择道具')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,7 +11,10 @@ const prismaMock = vi.hoisted(() => ({
|
|||||||
novelPromotionEpisode: { findFirst: vi.fn() },
|
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, {
|
||||||
locationId: 'loc-new-1',
|
data: [
|
||||||
imageIndex: 0,
|
{
|
||||||
description: '雨夜街道',
|
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({
|
expect(prismaMock.novelPromotionProject.update).toHaveBeenCalledWith({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user