'use client' import { logError as _ulogError } from '@/lib/logging/core' import { apiFetch } from '@/lib/api-fetch' import JSZip from 'jszip' 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, PropCreationModal, CharacterEditModal, LocationEditModal, PropEditModal } 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 { useAssets, useAssetActions, useRefreshAssets, useGlobalFolders, useSSE, } 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(null) // 使用 React Query 获取数据 const { data: folders = [], isLoading: foldersLoading } = useGlobalFolders() const { data: assets = [], isLoading: assetsLoading } = useAssets({ scope: 'global', folderId: selectedFolderId, }) const characterActions = useAssetActions({ scope: 'global', kind: 'character' }) const locationActions = useAssetActions({ scope: 'global', kind: 'location' }) const propActions = useAssetActions({ scope: 'global', kind: 'prop' }) const refreshAssets = useRefreshAssets({ scope: 'global' }) const loading = foldersLoading || assetsLoading useSSE({ projectId: 'global-asset-hub', enabled: true }) // 弹窗状态 const [showAddCharacter, setShowAddCharacter] = useState(false) const [showAddLocation, setShowAddLocation] = useState(false) const [showAddProp, setShowAddProp] = useState(false) const [showFolderModal, setShowFolderModal] = useState(false) const [editingFolder, setEditingFolder] = useState<{ id: string; name: string } | null>(null) const [previewImage, setPreviewImage] = useState(null) const [imageEditModal, setImageEditModal] = useState<{ type: 'character' | 'location' | 'prop' 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(null) const [isDownloading, setIsDownloading] = useState(false) // 编辑角色弹窗状态 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 [propEditModal, setPropEditModal] = useState<{ propId: string propName: string summary: string description: string variantId?: 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' | 'prop', 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) { void characterActions.modifyRender({ id, appearanceIndex, imageIndex, modifyPrompt, extraImageUrls }).catch(() => { alert(t('editFailed')) }) } else if (type === 'location') { void locationActions.modifyRender({ id, imageIndex, modifyPrompt, extraImageUrls }).catch(() => { alert(t('editFailed')) }) } else if (type === 'prop') { void propActions.modifyRender({ id, imageIndex, modifyPrompt, extraImageUrls, }).catch(() => { alert(t('editFailed')) }) } } // 打开 AI 声音设计对话框 const handleOpenVoiceDesign = (characterId: string, characterName: string) => { const character = assets.find((asset) => asset.kind === 'character' && asset.id === characterId) setVoiceDesignCharacter({ id: characterId, name: characterName, hasExistingVoice: character?.kind === 'character' ? !!character.voice.customVoiceUrl : false, }) } // 保存 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() }) refreshAssets() } 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 { id: string name: string appearances: Array<{ id: string appearanceIndex: number changeReason: string description: string | null }> } const typedAppearance = appearance as { id: string appearanceIndex: number changeReason: string artStyle?: string | null description: string | null } 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 handleOpenPropEdit = (prop: unknown, imageIndex: number) => { const typedProp = prop as { id: string name: string summary: string | null images: Array<{ id: string; imageIndex: number; description: string | null }> } const variant = typedProp.images.find((image) => image.imageIndex === imageIndex) setPropEditModal({ propId: typedProp.id, propName: typedProp.name, summary: typedProp.summary || '', description: variant?.description || typedProp.summary || '', variantId: variant?.id, }) } // 角色编辑后触发生成 const handleCharacterEditGenerate = async () => { if (!characterEditModal) return try { await characterActions.generate({ 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 locationActions.generate({ 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 { await characterActions.bindVoice({ characterId: voicePickerCharacterId, globalVoiceId: voice.id, customVoiceUrl: voice.customVoiceUrl, }) queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() }) setVoicePickerCharacterId(null) } catch (error) { _ulogError('绑定音色失败:', error) alert(t('bindVoiceFailed')) } } // 打包下载所有图片资产 const handleDownloadAll = async () => { // 收集所有有效图片 const imageEntries: Array<{ filename: string; url: string }> = [] // 角色图片:每个角色每个外貌的当前选中图 for (const asset of assets) { if (asset.kind !== 'character') continue for (const variant of asset.variants) { const selectedRender = variant.renders.find((render) => render.isSelected) ?? variant.renders[0] const url = selectedRender?.imageUrl if (!url) continue const safeName = asset.name.replace(/[/\\:*?"<>|]/g, '_') const filename = variant.index === 0 ? `characters/${safeName}.jpg` : `characters/${safeName}_appearance${variant.index}.jpg` imageEntries.push({ filename, url }) } } // 场景图片:每个场景的选中图 for (const asset of assets) { if (asset.kind !== 'location') continue for (const variant of asset.variants) { const render = variant.renders[0] const url = render?.imageUrl if (!url) continue const safeName = asset.name.replace(/[/\\:*?"<>|]/g, '_') const filename = asset.variants.length <= 1 ? `locations/${safeName}.jpg` : `locations/${safeName}_${variant.index + 1}.jpg` imageEntries.push({ filename, url }) } } for (const asset of assets) { if (asset.kind !== 'prop') continue for (const variant of asset.variants) { const render = variant.renders[0] const url = render?.imageUrl if (!url) continue const safeName = asset.name.replace(/[/\\:*?"<>|]/g, '_') const filename = asset.variants.length <= 1 ? `props/${safeName}.jpg` : `props/${safeName}_${variant.index + 1}.jpg` imageEntries.push({ filename, url }) } } if (imageEntries.length === 0) { alert(t('downloadEmpty')) return } setIsDownloading(true) try { const zip = new JSZip() // 并发 fetch 所有图片 await Promise.all( imageEntries.map(async ({ filename, url }) => { try { const response = await fetch(url) if (!response.ok) return const blob = await response.blob() zip.file(filename, blob) } catch { // 单张图片失败不阻断整个流程 } }) ) const content = await zip.generateAsync({ type: 'blob' }) const link = document.createElement('a') link.href = URL.createObjectURL(content) link.download = `asset-hub_${new Date().toISOString().slice(0, 10)}.zip` document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(link.href) } catch (error) { _ulogError('打包下载失败:', error) alert(t('downloadFailed')) } finally { setIsDownloading(false) } } return (
{/* 页面标题 */}

