feat: initial release v0.3.0
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
'use client'
|
||||
import { logError as _ulogError } from '@/lib/logging/core'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { ART_STYLES } from '@/lib/constants'
|
||||
import { useAiDesignLocation, useCreateAssetHubLocation } from '@/lib/query/hooks'
|
||||
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface AddLocationModalProps {
|
||||
folderId: string | null
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
// 内联 SVG 图标
|
||||
const XMarkIcon = ({ className }: { className?: string }) => (
|
||||
<AppIcon name="close" className={className} />
|
||||
)
|
||||
|
||||
const SparklesIcon = ({ className }: { className?: string }) => (
|
||||
<AppIcon name="sparklesAlt" className={className} />
|
||||
)
|
||||
|
||||
export function AddLocationModal({ folderId, onClose, onSuccess }: AddLocationModalProps) {
|
||||
const t = useTranslations('assetHub')
|
||||
|
||||
// 表单字段
|
||||
const [name, setName] = useState('')
|
||||
const [summary, setSummary] = useState('')
|
||||
const [aiInstruction, setAiInstruction] = useState('')
|
||||
const [artStyle, setArtStyle] = useState('american-comic')
|
||||
|
||||
const aiDesignMutation = useAiDesignLocation()
|
||||
const createLocationMutation = useCreateAssetHubLocation()
|
||||
const { count: locationGenerationCount } = useImageGenerationCount('location')
|
||||
const isSubmitting = createLocationMutation.isPending
|
||||
const isAiDesigning = aiDesignMutation.isPending
|
||||
const aiDesigningState = isAiDesigning
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'generate',
|
||||
resource: 'image',
|
||||
hasOutput: false,
|
||||
})
|
||||
: null
|
||||
const submittingState = isSubmitting
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'generate',
|
||||
resource: 'image',
|
||||
hasOutput: false,
|
||||
})
|
||||
: null
|
||||
|
||||
// AI 设计描述
|
||||
const handleAiDesign = async () => {
|
||||
if (!aiInstruction.trim()) return
|
||||
|
||||
try {
|
||||
const data = await aiDesignMutation.mutateAsync(aiInstruction.trim())
|
||||
setSummary(data.prompt || '')
|
||||
setAiInstruction('')
|
||||
} catch (error) {
|
||||
_ulogError('AI设计失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !summary.trim()) return
|
||||
|
||||
try {
|
||||
await createLocationMutation.mutateAsync({
|
||||
name: name.trim(),
|
||||
summary: summary.trim(),
|
||||
folderId,
|
||||
artStyle,
|
||||
count: locationGenerationCount,
|
||||
})
|
||||
onSuccess()
|
||||
} catch (error) {
|
||||
_ulogError('创建场景失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4">
|
||||
<div className="glass-surface-modal max-w-lg w-full max-h-[85vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">
|
||||
{t('modal.newLocation')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="glass-btn-base glass-btn-soft h-8 w-8 rounded-full flex items-center justify-center text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* AI 设计区域 */}
|
||||
<div className="glass-surface-soft border border-[var(--glass-stroke-base)] rounded-xl p-4 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--glass-text-primary)]">
|
||||
<SparklesIcon className="w-4 h-4" />
|
||||
<span>{t('modal.aiDesign')}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={aiInstruction}
|
||||
onChange={(e) => setAiInstruction(e.target.value)}
|
||||
placeholder={t('modal.aiDesignLocationPlaceholder')}
|
||||
className="glass-input-base flex-1 px-3 py-2 text-sm"
|
||||
disabled={isAiDesigning}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleAiDesign()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAiDesign}
|
||||
disabled={isAiDesigning || !aiInstruction.trim()}
|
||||
className="glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
{isAiDesigning ? (
|
||||
<TaskStatusInline state={aiDesigningState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<>
|
||||
<SparklesIcon className="w-4 h-4" />
|
||||
<span>{t('modal.generate')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="glass-field-hint">
|
||||
{t('modal.aiDesignLocationTip')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 场景名称 */}
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('modal.locationNameLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('modal.locationNamePlaceholder')}
|
||||
className="glass-input-base w-full px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 风格选择 */}
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
画面风格
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ART_STYLES.map((style) => (
|
||||
<button
|
||||
key={style.value}
|
||||
type="button"
|
||||
onClick={() => setArtStyle(style.value)}
|
||||
className={`glass-btn-base px-3 py-2 rounded-lg text-sm border flex items-center justify-start transition-all ${artStyle === style.value
|
||||
? 'glass-btn-tone-info border-[var(--glass-stroke-focus)]'
|
||||
: 'glass-btn-soft border-[var(--glass-stroke-base)] text-[var(--glass-text-secondary)] hover:border-[var(--glass-stroke-strong)]'
|
||||
}`}
|
||||
>
|
||||
<span>{style.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 场景描述 */}
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('modal.locationSummaryLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder={t('modal.locationSummaryPlaceholder')}
|
||||
className="glass-textarea-base w-full h-40 px-3 py-2 text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 按钮区 */}
|
||||
<div className="flex gap-3 justify-end mt-6 pt-4 border-t border-[var(--glass-stroke-base)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg text-sm"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !name.trim() || !summary.trim()}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<TaskStatusInline state={submittingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<span>{t('modal.addLocation')}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
300
src/app/[locale]/workspace/asset-hub/components/AssetGrid.tsx
Normal file
300
src/app/[locale]/workspace/asset-hub/components/AssetGrid.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useState } from 'react'
|
||||
import { CharacterCard } from './CharacterCard'
|
||||
import { LocationCard } from './LocationCard'
|
||||
import { VoiceCard } from './VoiceCard'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { SegmentedControl } from '@/components/ui/SegmentedControl'
|
||||
|
||||
interface Character {
|
||||
id: string
|
||||
name: string
|
||||
folderId: string | null
|
||||
customVoiceUrl: string | null
|
||||
appearances: Array<{
|
||||
id: string
|
||||
appearanceIndex: number
|
||||
changeReason: string
|
||||
description: string | null
|
||||
imageUrl: string | null
|
||||
imageUrls: string[]
|
||||
selectedIndex: number | null
|
||||
effectiveSelectedIndex?: number | null
|
||||
previousImageUrl: string | null
|
||||
previousImageUrls: string[]
|
||||
imageTaskRunning: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
interface Location {
|
||||
id: string
|
||||
name: string
|
||||
summary: string | null
|
||||
folderId: string | null
|
||||
images: Array<{
|
||||
id: string
|
||||
imageIndex: number
|
||||
description: string | null
|
||||
imageUrl: string | null
|
||||
previousImageUrl: string | null
|
||||
isSelected: boolean
|
||||
imageTaskRunning: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
interface Voice {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
voiceId: string | null
|
||||
voiceType: string
|
||||
customVoiceUrl: string | null
|
||||
voicePrompt: string | null
|
||||
gender: string | null
|
||||
language: string
|
||||
folderId: string | null
|
||||
}
|
||||
|
||||
interface AssetGridProps {
|
||||
characters: Character[]
|
||||
locations: Location[]
|
||||
voices: Voice[]
|
||||
loading: boolean
|
||||
onAddCharacter: () => void
|
||||
onAddLocation: () => void
|
||||
onAddVoice: () => void
|
||||
selectedFolderId: string | null
|
||||
onImageClick?: (url: string) => void
|
||||
onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => void
|
||||
onVoiceDesign?: (characterId: string, characterName: string) => void
|
||||
onCharacterEdit?: (character: unknown, appearance: unknown) => void
|
||||
onLocationEdit?: (location: unknown, imageIndex: number) => void
|
||||
onVoiceSelect?: (characterId: string) => void
|
||||
}
|
||||
|
||||
// 内联 SVG 图标
|
||||
const PlusIcon = ({ className }: { className?: string }) => (
|
||||
<AppIcon name="plus" className={className} />
|
||||
)
|
||||
|
||||
export function AssetGrid({
|
||||
characters,
|
||||
locations,
|
||||
voices,
|
||||
loading,
|
||||
onAddCharacter,
|
||||
onAddLocation,
|
||||
onAddVoice,
|
||||
selectedFolderId: _selectedFolderId,
|
||||
onImageClick,
|
||||
onImageEdit,
|
||||
onVoiceDesign,
|
||||
onCharacterEdit,
|
||||
onLocationEdit,
|
||||
onVoiceSelect
|
||||
}: AssetGridProps) {
|
||||
const t = useTranslations('assetHub')
|
||||
const loadingState = loading
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'generate',
|
||||
resource: 'image',
|
||||
hasOutput: false,
|
||||
})
|
||||
: null
|
||||
void _selectedFolderId
|
||||
|
||||
const [filter, setFilter] = useState<'all' | 'character' | 'location' | 'voice'>('all')
|
||||
const [sectionPage, setSectionPage] = useState<{ character: number; location: number; voice: number }>({
|
||||
character: 1,
|
||||
location: 1,
|
||||
voice: 1,
|
||||
})
|
||||
|
||||
const pageSize = 40
|
||||
const paginate = <T,>(rows: T[], page: number) => {
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize))
|
||||
const safePage = Math.min(Math.max(page, 1), totalPages)
|
||||
const start = (safePage - 1) * pageSize
|
||||
return {
|
||||
items: rows.slice(start, start + pageSize),
|
||||
page: safePage,
|
||||
totalPages,
|
||||
}
|
||||
}
|
||||
|
||||
const setPage = (type: 'character' | 'location' | 'voice', page: number) => {
|
||||
setSectionPage((prev) => ({ ...prev, [type]: page }))
|
||||
}
|
||||
|
||||
const charactersPage = paginate(characters, sectionPage.character)
|
||||
const locationsPage = paginate(locations, sectionPage.location)
|
||||
const voicesPage = paginate(voices, sectionPage.voice)
|
||||
|
||||
const renderPagination = (type: 'character' | 'location' | 'voice', page: number, totalPages: number) => {
|
||||
if (totalPages <= 1) return null
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setPage(type, page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="glass-btn-base glass-btn-secondary px-3 py-1.5 text-xs rounded-md disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('pagination.previous')}
|
||||
</button>
|
||||
<span className="text-xs text-[var(--glass-text-tertiary)]">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(type, page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="glass-btn-base glass-btn-secondary px-3 py-1.5 text-xs rounded-md disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{t('pagination.next')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center py-20">
|
||||
<TaskStatusInline state={loadingState} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isEmpty = characters.length === 0 && locations.length === 0 && voices.length === 0
|
||||
|
||||
const tabs = [
|
||||
{ id: 'all', label: t('allAssets') },
|
||||
{ id: 'character', label: t('characters') },
|
||||
{ id: 'location', label: t('locations') },
|
||||
{ id: 'voice', label: t('voices') },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header: 筛选 Tab + 操作按钮 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{/* 左侧筛选 */}
|
||||
{(() => {
|
||||
return (
|
||||
<SegmentedControl
|
||||
options={tabs.map(tab => ({ value: tab.id, label: tab.label }))}
|
||||
value={filter}
|
||||
onChange={(val) => setFilter(val as 'all' | 'character' | 'location' | 'voice')}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* 右侧新建按钮 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onAddCharacter}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>{t('addCharacter')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddLocation}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>{t('addLocation')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onAddVoice}
|
||||
className="glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span>{t('addVoice')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
/* 空状态 */
|
||||
<div className="glass-surface rounded-xl p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--glass-bg-muted)] flex items-center justify-center">
|
||||
<PlusIcon className="w-8 h-8 text-[var(--glass-text-tertiary)]" />
|
||||
</div>
|
||||
<p className="text-[var(--glass-text-secondary)] mb-2">{t('emptyState')}</p>
|
||||
<p className="text-sm text-[var(--glass-text-tertiary)]">{t('emptyStateHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* 角色区块 */}
|
||||
{(filter === 'all' || filter === 'character') && characters.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-[var(--glass-text-primary)] mb-3 flex items-center gap-2">
|
||||
{t('characters')}
|
||||
<span className="glass-chip glass-chip-neutral px-2 py-0.5">{characters.length}</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{charactersPage.items.map((character) => (
|
||||
<CharacterCard
|
||||
key={character.id}
|
||||
character={character}
|
||||
onImageClick={onImageClick}
|
||||
onImageEdit={onImageEdit}
|
||||
onVoiceDesign={onVoiceDesign}
|
||||
onEdit={onCharacterEdit}
|
||||
onVoiceSelect={onVoiceSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{renderPagination('character', charactersPage.page, charactersPage.totalPages)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 场景区块 */}
|
||||
{(filter === 'all' || filter === 'location') && locations.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-[var(--glass-text-primary)] mb-3 flex items-center gap-2">
|
||||
{t('locations')}
|
||||
<span className="glass-chip glass-chip-neutral px-2 py-0.5">{locations.length}</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{locationsPage.items.map((location) => (
|
||||
<LocationCard
|
||||
key={location.id}
|
||||
location={location}
|
||||
onImageClick={onImageClick}
|
||||
onImageEdit={onImageEdit}
|
||||
onEdit={onLocationEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{renderPagination('location', locationsPage.page, locationsPage.totalPages)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 音色区块 */}
|
||||
{(filter === 'all' || filter === 'voice') && voices.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-sm font-semibold text-[var(--glass-text-primary)] mb-3 flex items-center gap-2">
|
||||
{t('voices')}
|
||||
<span className="glass-chip glass-chip-info px-2 py-0.5">{voices.length}</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{voicesPage.items.map((voice) => (
|
||||
<VoiceCard
|
||||
key={voice.id}
|
||||
voice={voice}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{renderPagination('voice', voicesPage.page, voicesPage.totalPages)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
'use client'
|
||||
import { logInfo as _ulogInfo } from '@/lib/logging/core'
|
||||
import { resolveErrorDisplay } from '@/lib/errors/display'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
useGenerateCharacterImage,
|
||||
useSelectCharacterImage,
|
||||
useUndoCharacterImage,
|
||||
useUploadCharacterImage,
|
||||
useDeleteCharacter,
|
||||
useDeleteCharacterAppearance,
|
||||
useUploadCharacterVoice
|
||||
} from '@/lib/query/mutations'
|
||||
import VoiceSettings from './VoiceSettings'
|
||||
import { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import TaskStatusOverlay from '@/components/task/TaskStatusOverlay'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
|
||||
import { PRIMARY_APPEARANCE_INDEX } from '@/lib/constants'
|
||||
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
|
||||
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface Appearance {
|
||||
id: string
|
||||
appearanceIndex: number
|
||||
changeReason: string
|
||||
artStyle?: string | null
|
||||
description: string | null
|
||||
imageUrl: string | null
|
||||
imageUrls: string[]
|
||||
selectedIndex: number | null
|
||||
previousImageUrl: string | null
|
||||
previousImageUrls: string[]
|
||||
imageTaskRunning: boolean
|
||||
lastError?: { code: string; message: string } | null
|
||||
}
|
||||
|
||||
interface Character {
|
||||
id: string
|
||||
name: string
|
||||
folderId: string | null
|
||||
customVoiceUrl: string | null
|
||||
appearances: Appearance[]
|
||||
}
|
||||
|
||||
interface CharacterCardProps {
|
||||
character: Character
|
||||
onImageClick?: (url: string) => void
|
||||
onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => void
|
||||
onVoiceDesign?: (characterId: string, characterName: string) => void
|
||||
onEdit?: (character: Character, appearance: Appearance) => void
|
||||
onVoiceSelect?: (characterId: string) => void
|
||||
}
|
||||
|
||||
export function CharacterCard({ character, onImageClick, onImageEdit, onVoiceDesign, onEdit, onVoiceSelect }: CharacterCardProps) {
|
||||
// 🔥 使用 mutation hooks
|
||||
const generateImage = useGenerateCharacterImage()
|
||||
const selectImage = useSelectCharacterImage()
|
||||
const undoImage = useUndoCharacterImage()
|
||||
const uploadImage = useUploadCharacterImage()
|
||||
const deleteCharacter = useDeleteCharacter()
|
||||
const deleteAppearance = useDeleteCharacterAppearance()
|
||||
const uploadVoice = useUploadCharacterVoice()
|
||||
|
||||
const t = useTranslations('assetHub')
|
||||
const tAssets = useTranslations('assets')
|
||||
const { count: generationCount, setCount: setGenerationCount } = useImageGenerationCount('character')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const voiceInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [activeAppearance, setActiveAppearance] = useState(0)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showDeleteMenu, setShowDeleteMenu] = useState(false)
|
||||
const latestSelectRequestRef = useRef(0)
|
||||
|
||||
// 计算属性
|
||||
const appearance = character.appearances[activeAppearance] || character.appearances[0]
|
||||
const isPrimaryAppearance = appearance?.appearanceIndex === PRIMARY_APPEARANCE_INDEX
|
||||
const appearanceCount = character.appearances.length
|
||||
|
||||
// URL 验证函数
|
||||
const isValidUrl = (url: string | null | undefined): boolean => {
|
||||
if (!url || url.trim() === '') return false
|
||||
if (url.startsWith('/')) return true
|
||||
if (url.startsWith('data:') || url.startsWith('blob:')) return true
|
||||
try { new URL(url); return true } catch { return false }
|
||||
}
|
||||
|
||||
const imageUrls = appearance?.imageUrls || []
|
||||
const hasMultipleImages = imageUrls.filter(u => isValidUrl(u)).length > 1
|
||||
const effectiveSelectedIndex: number | null = appearance?.selectedIndex ?? null
|
||||
const currentImageUrl = appearance?.imageUrl || (effectiveSelectedIndex !== null ? imageUrls[effectiveSelectedIndex] : null) || imageUrls.find(u => u) || null
|
||||
const hasPreviousVersion = !!(appearance?.previousImageUrl || (appearance?.previousImageUrls && appearance.previousImageUrls.length > 0))
|
||||
|
||||
const displayImageUrl = isValidUrl(currentImageUrl) ? currentImageUrl : null
|
||||
const serverTaskRunning = !!appearance?.imageTaskRunning
|
||||
const transientSubmitting = generateImage.isPending
|
||||
const isAppearanceTaskRunning = serverTaskRunning || transientSubmitting
|
||||
const taskErrorDisplay = !isAppearanceTaskRunning && appearance?.lastError
|
||||
? resolveErrorDisplay(appearance.lastError)
|
||||
: null
|
||||
const displayTaskPresentation = isAppearanceTaskRunning
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: displayImageUrl ? 'process' : 'generate',
|
||||
resource: 'image',
|
||||
hasOutput: !!displayImageUrl,
|
||||
})
|
||||
: null
|
||||
const selectImageRunningState = selectImage.isPending
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'process',
|
||||
resource: 'image',
|
||||
hasOutput: !!displayImageUrl,
|
||||
})
|
||||
: null
|
||||
|
||||
// 生成图片
|
||||
const handleGenerate = (count = generationCount) => {
|
||||
generateImage.mutate(
|
||||
{
|
||||
characterId: character.id,
|
||||
appearanceIndex: appearance.appearanceIndex,
|
||||
artStyle: appearance.artStyle || undefined,
|
||||
count,
|
||||
},
|
||||
{ onError: (error) => alert(error.message || t('generateFailed')) }
|
||||
)
|
||||
}
|
||||
|
||||
// 选择图片(依赖 query 缓存乐观更新)
|
||||
const handleSelectImage = (imageIndex: number | null) => {
|
||||
if (imageIndex === effectiveSelectedIndex) return
|
||||
const requestId = latestSelectRequestRef.current + 1
|
||||
latestSelectRequestRef.current = requestId
|
||||
selectImage.mutate({
|
||||
characterId: character.id,
|
||||
appearanceIndex: appearance.appearanceIndex,
|
||||
imageIndex,
|
||||
confirm: false
|
||||
}, {
|
||||
onError: (error) => {
|
||||
if (latestSelectRequestRef.current !== requestId) return
|
||||
alert(error.message || t('selectFailed'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 确认选择
|
||||
const handleConfirmSelection = () => {
|
||||
const requestId = latestSelectRequestRef.current + 1
|
||||
latestSelectRequestRef.current = requestId
|
||||
selectImage.mutate({
|
||||
characterId: character.id,
|
||||
appearanceIndex: appearance.appearanceIndex,
|
||||
imageIndex: effectiveSelectedIndex,
|
||||
confirm: true
|
||||
}, {
|
||||
onError: (error) => {
|
||||
if (latestSelectRequestRef.current !== requestId) return
|
||||
alert(error.message || t('selectFailed'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 撤回
|
||||
const handleUndo = () => {
|
||||
undoImage.mutate({ characterId: character.id, appearanceIndex: appearance.appearanceIndex })
|
||||
}
|
||||
|
||||
// 上传图片
|
||||
const handleUpload = () => {
|
||||
const file = fileInputRef.current?.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploadImage.mutate(
|
||||
{
|
||||
file,
|
||||
characterId: character.id,
|
||||
appearanceIndex: appearance.appearanceIndex,
|
||||
labelText: `${character.name} - ${appearance.changeReason}`,
|
||||
imageIndex: effectiveSelectedIndex ?? undefined
|
||||
},
|
||||
{
|
||||
onError: (error) => alert(error.message || t('uploadFailed')),
|
||||
onSettled: () => {
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
const handleDelete = () => {
|
||||
deleteCharacter.mutate(character.id, {
|
||||
onSettled: () => setShowDeleteConfirm(false)
|
||||
})
|
||||
}
|
||||
|
||||
// 删除子形象
|
||||
const handleDeleteAppearance = () => {
|
||||
deleteAppearance.mutate(
|
||||
{ characterId: character.id, appearanceIndex: appearance.appearanceIndex },
|
||||
{
|
||||
onSuccess: () => setActiveAppearance(0),
|
||||
onSettled: () => setShowDeleteMenu(false)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 上传音色
|
||||
const handleUploadVoice = () => {
|
||||
const file = voiceInputRef.current?.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploadVoice.mutate(
|
||||
{ file, characterId: character.id },
|
||||
{
|
||||
onSettled: () => {
|
||||
if (voiceInputRef.current) voiceInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 多图选择模式
|
||||
if (hasMultipleImages) {
|
||||
return (
|
||||
<div className="col-span-3 glass-surface p-4 relative">
|
||||
{/* 隐藏输入 */}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
<input ref={voiceInputRef} type="file" accept="audio/*" onChange={handleUploadVoice} className="hidden" />
|
||||
|
||||
{/* 顶部:名字 + 操作 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--glass-text-primary)]">{character.name}</span>
|
||||
<span className="glass-chip glass-chip-neutral px-2 py-0.5 text-xs">{appearance.changeReason}</span>
|
||||
{isPrimaryAppearance ? (
|
||||
<span className="glass-chip glass-chip-success px-2 py-0.5 text-xs">{tAssets('character.primary')}</span>
|
||||
) : (
|
||||
<span className="glass-chip glass-chip-info px-2 py-0.5 text-xs">{tAssets('character.secondary')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ImageGenerationInlineCountButton
|
||||
prefix={isAppearanceTaskRunning ? (
|
||||
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
|
||||
) : (
|
||||
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
|
||||
)}
|
||||
suffix={null}
|
||||
value={generationCount}
|
||||
options={getImageGenerationCountOptions('character')}
|
||||
onValueChange={setGenerationCount}
|
||||
onClick={() => {
|
||||
_ulogInfo('[CharacterCard] 多图模式 - 重新生成按钮点击, characterId:', character.id, 'appearanceCount:', appearanceCount)
|
||||
handleGenerate(generationCount)
|
||||
}}
|
||||
disabled={isAppearanceTaskRunning}
|
||||
ariaLabel={tAssets('image.selectCount')}
|
||||
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
|
||||
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
|
||||
/>
|
||||
{hasPreviousVersion && (
|
||||
<button onClick={handleUndo} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md" title={tAssets('image.undo')}>
|
||||
<AppIcon name="sparkles" className="w-4 h-4 text-[var(--glass-tone-warning-fg)]" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
_ulogInfo('[CharacterCard] 多图模式 - 删除按钮点击, characterId:', character.id, 'appearanceCount:', appearanceCount, 'showDeleteMenu:', showDeleteMenu)
|
||||
if (appearanceCount <= 1) {
|
||||
setShowDeleteConfirm(true)
|
||||
return
|
||||
}
|
||||
setShowDeleteMenu(!showDeleteMenu)
|
||||
}} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md">
|
||||
<AppIcon name="trash" className="w-4 h-4 text-[var(--glass-tone-danger-fg)]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务失败错误提示 */}
|
||||
{taskErrorDisplay && !isAppearanceTaskRunning && (
|
||||
<div className="flex items-center gap-2 mb-3 p-2 rounded-lg bg-[var(--glass-danger-ring)] text-[var(--glass-tone-danger-fg)]">
|
||||
<AppIcon name="alert" className="w-4 h-4 shrink-0" />
|
||||
<span className="text-xs line-clamp-2">{taskErrorDisplay.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片列表 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{imageUrls.map((url, index) => {
|
||||
if (!isValidUrl(url)) return null
|
||||
const validUrl = url as string
|
||||
const isSelected = effectiveSelectedIndex === index
|
||||
return (
|
||||
<div key={index} className="relative group/thumb">
|
||||
<div
|
||||
onClick={() => onImageClick?.(validUrl)}
|
||||
className={`rounded-lg overflow-hidden border-2 cursor-zoom-in transition-all ${isSelected ? 'border-[var(--glass-stroke-success)] ring-2 ring-[var(--glass-success-ring)]' : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'}`}
|
||||
>
|
||||
<MediaImageWithLoading
|
||||
src={validUrl}
|
||||
alt={`${character.name} ${index + 1}`}
|
||||
containerClassName="w-full min-h-[96px]"
|
||||
className="w-full h-auto object-contain"
|
||||
/>
|
||||
<div className={`absolute bottom-2 left-2 text-xs px-2 py-0.5 rounded ${isSelected ? 'glass-chip glass-chip-success' : 'glass-chip glass-chip-neutral'}`}>
|
||||
{tAssets('image.optionNumber', { number: index + 1 })}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSelectImage(isSelected ? null : index) }}
|
||||
className={`absolute top-2 right-2 glass-btn-base w-7 h-7 rounded-full flex items-center justify-center ${isSelected ? 'glass-btn-tone-success' : 'glass-btn-secondary'}`}
|
||||
>
|
||||
<AppIcon name="check" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
{effectiveSelectedIndex !== null && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button onClick={handleConfirmSelection} disabled={selectImage.isPending} className="glass-btn-base glass-btn-tone-success px-4 py-2 rounded-lg flex items-center gap-2 text-sm">
|
||||
{selectImage.isPending ? (
|
||||
<TaskStatusInline state={selectImageRunningState} className="text-white [&>span]:sr-only [&_svg]:text-white" />
|
||||
) : (
|
||||
<AppIcon name="check" className="w-4 h-4" />
|
||||
)}
|
||||
{tAssets('image.confirmOption', { number: effectiveSelectedIndex + 1 })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 音色设置 */}
|
||||
<VoiceSettings
|
||||
characterId={character.id}
|
||||
characterName={character.name}
|
||||
customVoiceUrl={character.customVoiceUrl}
|
||||
onVoiceDesign={onVoiceDesign}
|
||||
onVoiceSelect={onVoiceSelect}
|
||||
compact={true}
|
||||
/>
|
||||
|
||||
{/* 删除菜单 */}
|
||||
{showDeleteMenu && appearanceCount > 1 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowDeleteMenu(false)} />
|
||||
<div className="absolute right-4 top-12 z-20 glass-surface-modal py-1 min-w-[120px]">
|
||||
<button onClick={handleDeleteAppearance} className="glass-btn-base glass-btn-soft w-full justify-start rounded-none px-3 py-1.5 text-left text-xs">{tAssets('image.deleteThis')}</button>
|
||||
<button onClick={() => { setShowDeleteMenu(false); setShowDeleteConfirm(true) }} className="glass-btn-base glass-btn-soft w-full justify-start rounded-none px-3 py-1.5 text-left text-xs text-[var(--glass-tone-danger-fg)]">{tAssets('character.deleteWhole')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 删除确认对话框 - 多图模式也需要 */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50">
|
||||
<div className="glass-surface-modal p-4 m-4 max-w-sm">
|
||||
<p className="mb-4 text-sm text-[var(--glass-text-primary)]">{t('confirmDeleteCharacter')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setShowDeleteConfirm(false)} className="glass-btn-base glass-btn-secondary px-3 py-1.5 rounded-lg text-sm">{t('cancel')}</button>
|
||||
<button onClick={handleDelete} className="glass-btn-base glass-btn-danger px-3 py-1.5 rounded-lg text-sm">{t('delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 单图模式
|
||||
return (
|
||||
<div className="glass-surface overflow-hidden relative group">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
<input ref={voiceInputRef} type="file" accept="audio/*" onChange={handleUploadVoice} className="hidden" />
|
||||
|
||||
{/* 图片区域 */}
|
||||
<div className="relative bg-[var(--glass-bg-muted)] min-h-[100px]">
|
||||
{displayImageUrl ? (
|
||||
<>
|
||||
<MediaImageWithLoading
|
||||
src={displayImageUrl}
|
||||
alt={character.name}
|
||||
containerClassName="w-full min-h-[120px]"
|
||||
className="w-full h-auto object-contain cursor-zoom-in"
|
||||
onClick={() => onImageClick?.(displayImageUrl)}
|
||||
/>
|
||||
{/* 操作按钮 - 非生成时显示 */}
|
||||
{!isAppearanceTaskRunning && (
|
||||
<div className="absolute top-2 left-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => fileInputRef.current?.click()} disabled={uploadImage.isPending} className="glass-btn-base glass-btn-secondary h-7 w-7 rounded-full">
|
||||
<AppIcon name="upload" className="w-4 h-4 text-[var(--glass-tone-success-fg)]" />
|
||||
</button>
|
||||
<button onClick={() => onImageEdit?.('character', character.id, character.name, effectiveSelectedIndex ?? 0, appearance.appearanceIndex)} className="glass-btn-base glass-btn-tone-info h-7 w-7 rounded-full">
|
||||
<AppIcon name="edit" className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleGenerate()} className="glass-btn-base glass-btn-secondary h-7 w-7 rounded-full">
|
||||
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
|
||||
</button>
|
||||
{hasPreviousVersion && (
|
||||
<button onClick={handleUndo} className="glass-btn-base glass-btn-secondary h-7 w-7 rounded-full">
|
||||
<AppIcon name="sparkles" className="w-4 h-4 text-[var(--glass-tone-warning-fg)]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="image" className="w-12 h-12 mb-3" />
|
||||
<ImageGenerationInlineCountButton
|
||||
prefix={<span>{tAssets('image.generateCountPrefix')}</span>}
|
||||
suffix={<span>{tAssets('image.generateCountSuffix')}</span>}
|
||||
value={generationCount}
|
||||
options={getImageGenerationCountOptions('character')}
|
||||
onValueChange={setGenerationCount}
|
||||
onClick={() => handleGenerate(generationCount)}
|
||||
ariaLabel={tAssets('image.selectCount')}
|
||||
className="glass-btn-base glass-btn-primary flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg"
|
||||
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-sm font-semibold text-current outline-none cursor-pointer leading-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isAppearanceTaskRunning && (
|
||||
<TaskStatusOverlay state={displayTaskPresentation} />
|
||||
)}
|
||||
{taskErrorDisplay && !isAppearanceTaskRunning && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[var(--glass-danger-ring)] text-[var(--glass-tone-danger-fg)] p-3 gap-1">
|
||||
<AppIcon name="alert" className="w-6 h-6" />
|
||||
<span className="text-xs text-center font-medium line-clamp-3">{taskErrorDisplay.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区域 */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-[var(--glass-text-primary)] text-sm truncate">{character.name}</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 编辑按钮 */}
|
||||
<button
|
||||
onClick={() => onEdit?.(character, appearance)}
|
||||
className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md opacity-0 group-hover:opacity-100"
|
||||
title={tAssets('video.panelCard.editPrompt')}
|
||||
>
|
||||
<AppIcon name="edit" className="w-4 h-4 text-[var(--glass-text-secondary)]" />
|
||||
</button>
|
||||
{/* 删除按钮 */}
|
||||
<button onClick={() => appearanceCount <= 1 ? setShowDeleteConfirm(true) : setShowDeleteMenu(!showDeleteMenu)} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md text-[var(--glass-tone-danger-fg)] opacity-0 group-hover:opacity-100">
|
||||
<AppIcon name="trash" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 形象切换 */}
|
||||
{appearanceCount > 1 && (
|
||||
<div className="flex gap-1 mt-2 overflow-x-auto">
|
||||
{character.appearances.map((app, index) => (
|
||||
<button key={app.id} onClick={() => setActiveAppearance(index)} className={`glass-btn-base px-2 py-0.5 text-xs rounded-full whitespace-nowrap ${index === activeAppearance ? 'glass-btn-primary' : 'glass-btn-soft text-[var(--glass-text-secondary)]'}`}>
|
||||
{app.changeReason || `形象 ${app.appearanceIndex}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appearance?.description && <p className="mt-2 text-xs text-[var(--glass-text-secondary)] line-clamp-2">{appearance.description}</p>}
|
||||
|
||||
{/* 音色设置 */}
|
||||
<VoiceSettings
|
||||
characterId={character.id}
|
||||
characterName={character.name}
|
||||
customVoiceUrl={character.customVoiceUrl}
|
||||
onVoiceDesign={onVoiceDesign}
|
||||
onVoiceSelect={onVoiceSelect}
|
||||
compact={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 删除确认 */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="absolute inset-0 glass-overlay flex items-center justify-center z-20">
|
||||
<div className="glass-surface-modal p-4 m-4">
|
||||
<p className="mb-4 text-sm text-[var(--glass-text-primary)]">{t('confirmDeleteCharacter')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setShowDeleteConfirm(false)} className="glass-btn-base glass-btn-secondary px-3 py-1.5 rounded-lg text-sm">{t('cancel')}</button>
|
||||
<button onClick={handleDelete} className="glass-btn-base glass-btn-danger px-3 py-1.5 rounded-lg text-sm">{t('delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除菜单 */}
|
||||
{showDeleteMenu && appearanceCount > 1 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowDeleteMenu(false)} />
|
||||
<div className="absolute right-3 top-auto bottom-16 z-20 glass-surface-modal py-1 min-w-[120px]">
|
||||
<button onClick={handleDeleteAppearance} className="glass-btn-base glass-btn-soft w-full justify-start rounded-none px-3 py-1.5 text-left text-xs">{tAssets('image.deleteThis')}</button>
|
||||
<button onClick={() => { setShowDeleteMenu(false); setShowDeleteConfirm(true) }} className="glass-btn-base glass-btn-soft w-full justify-start rounded-none px-3 py-1.5 text-left text-xs text-[var(--glass-tone-danger-fg)]">{tAssets('character.deleteWhole')}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
'use client'
|
||||
import { logError as _ulogError } from '@/lib/logging/core'
|
||||
|
||||
/**
|
||||
* 资产中心 - 角色形象编辑弹窗
|
||||
* 与项目级资产库的 CharacterEditModal 保持一致
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { shouldShowError } from '@/lib/error-utils'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import {
|
||||
useRefreshGlobalAssets,
|
||||
useUpdateCharacterName,
|
||||
useAiModifyCharacterDescription,
|
||||
useUpdateCharacterAppearanceDescription,
|
||||
} from '@/lib/query/hooks'
|
||||
|
||||
interface CharacterEditModalProps {
|
||||
characterId: string
|
||||
characterName: string
|
||||
appearanceIndex: number
|
||||
changeReason: string
|
||||
description: string
|
||||
onClose: () => void
|
||||
onSave: () => void // 触发生成图片
|
||||
}
|
||||
|
||||
export function CharacterEditModal({
|
||||
characterId,
|
||||
characterName,
|
||||
appearanceIndex,
|
||||
changeReason,
|
||||
description,
|
||||
onClose,
|
||||
onSave
|
||||
}: CharacterEditModalProps) {
|
||||
// 🔥 使用 React Query
|
||||
const onRefresh = useRefreshGlobalAssets()
|
||||
const updateName = useUpdateCharacterName()
|
||||
const modifyDescription = useAiModifyCharacterDescription()
|
||||
const updateAppearanceDescription = useUpdateCharacterAppearanceDescription()
|
||||
const t = useTranslations('assets')
|
||||
|
||||
const [editingName, setEditingName] = useState(characterName)
|
||||
const [editingDescription, setEditingDescription] = useState(description)
|
||||
const [aiModifyInstruction, setAiModifyInstruction] = useState('')
|
||||
const [isAiModifying, setIsAiModifying] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const aiModifyingState = isAiModifying
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'modify',
|
||||
resource: 'image',
|
||||
hasOutput: true,
|
||||
})
|
||||
: null
|
||||
const savingState = isSaving
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'modify',
|
||||
resource: 'image',
|
||||
hasOutput: false,
|
||||
})
|
||||
: null
|
||||
|
||||
// AI 修改描述
|
||||
const handleAiModify = async () => {
|
||||
if (!aiModifyInstruction.trim()) return
|
||||
|
||||
try {
|
||||
setIsAiModifying(true)
|
||||
const data = await modifyDescription.mutateAsync({
|
||||
characterId,
|
||||
appearanceIndex,
|
||||
currentDescription: editingDescription,
|
||||
modifyInstruction: aiModifyInstruction,
|
||||
})
|
||||
setEditingDescription(data.modifiedDescription ?? '')
|
||||
setAiModifyInstruction('')
|
||||
} catch (error: unknown) {
|
||||
if (shouldShowError(error)) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
alert(t('modal.modifyFailed') + ': ' + message)
|
||||
}
|
||||
} finally {
|
||||
setIsAiModifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存名字
|
||||
const handleSaveName = () => {
|
||||
if (!editingName.trim() || editingName === characterName) return
|
||||
|
||||
updateName.mutate(
|
||||
{ characterId, name: editingName.trim() },
|
||||
{
|
||||
onError: (error) => {
|
||||
if (shouldShowError(error)) {
|
||||
alert(t('modal.saveName') + t('errors.failed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 仅保存(不生成图片)
|
||||
const handleSaveOnly = async () => {
|
||||
try {
|
||||
setIsSaving(true)
|
||||
|
||||
// 如果名字变了,先保存名字
|
||||
if (editingName.trim() !== characterName) {
|
||||
await updateName.mutateAsync({ characterId, name: editingName.trim() })
|
||||
}
|
||||
|
||||
// 保存描述
|
||||
await updateAppearanceDescription.mutateAsync({
|
||||
characterId,
|
||||
appearanceIndex,
|
||||
description: editingDescription,
|
||||
})
|
||||
|
||||
onRefresh()
|
||||
onClose()
|
||||
} catch (error: unknown) {
|
||||
if (shouldShowError(error)) {
|
||||
alert(t('errors.saveFailed'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存并生成图片
|
||||
const handleSaveAndGenerate = async () => {
|
||||
const descToSave = editingDescription
|
||||
const nameToSave = editingName.trim()
|
||||
|
||||
// 立即关闭弹窗
|
||||
onClose()
|
||||
|
||||
// 后台执行保存和生成
|
||||
; (async () => {
|
||||
try {
|
||||
// 如果名字变了,先保存名字
|
||||
if (nameToSave !== characterName) {
|
||||
await updateName.mutateAsync({ characterId, name: nameToSave })
|
||||
}
|
||||
|
||||
// 保存描述
|
||||
await updateAppearanceDescription.mutateAsync({
|
||||
characterId,
|
||||
appearanceIndex,
|
||||
description: descToSave,
|
||||
})
|
||||
|
||||
// 触发生成
|
||||
onSave()
|
||||
onRefresh()
|
||||
} catch (error: unknown) {
|
||||
_ulogError('保存并生成失败:', error)
|
||||
if (shouldShowError(error)) {
|
||||
alert(t('errors.saveFailed'))
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4">
|
||||
<div className="glass-surface-modal max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">
|
||||
{t('modal.editCharacter')} - {characterName}
|
||||
</h3>
|
||||
<button onClick={onClose} className="glass-btn-base glass-btn-soft h-8 w-8 rounded-full text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]">
|
||||
<AppIcon name="close" className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 角色名字编辑 */}
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('character.name')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
className="glass-input-base flex-1 px-3 py-2"
|
||||
placeholder={t('modal.namePlaceholder')}
|
||||
/>
|
||||
{editingName !== characterName && (
|
||||
<button
|
||||
onClick={handleSaveName}
|
||||
disabled={updateName.isPending || !editingName.trim()}
|
||||
className="glass-btn-base glass-btn-tone-success px-3 py-2 rounded-lg text-sm whitespace-nowrap"
|
||||
>
|
||||
{updateName.isPending ? t('smartImport.preview.saving') : t('modal.saveName')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 形象标识 */}
|
||||
<div className="text-sm text-[var(--glass-text-secondary)]">
|
||||
{t('character.appearance')}: <span className="font-medium text-[var(--glass-text-primary)]">{changeReason}</span>
|
||||
</div>
|
||||
|
||||
{/* AI 修改区域 */}
|
||||
<div className="space-y-2 glass-surface-soft p-4 rounded-lg border border-[var(--glass-stroke-base)]">
|
||||
<label className="glass-field-label block flex items-center gap-2">
|
||||
<AppIcon name="bolt" className="w-4 h-4" />
|
||||
{t('modal.smartModify')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={aiModifyInstruction}
|
||||
onChange={(e) => setAiModifyInstruction(e.target.value)}
|
||||
placeholder={t('modal.modifyPlaceholderCharacter')}
|
||||
className="glass-input-base flex-1 px-3 py-2"
|
||||
disabled={isAiModifying}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleAiModify()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAiModify}
|
||||
disabled={isAiModifying || !aiModifyInstruction.trim()}
|
||||
className="glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
{isAiModifying ? (
|
||||
<TaskStatusInline state={aiModifyingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<>
|
||||
<AppIcon name="bolt" className="w-4 h-4" />
|
||||
{t('modal.smartModify')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="glass-field-hint">
|
||||
{t('modal.aiTipSub')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 描述编辑 */}
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('modal.appearancePrompt')}
|
||||
</label>
|
||||
<textarea
|
||||
value={editingDescription}
|
||||
onChange={(e) => setEditingDescription(e.target.value)}
|
||||
className="glass-textarea-base w-full h-64 px-3 py-2 resize-none"
|
||||
placeholder={t('modal.descPlaceholder')}
|
||||
disabled={isAiModifying}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveOnly}
|
||||
disabled={isSaving || !editingDescription.trim()}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<TaskStatusInline state={savingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
t('modal.saveOnly')
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveAndGenerate}
|
||||
disabled={isSaving || !editingDescription.trim()}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{t('modal.saveAndGenerate')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CharacterEditModal
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface Folder {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface FolderModalProps {
|
||||
folder: Folder | null
|
||||
onClose: () => void
|
||||
onSave: (name: string) => void
|
||||
}
|
||||
|
||||
// 内联 SVG 图标
|
||||
const XMarkIcon = ({ className }: { className?: string }) => (
|
||||
<AppIcon name="close" className={className} />
|
||||
)
|
||||
|
||||
export function FolderModal({ folder, onClose, onSave }: FolderModalProps) {
|
||||
const t = useTranslations('assetHub')
|
||||
const [name, setName] = useState(folder?.name || '')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (name.trim()) {
|
||||
onSave(name.trim())
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4">
|
||||
<div className="glass-surface-modal max-w-sm w-full">
|
||||
<div className="p-5">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">
|
||||
{folder ? t('editFolder') : t('newFolder')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="glass-btn-base glass-btn-soft h-8 w-8 rounded-full flex items-center justify-center text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-5">
|
||||
<label className="block text-sm font-medium text-[var(--glass-text-secondary)] mb-2">
|
||||
{t('folderName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('folderNamePlaceholder')}
|
||||
className="glass-input-base w-full px-3 py-2 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg text-sm"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim()}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{folder ? t('save') : t('create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface Folder {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface FolderSidebarProps {
|
||||
folders: Folder[]
|
||||
selectedFolderId: string | null
|
||||
onSelectFolder: (folderId: string | null) => void
|
||||
onCreateFolder: () => void
|
||||
onEditFolder: (folder: Folder) => void
|
||||
onDeleteFolder: (folderId: string) => void
|
||||
}
|
||||
|
||||
// 内联 SVG 图标
|
||||
const FolderIcon = ({ className }: { className?: string }) => (
|
||||
<AppIcon name="folder" className={className} />
|
||||
)
|
||||
|
||||
const PlusIcon = ({ className }: { className?: string }) => (
|
||||
<AppIcon name="plus" className={className} />
|
||||
)
|
||||
|
||||
const PencilIcon = ({ className }: { className?: string }) => (
|
||||
<AppIcon name="edit" className={className} />
|
||||
)
|
||||
|
||||
const TrashIcon = ({ className }: { className?: string }) => (
|
||||
<AppIcon name="trash" className={className} />
|
||||
)
|
||||
|
||||
export function FolderSidebar({
|
||||
folders,
|
||||
selectedFolderId,
|
||||
onSelectFolder,
|
||||
onCreateFolder,
|
||||
onEditFolder,
|
||||
onDeleteFolder
|
||||
}: FolderSidebarProps) {
|
||||
const t = useTranslations('assetHub')
|
||||
|
||||
return (
|
||||
<div className="w-56 flex-shrink-0">
|
||||
<div className="glass-surface p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-[var(--glass-text-secondary)]">{t('folders')}</h3>
|
||||
<button
|
||||
onClick={onCreateFolder}
|
||||
className="glass-btn-base glass-btn-primary h-6 w-6 rounded-full flex items-center justify-center"
|
||||
title={t('newFolder')}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{/* 所有资产 */}
|
||||
<button
|
||||
onClick={() => onSelectFolder(null)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left text-sm transition-colors ${selectedFolderId === null
|
||||
? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]'
|
||||
: 'text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)]'
|
||||
}`}
|
||||
>
|
||||
<FolderIcon className="w-4 h-4" />
|
||||
<span className="truncate">{t('allAssets')}</span>
|
||||
</button>
|
||||
|
||||
{/* 文件夹列表 */}
|
||||
{folders.map((folder) => (
|
||||
<div
|
||||
key={folder.id}
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${selectedFolderId === folder.id
|
||||
? 'bg-[var(--glass-tone-info-bg)] text-[var(--glass-tone-info-fg)]'
|
||||
: 'text-[var(--glass-text-secondary)] hover:bg-[var(--glass-bg-muted)]'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => onSelectFolder(folder.id)}
|
||||
className="flex-1 flex items-center gap-2 text-left text-sm min-w-0"
|
||||
>
|
||||
<FolderIcon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
</button>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="hidden group-hover:flex items-center gap-0.5">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEditFolder(folder)
|
||||
}}
|
||||
className="glass-btn-base glass-btn-soft h-5 w-5 rounded flex items-center justify-center"
|
||||
title={t('editFolder')}
|
||||
>
|
||||
<PencilIcon className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDeleteFolder(folder.id)
|
||||
}}
|
||||
className="glass-btn-base glass-btn-tone-danger h-5 w-5 rounded flex items-center justify-center"
|
||||
title={t('deleteFolder')}
|
||||
>
|
||||
<TrashIcon className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{folders.length === 0 && (
|
||||
<div className="text-xs text-[var(--glass-text-tertiary)] text-center py-4">
|
||||
{t('noFolders')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
469
src/app/[locale]/workspace/asset-hub/components/LocationCard.tsx
Normal file
469
src/app/[locale]/workspace/asset-hub/components/LocationCard.tsx
Normal file
@@ -0,0 +1,469 @@
|
||||
'use client'
|
||||
import { resolveErrorDisplay } from '@/lib/errors/display'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
useGenerateLocationImage,
|
||||
useSelectLocationImage,
|
||||
useUndoLocationImage,
|
||||
useUploadLocationImage,
|
||||
useDeleteLocation
|
||||
} from '@/lib/query/mutations'
|
||||
import { MediaImageWithLoading } from '@/components/media/MediaImageWithLoading'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import TaskStatusOverlay from '@/components/task/TaskStatusOverlay'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
|
||||
import ImageGenerationSlotOverlay from '@/components/image-generation/ImageGenerationSlotOverlay'
|
||||
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
|
||||
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
|
||||
import {
|
||||
countGeneratedImageSlots,
|
||||
resolveGroupedImageSlotPhase,
|
||||
resolveDisplayImageSlots,
|
||||
} from '@/lib/image-generation/slot-state'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface LocationImage {
|
||||
id: string
|
||||
imageIndex: number
|
||||
description: string | null
|
||||
imageUrl: string | null
|
||||
previousImageUrl: string | null
|
||||
isSelected: boolean
|
||||
imageTaskRunning: boolean
|
||||
imageErrorMessage?: string | null
|
||||
lastError?: { code: string; message: string } | null
|
||||
}
|
||||
|
||||
interface Location {
|
||||
id: string
|
||||
name: string
|
||||
summary: string | null
|
||||
artStyle?: string | null
|
||||
folderId: string | null
|
||||
images: LocationImage[]
|
||||
}
|
||||
|
||||
interface LocationCardProps {
|
||||
location: Location
|
||||
onImageClick?: (url: string) => void
|
||||
onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number) => void
|
||||
onEdit?: (location: Location, imageIndex: number) => void
|
||||
}
|
||||
|
||||
export function LocationCard({ location, onImageClick, onImageEdit, onEdit }: LocationCardProps) {
|
||||
// 🔥 使用 mutation hooks
|
||||
const generateImage = useGenerateLocationImage()
|
||||
const selectImage = useSelectLocationImage()
|
||||
const undoImage = useUndoLocationImage()
|
||||
const uploadImage = useUploadLocationImage()
|
||||
const deleteLocation = useDeleteLocation()
|
||||
|
||||
const t = useTranslations('assetHub')
|
||||
const tAssets = useTranslations('assets')
|
||||
const { count: generationCount, setCount: setGenerationCount } = useImageGenerationCount('location')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const latestSelectRequestRef = useRef(0)
|
||||
|
||||
// 解析图片
|
||||
const orderedImages = [...(location.images || [])].sort((left, right) => left.imageIndex - right.imageIndex)
|
||||
const imagesWithUrl = orderedImages.filter((img) => img.imageUrl)
|
||||
const generatedImageCount = countGeneratedImageSlots(orderedImages)
|
||||
const selectedImage = orderedImages.find((img) => img.isSelected)
|
||||
const serverSelectedIndex = selectedImage?.imageIndex ?? null
|
||||
const effectiveSelectedIndex = serverSelectedIndex
|
||||
const currentImageUrl = selectedImage?.imageUrl || imagesWithUrl[0]?.imageUrl || null
|
||||
const currentImageIndex = effectiveSelectedIndex ?? imagesWithUrl[0]?.imageIndex ?? 0
|
||||
const hasPreviousVersion = location.images?.some(img => img.previousImageUrl) || false
|
||||
|
||||
const isValidUrl = (url: string | null | undefined): boolean => {
|
||||
if (!url || url.trim() === '') return false
|
||||
if (url.startsWith('/')) return true
|
||||
if (url.startsWith('data:') || url.startsWith('blob:')) return true
|
||||
try { new URL(url); return true } catch { return false }
|
||||
}
|
||||
const displayImageUrl = isValidUrl(currentImageUrl) ? currentImageUrl : null
|
||||
const serverTaskRunning = (location.images || []).some((image) => image.imageTaskRunning)
|
||||
const transientSubmitting = generateImage.isPending
|
||||
const isTaskRunning = serverTaskRunning || transientSubmitting
|
||||
const displaySelectionImages = resolveDisplayImageSlots(orderedImages, {
|
||||
hasRunningTask: isTaskRunning,
|
||||
requestedCount: generationCount,
|
||||
})
|
||||
const displaySlotCount = displaySelectionImages.length
|
||||
const hasMultipleImages = generatedImageCount > 1
|
||||
const displayTaskPresentation = isTaskRunning
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: displayImageUrl ? 'process' : 'generate',
|
||||
resource: 'image',
|
||||
hasOutput: !!displayImageUrl,
|
||||
})
|
||||
: null
|
||||
// 取第一个有错误的 image 的 lastError
|
||||
const firstImageError = !isTaskRunning
|
||||
? (location.images || []).find(img => img.lastError)?.lastError || null
|
||||
: null
|
||||
const taskErrorDisplay = firstImageError ? resolveErrorDisplay(firstImageError) : null
|
||||
const selectImageRunningState = selectImage.isPending
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'process',
|
||||
resource: 'image',
|
||||
hasOutput: !!displayImageUrl,
|
||||
})
|
||||
: null
|
||||
|
||||
// 生成图片
|
||||
const handleGenerate = (count = generationCount) => {
|
||||
generateImage.mutate({
|
||||
locationId: location.id,
|
||||
artStyle: location.artStyle || undefined,
|
||||
count,
|
||||
}, {
|
||||
onError: (error) => alert(error.message || t('generateFailed'))
|
||||
})
|
||||
}
|
||||
|
||||
// 选择图片(依赖 query 缓存乐观更新)
|
||||
const handleSelectImage = (imageIndex: number | null) => {
|
||||
if (imageIndex === effectiveSelectedIndex) return
|
||||
const requestId = latestSelectRequestRef.current + 1
|
||||
latestSelectRequestRef.current = requestId
|
||||
selectImage.mutate({
|
||||
locationId: location.id,
|
||||
imageIndex,
|
||||
confirm: false
|
||||
}, {
|
||||
onError: (error) => {
|
||||
if (latestSelectRequestRef.current !== requestId) return
|
||||
alert(error.message || t('selectFailed'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 确认选择
|
||||
const handleConfirmSelection = () => {
|
||||
if (effectiveSelectedIndex === null) return
|
||||
const requestId = latestSelectRequestRef.current + 1
|
||||
latestSelectRequestRef.current = requestId
|
||||
selectImage.mutate({
|
||||
locationId: location.id,
|
||||
imageIndex: effectiveSelectedIndex,
|
||||
confirm: true
|
||||
}, {
|
||||
onError: (error) => {
|
||||
if (latestSelectRequestRef.current !== requestId) return
|
||||
alert(error.message || t('selectFailed'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 撤回
|
||||
const handleUndo = () => {
|
||||
undoImage.mutate(location.id)
|
||||
}
|
||||
|
||||
// 上传图片
|
||||
const handleUpload = () => {
|
||||
const file = fileInputRef.current?.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploadImage.mutate(
|
||||
{
|
||||
file,
|
||||
locationId: location.id,
|
||||
labelText: location.name,
|
||||
imageIndex: currentImageIndex
|
||||
},
|
||||
{
|
||||
onError: (error) => alert(error.message || t('uploadFailed')),
|
||||
onSettled: () => {
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 删除场景
|
||||
const handleDelete = () => {
|
||||
deleteLocation.mutate(location.id, {
|
||||
onSettled: () => setShowDeleteConfirm(false)
|
||||
})
|
||||
}
|
||||
|
||||
// 多图选择模式
|
||||
if (displaySlotCount > 1) {
|
||||
const selectionStatusText = isTaskRunning || generatedImageCount < displaySlotCount
|
||||
? tAssets('image.generatedProgress', { generated: generatedImageCount, total: displaySlotCount })
|
||||
: effectiveSelectedIndex !== null
|
||||
? tAssets('image.optionSelected', { number: effectiveSelectedIndex + 1 })
|
||||
: tAssets('image.selectFirst')
|
||||
|
||||
return (
|
||||
<div className="col-span-3 glass-surface p-4 relative">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
|
||||
{/* 顶部:名字 + 操作 */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-semibold text-[var(--glass-text-primary)]">{location.name}</span>
|
||||
</div>
|
||||
{location.summary && (
|
||||
<div className="text-xs text-[var(--glass-text-secondary)] mb-1 line-clamp-2" title={location.summary}>
|
||||
{location.summary}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-[var(--glass-text-tertiary)]">{selectionStatusText}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<ImageGenerationInlineCountButton
|
||||
prefix={isTaskRunning ? (
|
||||
<TaskStatusInline state={displayTaskPresentation} className="[&_span]:sr-only [&_svg]:text-[var(--glass-tone-info-fg)]" />
|
||||
) : (
|
||||
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
|
||||
)}
|
||||
suffix={null}
|
||||
value={generationCount}
|
||||
options={getImageGenerationCountOptions('location')}
|
||||
onValueChange={setGenerationCount}
|
||||
onClick={() => handleGenerate(generationCount)}
|
||||
disabled={isTaskRunning}
|
||||
ariaLabel={tAssets('image.selectCount')}
|
||||
className="inline-flex h-6 items-center gap-0.5 rounded px-1 hover:bg-[var(--glass-tone-info-bg)] transition-colors disabled:opacity-50"
|
||||
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-[10px] font-semibold text-[var(--glass-tone-info-fg)] outline-none cursor-pointer leading-none transition-colors"
|
||||
/>
|
||||
{hasPreviousVersion && (
|
||||
<button onClick={handleUndo} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md" title={tAssets('image.undo')}>
|
||||
<AppIcon name="sparkles" className="w-4 h-4 text-[var(--glass-tone-warning-fg)]" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => setShowDeleteConfirm(true)} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md">
|
||||
<AppIcon name="trash" className="w-4 h-4 text-[var(--glass-tone-danger-fg)]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务失败错误提示 */}
|
||||
{taskErrorDisplay && !isTaskRunning && (
|
||||
<div className="flex items-center gap-2 mb-3 p-2 rounded-lg bg-[var(--glass-danger-ring)] text-[var(--glass-tone-danger-fg)]">
|
||||
<AppIcon name="alert" className="w-4 h-4 shrink-0" />
|
||||
<span className="text-xs line-clamp-2">{taskErrorDisplay.message}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片列表 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{displaySelectionImages.map((img) => {
|
||||
const isThisSelected = img.isSelected
|
||||
const hasPendingEmptySlots = isTaskRunning && generatedImageCount < displaySlotCount
|
||||
const slotTaskRunning = hasPendingEmptySlots
|
||||
? !img.imageUrl && isTaskRunning
|
||||
: !!img.imageTaskRunning
|
||||
const phase = resolveGroupedImageSlotPhase(
|
||||
{ imageUrl: img.imageUrl },
|
||||
{
|
||||
isGroupRunning: isTaskRunning,
|
||||
isSlotRunning: slotTaskRunning,
|
||||
hasPendingEmptySlots,
|
||||
},
|
||||
)
|
||||
const imageError = resolveErrorDisplay(img.lastError || {
|
||||
code: img.imageErrorMessage || null,
|
||||
message: img.imageErrorMessage || null,
|
||||
})
|
||||
return (
|
||||
<div key={img.id} className="relative group/thumb">
|
||||
<div
|
||||
onClick={() => {
|
||||
if (img.imageUrl) {
|
||||
onImageClick?.(img.imageUrl)
|
||||
}
|
||||
}}
|
||||
className={`rounded-lg overflow-hidden border-2 transition-all ${img.imageUrl ? 'cursor-zoom-in' : 'cursor-default'} ${isThisSelected ? 'border-[var(--glass-stroke-success)] ring-2 ring-[var(--glass-success-ring)]' : 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)]'}`}
|
||||
>
|
||||
{img.imageUrl ? (
|
||||
<MediaImageWithLoading
|
||||
src={img.imageUrl}
|
||||
alt={`${location.name} ${img.imageIndex + 1}`}
|
||||
containerClassName="w-full min-h-[88px]"
|
||||
className="w-full h-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex min-h-[88px] items-center justify-center bg-[var(--glass-bg-muted)]">
|
||||
{imageError && !isTaskRunning ? (
|
||||
<div className="flex flex-col items-center justify-center px-3 py-6 text-center">
|
||||
<AppIcon name="alert" className="mb-2 h-6 w-6 text-[var(--glass-tone-danger-fg)]" />
|
||||
<span className="text-xs font-medium text-[var(--glass-tone-danger-fg)]">{tAssets('common.generateFailed')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-2 px-3 py-6 text-[var(--glass-text-tertiary)]">
|
||||
<div className="h-12 w-12 animate-pulse rounded-xl bg-[var(--glass-bg-surface-strong)]" />
|
||||
<span className="text-xs">{tAssets('image.generatingPlaceholder')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{phase === 'generating' && (
|
||||
<ImageGenerationSlotOverlay label={tAssets('image.generating')} />
|
||||
)}
|
||||
{phase === 'regenerating' && (
|
||||
<ImageGenerationSlotOverlay label={tAssets('image.regenerating')} />
|
||||
)}
|
||||
<div className={`absolute bottom-2 left-2 text-xs px-2 py-0.5 rounded ${isThisSelected ? 'glass-chip glass-chip-success' : 'glass-chip glass-chip-neutral'}`}>
|
||||
{tAssets('image.optionNumber', { number: img.imageIndex + 1 })}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!img.imageUrl || phase === 'generating' || phase === 'regenerating') return
|
||||
handleSelectImage(isThisSelected ? null : img.imageIndex)
|
||||
}}
|
||||
disabled={!img.imageUrl || phase === 'generating' || phase === 'regenerating'}
|
||||
className={`absolute top-2 right-2 glass-btn-base h-7 w-7 rounded-full ${isThisSelected ? 'glass-btn-tone-success' : 'glass-btn-secondary'} disabled:opacity-50`}
|
||||
>
|
||||
<AppIcon name="check" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
{effectiveSelectedIndex !== null && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button onClick={handleConfirmSelection} disabled={selectImage.isPending} className="glass-btn-base glass-btn-tone-success px-4 py-2 rounded-lg flex items-center gap-2 text-sm">
|
||||
{selectImage.isPending ? (
|
||||
<TaskStatusInline state={selectImageRunningState} className="text-white [&>span]:sr-only [&_svg]:text-white" />
|
||||
) : (
|
||||
<AppIcon name="check" className="w-4 h-4" />
|
||||
)}
|
||||
{tAssets('image.confirmOption', { number: effectiveSelectedIndex + 1 })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认 */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="absolute inset-0 glass-overlay flex items-center justify-center z-20 rounded-xl">
|
||||
<div className="glass-surface-modal p-4 m-4">
|
||||
<p className="mb-4 text-sm text-[var(--glass-text-primary)]">{t('confirmDeleteLocation')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setShowDeleteConfirm(false)} className="glass-btn-base glass-btn-secondary px-3 py-1.5 rounded-lg text-sm">{t('cancel')}</button>
|
||||
<button onClick={handleDelete} className="glass-btn-base glass-btn-danger px-3 py-1.5 rounded-lg text-sm">{t('delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 单图模式
|
||||
return (
|
||||
<div className="glass-surface overflow-hidden relative group">
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleUpload} className="hidden" />
|
||||
|
||||
{/* 图片区域 */}
|
||||
<div className="relative bg-[var(--glass-bg-muted)] min-h-[100px]">
|
||||
{displayImageUrl ? (
|
||||
<>
|
||||
<MediaImageWithLoading
|
||||
src={displayImageUrl}
|
||||
alt={location.name}
|
||||
containerClassName="w-full min-h-[120px]"
|
||||
className="w-full h-auto object-contain cursor-zoom-in"
|
||||
onClick={() => onImageClick?.(displayImageUrl)}
|
||||
/>
|
||||
{/* 操作按钮 - 非生成时显示 */}
|
||||
{!isTaskRunning && (
|
||||
<div className="absolute top-2 left-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={() => fileInputRef.current?.click()} disabled={uploadImage.isPending} className="glass-btn-base glass-btn-secondary h-7 w-7 rounded-full">
|
||||
<AppIcon name="upload" className="w-4 h-4 text-[var(--glass-tone-success-fg)]" />
|
||||
</button>
|
||||
<button onClick={() => onImageEdit?.('location', location.id, location.name, currentImageIndex)} className="glass-btn-base glass-btn-tone-info h-7 w-7 rounded-full">
|
||||
<AppIcon name="edit" className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={() => handleGenerate()} className="glass-btn-base glass-btn-secondary h-7 w-7 rounded-full">
|
||||
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
|
||||
</button>
|
||||
{hasPreviousVersion && (
|
||||
<button onClick={handleUndo} className="glass-btn-base glass-btn-secondary h-7 w-7 rounded-full">
|
||||
<AppIcon name="sparkles" className="w-4 h-4 text-[var(--glass-tone-warning-fg)]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="globe2" className="w-12 h-12 mb-3" />
|
||||
<ImageGenerationInlineCountButton
|
||||
prefix={<span>{tAssets('image.generateCountPrefix')}</span>}
|
||||
suffix={<span>{tAssets('image.generateCountSuffix')}</span>}
|
||||
value={generationCount}
|
||||
options={getImageGenerationCountOptions('location')}
|
||||
onValueChange={setGenerationCount}
|
||||
onClick={() => handleGenerate(generationCount)}
|
||||
ariaLabel={tAssets('image.selectCount')}
|
||||
className="glass-btn-base glass-btn-primary flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg"
|
||||
selectClassName="appearance-none bg-transparent border-0 pl-0 pr-3 text-sm font-semibold text-current outline-none cursor-pointer leading-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isTaskRunning && (
|
||||
<TaskStatusOverlay state={displayTaskPresentation} />
|
||||
)}
|
||||
{taskErrorDisplay && !isTaskRunning && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[var(--glass-danger-ring)] text-[var(--glass-tone-danger-fg)] p-3 gap-1">
|
||||
<AppIcon name="alert" className="w-6 h-6" />
|
||||
<span className="text-xs text-center font-medium line-clamp-3">{taskErrorDisplay.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区域 */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-[var(--glass-text-primary)] text-sm truncate">{location.name}</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 编辑按钮 */}
|
||||
<button
|
||||
onClick={() => onEdit?.(location, currentImageIndex)}
|
||||
className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md opacity-0 group-hover:opacity-100"
|
||||
title={tAssets('video.panelCard.editPrompt')}
|
||||
>
|
||||
<AppIcon name="edit" className="w-4 h-4 text-[var(--glass-text-secondary)]" />
|
||||
</button>
|
||||
{/* 删除按钮 */}
|
||||
<button onClick={() => setShowDeleteConfirm(true)} className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md text-[var(--glass-tone-danger-fg)] opacity-0 group-hover:opacity-100">
|
||||
<AppIcon name="trash" className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{location.summary && <p className="mt-1 text-xs text-[var(--glass-text-secondary)] line-clamp-2">{location.summary}</p>}
|
||||
</div>
|
||||
|
||||
{/* 删除确认 */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="absolute inset-0 glass-overlay flex items-center justify-center z-20">
|
||||
<div className="glass-surface-modal p-4 m-4">
|
||||
<p className="mb-4 text-sm text-[var(--glass-text-primary)]">{t('confirmDeleteLocation')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setShowDeleteConfirm(false)} className="glass-btn-base glass-btn-secondary px-3 py-1.5 rounded-lg text-sm">{t('cancel')}</button>
|
||||
<button onClick={handleDelete} className="glass-btn-base glass-btn-danger px-3 py-1.5 rounded-lg text-sm">{t('delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LocationCard
|
||||
@@ -0,0 +1,292 @@
|
||||
'use client'
|
||||
import { logError as _ulogError } from '@/lib/logging/core'
|
||||
|
||||
/**
|
||||
* 资产中心 - 场景编辑弹窗
|
||||
* 与项目级资产库的 LocationEditModal 保持一致
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { shouldShowError } from '@/lib/error-utils'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import {
|
||||
useRefreshGlobalAssets,
|
||||
useUpdateLocationName,
|
||||
useAiModifyLocationDescription,
|
||||
useUpdateLocationSummary,
|
||||
} from '@/lib/query/hooks'
|
||||
|
||||
interface LocationEditModalProps {
|
||||
locationId: string
|
||||
locationName: string
|
||||
summary: string
|
||||
imageIndex: number
|
||||
description: string
|
||||
onClose: () => void
|
||||
onSave: () => void // 触发生成图片
|
||||
}
|
||||
|
||||
export function LocationEditModal({
|
||||
locationId,
|
||||
locationName,
|
||||
summary,
|
||||
imageIndex,
|
||||
description,
|
||||
onClose,
|
||||
onSave
|
||||
}: LocationEditModalProps) {
|
||||
// 🔥 使用 React Query
|
||||
const onRefresh = useRefreshGlobalAssets()
|
||||
const updateName = useUpdateLocationName()
|
||||
const modifyDescription = useAiModifyLocationDescription()
|
||||
const updateSummary = useUpdateLocationSummary()
|
||||
const t = useTranslations('assets')
|
||||
|
||||
const [editingName, setEditingName] = useState(locationName)
|
||||
const [editingDescription, setEditingDescription] = useState(description || summary || '')
|
||||
const [aiModifyInstruction, setAiModifyInstruction] = useState('')
|
||||
const [isAiModifying, setIsAiModifying] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const aiModifyingState = isAiModifying
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'modify',
|
||||
resource: 'image',
|
||||
hasOutput: true,
|
||||
})
|
||||
: null
|
||||
const savingState = isSaving
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'modify',
|
||||
resource: 'image',
|
||||
hasOutput: false,
|
||||
})
|
||||
: null
|
||||
|
||||
// AI 修改描述
|
||||
const handleAiModify = async () => {
|
||||
if (!aiModifyInstruction.trim()) return
|
||||
|
||||
try {
|
||||
setIsAiModifying(true)
|
||||
const data = await modifyDescription.mutateAsync({
|
||||
locationId,
|
||||
imageIndex,
|
||||
currentDescription: editingDescription,
|
||||
modifyInstruction: aiModifyInstruction,
|
||||
})
|
||||
setEditingDescription(data.modifiedDescription ?? '')
|
||||
setAiModifyInstruction('')
|
||||
} catch (error: unknown) {
|
||||
if (shouldShowError(error)) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
alert(t('modal.modifyFailed') + ': ' + message)
|
||||
}
|
||||
} finally {
|
||||
setIsAiModifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存名字
|
||||
const handleSaveName = () => {
|
||||
if (!editingName.trim() || editingName === locationName) return
|
||||
|
||||
updateName.mutate(
|
||||
{ locationId, name: editingName.trim() },
|
||||
{
|
||||
onError: (error) => {
|
||||
if (shouldShowError(error)) {
|
||||
alert(t('modal.saveName') + t('errors.failed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 仅保存(不生成图片)
|
||||
const handleSaveOnly = async () => {
|
||||
try {
|
||||
setIsSaving(true)
|
||||
|
||||
// 如果名字变了,先保存名字和 summary
|
||||
if (editingName.trim() !== locationName) {
|
||||
await updateName.mutateAsync({ locationId, name: editingName.trim() })
|
||||
await updateSummary.mutateAsync({ locationId, summary: editingDescription })
|
||||
} else {
|
||||
// 只保存 summary
|
||||
await updateSummary.mutateAsync({ locationId, summary: editingDescription })
|
||||
}
|
||||
|
||||
onRefresh()
|
||||
onClose()
|
||||
} catch (error: unknown) {
|
||||
if (shouldShowError(error)) {
|
||||
alert(t('errors.saveFailed'))
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存并生成图片
|
||||
const handleSaveAndGenerate = async () => {
|
||||
const descToSave = editingDescription
|
||||
const nameToSave = editingName.trim()
|
||||
|
||||
// 立即关闭弹窗
|
||||
onClose()
|
||||
|
||||
// 后台执行保存和生成
|
||||
; (async () => {
|
||||
try {
|
||||
// 保存名字和描述
|
||||
if (nameToSave !== locationName) {
|
||||
await updateName.mutateAsync({ locationId, name: nameToSave })
|
||||
}
|
||||
await updateSummary.mutateAsync({ locationId, summary: descToSave })
|
||||
|
||||
// 触发生成
|
||||
onSave()
|
||||
onRefresh()
|
||||
} catch (error: unknown) {
|
||||
_ulogError('保存并生成失败:', error)
|
||||
if (shouldShowError(error)) {
|
||||
alert(t('errors.saveFailed'))
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 glass-overlay flex items-center justify-center z-50 p-4">
|
||||
<div className="glass-surface-modal max-w-2xl w-full max-h-[80vh] overflow-y-auto">
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-[var(--glass-text-primary)]">
|
||||
{t('modal.editLocation')} - {locationName}
|
||||
</h3>
|
||||
<button onClick={onClose} className="glass-btn-base glass-btn-soft h-8 w-8 rounded-full text-[var(--glass-text-tertiary)] hover:text-[var(--glass-text-secondary)]">
|
||||
<AppIcon name="close" className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 场景名字编辑 */}
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('location.name')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
className="glass-input-base flex-1 px-3 py-2"
|
||||
placeholder={t('modal.namePlaceholder')}
|
||||
/>
|
||||
{editingName !== locationName && (
|
||||
<button
|
||||
onClick={handleSaveName}
|
||||
disabled={updateName.isPending || !editingName.trim()}
|
||||
className="glass-btn-base glass-btn-tone-success px-3 py-2 rounded-lg text-sm whitespace-nowrap"
|
||||
>
|
||||
{updateName.isPending ? t('smartImport.preview.saving') : t('modal.saveName')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI 修改区域 */}
|
||||
<div className="space-y-2 glass-surface-soft p-4 rounded-lg border border-[var(--glass-stroke-base)]">
|
||||
<label className="glass-field-label block flex items-center gap-2">
|
||||
<AppIcon name="bolt" className="w-4 h-4" />
|
||||
{t('modal.smartModify')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={aiModifyInstruction}
|
||||
onChange={(e) => setAiModifyInstruction(e.target.value)}
|
||||
placeholder={t('modal.modifyPlaceholder')}
|
||||
className="glass-input-base flex-1 px-3 py-2"
|
||||
disabled={isAiModifying}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleAiModify()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAiModify}
|
||||
disabled={isAiModifying || !aiModifyInstruction.trim()}
|
||||
className="glass-btn-base glass-btn-tone-info px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
{isAiModifying ? (
|
||||
<TaskStatusInline state={aiModifyingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
<>
|
||||
<AppIcon name="bolt" className="w-4 h-4" />
|
||||
{t('modal.smartModify')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="glass-field-hint">
|
||||
{t('modal.aiLocationTip')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 描述编辑 */}
|
||||
<div className="space-y-2">
|
||||
<label className="glass-field-label block">
|
||||
{t('location.description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={editingDescription}
|
||||
onChange={(e) => setEditingDescription(e.target.value)}
|
||||
className="glass-textarea-base w-full h-48 px-3 py-2 resize-none"
|
||||
placeholder={t('modal.descPlaceholder')}
|
||||
disabled={isAiModifying}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveOnly}
|
||||
disabled={isSaving || !editingDescription.trim()}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<TaskStatusInline state={savingState} className="text-white [&>span]:text-white [&_svg]:text-white" />
|
||||
) : (
|
||||
t('modal.saveOnly')
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveAndGenerate}
|
||||
disabled={isSaving || !editingDescription.trim()}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{t('modal.saveAndGenerate')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LocationEditModal
|
||||
152
src/app/[locale]/workspace/asset-hub/components/VoiceCard.tsx
Normal file
152
src/app/[locale]/workspace/asset-hub/components/VoiceCard.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useDeleteVoice } from '@/lib/query/mutations'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface Voice {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
voiceId: string | null
|
||||
voiceType: string
|
||||
customVoiceUrl: string | null
|
||||
voicePrompt: string | null
|
||||
gender: string | null
|
||||
language: string
|
||||
folderId: string | null
|
||||
}
|
||||
|
||||
interface VoiceCardProps {
|
||||
voice: Voice
|
||||
onSelect?: (voice: Voice) => void // 选择模式时使用
|
||||
isSelected?: boolean // 是否被选中
|
||||
selectionMode?: boolean // 是否在选择模式
|
||||
}
|
||||
|
||||
export function VoiceCard({ voice, onSelect, isSelected = false, selectionMode = false }: VoiceCardProps) {
|
||||
// 🔥 使用 mutation hook
|
||||
const deleteVoice = useDeleteVoice()
|
||||
const t = useTranslations('assetHub')
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
// 播放预览
|
||||
const handlePlay = () => {
|
||||
if (!voice.customVoiceUrl) return
|
||||
|
||||
if (isPlaying && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
setIsPlaying(false)
|
||||
return
|
||||
}
|
||||
|
||||
const audio = new Audio(voice.customVoiceUrl)
|
||||
audioRef.current = audio
|
||||
audio.onended = () => setIsPlaying(false)
|
||||
audio.onerror = () => setIsPlaying(false)
|
||||
audio.play()
|
||||
setIsPlaying(true)
|
||||
}
|
||||
|
||||
// 删除音色
|
||||
const handleDelete = () => {
|
||||
deleteVoice.mutate(voice.id, {
|
||||
onSettled: () => setShowDeleteConfirm(false)
|
||||
})
|
||||
}
|
||||
|
||||
// 选择模式点击
|
||||
const handleCardClick = () => {
|
||||
if (selectionMode && onSelect) {
|
||||
onSelect(voice)
|
||||
}
|
||||
}
|
||||
|
||||
// 性别图标
|
||||
const genderIcon = voice.gender === 'male' ? 'M' : voice.gender === 'female' ? 'F' : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className={`glass-surface overflow-hidden relative group transition-all ${selectionMode ? 'cursor-pointer hover:ring-2 hover:ring-[var(--glass-focus-ring-strong)]' : ''
|
||||
} ${isSelected ? 'ring-2 ring-[var(--glass-stroke-focus)]' : ''}`}
|
||||
>
|
||||
{/* 选中标记 */}
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 w-6 h-6 glass-chip glass-chip-info rounded-full flex items-center justify-center z-10 p-0">
|
||||
<AppIcon name="checkSolid" className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 音色图标区域 */}
|
||||
<div className="relative bg-[var(--glass-bg-muted)] p-6 flex items-center justify-center">
|
||||
<div className="w-16 h-16 rounded-full glass-surface-soft flex items-center justify-center">
|
||||
<AppIcon name="mic" className="w-8 h-8 text-[var(--glass-tone-info-fg)]" />
|
||||
</div>
|
||||
|
||||
{/* 性别标签 */}
|
||||
{genderIcon && (
|
||||
<div className="absolute top-2 left-2 glass-chip glass-chip-neutral text-xs px-2 py-0.5 rounded-full">
|
||||
{genderIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 试听按钮 */}
|
||||
{voice.customVoiceUrl && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handlePlay() }}
|
||||
className={`absolute bottom-2 right-2 w-10 h-10 rounded-full glass-btn-base flex items-center justify-center transition-all ${isPlaying
|
||||
? 'glass-btn-tone-info animate-pulse'
|
||||
: 'glass-btn-secondary text-[var(--glass-tone-info-fg)]'
|
||||
}`}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<AppIcon name="pause" className="w-5 h-5" />
|
||||
) : (
|
||||
<AppIcon name="play" className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 信息区域 */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium text-[var(--glass-text-primary)] text-sm truncate">{voice.name}</h3>
|
||||
{!selectionMode && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowDeleteConfirm(true) }}
|
||||
className="glass-btn-base glass-btn-soft h-6 w-6 rounded-md text-[var(--glass-tone-danger-fg)] flex items-center justify-center opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<AppIcon name="trash" className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{voice.description && (
|
||||
<p className="mt-1 text-xs text-[var(--glass-text-secondary)] line-clamp-2">{voice.description}</p>
|
||||
)}
|
||||
{voice.voicePrompt && !voice.description && (
|
||||
<p className="mt-1 text-xs text-[var(--glass-text-tertiary)] line-clamp-2 italic">{voice.voicePrompt}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 删除确认 */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="absolute inset-0 glass-overlay flex items-center justify-center z-20">
|
||||
<div className="glass-surface-modal p-4 m-4" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="mb-4 text-sm text-[var(--glass-text-primary)]">{t('confirmDeleteVoice')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setShowDeleteConfirm(false)} className="glass-btn-base glass-btn-secondary px-3 py-1.5 rounded-lg text-sm">{t('cancel')}</button>
|
||||
<button onClick={handleDelete} className="glass-btn-base glass-btn-danger px-3 py-1.5 rounded-lg text-sm">{t('delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VoiceCard
|
||||
@@ -0,0 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import VoiceCreationModalShell, { type VoiceCreationModalShellProps } from './voice-creation/VoiceCreationModalShell'
|
||||
|
||||
export type { VoiceCreationModalShellProps as VoiceCreationModalProps } from './voice-creation/VoiceCreationModalShell'
|
||||
|
||||
export default function VoiceCreationModal(props: VoiceCreationModalShellProps) {
|
||||
return <VoiceCreationModalShell {...props} />
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import VoiceDesignDialogBase, {
|
||||
type VoiceDesignMutationPayload,
|
||||
type VoiceDesignMutationResult,
|
||||
} from '@/components/voice/VoiceDesignDialogBase'
|
||||
import { useDesignAssetHubVoice } from '@/lib/query/hooks'
|
||||
|
||||
interface VoiceDesignDialogProps {
|
||||
isOpen: boolean
|
||||
speaker: string
|
||||
hasExistingVoice?: boolean
|
||||
onClose: () => void
|
||||
onSave: (voiceId: string, audioBase64: string) => void
|
||||
}
|
||||
|
||||
export default function VoiceDesignDialog({
|
||||
isOpen,
|
||||
speaker,
|
||||
hasExistingVoice = false,
|
||||
onClose,
|
||||
onSave,
|
||||
}: VoiceDesignDialogProps) {
|
||||
const designVoiceMutation = useDesignAssetHubVoice()
|
||||
|
||||
const handleDesignVoice = async (
|
||||
payload: VoiceDesignMutationPayload,
|
||||
): Promise<VoiceDesignMutationResult> => {
|
||||
return await designVoiceMutation.mutateAsync(payload)
|
||||
}
|
||||
|
||||
return (
|
||||
<VoiceDesignDialogBase
|
||||
isOpen={isOpen}
|
||||
speaker={speaker}
|
||||
hasExistingVoice={hasExistingVoice}
|
||||
onClose={onClose}
|
||||
onSave={onSave}
|
||||
onDesignVoice={handleDesignVoice}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
import { logError as _ulogError } from '@/lib/logging/core'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useGlobalVoices } from '@/lib/query/hooks'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface Voice {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
voiceId: string | null
|
||||
voiceType: string
|
||||
customVoiceUrl: string | null
|
||||
voicePrompt: string | null
|
||||
gender: string | null
|
||||
language: string
|
||||
folderId: string | null
|
||||
}
|
||||
|
||||
interface VoicePickerDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSelect: (voice: Voice) => void
|
||||
}
|
||||
|
||||
export default function VoicePickerDialog({ isOpen, onClose, onSelect }: VoicePickerDialogProps) {
|
||||
const t = useTranslations('assetHub')
|
||||
const tv = useTranslations('voice.voiceDesign')
|
||||
const voicesQuery = useGlobalVoices()
|
||||
const [selectedVoice, setSelectedVoice] = useState<Voice | null>(null)
|
||||
const [playingId, setPlayingId] = useState<string | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const voices = (voicesQuery.data || []) as Voice[]
|
||||
const loading = isOpen ? voicesQuery.isFetching : false
|
||||
const loadingState = loading
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'process',
|
||||
resource: 'audio',
|
||||
hasOutput: false,
|
||||
})
|
||||
: null
|
||||
|
||||
const refetchVoices = voicesQuery.refetch
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
refetchVoices().catch((error) => {
|
||||
_ulogError('加载音色失败:', error)
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen])
|
||||
|
||||
// 播放预览
|
||||
const handlePlay = (voice: Voice) => {
|
||||
if (!voice.customVoiceUrl) return
|
||||
|
||||
if (playingId === voice.id && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
setPlayingId(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
|
||||
const audio = new Audio(voice.customVoiceUrl)
|
||||
audioRef.current = audio
|
||||
audio.onended = () => setPlayingId(null)
|
||||
audio.onerror = () => setPlayingId(null)
|
||||
audio.play()
|
||||
setPlayingId(voice.id)
|
||||
}
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
if (selectedVoice) {
|
||||
onSelect(selectedVoice)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭时清理
|
||||
const handleClose = () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
setSelectedVoice(null)
|
||||
setPlayingId(null)
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
const dialogContent = (
|
||||
<>
|
||||
{/* 背景遮罩 */}
|
||||
<div className="fixed inset-0 z-[9999] glass-overlay" onClick={handleClose} />
|
||||
|
||||
{/* 对话框 */}
|
||||
<div
|
||||
className="fixed z-[10000] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 glass-surface-modal w-full max-w-2xl max-h-[80vh] overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppIcon name="mic" className="w-5 h-5 text-[var(--glass-tone-info-fg)]" />
|
||||
<h2 className="font-semibold text-[var(--glass-text-primary)]">{t('voicePickerTitle')}</h2>
|
||||
</div>
|
||||
<button onClick={handleClose} className="glass-btn-base glass-btn-soft p-1 text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="close" className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 内容区 */}
|
||||
<div className="p-5 overflow-y-auto max-h-[60vh]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<TaskStatusInline state={loadingState} />
|
||||
</div>
|
||||
) : voices.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--glass-text-secondary)]">
|
||||
<AppIcon name="mic" className="w-16 h-16 mx-auto mb-4 text-[var(--glass-text-tertiary)]" />
|
||||
<p>{t('voicePickerEmpty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{voices.map(voice => {
|
||||
const isSelected = selectedVoice?.id === voice.id
|
||||
const isPlaying = playingId === voice.id
|
||||
const genderIcon = voice.gender === 'male' ? 'M' : voice.gender === 'female' ? 'F' : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
key={voice.id}
|
||||
onClick={() => setSelectedVoice(voice)}
|
||||
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all ${isSelected
|
||||
? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)]'
|
||||
: 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)] bg-[var(--glass-bg-surface)]'
|
||||
}`}
|
||||
>
|
||||
{/* 选中标记 */}
|
||||
{isSelected && (
|
||||
<div className="absolute -top-1.5 -right-1.5 w-5 h-5 glass-chip glass-chip-info rounded-full flex items-center justify-center p-0">
|
||||
<AppIcon name="checkSolid" className="w-3 h-3 text-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 音色信息 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full glass-surface-soft flex items-center justify-center flex-shrink-0">
|
||||
<AppIcon name="mic" className="w-5 h-5 text-[var(--glass-tone-info-fg)]" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium text-[var(--glass-text-primary)] text-sm truncate">{voice.name}</span>
|
||||
{genderIcon && <span className="glass-chip glass-chip-neutral text-[10px] px-1.5 py-0">{genderIcon}</span>}
|
||||
</div>
|
||||
{voice.description && (
|
||||
<p className="text-xs text-[var(--glass-text-secondary)] truncate">{voice.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 试听按钮 */}
|
||||
{voice.customVoiceUrl && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handlePlay(voice) }}
|
||||
className={`mt-2 w-full py-1.5 rounded-lg text-xs font-medium transition-all flex items-center justify-center gap-1 glass-btn-base ${isPlaying
|
||||
? 'glass-btn-tone-info'
|
||||
: 'glass-btn-secondary text-[var(--glass-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<>
|
||||
<AppIcon name="pause" className="w-3 h-3" />
|
||||
{tv('playing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AppIcon name="play" className="w-3 h-3" />
|
||||
{tv('preview')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="flex gap-2 p-4 border-t border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)]">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="glass-btn-base glass-btn-secondary flex-1 py-2 rounded-lg text-sm"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedVoice}
|
||||
className="glass-btn-base glass-btn-primary flex-1 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||
>
|
||||
{t('voicePickerConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
return createPortal(dialogContent, document.body)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* 音色设置组件 - 从 CharacterCard 提取
|
||||
* 支持上传自定义音频和 AI 声音设计
|
||||
*/
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { shouldShowError } from '@/lib/error-utils'
|
||||
import { useUploadCharacterVoice } from '@/lib/query/mutations'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
|
||||
interface VoiceSettingsProps {
|
||||
characterId: string
|
||||
characterName: string
|
||||
customVoiceUrl: string | null | undefined
|
||||
projectId?: string // 可选,Asset Hub 不需要
|
||||
onVoiceChange?: (characterId: string, customVoiceUrl?: string) => void
|
||||
onVoiceDesign?: (characterId: string, characterName: string) => void
|
||||
onVoiceSelect?: (characterId: string) => void // 从音色库选择
|
||||
compact?: boolean // 紧凑模式(单图卡片用)
|
||||
}
|
||||
|
||||
export default function VoiceSettings({
|
||||
characterId,
|
||||
characterName,
|
||||
customVoiceUrl,
|
||||
projectId,
|
||||
onVoiceChange,
|
||||
onVoiceDesign,
|
||||
onVoiceSelect,
|
||||
compact = false
|
||||
}: VoiceSettingsProps) {
|
||||
const t = useTranslations('assetHub')
|
||||
// 🔥 使用 mutation hook
|
||||
const uploadVoice = useUploadCharacterVoice()
|
||||
void projectId
|
||||
const voiceFileInputRef = useRef<HTMLInputElement>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [isPreviewingVoice, setIsPreviewingVoice] = useState(false)
|
||||
type UploadedVoiceResult = { audioUrl?: string }
|
||||
|
||||
const hasCustomVoice = !!customVoiceUrl
|
||||
|
||||
// 预览音色(播放/暂停自定义音频)
|
||||
const handlePreviewVoice = async () => {
|
||||
if (!customVoiceUrl) return
|
||||
|
||||
// 如果正在播放,点击则暂停
|
||||
if (isPreviewingVoice && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
setIsPreviewingVoice(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
const audio = new Audio(customVoiceUrl)
|
||||
audioRef.current = audio
|
||||
audio.play()
|
||||
audio.onended = () => setIsPreviewingVoice(false)
|
||||
audio.onerror = () => setIsPreviewingVoice(false)
|
||||
setIsPreviewingVoice(true)
|
||||
} catch (error: unknown) {
|
||||
if (shouldShowError(error)) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
alert(t('voiceSettings.previewFailed', { error: message }))
|
||||
}
|
||||
setIsPreviewingVoice(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 上传自定义音频
|
||||
const handleUploadVoice = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploadVoice.mutate(
|
||||
{ file, characterId },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
const result = (data || {}) as UploadedVoiceResult
|
||||
onVoiceChange?.(characterId, result.audioUrl)
|
||||
},
|
||||
onError: (error) => {
|
||||
if (shouldShowError(error)) {
|
||||
alert(t('voiceSettings.uploadFailed', { error: error.message }))
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
if (voiceFileInputRef.current) {
|
||||
voiceFileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 紧凑模式样式
|
||||
const containerClass = compact
|
||||
? 'glass-surface-soft border border-[var(--glass-stroke-base)] rounded-xl p-3'
|
||||
: 'mt-4 glass-surface-soft border border-[var(--glass-stroke-base)] rounded-xl p-4'
|
||||
|
||||
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'
|
||||
|
||||
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 ? 'glass-chip glass-chip-neutral p-0' : 'glass-chip glass-chip-warning p-0'}`}>
|
||||
<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 ${hasCustomVoice ? 'text-[var(--glass-text-secondary)]' : 'text-[var(--glass-tone-warning-fg)]'}`}>
|
||||
{t('voiceSettings.title')}{!hasCustomVoice && <span className="text-[var(--glass-tone-warning-fg)]">({t('voiceSettings.noVoice')})</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 隐藏的音频文件输入 */}
|
||||
<input
|
||||
ref={voiceFileInputRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleUploadVoice}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 w-full justify-center flex-wrap">
|
||||
<button
|
||||
onClick={() => voiceFileInputRef.current?.click()}
|
||||
disabled={uploadVoice.isPending}
|
||||
className="glass-btn-base glass-btn-secondary flex-1 min-w-[70px] px-2 py-1.5 rounded-lg text-xs font-medium 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('voiceSettings.uploading') : hasCustomVoice ? t('voiceSettings.uploaded') : t('voiceSettings.uploadAudio')}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{onVoiceDesign && (
|
||||
<button
|
||||
onClick={() => onVoiceDesign(characterId, characterName)}
|
||||
className="glass-btn-base glass-btn-tone-info flex-1 min-w-[70px] px-2 py-1.5 rounded-lg text-xs font-medium transition-all 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('voiceSettings.aiDesign')}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onVoiceSelect && (
|
||||
<button
|
||||
onClick={() => onVoiceSelect(characterId)}
|
||||
className="glass-btn-base glass-btn-secondary flex-1 min-w-[70px] px-2 py-1.5 rounded-lg text-xs text-[var(--glass-tone-info-fg)] font-medium transition-all whitespace-nowrap"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<AppIcon name="folderCards" className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>{t('voiceSettings.voiceLibrary')}</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 试听按钮 - 仅在有音频时显示 */}
|
||||
{hasCustomVoice && (
|
||||
<button
|
||||
onClick={handlePreviewVoice}
|
||||
className={`glass-btn-base w-full mt-2 px-3 py-2 border rounded-lg text-sm font-medium transition-all ${isPreviewingVoice
|
||||
? 'glass-btn-tone-info border-[var(--glass-stroke-focus)]'
|
||||
: 'glass-btn-secondary text-[var(--glass-tone-info-fg)] border-[var(--glass-stroke-base)]'
|
||||
}`}
|
||||
>
|
||||
<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('voiceSettings.pause') : t('voiceSettings.preview')}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { VoiceCreationRuntime } from './hooks/useVoiceCreation'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { SegmentedControl } from '@/components/ui/SegmentedControl'
|
||||
|
||||
interface VoiceCreationFormProps {
|
||||
runtime: VoiceCreationRuntime
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function VoiceCreationForm({ runtime, children }: VoiceCreationFormProps) {
|
||||
const {
|
||||
mode,
|
||||
voiceName,
|
||||
tHub,
|
||||
tvCreate,
|
||||
setVoiceName,
|
||||
handleClose,
|
||||
handleModeChange,
|
||||
} = runtime
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[10000] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 glass-surface-modal w-full max-w-xl overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-[var(--glass-stroke-base)] bg-[var(--glass-bg-surface-strong)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppIcon name="mic" className="w-5 h-5 text-[var(--glass-tone-info-fg)]" />
|
||||
<h2 className="font-semibold text-[var(--glass-text-primary)]">{tHub('addVoice')}</h2>
|
||||
</div>
|
||||
<button onClick={handleClose} className="glass-btn-base glass-btn-soft p-1 text-[var(--glass-text-tertiary)]">
|
||||
<AppIcon name="close" className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex border-b border-[var(--glass-stroke-base)]">
|
||||
<div className="flex-1 px-5 py-2.5">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: 'design' as const, label: tvCreate('aiDesignMode') },
|
||||
{ value: 'upload' as const, label: tvCreate('uploadMode') },
|
||||
]}
|
||||
value={mode}
|
||||
onChange={(val) => handleModeChange(val as 'design' | 'upload')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<div>
|
||||
<label className="glass-field-label mb-1 block">{tHub('voiceName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={voiceName}
|
||||
onChange={(e) => setVoiceName(e.target.value)}
|
||||
placeholder={tHub('voiceNamePlaceholder')}
|
||||
className="glass-input-base w-full px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import { createPortal } from 'react-dom'
|
||||
import VoiceCreationForm from './VoiceCreationForm'
|
||||
import VoicePreviewSection from './VoicePreviewSection'
|
||||
import { useVoiceCreation, type VoiceCreationModalShellProps } from './hooks/useVoiceCreation'
|
||||
|
||||
export type { VoiceCreationModalShellProps }
|
||||
|
||||
export default function VoiceCreationModalLayout(props: VoiceCreationModalShellProps) {
|
||||
const runtime = useVoiceCreation(props)
|
||||
|
||||
if (!runtime.isOpen) return null
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9999] glass-overlay" onClick={runtime.handleClose} />
|
||||
<VoiceCreationForm runtime={runtime}>
|
||||
<VoicePreviewSection runtime={runtime} />
|
||||
</VoiceCreationForm>
|
||||
</>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import VoiceCreationModalLayout, { type VoiceCreationModalShellProps } from './VoiceCreationModalLayout'
|
||||
|
||||
export type { VoiceCreationModalShellProps }
|
||||
|
||||
export default function VoiceCreationModalShell(props: VoiceCreationModalShellProps) {
|
||||
return <VoiceCreationModalLayout {...props} />
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import VoiceDesignGeneratorSection from '@/components/voice/VoiceDesignGeneratorSection'
|
||||
import type { VoiceCreationRuntime } from './hooks/useVoiceCreation'
|
||||
|
||||
interface VoicePreviewSectionProps {
|
||||
runtime: VoiceCreationRuntime
|
||||
}
|
||||
|
||||
export default function VoicePreviewSection({ runtime }: VoicePreviewSectionProps) {
|
||||
const {
|
||||
mode,
|
||||
voiceName,
|
||||
voicePrompt,
|
||||
previewText,
|
||||
schemeCount,
|
||||
isVoiceCreationSubmitting,
|
||||
isSaving,
|
||||
error,
|
||||
generatedVoices,
|
||||
selectedIndex,
|
||||
playingIndex,
|
||||
uploadFile,
|
||||
uploadPreviewUrl,
|
||||
isUploading,
|
||||
isDragging,
|
||||
fileInputRef,
|
||||
voiceCreationSubmittingState,
|
||||
uploadSubmittingState,
|
||||
tHub,
|
||||
tvCreate,
|
||||
setVoicePrompt,
|
||||
setPreviewText,
|
||||
setSchemeCount,
|
||||
setSelectedIndex,
|
||||
setUploadFile,
|
||||
setUploadPreviewUrl,
|
||||
handleGenerate,
|
||||
handlePlayVoice,
|
||||
handleSaveDesigned,
|
||||
handleFileSelect,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handlePlayUpload,
|
||||
handleSaveUploaded,
|
||||
} = runtime
|
||||
|
||||
return (
|
||||
<>
|
||||
{mode === 'design' && (
|
||||
<VoiceDesignGeneratorSection
|
||||
voicePrompt={voicePrompt}
|
||||
onVoicePromptChange={setVoicePrompt}
|
||||
previewText={previewText}
|
||||
onPreviewTextChange={setPreviewText}
|
||||
schemeCount={schemeCount}
|
||||
onSchemeCountChange={setSchemeCount}
|
||||
isSubmitting={isVoiceCreationSubmitting}
|
||||
submittingState={voiceCreationSubmittingState}
|
||||
error={error}
|
||||
generatedVoices={generatedVoices}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelectIndex={setSelectedIndex}
|
||||
playingIndex={playingIndex}
|
||||
onPlayVoice={handlePlayVoice}
|
||||
onGenerate={() => {
|
||||
void handleGenerate()
|
||||
}}
|
||||
footer={(
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
void handleGenerate()
|
||||
}}
|
||||
disabled={isVoiceCreationSubmitting}
|
||||
className="glass-btn-base glass-btn-secondary flex-1 py-2 rounded-lg text-sm"
|
||||
>
|
||||
{tHub('regenerate')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
void handleSaveDesigned()
|
||||
}}
|
||||
disabled={selectedIndex === null || isSaving || !voiceName.trim()}
|
||||
className="glass-btn-base glass-btn-tone-success flex-1 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||
>
|
||||
{isSaving ? tHub('modal.adding') : tHub('save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'upload' && (
|
||||
<>
|
||||
{!uploadFile ? (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${isDragging
|
||||
? 'border-[var(--glass-stroke-focus)] bg-[var(--glass-tone-info-bg)]'
|
||||
: 'border-[var(--glass-stroke-base)] hover:border-[var(--glass-stroke-focus)] hover:bg-[var(--glass-bg-muted)]'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm text-[var(--glass-text-secondary)] mb-2">{tvCreate('dropOrClick')}</div>
|
||||
<div className="text-xs text-[var(--glass-text-tertiary)]">{tvCreate('supportedFormats')}</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="audio/*,.mp3,.wav,.ogg,.m4a,.aac"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFileSelect(file)
|
||||
}}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-surface-soft border border-[var(--glass-stroke-base)] rounded-xl p-4">
|
||||
<div className="text-sm font-medium text-[var(--glass-text-primary)] truncate">{uploadFile.name}</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setUploadFile(null)
|
||||
if (uploadPreviewUrl) URL.revokeObjectURL(uploadPreviewUrl)
|
||||
setUploadPreviewUrl(null)
|
||||
}}
|
||||
className="glass-btn-base glass-btn-soft p-1 mt-2"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{uploadPreviewUrl && (
|
||||
<button
|
||||
onClick={handlePlayUpload}
|
||||
className="glass-btn-base glass-btn-tone-info w-full py-2 rounded-lg text-sm font-medium mt-2"
|
||||
>
|
||||
{tvCreate('previewAudio')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadFile && (
|
||||
<button
|
||||
onClick={handleSaveUploaded}
|
||||
disabled={isUploading || !voiceName.trim()}
|
||||
className="glass-btn-base glass-btn-tone-success w-full py-2.5 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium flex items-center justify-center gap-2"
|
||||
>
|
||||
{isUploading ? (
|
||||
<TaskStatusInline
|
||||
state={uploadSubmittingState}
|
||||
className="text-white [&>span]:text-white [&_svg]:text-white"
|
||||
/>
|
||||
) : (
|
||||
tHub('save')
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'upload' && error && (
|
||||
<div className="text-sm text-[var(--glass-tone-danger-fg)] bg-[var(--glass-tone-danger-bg)] px-3 py-2 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
useDesignAssetHubVoice,
|
||||
useSaveDesignedAssetHubVoice,
|
||||
useUploadAssetHubVoice,
|
||||
} from '@/lib/query/hooks'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import {
|
||||
DEFAULT_VOICE_SCHEME_COUNT,
|
||||
generateVoiceDesignOptions,
|
||||
type GeneratedVoice,
|
||||
} from '@/components/voice/voice-design-shared'
|
||||
|
||||
export interface VoiceCreationModalShellProps {
|
||||
isOpen: boolean
|
||||
folderId: string | null
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
/** 预填充的音色名称(如发言人名字) */
|
||||
initialVoiceName?: string
|
||||
}
|
||||
|
||||
type CreationMode = 'design' | 'upload'
|
||||
|
||||
export function useVoiceCreation({ isOpen, folderId, onClose, onSuccess, initialVoiceName }: VoiceCreationModalShellProps) {
|
||||
const t = useTranslations('common')
|
||||
const tHub = useTranslations('assetHub')
|
||||
const tv = useTranslations('voice.voiceDesign')
|
||||
const tvCreate = useTranslations('voice.voiceCreate')
|
||||
|
||||
// 创建模式:设计 or 上传
|
||||
const [mode, setMode] = useState<CreationMode>('design')
|
||||
|
||||
// 设计模式状态
|
||||
const [voiceName, setVoiceName] = useState(initialVoiceName ?? '')
|
||||
const [voicePrompt, setVoicePrompt] = useState('')
|
||||
const [previewText, setPreviewText] = useState(tv('defaultPreviewText'))
|
||||
const [schemeCount, setSchemeCount] = useState(String(DEFAULT_VOICE_SCHEME_COUNT))
|
||||
const [isVoiceCreationSubmitting, setIsVoiceCreationSubmitting] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [generatedVoices, setGeneratedVoices] = useState<GeneratedVoice[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
const [playingIndex, setPlayingIndex] = useState<number | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const voiceCreationSubmittingState = isVoiceCreationSubmitting
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'generate',
|
||||
resource: 'audio',
|
||||
hasOutput: false,
|
||||
})
|
||||
: null
|
||||
|
||||
// 上传模式状态
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null)
|
||||
const [uploadPreviewUrl, setUploadPreviewUrl] = useState<string | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const uploadSubmittingState = isUploading
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
intent: 'generate',
|
||||
resource: 'audio',
|
||||
hasOutput: false,
|
||||
})
|
||||
: null
|
||||
const designVoiceMutation = useDesignAssetHubVoice()
|
||||
const saveDesignedMutation = useSaveDesignedAssetHubVoice()
|
||||
const uploadVoiceMutation = useUploadAssetHubVoice()
|
||||
|
||||
// 生成音色
|
||||
const handleGenerate = async () => {
|
||||
if (!voicePrompt.trim()) {
|
||||
setError(tv('pleaseSelectStyle'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsVoiceCreationSubmitting(true)
|
||||
setError(null)
|
||||
setGeneratedVoices([])
|
||||
setSelectedIndex(null)
|
||||
|
||||
try {
|
||||
const voices = await generateVoiceDesignOptions({
|
||||
count: schemeCount,
|
||||
voicePrompt,
|
||||
previewText,
|
||||
defaultPreviewText: tv('defaultPreviewText'),
|
||||
onDesignVoice: (payload) => designVoiceMutation.mutateAsync(payload),
|
||||
})
|
||||
setGeneratedVoices(voices)
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : 'Unknown error'
|
||||
const status = (err as Error & { status?: number }).status
|
||||
if (status === 402) {
|
||||
alert(t('insufficientBalance') + '\n\n' + t('insufficientBalanceDetail'))
|
||||
} else if (errMsg === 'VOICE_DESIGN_EMPTY_RESULT') {
|
||||
setError(tv('noVoiceGenerated'))
|
||||
} else if (errMsg !== 'INSUFFICIENT_BALANCE') {
|
||||
setError(errMsg || tv('generationError'))
|
||||
}
|
||||
} finally {
|
||||
setIsVoiceCreationSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 播放音色(支持暂停切换)
|
||||
const handlePlayVoice = (index: number) => {
|
||||
// 点击正在播放的音色 → 暂停
|
||||
if (playingIndex === index && audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
setPlayingIndex(null)
|
||||
return
|
||||
}
|
||||
// 停止当前播放
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
setPlayingIndex(index)
|
||||
const audio = new Audio(generatedVoices[index].audioUrl)
|
||||
audioRef.current = audio
|
||||
audio.onended = () => setPlayingIndex(null)
|
||||
audio.onerror = () => setPlayingIndex(null)
|
||||
void audio.play()
|
||||
}
|
||||
|
||||
// 保存音色到音色库(设计模式)
|
||||
const handleSaveDesigned = async () => {
|
||||
if (selectedIndex === null || !generatedVoices[selectedIndex]) return
|
||||
if (!voiceName.trim()) {
|
||||
setError(tHub('voiceNameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const voice = generatedVoices[selectedIndex]
|
||||
|
||||
await saveDesignedMutation.mutateAsync({
|
||||
voiceId: voice.voiceId,
|
||||
voiceBase64: voice.audioBase64,
|
||||
voiceName: voiceName.trim(),
|
||||
folderId,
|
||||
voicePrompt: voicePrompt.trim()
|
||||
})
|
||||
|
||||
onSuccess()
|
||||
handleClose()
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : tHub('saveVoiceFailed')
|
||||
setError(errMsg)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理文件选择
|
||||
const handleFileSelect = useCallback((file: File) => {
|
||||
// 验证文件类型(仅音频)
|
||||
const audioTypes = ['audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a', 'audio/aac']
|
||||
const isValid = audioTypes.includes(file.type) || file.name.match(/\.(mp3|wav|ogg|m4a|aac)$/i)
|
||||
|
||||
if (!isValid) {
|
||||
setError(tvCreate('invalidFileType'))
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件大小(最大 50MB)
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
setError(tvCreate('fileTooLarge'))
|
||||
return
|
||||
}
|
||||
|
||||
setUploadFile(file)
|
||||
setError(null)
|
||||
|
||||
// 创建预览 URL
|
||||
const url = URL.createObjectURL(file)
|
||||
setUploadPreviewUrl(url)
|
||||
|
||||
// 自动填充名称(如果为空)
|
||||
if (!voiceName.trim()) {
|
||||
const baseName = file.name.replace(/\.[^/.]+$/, '') // 移除扩展名
|
||||
setVoiceName(baseName)
|
||||
}
|
||||
}, [voiceName, tvCreate])
|
||||
|
||||
// 处理拖放
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) {
|
||||
handleFileSelect(file)
|
||||
}
|
||||
}, [handleFileSelect])
|
||||
|
||||
// 播放上传的音频
|
||||
const handlePlayUpload = () => {
|
||||
if (!uploadPreviewUrl) return
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
const audio = new Audio(uploadPreviewUrl)
|
||||
audioRef.current = audio
|
||||
audio.play()
|
||||
}
|
||||
|
||||
// 上传文件保存
|
||||
const handleSaveUploaded = async () => {
|
||||
if (!uploadFile) return
|
||||
if (!voiceName.trim()) {
|
||||
setError(tHub('voiceNameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsUploading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await uploadVoiceMutation.mutateAsync({
|
||||
uploadFile,
|
||||
voiceName: voiceName.trim(),
|
||||
folderId
|
||||
})
|
||||
|
||||
onSuccess()
|
||||
handleClose()
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : tvCreate('uploadFailed')
|
||||
setError(errMsg)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleClose = () => {
|
||||
setMode('design')
|
||||
setVoiceName(initialVoiceName ?? '')
|
||||
setVoicePrompt('')
|
||||
setPreviewText(tv('defaultPreviewText'))
|
||||
setSchemeCount(String(DEFAULT_VOICE_SCHEME_COUNT))
|
||||
setError(null)
|
||||
setGeneratedVoices([])
|
||||
setSelectedIndex(null)
|
||||
setPlayingIndex(null)
|
||||
setUploadFile(null)
|
||||
if (uploadPreviewUrl) {
|
||||
URL.revokeObjectURL(uploadPreviewUrl)
|
||||
}
|
||||
setUploadPreviewUrl(null)
|
||||
setIsUploading(false)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
// 切换模式
|
||||
const handleModeChange = (newMode: CreationMode) => {
|
||||
setMode(newMode)
|
||||
setError(null)
|
||||
// 清理状态
|
||||
setGeneratedVoices([])
|
||||
setSelectedIndex(null)
|
||||
setUploadFile(null)
|
||||
if (uploadPreviewUrl) {
|
||||
URL.revokeObjectURL(uploadPreviewUrl)
|
||||
}
|
||||
setUploadPreviewUrl(null)
|
||||
}
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
mode,
|
||||
voiceName,
|
||||
voicePrompt,
|
||||
previewText,
|
||||
schemeCount,
|
||||
isVoiceCreationSubmitting,
|
||||
isSaving,
|
||||
error,
|
||||
generatedVoices,
|
||||
selectedIndex,
|
||||
playingIndex,
|
||||
uploadFile,
|
||||
uploadPreviewUrl,
|
||||
isUploading,
|
||||
isDragging,
|
||||
fileInputRef,
|
||||
voiceCreationSubmittingState,
|
||||
uploadSubmittingState,
|
||||
t,
|
||||
tHub,
|
||||
tvCreate,
|
||||
setMode,
|
||||
setVoiceName,
|
||||
setVoicePrompt,
|
||||
setPreviewText,
|
||||
setSchemeCount,
|
||||
setError,
|
||||
setGeneratedVoices,
|
||||
setSelectedIndex,
|
||||
setUploadFile,
|
||||
setUploadPreviewUrl,
|
||||
setIsDragging,
|
||||
handleGenerate,
|
||||
handlePlayVoice,
|
||||
handleSaveDesigned,
|
||||
handleFileSelect,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handlePlayUpload,
|
||||
handleSaveUploaded,
|
||||
handleClose,
|
||||
handleModeChange,
|
||||
}
|
||||
}
|
||||
|
||||
export type VoiceCreationRuntime = ReturnType<typeof useVoiceCreation>
|
||||
521
src/app/[locale]/workspace/asset-hub/page.tsx
Normal file
521
src/app/[locale]/workspace/asset-hub/page.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
'use client'
|
||||
import { logError as _ulogError } from '@/lib/logging/core'
|
||||
import { apiFetch } from '@/lib/api-fetch'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Navbar from '@/components/Navbar'
|
||||
import { FolderSidebar } from './components/FolderSidebar'
|
||||
import { AssetGrid } from './components/AssetGrid'
|
||||
import { CharacterCreationModal, LocationCreationModal, CharacterEditModal, LocationEditModal } from '@/components/shared/assets'
|
||||
import { FolderModal } from './components/FolderModal'
|
||||
import ImagePreviewModal from '@/components/ui/ImagePreviewModal'
|
||||
import ImageEditModal from '@/app/[locale]/workspace/[projectId]/modes/novel-promotion/components/assets/ImageEditModal'
|
||||
import VoiceDesignDialog from './components/VoiceDesignDialog'
|
||||
import VoiceCreationModal from './components/VoiceCreationModal'
|
||||
import VoicePickerDialog from './components/VoicePickerDialog'
|
||||
import {
|
||||
useGlobalCharacters,
|
||||
useGlobalLocations,
|
||||
useGlobalVoices,
|
||||
useGlobalFolders,
|
||||
useSSE,
|
||||
useModifyCharacterImage,
|
||||
useModifyLocationImage,
|
||||
type GlobalCharacter,
|
||||
} from '@/lib/query/hooks'
|
||||
import { queryKeys } from '@/lib/query/keys'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
|
||||
|
||||
export default function AssetHubPage() {
|
||||
const t = useTranslations('assetHub')
|
||||
const queryClient = useQueryClient()
|
||||
const { count: characterGenerationCount } = useImageGenerationCount('character')
|
||||
const { count: locationGenerationCount } = useImageGenerationCount('location')
|
||||
|
||||
// 文件夹选择状态
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)
|
||||
|
||||
// 使用 React Query 获取数据
|
||||
const { data: folders = [], isLoading: foldersLoading } = useGlobalFolders()
|
||||
const { data: characters = [], isLoading: charactersLoading } = useGlobalCharacters(selectedFolderId)
|
||||
const { data: locations = [], isLoading: locationsLoading } = useGlobalLocations(selectedFolderId)
|
||||
const { data: voices = [], isLoading: voicesLoading } = useGlobalVoices(selectedFolderId)
|
||||
|
||||
const loading = foldersLoading || charactersLoading || locationsLoading || voicesLoading
|
||||
useSSE({ projectId: 'global-asset-hub', enabled: true })
|
||||
|
||||
// Mutation hooks
|
||||
const modifyCharacterImage = useModifyCharacterImage()
|
||||
const modifyLocationImage = useModifyLocationImage()
|
||||
|
||||
// 弹窗状态
|
||||
const [showAddCharacter, setShowAddCharacter] = useState(false)
|
||||
const [showAddLocation, setShowAddLocation] = useState(false)
|
||||
const [showFolderModal, setShowFolderModal] = useState(false)
|
||||
const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null)
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
const [imageEditModal, setImageEditModal] = useState<{
|
||||
type: 'character' | 'location'
|
||||
id: string
|
||||
name: string
|
||||
imageIndex: number
|
||||
appearanceIndex?: number
|
||||
} | null>(null)
|
||||
|
||||
const [voiceDesignCharacter, setVoiceDesignCharacter] = useState<{
|
||||
id: string
|
||||
name: string
|
||||
hasExistingVoice: boolean
|
||||
} | null>(null)
|
||||
|
||||
// 音色库弹窗状态
|
||||
const [showAddVoice, setShowAddVoice] = useState(false)
|
||||
const [voicePickerCharacterId, setVoicePickerCharacterId] = useState<string | null>(null)
|
||||
|
||||
// 编辑角色弹窗状态
|
||||
const [characterEditModal, setCharacterEditModal] = useState<{
|
||||
characterId: string
|
||||
characterName: string
|
||||
appearanceId: string
|
||||
appearanceIndex: number
|
||||
changeReason: string
|
||||
artStyle: string | null
|
||||
description: string
|
||||
} | null>(null)
|
||||
|
||||
// 编辑场景弹窗状态
|
||||
const [locationEditModal, setLocationEditModal] = useState<{
|
||||
locationId: string
|
||||
locationName: string
|
||||
summary: string
|
||||
imageIndex: number
|
||||
artStyle: string | null
|
||||
description: string
|
||||
} | null>(null)
|
||||
|
||||
// 创建文件夹
|
||||
const handleCreateFolder = async (name: string) => {
|
||||
try {
|
||||
const res = await apiFetch('/api/asset-hub/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
})
|
||||
if (res.ok) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.folders() })
|
||||
setShowFolderModal(false)
|
||||
}
|
||||
} catch (error) {
|
||||
_ulogError('创建文件夹失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新文件夹
|
||||
const handleUpdateFolder = async (folderId: string, name: string) => {
|
||||
try {
|
||||
const res = await apiFetch(`/api/asset-hub/folders/${folderId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
})
|
||||
if (res.ok) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.folders() })
|
||||
setEditingFolder(null)
|
||||
setShowFolderModal(false)
|
||||
}
|
||||
} catch (error) {
|
||||
_ulogError('更新文件夹失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除文件夹
|
||||
const handleDeleteFolder = async (folderId: string) => {
|
||||
if (!confirm(t('confirmDeleteFolder'))) return
|
||||
|
||||
try {
|
||||
const res = await apiFetch(`/api/asset-hub/folders/${folderId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (res.ok) {
|
||||
if (selectedFolderId === folderId) {
|
||||
setSelectedFolderId(null)
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.all() })
|
||||
}
|
||||
} catch (error) {
|
||||
_ulogError('删除文件夹失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 打开图片编辑弹窗
|
||||
const handleOpenImageEdit = (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => {
|
||||
setImageEditModal({ type, id, name, imageIndex, appearanceIndex })
|
||||
}
|
||||
|
||||
// 处理图片编辑确认 - 使用 mutation
|
||||
const handleImageEdit = async (modifyPrompt: string, extraImageUrls?: string[]) => {
|
||||
if (!imageEditModal) return
|
||||
|
||||
const { type, id, imageIndex, appearanceIndex } = imageEditModal
|
||||
setImageEditModal(null)
|
||||
|
||||
if (type === 'character' && appearanceIndex !== undefined) {
|
||||
modifyCharacterImage.mutate({
|
||||
characterId: id,
|
||||
appearanceIndex,
|
||||
imageIndex,
|
||||
modifyPrompt,
|
||||
extraImageUrls
|
||||
}, {
|
||||
onError: () => {
|
||||
alert(t('editFailed'))
|
||||
}
|
||||
})
|
||||
} else if (type === 'location') {
|
||||
modifyLocationImage.mutate({
|
||||
locationId: id,
|
||||
imageIndex,
|
||||
modifyPrompt,
|
||||
extraImageUrls
|
||||
}, {
|
||||
onError: () => {
|
||||
alert(t('editFailed'))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 打开 AI 声音设计对话框
|
||||
const handleOpenVoiceDesign = (characterId: string, characterName: string) => {
|
||||
const character = characters.find(c => c.id === characterId)
|
||||
setVoiceDesignCharacter({
|
||||
id: characterId,
|
||||
name: characterName,
|
||||
hasExistingVoice: !!character?.customVoiceUrl
|
||||
})
|
||||
}
|
||||
|
||||
// 保存 AI 设计的声音
|
||||
const handleVoiceDesignSave = async (voiceId: string, audioBase64: string) => {
|
||||
if (!voiceDesignCharacter) return
|
||||
|
||||
try {
|
||||
const res = await apiFetch('/api/asset-hub/character-voice', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
characterId: voiceDesignCharacter.id,
|
||||
voiceId,
|
||||
audioBase64
|
||||
})
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
alert(t('voiceDesignSaved', { name: voiceDesignCharacter.name }))
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })
|
||||
} else {
|
||||
const data = await res.json()
|
||||
alert(
|
||||
typeof data.error === 'string'
|
||||
? t('saveVoiceFailedDetail', { error: data.error })
|
||||
: t('saveVoiceFailed'),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
_ulogError('保存声音失败:', error)
|
||||
alert(t('saveVoiceFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
// 打开角色编辑弹窗
|
||||
const handleOpenCharacterEdit = (character: unknown, appearance: unknown) => {
|
||||
const typedCharacter = character as GlobalCharacter
|
||||
const typedAppearance = appearance as GlobalCharacter['appearances'][0]
|
||||
setCharacterEditModal({
|
||||
characterId: typedCharacter.id,
|
||||
characterName: typedCharacter.name,
|
||||
appearanceId: typedAppearance.id,
|
||||
appearanceIndex: typedAppearance.appearanceIndex,
|
||||
changeReason: typedAppearance.changeReason || t('appearanceLabel', { index: typedAppearance.appearanceIndex }),
|
||||
artStyle: typedAppearance.artStyle || null,
|
||||
description: typedAppearance.description || ''
|
||||
})
|
||||
}
|
||||
|
||||
// 打开场景编辑弹窗
|
||||
const handleOpenLocationEdit = (location: unknown, imageIndex: number) => {
|
||||
const typedLocation = location as {
|
||||
id: string
|
||||
name: string
|
||||
summary: string | null
|
||||
artStyle: string | null
|
||||
images: Array<{ imageIndex: number; description: string | null }>
|
||||
}
|
||||
const image = typedLocation.images.find(img => img.imageIndex === imageIndex)
|
||||
setLocationEditModal({
|
||||
locationId: typedLocation.id,
|
||||
locationName: typedLocation.name,
|
||||
summary: typedLocation.summary || '',
|
||||
imageIndex: imageIndex,
|
||||
artStyle: typedLocation.artStyle || null,
|
||||
description: image?.description || typedLocation.summary || ''
|
||||
})
|
||||
}
|
||||
|
||||
// 角色编辑后触发生成
|
||||
const handleCharacterEditGenerate = async () => {
|
||||
if (!characterEditModal) return
|
||||
|
||||
try {
|
||||
await apiFetch('/api/asset-hub/generate-image', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'character',
|
||||
id: characterEditModal.characterId,
|
||||
appearanceIndex: characterEditModal.appearanceIndex,
|
||||
artStyle: characterEditModal.artStyle || undefined,
|
||||
count: characterGenerationCount,
|
||||
})
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })
|
||||
} catch (error) {
|
||||
_ulogError('触发生成失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 场景编辑后触发生成
|
||||
const handleLocationEditGenerate = async () => {
|
||||
if (!locationEditModal) return
|
||||
|
||||
try {
|
||||
await apiFetch('/api/asset-hub/generate-image', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'location',
|
||||
id: locationEditModal.locationId,
|
||||
artStyle: locationEditModal.artStyle || undefined,
|
||||
count: locationGenerationCount,
|
||||
})
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.locations() })
|
||||
} catch (error) {
|
||||
_ulogError('触发生成失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 从音色库选择后绑定到角色
|
||||
const handleVoiceSelect = async (voice: { id: string; customVoiceUrl: string | null }) => {
|
||||
if (!voicePickerCharacterId) return
|
||||
|
||||
try {
|
||||
const res = await apiFetch(`/api/asset-hub/characters/${voicePickerCharacterId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
globalVoiceId: voice.id,
|
||||
customVoiceUrl: voice.customVoiceUrl
|
||||
})
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })
|
||||
setVoicePickerCharacterId(null)
|
||||
} else {
|
||||
const data = await res.json()
|
||||
alert(
|
||||
typeof data.error === 'string'
|
||||
? t('bindVoiceFailedDetail', { error: data.error })
|
||||
: t('bindVoiceFailed'),
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
_ulogError('绑定音色失败:', error)
|
||||
alert(t('bindVoiceFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-page min-h-screen">
|
||||
<Navbar />
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* 页面标题 */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-[var(--glass-text-primary)]">{t('title')}</h1>
|
||||
<p className="text-sm text-[var(--glass-text-secondary)] mt-1">{t('description')}</p>
|
||||
<p className="text-xs text-[var(--glass-text-tertiary)] mt-2 flex items-center gap-1">
|
||||
<AppIcon name="info" className="w-3.5 h-3.5" />
|
||||
{t('modelHint')}
|
||||
<Link href={{ pathname: '/profile' }} className="text-[var(--glass-tone-info-fg)] hover:underline">{t('modelHintLink')}</Link>
|
||||
{t('modelHintSuffix')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* 左侧文件夹树 */}
|
||||
<FolderSidebar
|
||||
folders={folders}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelectFolder={setSelectedFolderId}
|
||||
onCreateFolder={() => {
|
||||
setEditingFolder(null)
|
||||
setShowFolderModal(true)
|
||||
}}
|
||||
onEditFolder={(folder) => {
|
||||
setEditingFolder(folder)
|
||||
setShowFolderModal(true)
|
||||
}}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
/>
|
||||
|
||||
{/* 右侧资产网格 */}
|
||||
<AssetGrid
|
||||
characters={characters}
|
||||
locations={locations}
|
||||
voices={voices}
|
||||
loading={loading}
|
||||
onAddCharacter={() => setShowAddCharacter(true)}
|
||||
onAddLocation={() => setShowAddLocation(true)}
|
||||
onAddVoice={() => setShowAddVoice(true)}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onImageClick={setPreviewImage}
|
||||
onImageEdit={handleOpenImageEdit}
|
||||
onVoiceDesign={handleOpenVoiceDesign}
|
||||
onCharacterEdit={handleOpenCharacterEdit}
|
||||
onLocationEdit={handleOpenLocationEdit}
|
||||
onVoiceSelect={(characterId) => setVoicePickerCharacterId(characterId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 新建角色弹窗 */}
|
||||
{showAddCharacter && (
|
||||
<CharacterCreationModal
|
||||
mode="asset-hub"
|
||||
folderId={selectedFolderId}
|
||||
onClose={() => setShowAddCharacter(false)}
|
||||
onSuccess={() => {
|
||||
setShowAddCharacter(false)
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 新建场景弹窗 */}
|
||||
{showAddLocation && (
|
||||
<LocationCreationModal
|
||||
mode="asset-hub"
|
||||
folderId={selectedFolderId}
|
||||
onClose={() => setShowAddLocation(false)}
|
||||
onSuccess={() => {
|
||||
setShowAddLocation(false)
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.locations() })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 文件夹编辑弹窗 */}
|
||||
{showFolderModal && (
|
||||
<FolderModal
|
||||
folder={editingFolder}
|
||||
onClose={() => {
|
||||
setShowFolderModal(false)
|
||||
setEditingFolder(null)
|
||||
}}
|
||||
onSave={(name) => {
|
||||
if (editingFolder) {
|
||||
handleUpdateFolder(editingFolder.id, name)
|
||||
} else {
|
||||
handleCreateFolder(name)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片预览弹窗 */}
|
||||
{previewImage && (
|
||||
<ImagePreviewModal
|
||||
imageUrl={previewImage}
|
||||
onClose={() => setPreviewImage(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 图片编辑弹窗 */}
|
||||
{imageEditModal && (
|
||||
<ImageEditModal
|
||||
type={imageEditModal.type}
|
||||
name={imageEditModal.name}
|
||||
onClose={() => setImageEditModal(null)}
|
||||
onConfirm={handleImageEdit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI 声音设计对话框 */}
|
||||
{voiceDesignCharacter && (
|
||||
<VoiceDesignDialog
|
||||
isOpen={!!voiceDesignCharacter}
|
||||
speaker={voiceDesignCharacter.name}
|
||||
hasExistingVoice={voiceDesignCharacter.hasExistingVoice}
|
||||
onClose={() => setVoiceDesignCharacter(null)}
|
||||
onSave={handleVoiceDesignSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 角色编辑弹窗 */}
|
||||
{characterEditModal && (
|
||||
<CharacterEditModal
|
||||
mode="asset-hub"
|
||||
characterId={characterEditModal.characterId}
|
||||
characterName={characterEditModal.characterName}
|
||||
appearanceId={characterEditModal.appearanceId}
|
||||
appearanceIndex={characterEditModal.appearanceIndex}
|
||||
changeReason={characterEditModal.changeReason}
|
||||
description={characterEditModal.description}
|
||||
onClose={() => setCharacterEditModal(null)}
|
||||
onSave={handleCharacterEditGenerate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 场景编辑弹窗 */}
|
||||
{locationEditModal && (
|
||||
<LocationEditModal
|
||||
mode="asset-hub"
|
||||
locationId={locationEditModal.locationId}
|
||||
locationName={locationEditModal.locationName}
|
||||
summary={locationEditModal.summary}
|
||||
imageIndex={locationEditModal.imageIndex}
|
||||
description={locationEditModal.description}
|
||||
onClose={() => setLocationEditModal(null)}
|
||||
onSave={handleLocationEditGenerate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 新建音色弹窗 */}
|
||||
{showAddVoice && (
|
||||
<VoiceCreationModal
|
||||
isOpen={showAddVoice}
|
||||
folderId={selectedFolderId}
|
||||
onClose={() => setShowAddVoice(false)}
|
||||
onSuccess={() => {
|
||||
setShowAddVoice(false)
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.voices() })
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 从音色库选择弹窗 */}
|
||||
{voicePickerCharacterId && (
|
||||
<VoicePickerDialog
|
||||
isOpen={!!voicePickerCharacterId}
|
||||
onClose={() => setVoicePickerCharacterId(null)}
|
||||
onSelect={handleVoiceSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user