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

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

View File

@@ -1,6 +1,6 @@
You are a prop asset extractor.
You are a key story prop extractor.
Task: identify reusable physical props from the input text and return JSON only.
Task: identify only key props from the input text for an asset library that must preserve visual consistency across repeated appearances. Be conservative. Return JSON only.
Output format:
{
@@ -12,16 +12,38 @@ Output format:
]
}
Rules:
1. Only include concrete reusable physical props that actually appear in the story.
2. Only output `name` and `summary`.
3. `name` and `summary` must both be non-empty.
4. Do not repeat props that already exist in the prop library with the exact same name.
5. Exclude abstract concepts, powers, roles, places, creatures, outfits, and makeup.
6. Keep names stable and short.
7. Keep summaries objective.
8. If none exist, return {"props": []}.
9. Replace raw quotation marks inside JSON string values with corner brackets「」.
Key prop criteria:
1. It must be a real physical object that actually appears in the story.
2. It must serve a clear story function rather than being background dressing.
3. It must satisfy at least one of the following:
- characters hold it, use it, fight over it, deliver it, hide it, lose it, or search for it
- it is a key tool, weapon, artifact, piece of evidence, token, key, or clue carrier
- it is likely to reappear and therefore needs a consistent visual design
- removing it would materially weaken plot comprehension or a key action
Strictly exclude:
1. Ordinary background items, furniture, tableware, food, drinks, daily necessities, and decorations.
2. Objects that are only mentioned in passing and have no story function.
3. Environmental elements that belong to the scene unless they are explicitly used as key props.
4. Ordinary clothing, makeup, and accessories unless they are themselves key clues or tokens.
5. Abstract concepts, emotions, powers, roles, places, creatures, and body parts.
Decision bias:
1. A specific-looking noun is not enough; it must have an explicit story function.
2. If an object could be either a background item or a prop, treat it as background and do not output it.
3. If it merely appears but is not used, emphasized, or plot-relevant, do not output it.
4. If you are unsure whether it deserves an asset entry, do not output it.
5. Prefer under-extraction. Never output props just to increase the count.
Output rules:
1. Only output `name` and `summary`.
2. `name` and `summary` must both be non-empty.
3. Do not repeat props that already exist in the prop library with the exact same name.
4. Keep names stable and short.
5. Keep summaries objective.
6. Usually output no more than 3-5 props unless more are clearly all key props.
7. If none exist, return {"props": []}.
8. Replace raw quotation marks inside JSON string values with corner brackets「」.
Input:
{input}

View File

@@ -1,6 +1,6 @@
你是“故事道具资产分析师”。
你是“关键剧情道具资产分析师”。
任务:从输入文本中识别适合做成长期复用资产的道具,只返回 JSON不得包含任何额外解释或 markdown。
任务:从输入文本中识别【关键道具】,用于建立需要长期保持外观一致的资产库。宁缺毋滥。只返回 JSON不得包含任何额外解释或 markdown。
输出格式:
{
@@ -12,16 +12,38 @@
]
}
规则
1. 只保留在剧情中真实出现、可被反复引用、值得进入资产库的实体道具
2. 只输出两个字段name、summary
3. name 不能为空summary 不能为空。
4. 如果道具库里已经有完全同名道具,不要重复输出。
5. 禁止输出抽象概念、情绪、能力、身份、地点、生物、服装妆容。
6. 名称尽量简洁稳定,例如“青铜匕首”“录音笔”“红绳手链”。
7. summary 只写客观描述,不写剧情推断。
8. 如果没有合适道具,返回 {"props": []}。
9. JSON 字符串值中的引号统一替换为「」。
关键道具判定标准
1. 必须是剧情中真实出现的实体物件
2. 必须在剧情中承担明确功能,而不只是背景摆设
3. 必须至少满足以下一种情况:
- 被角色持有、使用、争夺、交付、隐藏、丢失、寻找
- 是推进情节的关键工具、武器、法器、证物、信物、钥匙、线索载体
- 后续大概率需要重复出镜,且需要保持外观一致
- 去掉它会明显影响剧情理解或关键动作成立
严格不提取:
1. 普通背景陈设、家具、餐具、食物、饮料、日用品、装饰物。
2. 仅被顺带提及、没有剧情功能的物件。
3. 场景自带的环境元素,除非它被明确当作关键道具使用。
4. 普通服装、妆容、饰品,除非它本身就是关键线索或关键信物。
5. 抽象概念、情绪、能力、身份、地点、生物、身体部位。
判断倾向:
1. 仅因外观具体、名词明确,不足以成为关键道具;必须有明确剧情作用。
2. 如果一个物件既可能是背景物,也可能是道具,默认按背景物处理,不输出。
3. 如果只是“出现过”,但没有“被使用/被强调/影响剧情”,不输出。
4. 如果不确定它是否值得进入资产库,直接不输出。
5. 优先少报,禁止为了凑数量而输出。
输出要求:
1. 只输出两个字段name、summary。
2. name 不能为空summary 不能为空。
3. 如果道具库里已经有完全同名道具,不要重复输出。
4. 名称尽量简洁稳定,例如“青铜匕首”“录音笔”“红绳手链”。
5. summary 只写客观描述,不写剧情推断。
6. 通常不超过 3-5 个;只有确实都是关键道具时才可更多。
7. 如果没有合适道具,返回 {"props": []}。
8. JSON 字符串值中的引号统一替换为「」。
输入文本:
{input}

View File

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

View File

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

View File

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

View File

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

10
package-lock.json generated
View File

