fix: resolve confirmed character hidden bug, remove online font dependency, improve UI/UX experience
This commit is contained in:
@@ -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') && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import CharacterProfileCard from './CharacterProfileCard'
|
||||
import { parseProfileData } from '@/types/character-profile'
|
||||
import type { Character } from '@/types/project'
|
||||
import type { TaskPresentationState } from '@/lib/task/presentation'
|
||||
|
||||
interface UnconfirmedProfilesSectionProps {
|
||||
unconfirmedCharacters: Character[]
|
||||
confirmTitle: string
|
||||
confirmHint: string
|
||||
confirmAllLabel: string
|
||||
batchConfirming: boolean
|
||||
batchConfirmingState: TaskPresentationState | null
|
||||
deletingCharacterId: string | null
|
||||
isConfirmingCharacter: (characterId: string) => boolean
|
||||
onBatchConfirm: () => void
|
||||
onEditProfile: (characterId: string, characterName: string) => void
|
||||
onConfirmProfile: (characterId: string) => void
|
||||
onUseExistingProfile: (characterId: string) => void
|
||||
onDeleteProfile: (characterId: string) => void
|
||||
}
|
||||
|
||||
export default function UnconfirmedProfilesSection({
|
||||
unconfirmedCharacters,
|
||||
confirmTitle,
|
||||
confirmHint,
|
||||
confirmAllLabel,
|
||||
batchConfirming,
|
||||
batchConfirmingState,
|
||||
deletingCharacterId,
|
||||
isConfirmingCharacter,
|
||||
onBatchConfirm,
|
||||
onEditProfile,
|
||||
onConfirmProfile,
|
||||
onUseExistingProfile,
|
||||
onDeleteProfile,
|
||||
}: UnconfirmedProfilesSectionProps) {
|
||||
if (unconfirmedCharacters.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--glass-tone-warning-bg)] border border-[var(--glass-stroke-warning)] rounded-xl p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">{confirmTitle}</h3>
|
||||
<p className="text-sm text-[var(--glass-text-secondary)]">{confirmHint}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onBatchConfirm}
|
||||
disabled={batchConfirming}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{batchConfirming ? (
|
||||
<TaskStatusInline state={batchConfirmingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
confirmAllLabel
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{unconfirmedCharacters.map((character) => {
|
||||
const profileData = parseProfileData(character.profileData!)
|
||||
if (!profileData) return null
|
||||
return (
|
||||
<CharacterProfileCard
|
||||
key={character.id}
|
||||
characterId={character.id}
|
||||
name={character.name}
|
||||
profileData={profileData}
|
||||
onEdit={() => onEditProfile(character.id, character.name)}
|
||||
onConfirm={() => onConfirmProfile(character.id)}
|
||||
onUseExisting={() => onUseExistingProfile(character.id)}
|
||||
onDelete={() => onDeleteProfile(character.id)}
|
||||
isConfirming={isConfirmingCharacter(character.id)}
|
||||
isDeleting={deletingCharacterId === character.id}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -114,94 +114,110 @@ export default function VoiceSettings({
|
||||
? 'border border-[var(--glass-stroke-base)] rounded-xl p-3 bg-[var(--glass-bg-surface-strong)]'
|
||||
: '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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Location, Prop } from '@/types/project'
|
||||
|
||||
export function canGenerateLocationBackedAsset(asset: Location | Prop): boolean {
|
||||
if (asset.summary && asset.summary.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (asset.images ?? []).some((image) =>
|
||||
typeof image.description === 'string' && image.description.trim().length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveLocationBackedGenerateType(
|
||||
assetType: 'location' | 'prop',
|
||||
): 'location' | 'prop' {
|
||||
return assetType
|
||||
}
|
||||
@@ -19,7 +19,7 @@ type LocationCardActionsProps =
|
||||
mode: 'compact'
|
||||
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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user