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

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