@@ -37,6 +37,7 @@
"cos-nodejs-sdk-v5": "^2.15.4",
"express": "^5.2.1",
"file-saver": "^2.0.5",
"geist": "^1.7.0",
"ioredis": "^5.9.2",
"jsonrepair": "^3.13.2",
"jszip": "^3.10.1",
@@ -10207,6 +10208,15 @@
"node": ">=18"
}
},
"node_modules/geist": {
"version": "1.7.0",
"resolved": "https://registry.npmmirror.com/geist/-/geist-1.7.0.tgz",
"integrity": "sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==",
"license": "SIL OPEN FONT LICENSE",
"peerDependencies": {
"next": ">=13.2.0"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz",

View File

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

View File

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

View File

@@ -49,7 +49,6 @@ import LocationSection from './assets/LocationSection'
import AssetToolbar from './assets/AssetToolbar'
import AssetFilterBar, { type AssetKindFilter } from './assets/AssetFilterBar'
import AssetsStageStatusOverlays from './assets/AssetsStageStatusOverlays'
import UnconfirmedProfilesSection from './assets/UnconfirmedProfilesSection'
import AssetsStageModals from './assets/AssetsStageModals'
interface AssetsStageProps {
@@ -207,12 +206,9 @@ export default function AssetsStage({
// 批量生成
const {
isBatchSubmitting,
batchProgress,
activeTaskKeys,
registerTransientTaskKey,
clearTransientTaskKey,
handleGenerateAllImages,
handleRegenerateAllImages
} = useBatchGeneration({
projectId,
handleGenerateImage
@@ -391,9 +387,6 @@ export default function AssetsStage({
isBatchSubmitting={isBatchSubmitting}
isAnalyzingAssets={isAnalyzingAssets}
isGlobalAnalyzing={isGlobalAnalyzing}
batchProgress={batchProgress}
onGenerateAll={handleGenerateAllImages}
onRegenerateAll={handleRegenerateAllImages}
onGlobalAnalyze={handleGlobalAnalyze}
episodeId={episodeFilter}
onEpisodeChange={setEpisodeFilter}
@@ -412,22 +405,6 @@ export default function AssetsStage({
}}
/>
<UnconfirmedProfilesSection
unconfirmedCharacters={unconfirmedCharacters}
confirmTitle={t('stage.confirmProfiles')}
confirmHint={t('stage.confirmHint')}
confirmAllLabel={t('stage.confirmAll', { count: unconfirmedCharacters.length })}
batchConfirming={batchConfirming}
batchConfirmingState={batchConfirmingState}
deletingCharacterId={deletingCharacterId}
isConfirmingCharacter={isConfirmingCharacter}
onBatchConfirm={handleBatchConfirm}
onEditProfile={handleEditProfile}
onConfirmProfile={handleConfirmProfile}
onUseExistingProfile={handleCopyFromGlobal}
onDeleteProfile={handleDeleteProfile}
/>
{(kindFilter === 'all' || kindFilter === 'character') && (
<CharacterSection
key="character"
@@ -456,6 +433,17 @@ export default function AssetsStage({
onCopyFromGlobal={handleCopyFromGlobal}
getAppearances={getAppearances}
filterIds={episodeAssetIds?.charIds ?? null}
// 🔥 V7待确认角色档案内嵌到 CharacterSection
unconfirmedCharacters={unconfirmedCharacters}
isConfirmingCharacter={isConfirmingCharacter}
deletingCharacterId={deletingCharacterId}
batchConfirming={batchConfirming}
batchConfirmingState={batchConfirmingState}
onBatchConfirm={handleBatchConfirm}
onEditProfile={handleEditProfile}
onConfirmProfile={handleConfirmProfile}
onUseExistingProfile={handleCopyFromGlobal}
onDeleteProfile={handleDeleteProfile}
/>
)}
{(kindFilter === 'all' || kindFilter === 'location') && (

View File

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

View File

@@ -2,16 +2,14 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { useTranslations } from 'next-intl'
import { useRefreshProjectAssets, useProjectAssets, useProjectData } from '@/lib/query/hooks'
import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
import { useProjectAssets, useProjectData } from '@/lib/query/hooks'
import { AppIcon } from '@/components/ui/icons'
import JSZip from 'jszip'
import { logError as _logError } from '@/lib/logging/core'
/**
* AssetToolbar - 资产管理工具栏组件
* 从 AssetsStage.tsx 提取,负责批量操作和刷新按钮
* 从 AssetsStage.tsx 提取,负责资产统计与顶部操作
*/
interface EpisodeOption {
@@ -29,9 +27,6 @@ interface AssetToolbarProps {
isBatchSubmitting: boolean
isAnalyzingAssets: boolean
isGlobalAnalyzing?: boolean
batchProgress: { current: number; total: number }
onGenerateAll: () => void
onRegenerateAll: () => void
onGlobalAnalyze?: () => void
/** Episode filter */
episodeId: string | null
@@ -166,30 +161,17 @@ export default function AssetToolbar({
isBatchSubmitting,
isAnalyzingAssets,
isGlobalAnalyzing = false,
batchProgress,
onGenerateAll,
onRegenerateAll,
onGlobalAnalyze,
episodeId,
onEpisodeChange,
episodes,
}: AssetToolbarProps) {
const onRefresh = useRefreshProjectAssets(projectId)
const t = useTranslations('assets')
const { data: assets } = useProjectAssets(projectId)
const { data: projectData } = useProjectData(projectId)
const projectName = projectData?.name
const [isDownloading, setIsDownloading] = useState(false)
const assetTaskRunningState = isBatchSubmitting
? resolveTaskPresentationState({
phase: 'processing',
intent: 'generate',
resource: 'image',
hasOutput: true,
})
: null
const handleDownloadAll = async () => {
const characters = assets?.characters ?? []
const locations = assets?.locations ?? []
@@ -297,39 +279,6 @@ export default function AssetToolbar({
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onGenerateAll}
disabled={isBatchSubmitting || isAnalyzingAssets || isGlobalAnalyzing}
className="glass-btn-base glass-btn-tone-success flex items-center gap-2 px-4 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isBatchSubmitting ? (
<>
<TaskStatusInline state={assetTaskRunningState} className="text-white [&>span]:text-white [&_svg]:text-white" />
<span className="text-xs text-white/90">({batchProgress.current}/{batchProgress.total})</span>
</>
) : (
<>
<AppIcon name="image" className="w-4 h-4" />
<span>{t("toolbar.generateAll")}</span>
</>
)}
</button>
<button
onClick={onRegenerateAll}
disabled={isBatchSubmitting || isAnalyzingAssets || isGlobalAnalyzing}
className="glass-btn-base glass-btn-tone-warning flex items-center gap-2 px-4 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
title={t("toolbar.regenerateAllHint")}
>
<AppIcon name="refresh" className="w-4 h-4" />
<span>{t("toolbar.regenerateAll")}</span>
</button>
<button
onClick={() => onRefresh()}
className="glass-btn-base glass-btn-secondary flex items-center gap-2 px-4 py-2 text-sm font-medium border border-[var(--glass-stroke-base)]"
>
<AppIcon name="refresh" className="w-4 h-4" />
<span>{t("common.refresh")}</span>
</button>
{/* 打包下载按钮 */}
<button
onClick={handleDownloadAll}

View File

@@ -23,13 +23,24 @@ interface CharacterProfileCardProps {
isDeleting?: boolean
}
const ROLE_LEVEL_COLORS = {
S: 'bg-[var(--glass-tone-warning-bg)] text-[var(--glass-tone-warning-fg)]',
A: 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]',
B: 'bg-[var(--glass-tone-neutral-bg)] text-[var(--glass-tone-neutral-fg)]',
C: 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-primary)]',
D: 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-secondary)]'
/**
* 游戏品质分级颜色系统
* S金橙 / A史诗紫 / B稀有蓝 / C精良绿 / D普通灰
*/
interface TierStyle {
gradient: string
glow: string
accent: string
}
const TIER_STYLES: Record<string, TierStyle> = {
S: { gradient: 'linear-gradient(135deg, #f59e0b, #ef4444)', glow: '0 2px 8px rgba(245,158,11,0.35)', accent: '#b45309' },
A: { gradient: 'linear-gradient(135deg, #a855f7, #6366f1)', glow: '0 2px 8px rgba(168,85,247,0.3)', accent: '#7c3aed' },
B: { gradient: 'linear-gradient(135deg, #3b82f6, #06b6d4)', glow: '0 2px 8px rgba(59,130,246,0.3)', accent: '#2563eb' },
C: { gradient: 'linear-gradient(135deg, #22c55e, #10b981)', glow: '0 2px 8px rgba(34,197,94,0.25)', accent: '#16a34a' },
D: { gradient: 'linear-gradient(135deg, #9ca3af, #6b7280)', glow: '0 2px 6px rgba(156,163,175,0.2)', accent: '#6b7280' },
}
const ROLE_LEVELS = ['S', 'A', 'B', 'C', 'D'] as const
type RoleLevel = (typeof ROLE_LEVELS)[number]
@@ -68,117 +79,124 @@ export default function CharacterProfileCard({
const roleLevelLabel = roleLevel
? t(`characterProfile.importance.${roleLevel}`)
: profileData.role_level
const roleLevelColor = roleLevel
? ROLE_LEVEL_COLORS[roleLevel]
: 'bg-[var(--glass-bg-muted)] text-[var(--glass-text-primary)]'
const tierStyle = roleLevel ? TIER_STYLES[roleLevel] : null
return (
<div className="bg-[var(--glass-bg-surface)] rounded-xl border border-[var(--glass-stroke-base)] p-4 hover:shadow-md transition-shadow">
{/* 头部 */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h3 className="font-semibold text-[var(--glass-text-primary)] mb-1">{name}</h3>
<div className="flex items-center gap-2 flex-wrap">
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${roleLevelColor}`}>
{roleLevelLabel}
</span>
<span className="text-xs text-[var(--glass-text-tertiary)]">{profileData.archetype}</span>
<div className="glass-surface overflow-hidden hover:shadow-md transition-shadow">
<div className="p-5">
{/* 头部 */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-bold text-[var(--glass-text-primary)] mb-1.5">{name}</h3>
<div className="flex items-center gap-2 flex-wrap">
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-[11px] font-black text-white tracking-wide"
style={{
background: tierStyle?.gradient ?? 'var(--glass-bg-muted)',
boxShadow: tierStyle?.glow ?? 'none',
...(!tierStyle ? { color: 'var(--glass-text-primary)' } : {}),
}}
>
{roleLevelLabel}
</span>
<span className="text-xs text-[var(--glass-text-tertiary)]">{profileData.archetype}</span>
</div>
</div>
{/* 删除按钮 */}
{onDelete && (
<button
onClick={onDelete}
disabled={isConfirming || isDeleting}
className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-danger-fg)] hover:bg-[var(--glass-tone-danger-bg)] transition-colors disabled:opacity-50 shrink-0"
title={t('characterProfile.delete')}
>
{isDeleting ? (
<TaskStatusInline state={deletingState} className="[&_span]:sr-only [&_svg]:text-current" />
) : (
<AppIcon name="trash" className="w-4 h-4" />
)}
</button>
)}
</div>
{/* 删除按钮 */}
{onDelete && (
{/* 档案摘要 */}
<div className="space-y-1.5 mb-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.gender')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.gender}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.age')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.age_range}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.era')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.era_period}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.class')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.social_class}</span>
</div>
{profileData.occupation && (
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.occupation')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.occupation}</span>
</div>
)}
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.personality')}</span>
<div className="flex flex-wrap gap-1">
{profileData.personality_tags.map((tag, i) => (
<span key={i} className="px-1.5 py-0.5 bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] rounded text-xs font-medium">
{tag}
</span>
))}
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.costume')}</span>
<span className="text-[var(--glass-text-primary)]">
{'●'.repeat(profileData.costume_tier)}{'○'.repeat(5 - profileData.costume_tier)}
</span>
</div>
{profileData.primary_identifier && (
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-[2.5rem] shrink-0 text-xs">{t('characterProfile.summary.identifier')}</span>
<span className="font-medium" style={{ color: tierStyle?.accent ?? 'var(--glass-tone-warning-fg)' }}>{profileData.primary_identifier}</span>
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex gap-2 pt-3 border-t border-[var(--glass-stroke-base)]">
<button
onClick={onDelete}
disabled={isConfirming || isDeleting}
className="p-1.5 text-[var(--glass-text-tertiary)] hover:text-[var(--glass-tone-danger-fg)] hover:bg-[var(--glass-tone-danger-bg)] rounded-lg transition-colors disabled:opacity-50"
title={t('characterProfile.delete')}
onClick={onEdit}
disabled={isConfirming}
className="glass-btn-base glass-btn-secondary flex-1 px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
>
{isDeleting ? (
<TaskStatusInline state={deletingState} className="[&_span]:sr-only [&_svg]:text-current" />
{t('characterProfile.editProfile')}
</button>
{onUseExisting && (
<button
onClick={onUseExisting}
disabled={isConfirming}
className="glass-btn-base glass-btn-tone-info flex-1 px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
>
{t('characterProfile.useExisting')}
</button>
)}
<button
onClick={onConfirm}
disabled={isConfirming}
className="glass-btn-base glass-btn-primary flex-1 px-3 py-1.5 text-sm rounded-lg disabled:opacity-50"
>
{isConfirming ? (
<TaskStatusInline state={confirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
) : (
<AppIcon name="trash" className="w-4 h-4" />
t('characterProfile.confirmAndGenerate')
)}
</button>
)}
</div>
{/* 档案摘要 */}
<div className="space-y-1.5 mb-3">
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.gender')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.gender}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.age')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.age_range}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.era')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.era_period}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.class')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.social_class}</span>
</div>
{profileData.occupation && (
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.occupation')}</span>
<span className="text-[var(--glass-text-primary)]">{profileData.occupation}</span>
</div>
)}
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.personality')}</span>
<div className="flex flex-wrap gap-1">
{profileData.personality_tags.map((tag, i) => (
<span key={i} className="px-1.5 py-0.5 bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)] rounded text-xs">
{tag}
</span>
))}
</div>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.costume')}</span>
<span className="text-[var(--glass-text-primary)]">
{'●'.repeat(profileData.costume_tier)}
</span>
</div>
{profileData.primary_identifier && (
<div className="flex items-center gap-2 text-sm">
<span className="text-[var(--glass-text-tertiary)] w-16">{t('characterProfile.summary.identifier')}</span>
<span className="text-[var(--glass-tone-warning-fg)] font-medium">{profileData.primary_identifier}</span>
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex gap-2">
<button
onClick={onEdit}
disabled={isConfirming}
className="flex-1 px-3 py-1.5 text-sm border border-[var(--glass-stroke-strong)] rounded-lg hover:bg-[var(--glass-bg-muted)] transition-colors disabled:opacity-50"
>
{t('characterProfile.editProfile')}
</button>
{onUseExisting && (
<button
onClick={onUseExisting}
disabled={isConfirming}
className="flex-1 px-3 py-1.5 text-sm border border-[var(--glass-stroke-focus)] text-[var(--glass-tone-info-fg)] rounded-lg hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50 flex items-center justify-center gap-1"
>
{t('characterProfile.useExisting')}
</button>
)}
<button
onClick={onConfirm}
disabled={isConfirming}
className="flex-1 px-3 py-1.5 text-sm bg-[var(--glass-accent-from)] text-white rounded-lg hover:bg-[var(--glass-accent-to)] transition-colors disabled:opacity-50 flex items-center justify-center gap-1"
>
{isConfirming ? (
<TaskStatusInline state={confirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
) : (
t('characterProfile.confirmAndGenerate')
)}
</button>
</div>
</div>
)

View File

@@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl'
import { useEffect, useMemo, useRef, useState } from 'react'
import TaskStatusInline from '@/components/task/TaskStatusInline'
import { resolveTaskPresentationState } from '@/lib/task/presentation'
import type { TaskPresentationState } from '@/lib/task/presentation'
import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
/**
@@ -11,11 +12,14 @@ import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
* 从 AssetsStage.tsx 提取,负责角色列表的展示和操作
*
* 🔥 V6.5 重构:内部直接订阅 useProjectAssets消除 props drilling
* 🔥 V7 重构:待确认角色档案内嵌显示,不再使用独立 Section
*/
import { Character, CharacterAppearance } from '@/types/project'
import { useProjectAssets } from '@/lib/query/hooks/useProjectAssets'
import CharacterCard from './CharacterCard'
import CharacterProfileCard from './CharacterProfileCard'
import { parseProfileData } from '@/types/character-profile'
import { AppIcon } from '@/components/ui/icons'
interface CharacterSectionProps {
@@ -49,6 +53,17 @@ interface CharacterSectionProps {
getAppearances: (character: Character) => CharacterAppearance[]
/** 分集筛选:仅显示指定 ID 的角色null 表示显示全部 */
filterIds?: Set<string> | null
// 🔥 V7待确认角色档案内嵌到 CharacterSection
unconfirmedCharacters: Character[]
isConfirmingCharacter: (characterId: string) => boolean
deletingCharacterId: string | null
batchConfirming: boolean
batchConfirmingState: TaskPresentationState | null
onBatchConfirm: () => void
onEditProfile: (characterId: string, characterName: string) => void
onConfirmProfile: (characterId: string) => void
onUseExistingProfile: (characterId: string) => void
onDeleteProfile: (characterId: string) => void
}
export default function CharacterSection({
@@ -78,6 +93,17 @@ export default function CharacterSection({
onCopyFromGlobal,
getAppearances,
filterIds = null,
// 🔥 V7待确认角色
unconfirmedCharacters,
isConfirmingCharacter,
deletingCharacterId,
batchConfirming,
batchConfirmingState,
onBatchConfirm,
onEditProfile,
onConfirmProfile,
onUseExistingProfile,
onDeleteProfile,
}: CharacterSectionProps) {
const t = useTranslations('assets')
const analyzingAssetsState = isAnalyzingAssets
@@ -91,9 +117,17 @@ export default function CharacterSection({
const { data: assets } = useProjectAssets(projectId)
const allCharacters: Character[] = useMemo(() => assets?.characters ?? [], [assets?.characters])
// 🔥 V7排除待确认角色避免同一角色在待确认区与已确认网格中重复出现
const unconfirmedIds = useMemo(
() => new Set(unconfirmedCharacters.map((c) => c.id)),
[unconfirmedCharacters],
)
const characters: Character[] = useMemo(
() => filterIds ? allCharacters.filter((c) => filterIds.has(c.id)) : allCharacters,
[allCharacters, filterIds],
() => {
const base = filterIds ? allCharacters.filter((c) => filterIds.has(c.id)) : allCharacters
return base.filter((c) => !unconfirmedIds.has(c.id))
},
[allCharacters, filterIds, unconfirmedIds],
)
const [highlightedCharacterId, setHighlightedCharacterId] = useState<string | null>(null)
const scrollAnimationRef = useRef<number | null>(null)
@@ -179,6 +213,54 @@ export default function CharacterSection({
</button>
</div>
{/* 🔥 V7待确认角色档案 - 内嵌引导横幅 */}
{unconfirmedCharacters.length > 0 && (
<div className="mb-6">
{/* 引导横幅 */}
<div className="flex items-center justify-between mb-3 px-1">
<div className="flex items-center gap-2">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-md bg-[var(--glass-tone-info-bg)]">
<AppIcon name="sparkles" className="h-3 w-3 text-[var(--glass-tone-info-fg)]" />
</span>
<span className="text-sm font-semibold text-[var(--glass-text-primary)]">{t('stage.pendingProfilesBanner')}</span>
<span className="text-xs text-[var(--glass-text-tertiary)]">{t('stage.pendingProfilesHint')}</span>
</div>
<button
onClick={onBatchConfirm}
disabled={batchConfirming}
className="glass-btn-base glass-btn-primary px-3 py-1.5 text-sm disabled:opacity-50 flex items-center gap-1.5"
>
{batchConfirming ? (
<TaskStatusInline state={batchConfirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
) : (
t('stage.confirmAll', { count: unconfirmedCharacters.length })
)}
</button>
</div>
{/* 待确认卡片网格 */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{unconfirmedCharacters.map((character) => {
const profileData = parseProfileData(character.profileData!)
if (!profileData) return null
return (
<CharacterProfileCard
key={character.id}
characterId={character.id}
name={character.name}
profileData={profileData}
onEdit={() => onEditProfile(character.id, character.name)}
onConfirm={() => onConfirmProfile(character.id)}
onUseExisting={() => onUseExistingProfile(character.id)}
onDelete={() => onDeleteProfile(character.id)}
isConfirming={isConfirmingCharacter(character.id)}
isDeleting={deletingCharacterId === character.id}
/>
)
})}
</div>
</div>
)}
{/* 按角色分组显示:外层 grid 让多角色并排 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{characters.map(character => {
@@ -198,7 +280,7 @@ export default function CharacterSection({
className={`glass-surface rounded-xl p-4 scroll-mt-24 transition-all duration-700 ${highlightedCharacterId === character.id ? 'ring-2 ring-[var(--glass-focus-ring)] bg-[var(--glass-tone-info-bg)]/40' : ''}`}
>
{/* 角色标题 */}
<div className="flex items-center justify-between border-b border-[var(--glass-stroke-base)] pb-2">
<div className="flex items-center justify-between pb-2">
<div className="flex items-center gap-3">
<h3 className="text-base font-semibold text-[var(--glass-text-primary)]">{character.name}</h3>
<span className="text-xs text-[var(--glass-text-tertiary)]">

View File

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

View File

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

View File

@@ -1,85 +0,0 @@
'use client'
import TaskStatusInline from '@/components/task/TaskStatusInline'
import CharacterProfileCard from './CharacterProfileCard'
import { parseProfileData } from '@/types/character-profile'
import type { Character } from '@/types/project'
import type { TaskPresentationState } from '@/lib/task/presentation'
interface UnconfirmedProfilesSectionProps {
unconfirmedCharacters: Character[]
confirmTitle: string
confirmHint: string
confirmAllLabel: string
batchConfirming: boolean
batchConfirmingState: TaskPresentationState | null
deletingCharacterId: string | null
isConfirmingCharacter: (characterId: string) => boolean
onBatchConfirm: () => void
onEditProfile: (characterId: string, characterName: string) => void
onConfirmProfile: (characterId: string) => void
onUseExistingProfile: (characterId: string) => void
onDeleteProfile: (characterId: string) => void
}
export default function UnconfirmedProfilesSection({
unconfirmedCharacters,
confirmTitle,
confirmHint,
confirmAllLabel,
batchConfirming,
batchConfirmingState,
deletingCharacterId,
isConfirmingCharacter,
onBatchConfirm,
onEditProfile,
onConfirmProfile,
onUseExistingProfile,
onDeleteProfile,
}: UnconfirmedProfilesSectionProps) {
if (unconfirmedCharacters.length === 0) {
return null
}
return (
<div className="bg-[var(--glass-tone-warning-bg)] border border-[var(--glass-stroke-warning)] rounded-xl p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">{confirmTitle}</h3>
<p className="text-sm text-[var(--glass-text-secondary)]">{confirmHint}</p>
</div>
<button
onClick={onBatchConfirm}
disabled={batchConfirming}
className="glass-btn-base glass-btn-primary px-4 py-2 disabled:opacity-50 flex items-center gap-2"
>
{batchConfirming ? (
<TaskStatusInline state={batchConfirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
) : (
confirmAllLabel
)}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{unconfirmedCharacters.map((character) => {
const profileData = parseProfileData(character.profileData!)
if (!profileData) return null
return (
<CharacterProfileCard
key={character.id}
characterId={character.id}
name={character.name}
profileData={profileData}
onEdit={() => onEditProfile(character.id, character.name)}
onConfirm={() => onConfirmProfile(character.id)}
onUseExisting={() => onUseExistingProfile(character.id)}
onDelete={() => onDeleteProfile(character.id)}
isConfirming={isConfirmingCharacter(character.id)}
isDeleting={deletingCharacterId === character.id}
/>
)
})}
</div>
</div>
)
}

View File

@@ -114,94 +114,110 @@ export default function VoiceSettings({
? 'border border-[var(--glass-stroke-base)] rounded-xl p-3 bg-[var(--glass-bg-surface-strong)]'
: 'mt-4 border border-[var(--glass-stroke-base)] rounded-xl p-4 bg-[var(--glass-bg-surface-strong)]'
const headerClass = compact
? 'flex items-center gap-2 mb-2 pb-2 border-b'
: 'flex items-center gap-2 mb-3 pb-2 border-b'
const iconSize = compact ? 'w-5 h-5' : 'w-6 h-6'
const innerIconSize = compact ? 'w-3 h-3' : 'w-3.5 h-3.5'
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className={containerClass}>
<div className={`${headerClass} ${hasCustomVoice ? 'border-[var(--glass-stroke-base)]' : 'border-[var(--glass-stroke-warning)]'}`}>
<div className={`${iconSize} rounded-full flex items-center justify-center ${hasCustomVoice ? 'bg-[var(--glass-bg-muted)]' : 'bg-[var(--glass-tone-warning-bg)]'}`}>
<AppIcon name="mic" className={`${innerIconSize} ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`} />
{/* 折叠标题行 - 点击展开/收起 */}
<button
type="button"
onClick={() => setIsExpanded((v) => !v)}
className="w-full flex items-center justify-between cursor-pointer"
>
<div className="flex items-center gap-2">
<div className={`${iconSize} rounded-full flex items-center justify-center ${hasCustomVoice ? 'bg-[var(--glass-bg-muted)]' : 'bg-[var(--glass-tone-warning-bg)]'}`}>
<AppIcon name="mic" className={`${innerIconSize} ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`} />
</div>
<span className={`text-${compact ? 'xs' : 'sm'} font-medium text-[var(--glass-text-secondary)]`}>
{t('tts.title')}
</span>
<span className={`w-2 h-2 rounded-full ${hasCustomVoice ? 'bg-[var(--glass-tone-success-fg)]' : 'bg-[var(--glass-tone-warning-fg)]'}`} />
</div>
<span className={`text-${compact ? 'xs' : 'sm'} font-medium ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`}>
{t('tts.title')}{!hasCustomVoice && <span className="text-[var(--glass-tone-warning-fg)]">({t('tts.noVoice')})</span>}
</span>
</div>
<AppIcon
name="chevronDown"
className={`w-4 h-4 text-[var(--glass-text-tertiary)] transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
/>
</button>
{/* 隐藏的音频文件输入 */}
<input
ref={voiceFileInputRef}
type="file"
accept="audio/*"
onChange={handleUploadVoice}
className="hidden"
/>
{/* 展开内容 */}
{isExpanded && (
<div className="mt-3 pt-3 border-t border-[var(--glass-stroke-base)]">
{/* 隐藏的音频文件输入 */}
<input
ref={voiceFileInputRef}
type="file"
accept="audio/*"
onChange={handleUploadVoice}
className="hidden"
/>
<div className="flex flex-wrap gap-2 w-full justify-center">
{/* 上传音频按钮 */}
<button
onClick={() => {
if (!confirmUploadVoice()) return
voiceFileInputRef.current?.click()
}}
disabled={uploadVoice.isPending}
className="flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] rounded-lg text-xs text-[var(--glass-text-secondary)] font-medium hover:border-[var(--glass-stroke-success)] hover:bg-[var(--glass-tone-success-bg)] hover:text-[var(--glass-tone-success-fg)] transition-all relative group whitespace-nowrap"
>
<div className="flex items-center justify-center gap-1">
{hasCustomVoice && <div className="w-1.5 h-1.5 bg-[var(--glass-tone-success-fg)] rounded-full flex-shrink-0"></div>}
<span>{uploadVoice.isPending ? t('tts.uploading') : hasCustomVoice ? t('tts.uploaded') : t('tts.uploadAudio')}</span>
</div>
</button>
<div className="flex flex-wrap gap-2 w-full justify-center">
{/* 上传音频按钮 */}
<button
onClick={() => {
if (!confirmUploadVoice()) return
voiceFileInputRef.current?.click()
}}
disabled={uploadVoice.isPending}
className="flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-base)] rounded-lg text-xs text-[var(--glass-text-secondary)] font-medium hover:border-[var(--glass-stroke-success)] hover:bg-[var(--glass-tone-success-bg)] hover:text-[var(--glass-tone-success-fg)] transition-all relative group whitespace-nowrap"
>
<div className="flex items-center justify-center gap-1">
{hasCustomVoice && <div className="w-1.5 h-1.5 bg-[var(--glass-tone-success-fg)] rounded-full flex-shrink-0"></div>}
<span>{uploadVoice.isPending ? t('tts.uploading') : hasCustomVoice ? t('tts.uploaded') : t('tts.uploadAudio')}</span>
</div>
</button>
{/* 从资产中心选择按钮 */}
{onSelectFromHub && (
<button
onClick={() => onSelectFromHub(characterId)}
className="flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-focus)] rounded-lg text-xs text-[var(--glass-tone-info-fg)] font-medium hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-tone-info-bg)] transition-all whitespace-nowrap"
>
<div className="flex items-center justify-center gap-1">
<AppIcon name="copy" className="w-3.5 h-3.5 flex-shrink-0" />
<span>{t('assetLibrary.button')}</span>
</div>
</button>
)}
{/* AI设计按钮 */}
{onVoiceDesign && (
<button
onClick={() => onVoiceDesign(characterId, characterName)}
className="glass-btn-base glass-btn-primary flex-1 min-w-[80px] px-2 py-1.5 text-xs font-medium whitespace-nowrap"
>
<div className="flex items-center justify-center gap-1">
<AppIcon name="bolt" className="w-3.5 h-3.5 flex-shrink-0" />
<span>{t('modal.aiDesign')}</span>
</div>
</button>
)}
</div>
{/* 试听按钮 - 仅在有音频时显示 */}
{hasCustomVoice && (
<button
onClick={handlePreviewVoice}
className={`w-full mt-2 px-3 py-2 border rounded-lg text-sm font-medium transition-all ${isPreviewingVoice
? 'bg-[var(--glass-accent-from)] border-[var(--glass-stroke-focus)] text-white hover:bg-[var(--glass-accent-to)]'
: 'bg-[var(--glass-tone-info-bg)] border-[var(--glass-stroke-focus)] text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)]'
}`}
>
<div className="flex items-center justify-center gap-2">
{isPreviewingVoice ? (
<AppIcon name="pause" className="w-4 h-4" />
) : (
<AppIcon name="play" className="w-4 h-4" />
{/* 从资产中心选择按钮 */}
{onSelectFromHub && (
<button
onClick={() => onSelectFromHub(characterId)}
className="flex-1 min-w-[80px] px-2 py-1.5 bg-[var(--glass-bg-surface)] border border-[var(--glass-stroke-focus)] rounded-lg text-xs text-[var(--glass-tone-info-fg)] font-medium hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-tone-info-bg)] transition-all whitespace-nowrap"
>
<div className="flex items-center justify-center gap-1">
<AppIcon name="copy" className="w-3.5 h-3.5 flex-shrink-0" />
<span>{t('assetLibrary.button')}</span>
</div>
</button>
)}
{/* AI设计按钮 */}
{onVoiceDesign && (
<button
onClick={() => onVoiceDesign(characterId, characterName)}
className="glass-btn-base glass-btn-primary flex-1 min-w-[80px] px-2 py-1.5 text-xs font-medium whitespace-nowrap"
>
<div className="flex items-center justify-center gap-1">
<AppIcon name="bolt" className="w-3.5 h-3.5 flex-shrink-0" />
<span>{t('modal.aiDesign')}</span>
</div>
</button>
)}
{isPreviewingVoice ? t('tts.pause') : t('tts.preview')}
</div>
</button>
{/* 试听按钮 - 仅在有音频时显示 */}
{hasCustomVoice && (
<button
onClick={handlePreviewVoice}
className={`w-full mt-2 px-3 py-2 border rounded-lg text-sm font-medium transition-all ${isPreviewingVoice
? 'bg-[var(--glass-accent-from)] border-[var(--glass-stroke-focus)] text-white hover:bg-[var(--glass-accent-to)]'
: 'bg-[var(--glass-tone-info-bg)] border-[var(--glass-stroke-focus)] text-[var(--glass-tone-info-fg)] hover:bg-[var(--glass-tone-info-bg)]'
}`}
>
<div className="flex items-center justify-center gap-2">
{isPreviewingVoice ? (
<AppIcon name="pause" className="w-4 h-4" />
) : (
<AppIcon name="play" className="w-4 h-4" />
)}
{isPreviewingVoice ? t('tts.pause') : t('tts.preview')}
</div>
</button>
)}
</div>
)}
</div>
)

View File

@@ -0,0 +1,17 @@
import type { Location, Prop } from '@/types/project'
export function canGenerateLocationBackedAsset(asset: Location | Prop): boolean {
if (asset.summary && asset.summary.trim().length > 0) {
return true
}
return (asset.images ?? []).some((image) =>
typeof image.description === 'string' && image.description.trim().length > 0,
)
}
export function resolveLocationBackedGenerateType(
assetType: 'location' | 'prop',
): 'location' | 'prop' {
return assetType
}

View File

@@ -19,7 +19,7 @@ type LocationCardActionsProps =
mode: 'compact'
currentImageUrl: string | null | undefined
isTaskRunning: boolean
hasDescription: boolean
canGenerate: boolean
generationCount: number
onGenerationCountChange: (value: number) => void
onGenerate: (count?: number) => void
@@ -67,7 +67,7 @@ export default function LocationCardActions(props: LocationCardActionsProps) {
options={getImageGenerationCountOptions('location')}
onValueChange={props.onGenerationCountChange}
onClick={() => props.onGenerate(props.generationCount)}
disabled={!props.hasDescription}
disabled={!props.canGenerate}
ariaLabel={t('image.selectCount')}
className="glass-btn-base glass-btn-primary flex w-full items-center justify-center gap-1 py-1 text-xs disabled:opacity-50"
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-xs font-semibold text-current outline-none cursor-pointer leading-none transition-colors"

View File

@@ -321,6 +321,7 @@ export default function ScriptViewAssetsPanel({
const hasCharacterSelectionChanges = !setsEqual(initialAppearanceKeys, pendingAppearanceKeys) || hasCharacterLabelChanges
const hasLocationSelectionChanges = !setsEqual(new Set(activeLocationIds), pendingLocationIds) || hasLocationLabelChanges
const hasPropSelectionChanges = !setsEqual(new Set(activePropIds), pendingPropIds)
const hasProjectProps = props.length > 0
const handleConfirmCharacterSelection = async () => {
if (isSavingCharacterSelection) return
@@ -754,6 +755,7 @@ export default function ScriptViewAssetsPanel({
)}
</div>
{hasProjectProps ? (
<div className="relative">
<div className="flex justify-between items-center mb-3">
<h3 className="text-sm font-bold text-[var(--glass-text-secondary)]"> ({activePropIds.length})</h3>
@@ -855,6 +857,7 @@ export default function ScriptViewAssetsPanel({
</div>
)}
</div>
) : null}
</div>
</div>
@@ -874,7 +877,7 @@ export default function ScriptViewAssetsPanel({
<button
onClick={onGenerateStoryboard}
disabled={isSubmittingStoryboardBuild || clips.length === 0 || !allAssetsHaveImages}
className="w-full py-4 text-lg font-bold bg-[var(--glass-accent-from)] text-white rounded-2xl"
className="glass-btn-base glass-btn-primary w-full py-4 text-lg font-bold disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{isSubmittingStoryboardBuild ? tScript('generate.generating') : tScript('generate.startGenerate')}
</button>

View File

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

View File

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

View File

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

View File

@@ -63,6 +63,22 @@ function buildImageGroups(
return groups
}
function normalizeSeedDescriptions(input: {
descriptions?: string[]
fallbackDescription: string
}): string[] {
const normalized = (input.descriptions ?? [])
.map((description) => description.trim())
.filter((description) => description.length > 0)
if (normalized.length > 0) {
return normalized
}
const fallbackDescription = input.fallbackDescription.trim()
return fallbackDescription.length > 0 ? [fallbackDescription] : []
}
async function readProjectLocationBackedImages(locationIds: string[]): Promise<Map<string, LocationBackedImageRow[]>> {
if (locationIds.length === 0) {
return new Map()
@@ -194,6 +210,11 @@ export async function createProjectLocationBackedAsset(input: {
NOW()
)
`)
await seedProjectLocationBackedImageSlots({
locationId: id,
fallbackDescription: input.summary,
descriptions: [input.summary],
})
return { id }
}
@@ -229,9 +250,52 @@ export async function createGlobalLocationBackedAsset(input: {
NOW()
)
`)
await seedGlobalLocationBackedImageSlots({
locationId: id,
fallbackDescription: input.summary,
descriptions: [input.summary],
})
return { id }
}
export async function seedProjectLocationBackedImageSlots(input: {
locationId: string
fallbackDescription: string
descriptions?: string[]
}): Promise<void> {
const descriptions = normalizeSeedDescriptions(input)
if (descriptions.length === 0) {
return
}
await prisma.locationImage.createMany({
data: descriptions.map((description, imageIndex) => ({
locationId: input.locationId,
imageIndex,
description,
})),
})
}
export async function seedGlobalLocationBackedImageSlots(input: {
locationId: string
fallbackDescription: string
descriptions?: string[]
}): Promise<void> {
const descriptions = normalizeSeedDescriptions(input)
if (descriptions.length === 0) {
return
}
await prisma.globalLocationImage.createMany({
data: descriptions.map((description, imageIndex) => ({
locationId: input.locationId,
imageIndex,
description,
})),
})
}
export async function deleteProjectLocationBackedAsset(assetId: string): Promise<void> {
await prisma.$transaction([
prisma.$executeRaw(Prisma.sql`DELETE FROM location_images WHERE locationId = ${assetId}`),

View File

@@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { queryKeys } from '../keys'
import type { Character, Location, MediaRef, Prop } from '@/types/project'
import { useAssets } from './useAssets'
import type { AssetGroupMap } from '@/lib/assets/grouping'
import { groupAssetsByKind } from '@/lib/assets/grouping'
// ============ 类型定义 ============
@@ -14,6 +15,96 @@ export interface ProjectAssetsData {
props: Prop[]
}
function mapCharacterAssetToProjectCharacter(asset: AssetGroupMap['character'][number]): Character {
return {
id: asset.id,
name: asset.name,
aliases: null,
introduction: asset.introduction,
appearances: asset.variants.map((variant) => ({
id: variant.id,
appearanceIndex: variant.index,
changeReason: variant.label,
description: variant.description,
descriptions: null,
imageUrl: variant.renders.find((render) => render.isSelected)?.imageUrl
?? variant.renders[0]?.imageUrl
?? null,
media: variant.renders.find((render) => render.isSelected)?.media
?? variant.renders[0]?.media
?? null,
imageUrls: variant.renders.map((render) => render.imageUrl ?? '').filter((value) => value.length > 0),
imageMedias: variant.renders.map((render) => render.media).filter((media): media is MediaRef => !!media),
previousImageUrl: variant.renders[0]?.previousImageUrl ?? null,
previousMedia: variant.renders[0]?.previousMedia ?? null,
previousImageUrls: variant.renders.map((render) => render.previousImageUrl ?? '').filter((value) => value.length > 0),
previousImageMedias: variant.renders.map((render) => render.previousMedia).filter((media): media is MediaRef => !!media),
previousDescription: null,
previousDescriptions: null,
selectedIndex: variant.selectionState.selectedRenderIndex,
imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning,
imageErrorMessage: variant.taskState.lastError?.message ?? null,
lastError: variant.taskState.lastError ?? asset.taskState.lastError,
})),
voiceType: asset.voice.voiceType,
voiceId: asset.voice.voiceId,
customVoiceUrl: asset.voice.customVoiceUrl,
media: asset.voice.media,
profileData: asset.profileData,
profileConfirmed: asset.profileConfirmed ?? undefined,
profileConfirmTaskRunning: asset.profileTaskState.isRunning,
}
}
function mapLocationVariantToProjectImage(
asset: AssetGroupMap['location'][number] | AssetGroupMap['prop'][number],
variant: AssetGroupMap['location'][number]['variants'][number],
) {
const render = variant.renders[0] ?? null
return {
id: variant.id,
imageIndex: variant.index,
description: variant.description,
imageUrl: render?.imageUrl ?? null,
media: render?.media ?? null,
previousImageUrl: render?.previousImageUrl ?? null,
previousMedia: render?.previousMedia ?? null,
previousDescription: null,
isSelected: render?.isSelected ?? false,
imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning,
imageErrorMessage: variant.taskState.lastError?.message ?? null,
lastError: variant.taskState.lastError ?? asset.taskState.lastError,
}
}
function mapLocationAssetToProjectLocation(asset: AssetGroupMap['location'][number]): Location {
return {
id: asset.id,
name: asset.name,
summary: asset.summary,
selectedImageId: asset.selectedVariantId,
images: asset.variants.map((variant) => mapLocationVariantToProjectImage(asset, variant)),
}
}
function mapPropAssetToProjectProp(asset: AssetGroupMap['prop'][number]): Prop {
return {
id: asset.id,
name: asset.name,
summary: asset.summary,
selectedImageId: asset.selectedVariantId,
images: asset.variants.map((variant) => mapLocationVariantToProjectImage(asset, variant)),
}
}
export function mapAssetGroupsToProjectAssetsData(groups: AssetGroupMap): ProjectAssetsData {
return {
characters: groups.character.map(mapCharacterAssetToProjectCharacter),
locations: groups.location.map(mapLocationAssetToProjectLocation),
props: groups.prop.map(mapPropAssetToProjectProp),
}
}
// ============ 查询 Hooks ============
/**
@@ -25,92 +116,7 @@ export function useProjectAssets(projectId: string | null) {
projectId,
})
const groups = groupAssetsByKind(assetsQuery.data)
const data: ProjectAssetsData = {
characters: groups.character.map((asset) => ({
id: asset.id,
name: asset.name,
aliases: null,
introduction: asset.introduction,
appearances: asset.variants.map((variant) => ({
id: variant.id,
appearanceIndex: variant.index,
changeReason: variant.label,
description: variant.description,
descriptions: null,
imageUrl: variant.renders.find((render) => render.isSelected)?.imageUrl
?? variant.renders[0]?.imageUrl
?? null,
media: variant.renders.find((render) => render.isSelected)?.media
?? variant.renders[0]?.media
?? null,
imageUrls: variant.renders.map((render) => render.imageUrl ?? '').filter((value) => value.length > 0),
imageMedias: variant.renders.map((render) => render.media).filter((media): media is MediaRef => !!media),
previousImageUrl: variant.renders[0]?.previousImageUrl ?? null,
previousMedia: variant.renders[0]?.previousMedia ?? null,
previousImageUrls: variant.renders.map((render) => render.previousImageUrl ?? '').filter((value) => value.length > 0),
previousImageMedias: variant.renders.map((render) => render.previousMedia).filter((media): media is MediaRef => !!media),
previousDescription: null,
previousDescriptions: null,
selectedIndex: variant.selectionState.selectedRenderIndex,
imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning,
imageErrorMessage: variant.taskState.lastError?.message ?? null,
lastError: variant.taskState.lastError ?? asset.taskState.lastError,
})),
voiceType: asset.voice.voiceType,
voiceId: asset.voice.voiceId,
customVoiceUrl: asset.voice.customVoiceUrl,
media: asset.voice.media,
profileData: null,
profileConfirmed: asset.profileConfirmed ?? undefined,
profileConfirmTaskRunning: asset.profileTaskState.isRunning,
})),
locations: groups.location.map((asset) => ({
id: asset.id,
name: asset.name,
summary: asset.summary,
selectedImageId: asset.selectedVariantId,
images: asset.variants.map((variant) => {
const render = variant.renders[0] ?? null
return {
id: variant.id,
imageIndex: variant.index,
description: variant.description,
imageUrl: render?.imageUrl ?? null,
media: render?.media ?? null,
previousImageUrl: render?.previousImageUrl ?? null,
previousMedia: render?.previousMedia ?? null,
previousDescription: null,
isSelected: render?.isSelected ?? false,
imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning,
imageErrorMessage: variant.taskState.lastError?.message ?? null,
lastError: variant.taskState.lastError ?? asset.taskState.lastError,
}
}),
})),
props: groups.prop.map((asset) => ({
id: asset.id,
name: asset.name,
summary: asset.summary,
selectedImageId: asset.selectedVariantId,
images: asset.variants.map((variant) => {
const render = variant.renders[0] ?? null
return {
id: variant.id,
imageIndex: variant.index,
description: variant.description,
imageUrl: render?.imageUrl ?? null,
media: render?.media ?? null,
previousImageUrl: render?.previousImageUrl ?? null,
previousMedia: render?.previousMedia ?? null,
previousDescription: null,
isSelected: render?.isSelected ?? false,
imageTaskRunning: asset.taskState.isRunning || variant.taskState.isRunning,
imageErrorMessage: variant.taskState.lastError?.message ?? null,
lastError: variant.taskState.lastError ?? asset.taskState.lastError,
}
}),
})),
}
const data = mapAssetGroupsToProjectAssetsData(groups)
return {
...assetsQuery,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,8 @@ const prismaMock = vi.hoisted(() => ({
$queryRaw: vi.fn(),
$executeRaw: vi.fn(),
$transaction: vi.fn(),
locationImage: { createMany: vi.fn() },
globalLocationImage: { createMany: vi.fn() },
}))
vi.mock('@/lib/prisma', () => ({
@@ -44,4 +46,50 @@ describe('location-backed assets service', () => {
expect(imageSql).toContain('FROM location_images')
expect(imageSql).toContain('NULL AS previousImageMediaId')
})
it('seeds an initial project image slot when creating a prop asset', async () => {
const mod = await import('@/lib/assets/services/location-backed-assets')
const result = await mod.createProjectLocationBackedAsset({
novelPromotionProjectId: 'novel-project-1',
name: 'Bronze Dagger',
summary: 'Old bronze dagger',
kind: 'prop',
})
expect(prismaMock.locationImage.createMany).toHaveBeenCalledWith({
data: [
{
locationId: result.id,
imageIndex: 0,
description: 'Old bronze dagger',
},
],
})
})
it('seeds multiple project image slots when explicit descriptions are provided', async () => {
const mod = await import('@/lib/assets/services/location-backed-assets')
await mod.seedProjectLocationBackedImageSlots({
locationId: 'location-1',
descriptions: ['Night street', 'Rainy alley'],
fallbackDescription: 'Night street',
})
expect(prismaMock.locationImage.createMany).toHaveBeenCalledWith({
data: [
{
locationId: 'location-1',
imageIndex: 0,
description: 'Night street',
},
{
locationId: 'location-1',
imageIndex: 1,
description: 'Rainy alley',
},
],
})
})
})

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { canGenerateLocationBackedAsset, resolveLocationBackedGenerateType } from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/location-backed-asset'
describe('location-backed asset generation rules', () => {
it('allows props to generate from summary even before any image slot exists', () => {
expect(canGenerateLocationBackedAsset({
id: 'prop-1',
name: '金箍棒',
summary: '一根两头包裹金片的黑铁长棍',
images: [],
})).toBe(true)
})
it('allows locations to generate from seeded image descriptions', () => {
expect(canGenerateLocationBackedAsset({
id: 'location-1',
name: '雨夜街道',
summary: null,
images: [
{
id: 'image-1',
imageIndex: 0,
description: '潮湿反光的老街',
imageUrl: null,
previousImageUrl: null,
previousDescription: null,
isSelected: false,
},
],
})).toBe(true)
})
it('routes prop generation through the prop branch', () => {
expect(resolveLocationBackedGenerateType('prop')).toBe('prop')
expect(resolveLocationBackedGenerateType('location')).toBe('location')
})
})

View File

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

View File

@@ -0,0 +1,80 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import AssetToolbar from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/AssetToolbar'
vi.mock('@/lib/query/hooks', () => ({
useProjectAssets: vi.fn(() => ({ data: { characters: [], locations: [], props: [] } })),
useProjectData: vi.fn(() => ({ data: { name: '项目A' } })),
}))
const messages = {
assets: {
common: {
refresh: '刷新',
},
filterBar: {
allEpisodes: '全部集数',
},
toolbar: {
assetManagement: '资产管理',
assetCount: '共 {total} 个资产({appearances} 角色形象 + {locations} 场景 + {props} 道具)',
globalAnalyze: '全局分析',
globalAnalyzeHint: '分析所有资产',
downloadAll: '下载全部',
generateAll: '生成全部图片',
regenerateAll: '重新生成全部',
regenerateAllHint: '重新生成所有图片',
},
assetLibrary: {
downloadEmpty: '没有可下载图片',
downloadFailed: '下载失败',
},
},
} as const
const renderWithIntl = (node: ReactElement) => {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
describe('AssetToolbar', () => {
it('删除批量生成与刷新按钮 -> 仅保留全局分析和下载入口', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(AssetToolbar, {
projectId: 'project-1',
totalAssets: 24,
totalAppearances: 11,
totalLocations: 13,
totalProps: 0,
isBatchSubmitting: false,
isAnalyzingAssets: false,
isGlobalAnalyzing: false,
onGlobalAnalyze: () => undefined,
episodeId: null,
onEpisodeChange: () => undefined,
episodes: [],
}),
)
expect(html).toContain('全局分析')
expect(html).toContain('title="下载全部"')
expect(html).not.toContain('生成全部图片')
expect(html).not.toContain('重新生成全部')
expect(html).not.toContain('>刷新<')
})
})

View File

@@ -24,6 +24,9 @@ const messages = {
waitingModelOutput: '等待模型输出...',
reasoningNotProvided: '该步骤未返回思考过程',
},
streamStep: {
analyzeProps: '道具分析',
},
runtime: {
llm: {
processing: '模型处理中...',
@@ -69,4 +72,27 @@ describe('LLMStageStreamCard error rendering', () => {
expect(html).not.toContain('Copy error detail')
expect(html).not.toContain('Open feedback form')
})
it('resolves analyze props progress keys without missing message errors', () => {
Reflect.set(globalThis, 'React', React)
const html = renderWithIntl(
createElement(LLMStageStreamCard, {
title: 'progress.streamStep.analyzeProps',
stages: [{
id: 'analyze_props',
title: 'progress.streamStep.analyzeProps',
subtitle: 'progress.streamStep.analyzeProps',
status: 'processing',
progress: 35,
}],
activeStageId: 'analyze_props',
activeMessage: 'progress.streamStep.analyzeProps',
outputText: '',
}),
)
expect(html).toContain('道具分析')
expect(html).not.toContain('progress.streamStep.analyzeProps')
expect(html).not.toContain('MISSING_MESSAGE')
})
})

View File

@@ -0,0 +1,29 @@
import * as React from 'react'
import { createElement } from 'react'
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { SegmentedControl } from '@/components/ui/SegmentedControl'
describe('SegmentedControl', () => {
it('compact 布局 -> 输出左对齐的非拉伸结构', () => {
Reflect.set(globalThis, 'React', React)
const html = renderToStaticMarkup(
createElement(SegmentedControl, {
options: [
{ value: 'all', label: '全部 (24)' },
{ value: 'character', label: '角色 (11)' },
{ value: 'location', label: '场景 (13)' },
{ value: 'prop', label: '道具 (0)' },
],
value: 'all',
onChange: () => undefined,
layout: 'compact',
}),
)
expect(html).toContain('inline-block max-w-full')
expect(html).toContain('inline-grid grid-flow-col auto-cols-[minmax(96px,max-content)]')
expect(html).not.toContain('grid-template-columns:repeat(4,minmax(0,1fr))')
})
})

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest'
import { getPromptTemplate, PROMPT_IDS } from '@/lib/prompt-i18n'
describe('select prop prompt template', () => {
it('zh template restricts extraction to key story props and prefers omission when uncertain', () => {
const template = getPromptTemplate(PROMPT_IDS.NP_SELECT_PROP, 'zh')
expect(template).toContain('关键剧情道具资产分析师')
expect(template).toContain('宁缺毋滥')
expect(template).toContain('必须在剧情中承担明确功能')
expect(template).toContain('如果不确定它是否值得进入资产库,直接不输出')
expect(template).toContain('仅因外观具体、名词明确,不足以成为关键道具')
})
it('en template restricts extraction to key story props and prefers omission when uncertain', () => {
const template = getPromptTemplate(PROMPT_IDS.NP_SELECT_PROP, 'en')
expect(template).toContain('key story prop extractor')
expect(template).toContain('Be conservative')
expect(template).toContain('clear story function')
expect(template).toContain('If you are unsure whether it deserves an asset entry, do not output it')
expect(template).toContain('A specific-looking noun is not enough')
})
})

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { createEmptyAssetGroupMap } from '@/lib/assets/grouping'
import { mapAssetGroupsToProjectAssetsData } from '@/lib/query/hooks/useProjectAssets'
describe('useProjectAssets adapters', () => {
it('preserves profileData for unconfirmed character profiles', () => {
const groups = createEmptyAssetGroupMap()
groups.character.push({
id: 'character-1',
scope: 'project',
kind: 'character',
family: 'visual',
name: '林夏',
folderId: null,
capabilities: {
canGenerate: true,
canSelectRender: true,
canRevertRender: true,
canModifyRender: true,
canUploadRender: true,
canBindVoice: true,
canCopyFromGlobal: true,
},
taskRefs: [],
taskState: {
isRunning: false,
lastError: null,
},
variants: [],
introduction: '主角',
profileData: JSON.stringify({ archetype: 'lead' }),
profileConfirmed: false,
profileTaskRefs: [],
profileTaskState: {
isRunning: false,
lastError: null,
},
voice: {
voiceType: null,
voiceId: null,
customVoiceUrl: null,
media: null,
},
})
const data = mapAssetGroupsToProjectAssetsData(groups)
expect(data.characters).toHaveLength(1)
expect(data.characters[0]).toEqual(expect.objectContaining({
id: 'character-1',
profileData: JSON.stringify({ archetype: 'lead' }),
profileConfirmed: false,
}))
})
})

View File

@@ -0,0 +1,136 @@
import * as React from 'react'
import { createElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { NextIntlClientProvider } from 'next-intl'
import type { AbstractIntlMessages } from 'next-intl'
import ScriptViewAssetsPanel from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/script-view/ScriptViewAssetsPanel'
const messages = {
scriptView: {
inSceneAssets: '出场资产',
assetView: {
allClips: '全部片段',
},
segment: {
title: '片段 {index}',
},
asset: {
activeCharacters: '出场角色',
activeLocations: '出场场景',
defaultAppearance: '默认形象',
},
screenplay: {
noCharacter: '当前片段未选择角色',
noLocation: '当前片段未选择场景',
},
generate: {
startGenerate: '开始生成',
},
},
assets: {
character: {
primary: '初始形象',
},
},
novelPromotion: {
buttons: {
assetLibrary: '资产库',
},
},
common: {
edit: '编辑',
cancel: '取消',
confirm: '确定',
},
} as const
function renderWithIntl(node: ReactElement) {
const providerProps: ComponentProps<typeof NextIntlClientProvider> = {
locale: 'zh',
messages: messages as unknown as AbstractIntlMessages,
timeZone: 'Asia/Shanghai',
children: node,
}
return renderToStaticMarkup(
createElement(NextIntlClientProvider, providerProps),
)
}
function renderPanel(propsCount: number) {
Reflect.set(globalThis, 'React', React)
const props = Array.from({ length: propsCount }, (_, index) => ({
id: `prop-${index + 1}`,
name: `道具${index + 1}`,
summary: `道具描述${index + 1}`,
selectedImageId: null,
images: [],
}))
return renderWithIntl(
createElement(ScriptViewAssetsPanel, {
clips: [{ id: 'clip-1', location: null, props: null }],
assetViewMode: 'all',
setAssetViewMode: () => undefined,
setSelectedClipId: () => undefined,
characters: [],
locations: [],
props,
activeCharIds: [],
activeLocationIds: [],
activePropIds: [],
selectedAppearanceKeys: new Set<string>(),
onUpdateClipAssets: async () => undefined,
onOpenAssetLibrary: () => undefined,
assetsLoading: false,
assetsLoadingState: null,
allAssetsHaveImages: true,
globalCharIds: [],
globalLocationIds: [],
globalPropIds: [],
missingAssetsCount: 0,
onGenerateStoryboard: () => undefined,
isSubmittingStoryboardBuild: false,
getSelectedAppearances: () => [],
tScript: (key: string, values?: Record<string, unknown>) => {
if (key === 'inSceneAssets') return '出场资产'
if (key === 'assetView.allClips') return '全部片段'
if (key === 'segment.title') return `片段 ${String(values?.index ?? '')}`
if (key === 'asset.activeCharacters') return '出场角色'
if (key === 'asset.activeLocations') return '出场场景'
if (key === 'screenplay.noCharacter') return '当前片段未选择角色'
if (key === 'screenplay.noLocation') return '当前片段未选择场景'
if (key === 'generate.startGenerate') return '开始生成'
if (key === 'asset.defaultAppearance') return '默认形象'
return key
},
tAssets: (key: string) => (key === 'character.primary' ? '初始形象' : key),
tNP: (key: string) => (key === 'buttons.assetLibrary' ? '资产库' : key),
tCommon: (key: string) => {
if (key === 'edit') return '编辑'
if (key === 'cancel') return '取消'
if (key === 'confirm') return '确定'
return key
},
}),
)
}
describe('ScriptViewAssetsPanel', () => {
it('hides the prop section when the project has no prop assets', () => {
const html = renderPanel(0)
expect(html).not.toContain('道具 (0)')
expect(html).not.toContain('当前片段未选择道具')
})
it('keeps the prop section visible when the project has prop assets even if none are selected in the current clip', () => {
const html = renderPanel(1)
expect(html).toContain('道具 (0)')
expect(html).toContain('当前片段未选择道具')
})
})

View File

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

View File

@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const prismaMock = vi.hoisted(() => ({
$transaction: vi.fn(),
novelPromotionCharacter: {
findFirst: vi.fn(),
findMany: vi.fn(),
@@ -10,6 +11,7 @@ const prismaMock = vi.hoisted(() => ({
},
characterAppearance: {
create: vi.fn(async () => ({})),
deleteMany: vi.fn(async () => ({ count: 1 })),
},
}))
@@ -89,6 +91,9 @@ function buildJob(type: TaskJobData['type'], payload: Record<string, unknown>):
describe('worker character-profile behavior', () => {
beforeEach(() => {
vi.clearAllMocks()
prismaMock.$transaction.mockImplementation(async (callback: (tx: typeof prismaMock) => Promise<unknown>) => {
return await callback(prismaMock)
})
llmMock.getCompletionContent.mockReturnValue(
JSON.stringify({
@@ -134,10 +139,13 @@ describe('worker character-profile behavior', () => {
await expect(handleCharacterProfileTask(job)).rejects.toThrow('Unsupported character profile task type')
})
it('confirm profile success -> creates appearance and marks profileConfirmed', async () => {
it('confirm profile success -> rebuilds appearances and marks profileConfirmed', async () => {
const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' })
const result = await handleCharacterProfileTask(job)
expect(prismaMock.characterAppearance.deleteMany).toHaveBeenCalledWith({
where: { characterId: 'character-1' },
})
expect(prismaMock.characterAppearance.create).toHaveBeenCalledWith({
data: expect.objectContaining({
characterId: 'character-1',
@@ -149,7 +157,10 @@ describe('worker character-profile behavior', () => {
expect(prismaMock.novelPromotionCharacter.update).toHaveBeenCalledWith({
where: { id: 'character-1' },
data: { profileConfirmed: true },
data: {
profileData: JSON.stringify({ archetype: 'lead' }),
profileConfirmed: true,
},
})
expect(result).toEqual(expect.objectContaining({
@@ -171,4 +182,18 @@ describe('worker character-profile behavior', () => {
})
expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(2)
})
it('reconfirm with existing appearances -> replaces old rows instead of colliding on unique index', async () => {
const job = buildJob(TASK_TYPE.CHARACTER_PROFILE_CONFIRM, { characterId: 'character-1' })
await expect(handleCharacterProfileTask(job)).resolves.toEqual(expect.objectContaining({
success: true,
}))
expect(prismaMock.$transaction).toHaveBeenCalledTimes(1)
expect(prismaMock.characterAppearance.deleteMany).toHaveBeenCalledWith({
where: { characterId: 'character-1' },
})
expect(prismaMock.characterAppearance.create).toHaveBeenCalledTimes(1)
})
})