[feat] Add ZIP download support for multiple assets

## Summary
When downloading multiple selected assets, they are now bundled into a single ZIP file instead of downloading each file individually.

## Changes
- Use `client-zip` library to generate ZIP files in the browser
- Use `Promise.allSettled` to include remaining files in ZIP even if some fail
- Automatically add numbers to duplicate filenames
- Notify users with toast messages based on download status
- Auto-generate filename with timestamp to prevent filename collisions

### Modified Files
- `src/platform/assets/composables/useMediaAssetActions.ts`: Implement ZIP download logic
- `src/locales/en/main.json`: Add new i18n strings
- `package.json`: Add `client-zip` dependency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jin Yi
2025-11-27 22:55:57 +09:00
parent 9d131a4267
commit efebd5fc7c
5 changed files with 140 additions and 25 deletions

View File

@@ -159,6 +159,7 @@
"algoliasearch": "catalog:",
"axios": "catalog:",
"chart.js": "^4.5.0",
"client-zip": "catalog:",
"dompurify": "^3.2.5",
"dotenv": "catalog:",
"es-toolkit": "^1.39.9",

17
pnpm-lock.yaml generated
View File

@@ -15,9 +15,15 @@ catalogs:
'@eslint/js':
specifier: ^9.35.0
version: 9.35.0
'@iconify-json/lucide':
specifier: ^1.1.178
version: 1.2.66
'@iconify/json':
specifier: ^2.2.380
version: 2.2.380
'@iconify/tailwind':
specifier: ^1.1.3
version: 1.2.0
'@intlify/eslint-plugin-vue-i18n':
specifier: ^4.1.0
version: 4.1.0
@@ -129,6 +135,9 @@ catalogs:
axios:
specifier: ^1.8.2
version: 1.11.0
client-zip:
specifier: ^2.5.0
version: 2.5.0
cross-env:
specifier: ^10.1.0
version: 10.1.0
@@ -410,6 +419,9 @@ importers:
chart.js:
specifier: ^4.5.0
version: 4.5.0
client-zip:
specifier: 'catalog:'
version: 2.5.0
dompurify:
specifier: ^3.2.5
version: 3.2.5
@@ -4260,6 +4272,9 @@ packages:
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
engines: {node: '>=18'}
client-zip@2.5.0:
resolution: {integrity: sha512-ydG4nDZesbFurnNq0VVCp/yyomIBh+X/1fZPI/P24zbnG4dtC4tQAfI5uQsomigsUMeiRO2wiTPizLWQh+IAyQ==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -12063,6 +12078,8 @@ snapshots:
slice-ansi: 5.0.0
string-width: 7.2.0
client-zip@2.5.0: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.3

View File

@@ -46,6 +46,7 @@ catalog:
'@webgpu/types': ^0.1.66
algoliasearch: ^5.21.0
axios: ^1.8.2
client-zip: ^2.5.0
cross-env: ^10.1.0
dotenv: ^16.4.5
eslint: ^9.34.0

View File

@@ -2184,6 +2184,10 @@
"deleteSelected": "Delete",
"downloadStarted": "Downloading {count} files...",
"downloadsStarted": "Started downloading {count} file(s)",
"preparingZip": "Preparing ZIP with {count} file(s)...",
"zipDownloadStarted": "Started downloading {count} file(s) as ZIP",
"zipDownloadFailed": "Failed to download assets as ZIP",
"partialZipSuccess": "ZIP created with {succeeded} file(s). {failed} file(s) failed to download",
"assetsDeletedSuccessfully": "{count} asset(s) deleted successfully",
"failedToDeleteAssets": "Failed to delete selected assets",
"partialDeleteSuccess": "{succeeded} deleted successfully, {failed} failed"

View File

@@ -2,7 +2,7 @@ import { useToast } from 'primevue/usetoast'
import { inject } from 'vue'
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import { downloadFile } from '@/base/common/downloadUtil'
import { downloadBlob, downloadFile } from '@/base/common/downloadUtil'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
@@ -93,41 +93,133 @@ export function useMediaAssetActions() {
}
/**
* Download multiple assets at once
* Download multiple assets at once as a zip file
* @param assets Array of assets to download
*/
const downloadMultipleAssets = (assets: AssetItem[]) => {
const downloadMultipleAssets = async (assets: AssetItem[]) => {
if (!assets || assets.length === 0) return
// Show loading toast
const loadingToast = {
severity: 'info' as const,
summary: t('g.loading'),
detail: t('mediaAsset.selection.preparingZip', { count: assets.length }),
life: 0 // Keep until manually removed
}
toast.add(loadingToast)
try {
assets.forEach((asset) => {
const filename = asset.name
let downloadUrl: string
const { downloadZip } = await import('client-zip')
// In cloud, use preview_url directly (from GCS or other cloud storage)
// In OSS/localhost, use the /view endpoint
if (isCloud && asset.preview_url) {
downloadUrl = asset.preview_url
} else {
downloadUrl = getAssetUrl(asset)
}
downloadFile(downloadUrl, filename)
})
// Track filename usage to handle duplicates
const nameCount = new Map<string, number>()
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.selection.downloadsStarted', {
count: assets.length
}),
life: 2000
})
// Fetch all assets and prepare files for zip (handle partial failures)
const results = await Promise.allSettled(
assets.map(async (asset) => {
try {
let filename = asset.name
let downloadUrl: string
// In cloud, use preview_url directly (from GCS or other cloud storage)
// In OSS/localhost, use the /view endpoint
if (isCloud && asset.preview_url) {
downloadUrl = asset.preview_url
} else {
downloadUrl = getAssetUrl(asset)
}
const response = await fetch(downloadUrl)
if (!response.ok) {
console.warn(
`Failed to fetch ${filename}: ${response.status} ${response.statusText}`
)
return null
}
// Handle duplicate filenames by adding a number suffix
if (nameCount.has(filename)) {
const count = nameCount.get(filename)! + 1
nameCount.set(filename, count)
const parts = filename.split('.')
const ext = parts.length > 1 ? parts.pop() : ''
filename = ext
? `${parts.join('.')}_${count}.${ext}`
: `${filename}_${count}`
} else {
nameCount.set(filename, 1)
}
return {
name: filename,
input: response
}
} catch (error) {
console.warn(`Error fetching ${asset.name}:`, error)
return null
}
})
)
// Filter out failed downloads
const files = results
.map((result) => (result.status === 'fulfilled' ? result.value : null))
.filter(
(file): file is { name: string; input: Response } => file !== null
)
// Check if any files were successfully fetched
if (files.length === 0) {
throw new Error('No assets could be downloaded')
}
// Generate zip and get blob
const zipBlob = await downloadZip(files).blob()
// Create zip filename with timestamp to avoid collisions
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.slice(0, -5)
const zipFilename = `comfyui-assets-${timestamp}.zip`
// Download using existing utility
downloadBlob(zipFilename, zipBlob)
// Remove loading toast
toast.remove(loadingToast)
// Show appropriate success message based on results
const failedCount = assets.length - files.length
if (failedCount > 0) {
toast.add({
severity: 'warn',
summary: t('g.warning'),
detail: t('mediaAsset.selection.partialZipSuccess', {
succeeded: files.length,
failed: failedCount
}),
life: 4000
})
} else {
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.selection.zipDownloadStarted', {
count: assets.length
}),
life: 2000
})
}
} catch (error) {
console.error('Failed to download assets:', error)
// Remove loading toast on error
toast.remove(loadingToast)
console.error('Failed to download assets as zip:', error)
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('g.failedToDownloadImage'),
detail: t('mediaAsset.selection.zipDownloadFailed'),
life: 3000
})
}