feat(pl): add vehicle cert zip naming and collect helpers.

Pure functions for toolbar batch ZIP: plate folders, 主·/挂· labels, and collision-safe paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-08 20:19:50 +08:00
1 parent 65af6e6163
commit 8d3062a5af
2 files changed
+268

No files matched your search

@@ -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<string>()
assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证.jpg')
assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证_2.jpg')
assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证_3.jpg')
})
})
+147
View File
@@ -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<string>, 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
}