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