From 31ef9d4053899790d55ea9ed34530e386a439c4b Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Tue, 8 Sep 2026 20:25:27 +0800 Subject: [PATCH] feat(pl): pack selected vehicle cert attachments into zip. Add JSZip builder with concurrency, injectable fetch/save for tests, and multi-vehicle folders. Co-authored-by: Cursor --- .../src/utils/pl-vehicle-cert-zip.test.ts | 101 +++++++++++++++ web-admin/src/utils/pl-vehicle-cert-zip.ts | 118 ++++++++++++++++++ 2 files changed, 219 insertions(+) diff --git a/web-admin/src/utils/pl-vehicle-cert-zip.test.ts b/web-admin/src/utils/pl-vehicle-cert-zip.test.ts index d879b9bd1..0e1a833c6 100644 --- a/web-admin/src/utils/pl-vehicle-cert-zip.test.ts +++ b/web-admin/src/utils/pl-vehicle-cert-zip.test.ts @@ -3,6 +3,7 @@ import { describe, it } from 'node:test' import { collectVehicleCertZipEntries, buildVehicleCertZipFilename, + buildAndSaveVehicleCertZip, uniqueZipPath, sanitizeZipPathSegment } from './pl-vehicle-cert-zip' @@ -119,3 +120,103 @@ describe('uniqueZipPath', () => { assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证_3.jpg') }) }) + +describe('buildAndSaveVehicleCertZip', () => { + it('packs single vehicle flat; skips failed blobs; returns counts', async () => { + const saved: { name: string; size: number }[] = [] + const result = await buildAndSaveVehicleCertZip( + [ + { + id: 'v1', + licensePlate: '皖AD12345', + certList: [ + { + id: 'c1', + ownerType: 'vehicle', + ownerId: 'v1', + status: '1', + certName: '行驶证', + vehiclePart: 'head', + attachmentUrls: 'https://cdn.example/ok.jpg,https://cdn.example/bad.jpg' + } + ] + } + ], + { + yyyymmdd: '20260908', + fetchBlob: async (fileUrl) => { + if (fileUrl.includes('bad')) throw new Error('fail') + return new Blob([fileUrl], { type: 'application/octet-stream' }) + }, + saveAs: async (blob, name) => { + saved.push({ name, size: blob.size }) + } + } + ) + + assert.equal(result.okCount, 1) + assert.equal(result.failCount, 1) + assert.equal(result.zipName, '车辆证书_皖AD12345.zip') + assert.equal(saved.length, 1) + assert.equal(saved[0].name, '车辆证书_皖AD12345.zip') + }) + + it('uses subfolders for multiple vehicles', async () => { + const paths: string[] = [] + await buildAndSaveVehicleCertZip( + [ + { + id: 'v1', + licensePlate: '皖A1', + certList: [ + { + id: 'c1', + ownerType: 'vehicle', + ownerId: 'v1', + status: '1', + certName: '行驶证', + vehiclePart: 'head', + attachmentUrls: 'https://cdn.example/a.jpg' + } + ] + }, + { + id: 'v2', + licensePlate: '皖B2', + certList: [ + { + id: 'c2', + ownerType: 'vehicle', + ownerId: 'v2', + status: '1', + certName: '营运证', + vehiclePart: 'head', + attachmentUrls: 'https://cdn.example/b.pdf' + } + ] + } + ], + { + yyyymmdd: '20260908', + fetchBlob: async () => new Blob(['x']), + saveAs: async () => {}, + onFileAdded: (zipPath) => paths.push(zipPath) + } + ) + assert.deepEqual(paths.sort(), ['皖A1/行驶证.jpg', '皖B2/营运证.pdf'].sort()) + }) + + it('returns empty when no attachments', async () => { + const result = await buildAndSaveVehicleCertZip( + [{ id: 'v1', licensePlate: '皖A1', certList: [] }], + { + yyyymmdd: '20260908', + fetchBlob: async () => new Blob(['x']), + saveAs: async () => {} + } + ) + assert.equal(result.okCount, 0) + assert.equal(result.failCount, 0) + assert.equal(result.zipName, '') + }) +}) diff --git a/web-admin/src/utils/pl-vehicle-cert-zip.ts b/web-admin/src/utils/pl-vehicle-cert-zip.ts index 4e517af49..9847caa1a 100644 --- a/web-admin/src/utils/pl-vehicle-cert-zip.ts +++ b/web-admin/src/utils/pl-vehicle-cert-zip.ts @@ -1,3 +1,4 @@ +import JSZip from 'jszip' import type { CertRead } from '@/api/pl' import { formatPlVehiclePlateDisplay } from '@/utils/pl-display' import { @@ -145,3 +146,120 @@ export function uniqueZipPath(used: Set, relativePath: string): string { used.add(candidate) return candidate } + +export type BuildVehicleCertZipOptions = { + yyyymmdd?: string + concurrency?: number + fetchBlob?: (fileUrl: string, fileName: string) => Promise + saveAs?: (blob: Blob, filename: string) => void | Promise + onFileAdded?: (zipPath: string) => void +} + +export type BuildVehicleCertZipResult = { + okCount: number + failCount: number + zipName: string +} + +function todayYyyymmdd(): string { + const d = new Date() + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}${m}${day}` +} + +async function mapPool( + items: T[], + concurrency: number, + worker: (item: T) => Promise +): Promise { + const results: R[] = new Array(items.length) + let next = 0 + const runners = Array.from({ length: Math.min(concurrency, items.length) || 0 }, async () => { + while (next < items.length) { + const idx = next + next += 1 + results[idx] = await worker(items[idx]) + } + }) + await Promise.all(runners) + return results +} + +async function defaultFetchBlob(fileUrl: string, fileName: string): Promise { + const [{ fetchAttachmentBlob }, { resolvePhotoDisplayUrl }] = await Promise.all([ + import('@/utils/download-attachment'), + import('@/utils/pl-photo') + ]) + return fetchAttachmentBlob({ fileUrl, fileName }, { fullFileUrl: resolvePhotoDisplayUrl }) +} + +async function defaultSaveAs(blob: Blob, filename: string): Promise { + const { default: FileSaver } = await import('file-saver') + FileSaver.saveAs(blob, filename) +} + +export async function buildAndSaveVehicleCertZip( + vehicles: VehicleCertZipSource[], + options: BuildVehicleCertZipOptions = {} +): Promise { + const entries = collectVehicleCertZipEntries(vehicles) + if (!entries.length) { + return { okCount: 0, failCount: 0, zipName: '' } + } + + const yyyymmdd = options.yyyymmdd || todayYyyymmdd() + const zipName = buildVehicleCertZipFilename(entries, yyyymmdd) + const multi = entries.length > 1 + const concurrency = options.concurrency ?? 4 + + type Job = { + zipPath: string + fileUrl: string + fileName: string + } + + const used = new Set() + const jobs: Job[] = [] + for (const entry of entries) { + for (const file of entry.files) { + const leaf = `${file.baseName}${file.ext ? `.${file.ext}` : ''}` + const rawPath = multi ? `${entry.plateFolder}/${leaf}` : leaf + const zipPath = uniqueZipPath(used, rawPath) + jobs.push({ + zipPath, + fileUrl: file.fileUrl, + fileName: leaf + }) + } + } + + const fetchBlob = options.fetchBlob ?? defaultFetchBlob + + const zip = new JSZip() + let okCount = 0 + let failCount = 0 + + await mapPool(jobs, concurrency, async (job) => { + try { + const blob = await fetchBlob(job.fileUrl, job.fileName) + const data = await blob.arrayBuffer() + zip.file(job.zipPath, data) + options.onFileAdded?.(job.zipPath) + okCount += 1 + } catch { + failCount += 1 + } + }) + + if (okCount === 0) { + return { okCount: 0, failCount, zipName: '' } + } + + const blob = await zip.generateAsync({ type: 'blob' }) + const save = options.saveAs ?? defaultSaveAs + await save(blob, zipName) + + return { okCount, failCount, zipName } +}