feat: add asset library download button, fix env ports, update README, optimize semantics, support multi-image reading, and allow voiceover analysis for silent segments
This commit is contained in:
@@ -12,6 +12,9 @@ import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import AssetsStage from './AssetsStage'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { useProjectAssets } from '@/lib/query/hooks'
|
||||
import JSZip from 'jszip'
|
||||
import { logError as _logError } from '@/lib/logging/core'
|
||||
|
||||
interface AssetLibraryProps {
|
||||
projectId: string
|
||||
@@ -23,8 +26,77 @@ export default function AssetLibrary({
|
||||
isAnalyzingAssets
|
||||
}: AssetLibraryProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const t = useTranslations('assets')
|
||||
|
||||
// 获取项目资产数据用于下载
|
||||
const { data: assets } = useProjectAssets(projectId)
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
const characters = assets?.characters ?? []
|
||||
const locations = assets?.locations ?? []
|
||||
|
||||
// 收集所有有效图片
|
||||
const imageEntries: Array<{ filename: string; url: string }> = []
|
||||
|
||||
// 角色图片
|
||||
for (const character of characters) {
|
||||
for (const appearance of character.appearances ?? []) {
|
||||
const url = appearance.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = character.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = appearance.appearanceIndex === 0
|
||||
? `characters/${safeName}.jpg`
|
||||
: `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`
|
||||
imageEntries.push({ filename, url })
|
||||
}
|
||||
}
|
||||
|
||||
// 场景图片:取已选中的那张
|
||||
for (const location of locations) {
|
||||
const selectedImage = location.images?.find(img => img.isSelected) ?? location.images?.[0]
|
||||
const url = selectedImage?.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = location.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
imageEntries.push({ filename: `locations/${safeName}.jpg`, url })
|
||||
}
|
||||
|
||||
if (imageEntries.length === 0) {
|
||||
alert(t('assetLibrary.downloadEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsDownloading(true)
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
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 = `assets_${new Date().toISOString().slice(0, 10)}.zip`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
} catch (error) {
|
||||
_logError('打包下载失败:', error)
|
||||
alert(t('assetLibrary.downloadFailed'))
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 触发按钮 - 现代玻璃态风格 */}
|
||||
@@ -48,6 +120,20 @@ export default function AssetLibrary({
|
||||
<AppIcon name="folderCards" className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-[var(--glass-text-primary)]">{t('assetLibrary.title')}</h2>
|
||||
|
||||
{/* 下载按钮 - 紧贴标题 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadAll}
|
||||
disabled={isDownloading}
|
||||
title={t('common.download')}
|
||||
className="w-9 h-9 glass-btn-base glass-btn-secondary flex items-center justify-center disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<AppIcon
|
||||
name={isDownloading ? 'refresh' : 'download'}
|
||||
className={`w-4 h-4${isDownloading ? ' animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRefreshProjectAssets } from '@/lib/query/hooks'
|
||||
import { useRefreshProjectAssets, useProjectAssets, useProjectData } from '@/lib/query/hooks'
|
||||
import TaskStatusInline from '@/components/task/TaskStatusInline'
|
||||
import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import JSZip from 'jszip'
|
||||
import { logError as _logError } from '@/lib/logging/core'
|
||||
|
||||
/**
|
||||
* AssetToolbar - 资产管理工具栏组件
|
||||
@@ -37,9 +40,13 @@ export default function AssetToolbar({
|
||||
onRegenerateAll,
|
||||
onGlobalAnalyze
|
||||
}: AssetToolbarProps) {
|
||||
// 🔥 使用 React Query 刷新
|
||||
const onRefresh = useRefreshProjectAssets(projectId)
|
||||
const t = useTranslations('assets')
|
||||
const { data: assets } = useProjectAssets(projectId)
|
||||
const { data: projectData } = useProjectData(projectId)
|
||||
const projectName = projectData?.name
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
const assetTaskRunningState = isBatchSubmitting
|
||||
? resolveTaskPresentationState({
|
||||
phase: 'processing',
|
||||
@@ -48,6 +55,72 @@ export default function AssetToolbar({
|
||||
hasOutput: true,
|
||||
})
|
||||
: null
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
const characters = assets?.characters ?? []
|
||||
const locations = assets?.locations ?? []
|
||||
|
||||
const imageEntries: Array<{ filename: string; url: string }> = []
|
||||
|
||||
// 角色图片
|
||||
for (const character of characters) {
|
||||
for (const appearance of character.appearances ?? []) {
|
||||
const url = appearance.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = character.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = appearance.appearanceIndex === 0
|
||||
? `characters/${safeName}.jpg`
|
||||
: `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`
|
||||
imageEntries.push({ filename, url })
|
||||
}
|
||||
}
|
||||
|
||||
// 场景图片:取已选中的那张(或第一张)
|
||||
for (const location of locations) {
|
||||
const selectedImage = location.images?.find((img: { isSelected: boolean; imageUrl: string | null }) => img.isSelected) ?? location.images?.[0]
|
||||
const url = selectedImage?.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = location.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
imageEntries.push({ filename: `locations/${safeName}.jpg`, url })
|
||||
}
|
||||
|
||||
if (imageEntries.length === 0) {
|
||||
alert(t('assetLibrary.downloadEmpty'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsDownloading(true)
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
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)
|
||||
const safeName = projectName ? projectName.replace(/[/\\:*?"<>|]/g, '_') : 'assets'
|
||||
link.download = `${safeName}_${new Date().toISOString().slice(0, 10)}.zip`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
} catch (error) {
|
||||
_logError('打包下载失败:', error)
|
||||
alert(t('assetLibrary.downloadFailed'))
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-surface p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -106,6 +179,18 @@ export default function AssetToolbar({
|
||||
<AppIcon name="refresh" className="w-4 h-4" />
|
||||
<span>{t("common.refresh")}</span>
|
||||
</button>
|
||||
{/* 打包下载按钮 */}
|
||||
<button
|
||||
onClick={handleDownloadAll}
|
||||
disabled={isDownloading || totalAssets === 0}
|
||||
title={t("toolbar.downloadAll")}
|
||||
className="glass-btn-base glass-btn-secondary flex items-center justify-center w-9 h-9 disabled:opacity-50 disabled:cursor-not-allowed border border-[var(--glass-stroke-base)]"
|
||||
>
|
||||
<AppIcon
|
||||
name={isDownloading ? 'refresh' : 'download'}
|
||||
className={`w-4 h-4 ${isDownloading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,8 @@ import CharacterCardActions from './character-card/CharacterCardActions'
|
||||
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
|
||||
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { AI_EDIT_BUTTON_CLASS, AI_EDIT_ICON_CLASS } from '@/components/ui/ai-edit-style'
|
||||
import AISparklesIcon from '@/components/ui/icons/AISparklesIcon'
|
||||
|
||||
interface CharacterCardProps {
|
||||
character: Character
|
||||
@@ -218,15 +220,18 @@ export default function CharacterCard({
|
||||
prefix={isGroupTaskRunning ? (
|
||||
<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)]" />
|
||||
<>
|
||||
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
|
||||
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
|
||||
</>
|
||||
)}
|
||||
suffix={null}
|
||||
suffix={<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{t('image.regenCountSuffix')}</span>}
|
||||
value={generationCount}
|
||||
options={getImageGenerationCountOptions('character')}
|
||||
onValueChange={setGenerationCount}
|
||||
onClick={() => onRegenerate(generationCount)}
|
||||
disabled={isAppearanceTaskRunning || isAnyTaskRunning || uploadImage.isPending}
|
||||
ariaLabel={t('image.selectCount')}
|
||||
ariaLabel={t('image.regenCountAriaLabel')}
|
||||
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"
|
||||
/>
|
||||
@@ -333,11 +338,10 @@ export default function CharacterCard({
|
||||
{!isAppearanceTaskRunning && !isAnyTaskRunning && currentImageUrl && onImageEdit && (
|
||||
<button
|
||||
onClick={() => onImageEdit(character.id, appearance.id, selectedIndex !== null ? selectedIndex : 0)}
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center transition-all shadow-sm"
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center transition-all active:scale-95 ${AI_EDIT_BUTTON_CLASS}`}
|
||||
title={t('image.edit')}
|
||||
>
|
||||
<AppIcon name="edit" className="w-4 h-4 text-white" />
|
||||
<AISparklesIcon className={`w-4 h-4 ${AI_EDIT_ICON_CLASS}`} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -190,15 +190,18 @@ export default function LocationCard({
|
||||
prefix={isGroupTaskRunning ? (
|
||||
<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)]" />
|
||||
<>
|
||||
<AppIcon name="refresh" className="w-4 h-4 text-[var(--glass-tone-info-fg)]" />
|
||||
<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)] ml-0.5">{t('image.regenCountPrefix')}</span>
|
||||
</>
|
||||
)}
|
||||
suffix={null}
|
||||
suffix={<span className="text-[10px] font-medium text-[var(--glass-tone-info-fg)]">{t('image.regenCountSuffix')}</span>}
|
||||
value={generationCount}
|
||||
options={getImageGenerationCountOptions('location')}
|
||||
onValueChange={setGenerationCount}
|
||||
onClick={() => onRegenerate(generationCount)}
|
||||
disabled={isTaskRunning || isAnyTaskRunning || uploadImage.isPending}
|
||||
ariaLabel={t('image.selectCount')}
|
||||
ariaLabel={t('image.regenCountAriaLabel')}
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { AppIcon } from '@/components/ui/icons'
|
||||
import ImageGenerationInlineCountButton from '@/components/image-generation/ImageGenerationInlineCountButton'
|
||||
import { getImageGenerationCountOptions } from '@/lib/image-generation/count'
|
||||
import { useImageGenerationCount } from '@/lib/image-generation/use-image-generation-count'
|
||||
import { AI_EDIT_BUTTON_CLASS, AI_EDIT_ICON_CLASS } from '@/components/ui/ai-edit-style'
|
||||
import AISparklesIcon from '@/components/ui/icons/AISparklesIcon'
|
||||
|
||||
interface ImageSectionActionButtonsProps {
|
||||
panelId: string
|
||||
@@ -77,9 +79,10 @@ export default function ImageSectionActionButtons({
|
||||
{imageUrl && (
|
||||
<button
|
||||
onClick={onOpenEditModal}
|
||||
className={`glass-btn-base glass-btn-secondary flex items-center gap-0.5 px-1.5 py-0.5 rounded-md text-[10px] transition-all active:scale-95 ${isSubmittingPanelImageTask || isModifying ? 'opacity-75' : ''}`}
|
||||
className={`glass-btn-base h-6 w-6 rounded-full flex items-center justify-center transition-all active:scale-95 ${AI_EDIT_BUTTON_CLASS} ${isSubmittingPanelImageTask || isModifying ? 'opacity-75' : ''}`}
|
||||
title={t('image.editImage')}
|
||||
>
|
||||
<span>{t('image.editImage')}</span>
|
||||
<AISparklesIcon className={`w-2.5 h-2.5 ${AI_EDIT_ICON_CLASS}`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ import { resolveTaskPresentationState } from '@/lib/task/presentation'
|
||||
import { AppIcon } from '@/components/ui/icons'
|
||||
import { SegmentedControl } from '@/components/ui/SegmentedControl'
|
||||
|
||||
|
||||
|
||||
interface Character {
|
||||
id: string
|
||||
name: string
|
||||
@@ -67,6 +69,8 @@ interface AssetGridProps {
|
||||
onAddCharacter: () => void
|
||||
onAddLocation: () => void
|
||||
onAddVoice: () => void
|
||||
onDownloadAll?: () => void
|
||||
isDownloading?: boolean
|
||||
selectedFolderId: string | null
|
||||
onImageClick?: (url: string) => void
|
||||
onImageEdit?: (type: 'character' | 'location', id: string, name: string, imageIndex: number, appearanceIndex?: number) => void
|
||||
@@ -89,6 +93,8 @@ export function AssetGrid({
|
||||
onAddCharacter,
|
||||
onAddLocation,
|
||||
onAddVoice,
|
||||
onDownloadAll,
|
||||
isDownloading,
|
||||
selectedFolderId: _selectedFolderId,
|
||||
onImageClick,
|
||||
onImageEdit,
|
||||
@@ -192,8 +198,19 @@ export function AssetGrid({
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* 右侧新建按钮 */}
|
||||
{/* 右侧操作按钮 */}
|
||||
<div className="flex items-center gap-3">
|
||||
{onDownloadAll && (
|
||||
<button
|
||||
onClick={onDownloadAll}
|
||||
disabled={isDownloading || isEmpty}
|
||||
title={t('downloadAllTitle')}
|
||||
className="glass-btn-base glass-btn-secondary px-4 py-2 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<AppIcon name={isDownloading ? 'refresh' : 'download'} className={`w-4 h-4 ${isDownloading ? 'animate-spin' : ''}`} />
|
||||
<span>{isDownloading ? t('downloading') : t('downloadAll')}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onAddCharacter}
|
||||
className="glass-btn-base glass-btn-primary px-4 py-2 rounded-lg text-sm"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'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'
|
||||
@@ -75,6 +76,8 @@ export default function AssetHubPage() {
|
||||
// 音色库弹窗状态
|
||||
const [showAddVoice, setShowAddVoice] = useState(false)
|
||||
const [voicePickerCharacterId, setVoicePickerCharacterId] = useState<string | null>(null)
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
|
||||
// 编辑角色弹窗状态
|
||||
const [characterEditModal, setCharacterEditModal] = useState<{
|
||||
@@ -340,6 +343,74 @@ export default function AssetHubPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 打包下载所有图片资产
|
||||
const handleDownloadAll = async () => {
|
||||
// 收集所有有效图片
|
||||
const imageEntries: Array<{ filename: string; url: string }> = []
|
||||
|
||||
// 角色图片:每个角色每个外貌的当前选中图
|
||||
for (const character of characters) {
|
||||
for (const appearance of character.appearances) {
|
||||
const url = appearance.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = character.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = appearance.appearanceIndex === 0
|
||||
? `characters/${safeName}.jpg`
|
||||
: `characters/${safeName}_appearance${appearance.appearanceIndex}.jpg`
|
||||
imageEntries.push({ filename, url })
|
||||
}
|
||||
}
|
||||
|
||||
// 场景图片:每个场景的选中图
|
||||
for (const location of locations) {
|
||||
for (const image of location.images) {
|
||||
const url = image.imageUrl
|
||||
if (!url) continue
|
||||
const safeName = location.name.replace(/[/\\:*?"<>|]/g, '_')
|
||||
const filename = location.images.length <= 1
|
||||
? `locations/${safeName}.jpg`
|
||||
: `locations/${safeName}_${image.imageIndex + 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 (
|
||||
<div className="glass-page min-h-screen">
|
||||
<Navbar />
|
||||
@@ -382,6 +453,8 @@ export default function AssetHubPage() {
|
||||
onAddCharacter={() => setShowAddCharacter(true)}
|
||||
onAddLocation={() => setShowAddLocation(true)}
|
||||
onAddVoice={() => setShowAddVoice(true)}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={isDownloading}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onImageClick={setPreviewImage}
|
||||
onImageEdit={handleOpenImageEdit}
|
||||
|
||||
Reference in New Issue
Block a user