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