diff --git a/web-admin/src/utils/pl-vehicle-cert-zip.test.ts b/web-admin/src/utils/pl-vehicle-cert-zip.test.ts new file mode 100644 index 000000000..d879b9bd1 --- /dev/null +++ b/web-admin/src/utils/pl-vehicle-cert-zip.test.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + collectVehicleCertZipEntries, + buildVehicleCertZipFilename, + uniqueZipPath, + sanitizeZipPathSegment +} from './pl-vehicle-cert-zip' + +describe('sanitizeZipPathSegment', () => { + it('replaces unsafe path chars', () => { + assert.equal(sanitizeZipPathSegment('皖A/D12\\345'), '皖A_D12_345') + assert.equal(sanitizeZipPathSegment(' 皖A '), '皖A') + }) +}) + +describe('collectVehicleCertZipEntries', () => { + it('skips vehicles and certs without urls; prefixes 主·/挂· when trailer certs exist', () => { + const entries = collectVehicleCertZipEntries([ + { + id: 'v1', + licensePlate: '皖AD12345', + trailerNo: '挂A8888', + certList: [ + { + id: 'c1', + ownerType: 'vehicle', + ownerId: 'v1', + status: '1', + certName: '行驶证', + vehiclePart: 'head', + attachmentUrls: 'https://cdn.example/a.jpg' + }, + { + id: 'c2', + ownerType: 'vehicle', + ownerId: 'v1', + status: '1', + certName: '道路运输证', + vehiclePart: 'head', + attachmentUrls: '' + }, + { + id: 'c3', + ownerType: 'vehicle', + ownerId: 'v1', + status: '1', + certName: '行驶证', + vehiclePart: 'trailer', + attachmentUrls: 'https://cdn.example/b.pdf' + } + ] + }, + { + id: 'v2', + licensePlate: '皖BF67890', + certList: [] + } + ]) + + assert.equal(entries.length, 1) + assert.equal(entries[0].vehicleId, 'v1') + assert.equal(entries[0].plateFolder, '皖AD12345挂A8888') + assert.equal(entries[0].files.length, 2) + assert.equal(entries[0].files[0].baseName, '主·行驶证') + assert.equal(entries[0].files[0].ext, 'jpg') + assert.equal(entries[0].files[1].baseName, '挂·行驶证') + assert.equal(entries[0].files[1].ext, 'pdf') + }) + + it('omits 主·/挂· prefix when no trailer certs on that vehicle', () => { + const entries = collectVehicleCertZipEntries([ + { + id: 'v1', + licensePlate: '皖BF67890', + certList: [ + { + id: 'c1', + ownerType: 'vehicle', + ownerId: 'v1', + status: '1', + certName: '行驶证', + vehiclePart: 'head', + attachmentUrls: 'https://cdn.example/a.jpg,https://cdn.example/a2.png' + } + ] + } + ]) + assert.equal(entries[0].files.length, 2) + assert.equal(entries[0].files[0].baseName, '行驶证') + assert.equal(entries[0].files[1].baseName, '行驶证') + }) +}) + +describe('buildVehicleCertZipFilename', () => { + it('names single-vehicle zip with plate', () => { + assert.equal( + buildVehicleCertZipFilename([{ plateFolder: '皖AD12345挂A8888' }], '20260908'), + '车辆证书_皖AD12345挂A8888.zip' + ) + }) + + it('names multi-vehicle zip with count and date', () => { + assert.equal( + buildVehicleCertZipFilename( + [{ plateFolder: 'A' }, { plateFolder: 'B' }], + '20260908' + ), + '车辆证书批量_2辆_20260908.zip' + ) + }) +}) + +describe('uniqueZipPath', () => { + it('appends _2 _3 on collisions', () => { + const used = new Set() + assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证.jpg') + assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证_2.jpg') + assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证_3.jpg') + }) +}) diff --git a/web-admin/src/utils/pl-vehicle-cert-zip.ts b/web-admin/src/utils/pl-vehicle-cert-zip.ts new file mode 100644 index 000000000..4e517af49 --- /dev/null +++ b/web-admin/src/utils/pl-vehicle-cert-zip.ts @@ -0,0 +1,147 @@ +import type { CertRead } from '@/api/pl' +import { formatPlVehiclePlateDisplay } from '@/utils/pl-display' +import { + PL_VEHICLE_CERT_PART_TRAILER, + normalizeVehicleCertPart +} from '@/utils/pl-vehicle-cert' + +export type VehicleCertZipSource = { + id: string + licensePlate?: string | null + trailerNo?: string | null + certList?: CertRead[] | null +} + +export type VehicleCertZipFile = { + fileUrl: string + baseName: string + ext: string +} + +export type VehicleCertZipEntry = { + vehicleId: string + plateFolder: string + files: VehicleCertZipFile[] +} + +export function sanitizeZipPathSegment(raw: string): string { + return String(raw || '') + .trim() + .replace(/[\\/:*?"<>|]+/g, '_') + .replace(/\s+/g, ' ') + .trim() || '未命名' +} + +/** Mirrors `normalizeAttachmentUrls` in download-attachment.ts (inlined for node:test). */ +function normalizeAttachmentUrls(raw?: string | null): string { + if (!raw) return '' + const trimmed = String(raw).trim() + if (!trimmed) return '' + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed) + if (Array.isArray(parsed)) { + return parsed.map((url) => String(url).trim()).filter(Boolean).join(',') + } + } catch { + /* ignore */ + } + } + return trimmed +} + +function parseUrls(raw?: string | null): string[] { + const normalized = normalizeAttachmentUrls(raw) + if (!normalized) return [] + return normalized + .split(',') + .map((item) => item.trim()) + .filter(Boolean) +} + +function getFileExt(url: string): string { + const name = + String(url || '') + .split('?')[0] + .split('/') + .pop() || '' + const lastDot = name.lastIndexOf('.') + if (lastDot === -1) return '' + return name.slice(lastDot + 1).toLowerCase() +} + +function partPrefix(cert: CertRead, enablePart: boolean): string { + if (!enablePart) return '' + return normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER + ? '挂·' + : '主·' +} + +export function collectVehicleCertZipEntries( + vehicles: VehicleCertZipSource[] +): VehicleCertZipEntry[] { + const result: VehicleCertZipEntry[] = [] + + for (const vehicle of vehicles) { + const certs = vehicle.certList || [] + const enablePart = certs.some( + (cert) => normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER + ) + const files: VehicleCertZipFile[] = [] + + certs.forEach((cert) => { + const certName = String(cert.certName || '').trim() || '证书' + const prefix = partPrefix(cert, enablePart) + const baseName = `${prefix}${certName}` + parseUrls(cert.attachmentUrls).forEach((fileUrl) => { + files.push({ + fileUrl, + baseName, + ext: getFileExt(fileUrl) + }) + }) + }) + + if (!files.length) continue + + const plateRaw = formatPlVehiclePlateDisplay(vehicle.licensePlate, vehicle.trailerNo) + result.push({ + vehicleId: vehicle.id, + plateFolder: sanitizeZipPathSegment(plateRaw === '—' ? vehicle.id : plateRaw), + files + }) + } + + return result +} + +export function buildVehicleCertZipFilename( + entries: Array<{ plateFolder: string }>, + yyyymmdd: string +): string { + if (entries.length === 1) { + return `车辆证书_${entries[0].plateFolder}.zip` + } + return `车辆证书批量_${entries.length}辆_${yyyymmdd}.zip` +} + +export function uniqueZipPath(used: Set, relativePath: string): string { + if (!used.has(relativePath)) { + used.add(relativePath) + return relativePath + } + const lastSlash = relativePath.lastIndexOf('/') + const dir = lastSlash >= 0 ? relativePath.slice(0, lastSlash + 1) : '' + const name = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath + const lastDot = name.lastIndexOf('.') + const stem = lastDot > 0 ? name.slice(0, lastDot) : name + const ext = lastDot > 0 ? name.slice(lastDot) : '' + let i = 2 + let candidate = `${dir}${stem}_${i}${ext}` + while (used.has(candidate)) { + i += 1 + candidate = `${dir}${stem}_${i}${ext}` + } + used.add(candidate) + return candidate +}