feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

View File

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

View 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>
)
}

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View 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

View File

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

View File

@@ -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}
/>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
'use client'
import VoiceCreationModalLayout, { type VoiceCreationModalShellProps } from './VoiceCreationModalLayout'
export type { VoiceCreationModalShellProps }
export default function VoiceCreationModalShell(props: VoiceCreationModalShellProps) {
return <VoiceCreationModalLayout {...props} />
}

View File

@@ -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>
)}
</>
)
}

View File

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

View 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>
)
}