{t('title')}

{t('description')}

{t('modelHint')} {t('modelHintLink')} {t('modelHintSuffix')}

{/* 左侧文件夹树 */} { setEditingFolder(null) setShowFolderModal(true) }} onEditFolder={(folder) => { setEditingFolder(folder) setShowFolderModal(true) }} onDeleteFolder={handleDeleteFolder} /> {/* 右侧资产网格 */} setShowAddCharacter(true)} onAddLocation={() => setShowAddLocation(true)} onAddProp={() => setShowAddProp(true)} onAddVoice={() => setShowAddVoice(true)} onDownloadAll={handleDownloadAll} isDownloading={isDownloading} selectedFolderId={selectedFolderId} onImageClick={setPreviewImage} onImageEdit={handleOpenImageEdit} onVoiceDesign={handleOpenVoiceDesign} onCharacterEdit={handleOpenCharacterEdit} onLocationEdit={handleOpenLocationEdit} onPropEdit={handleOpenPropEdit} onVoiceSelect={(characterId) => setVoicePickerCharacterId(characterId)} />
{/* 新建角色弹窗 */} {showAddCharacter && ( setShowAddCharacter(false)} onSuccess={() => { setShowAddCharacter(false) queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.characters() }) refreshAssets() }} /> )} {/* 新建场景弹窗 */} {showAddLocation && ( setShowAddLocation(false)} onSuccess={() => { setShowAddLocation(false) queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.locations() }) refreshAssets() }} /> )} {showAddProp && ( setShowAddProp(false)} onSuccess={() => { setShowAddProp(false) refreshAssets() }} /> )} {/* 文件夹编辑弹窗 */} {showFolderModal && ( { setShowFolderModal(false) setEditingFolder(null) }} onSave={(name) => { if (editingFolder) { handleUpdateFolder(editingFolder.id, name) } else { handleCreateFolder(name) } }} /> )} {/* 图片预览弹窗 */} {previewImage && ( setPreviewImage(null)} /> )} {/* 图片编辑弹窗 */} {imageEditModal && ( setImageEditModal(null)} onConfirm={handleImageEdit} /> )} {/* AI 声音设计对话框 */} {voiceDesignCharacter && ( setVoiceDesignCharacter(null)} onSave={handleVoiceDesignSave} /> )} {/* 角色编辑弹窗 */} {characterEditModal && ( setCharacterEditModal(null)} onSave={handleCharacterEditGenerate} /> )} {/* 场景编辑弹窗 */} {locationEditModal && ( setLocationEditModal(null)} onSave={handleLocationEditGenerate} /> )} {propEditModal && ( setPropEditModal(null)} onRefresh={refreshAssets} /> )} {/* 新建音色弹窗 */} {showAddVoice && ( setShowAddVoice(false)} onSuccess={() => { setShowAddVoice(false) queryClient.invalidateQueries({ queryKey: queryKeys.globalAssets.voices() }) refreshAssets() }} /> )} {/* 从音色库选择弹窗 */} {voicePickerCharacterId && ( setVoicePickerCharacterId(null)} onSelect={handleVoiceSelect} /> )}
) }