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:
saturn
2026-03-13 17:37:52 +08:00
parent be1853534a
commit eec27fbabf
41 changed files with 977 additions and 187 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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