Break the frontend JSZip approach into TDD tasks for helpers, blob fetch, packing, and toolbar wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
23 KiB
三方车辆证书附件批量 ZIP 下载 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在三方车辆列表工具栏增加「批量下载证书 ZIP」,将勾选车辆的「证书附件」列文件在前端打成 ZIP 下载。
Architecture: 纯前端。从列表行 certList 收集 URL(命名对齐 PlCertListAttachmentCell),经现有预签名接口拉取 Blob,用已有 jszip + file-saver 打包;不新增后端接口。
Tech Stack: Vue 3、Element Plus、jszip、file-saver、node:test + pnpm exec tsx --test
Spec: docs/superpowers/specs/2026-09-08-vehicle-cert-batch-zip-design.md
文件结构
| 操作 | 文件 | 职责 |
|---|---|---|
| Create | web-admin/src/utils/pl-vehicle-cert-zip.ts |
收集附件、安全命名、ZIP 目录结构、打包与保存 |
| Create | web-admin/src/utils/pl-vehicle-cert-zip.test.ts |
纯函数与打包逻辑单测 |
| Modify | web-admin/src/utils/download-attachment.ts |
新增 fetchAttachmentBlob(预签名 → Blob,不触发 <a>) |
| Modify | web-admin/src/views/pl/vehicle/index.vue |
工具栏按钮、loading、Message、调用打包 |
不变: 后端 API、PlCertListAttachmentCell 单文件下载、车辆照片/人员证书。
参考:
- 列组件命名:
web-admin/src/components/business/pl-cert-list-attachment-cell/index.vue - 车牌展示:
formatPlVehiclePlateDisplay(web-admin/src/utils/pl-display.ts) - 单文件下载:
downloadAttachmentFile(web-admin/src/utils/download-attachment.ts) - 主·/挂·:
normalizeVehicleCertPart/PL_VEHICLE_CERT_PART_TRAILER(web-admin/src/utils/pl-vehicle-cert.ts)
Task 1: 纯函数 — 收集附件与命名
Files:
-
Create:
web-admin/src/utils/pl-vehicle-cert-zip.ts -
Create:
web-admin/src/utils/pl-vehicle-cert-zip.test.ts -
Step 1: 写失败测试(收集与命名)
创建 web-admin/src/utils/pl-vehicle-cert-zip.test.ts:
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')
})
})
- Step 2: 跑测试确认失败
Run:
cd web-admin && pnpm exec tsx --test src/utils/pl-vehicle-cert-zip.test.ts
Expected: FAIL(模块不存在或导出缺失)
- Step 3: 实现纯函数
创建 web-admin/src/utils/pl-vehicle-cert-zip.ts(本 Task 只实现下列导出;ZIP 下载函数放 Task 3):
import type { CertRead } from '@/api/pl'
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
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() || '未命名'
}
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
? '挂·'
: '主·'
}
/** 对齐 PlCertListAttachmentCell:有挂车证书时加主·/挂·前缀;仅收集有 URL 的项 */
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`
}
/** 同目录下重名追加 _2、_3(在扩展名之前) */
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
}
- Step 4: 跑测试确认通过
Run:
cd web-admin && pnpm exec tsx --test src/utils/pl-vehicle-cert-zip.test.ts
Expected: PASS(本文件全部用例)
- Step 5: Commit
git add web-admin/src/utils/pl-vehicle-cert-zip.ts web-admin/src/utils/pl-vehicle-cert-zip.test.ts
git commit -m "$(cat <<'EOF'
feat(pl): add vehicle cert zip naming and collect helpers.
Pure functions for toolbar batch ZIP: plate folders, 主·/挂· labels, and collision-safe paths.
EOF
)"
Task 2: 预签名拉取 Blob
Files:
-
Modify:
web-admin/src/utils/download-attachment.ts -
Step 1: 在
downloadAttachmentFile之后追加fetchAttachmentBlob
/** 附件内容:预签名 URL → Blob(供 ZIP 打包);预签名失败时回退直连。 */
export async function fetchAttachmentBlob(
file: AttachmentDownloadItem,
options?: { fullFileUrl?: (url: string) => string }
): Promise<Blob> {
const fileUrl = String(file.fileUrl || '').trim()
if (!fileUrl) {
throw new Error('empty fileUrl')
}
const fileName = String(file.fileName || '').trim() || fileUrl.split('/').pop() || 'download'
let href = ''
try {
const res = await fetchAttachmentDownloadUrl({ fileUrl, fileName })
if (res?.downloadUrl) href = res.downloadUrl
} catch {
// fall through
}
if (!href) {
const resolveUrl = options?.fullFileUrl || ((url: string) => url)
href = resolveUrl(fileUrl)
}
if (!href) {
throw new Error('无法获取文件下载地址')
}
const resp = await fetch(href)
if (!resp.ok) {
throw new Error(`download failed: ${resp.status}`)
}
return resp.blob()
}
- Step 2: 确认单文件下载函数签名未改、无语法错误
目视检查:downloadAttachmentFile 行为不变,仍走 triggerAnchorDownload。
- Step 3: Commit
git add web-admin/src/utils/download-attachment.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add fetchAttachmentBlob for zip packing.
Reuse attachment download-url flow to load file bytes without triggering anchor download.
EOF
)"
Task 3: JSZip 打包与保存
Files:
-
Modify:
web-admin/src/utils/pl-vehicle-cert-zip.ts -
Modify:
web-admin/src/utils/pl-vehicle-cert-zip.test.ts -
Step 1: 追加打包结果类型测试(可注入 fetchBlob)
在 pl-vehicle-cert-zip.test.ts 末尾追加:
import { buildAndSaveVehicleCertZip } from './pl-vehicle-cert-zip'
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, '')
})
})
- Step 2: 跑测试确认新用例失败
Run:
cd web-admin && pnpm exec tsx --test src/utils/pl-vehicle-cert-zip.test.ts
Expected: FAIL(buildAndSaveVehicleCertZip 未导出)
- Step 3: 实现
buildAndSaveVehicleCertZip
在 pl-vehicle-cert-zip.ts 末尾追加:
import JSZip from 'jszip'
import FileSaver from 'file-saver'
import { fetchAttachmentBlob } from '@/utils/download-attachment'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'
export type BuildVehicleCertZipOptions = {
yyyymmdd?: string
concurrency?: number
fetchBlob?: (fileUrl: string, fileName: string) => Promise<Blob>
saveAs?: (blob: Blob, filename: string) => void | Promise<void>
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<T, R>(
items: T[],
concurrency: number,
worker: (item: T) => Promise<R>
): Promise<R[]> {
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
}
export async function buildAndSaveVehicleCertZip(
vehicles: VehicleCertZipSource[],
options: BuildVehicleCertZipOptions = {}
): Promise<BuildVehicleCertZipResult> {
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<string>()
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 ||
((fileUrl: string, fileName: string) =>
fetchAttachmentBlob(
{ fileUrl, fileName },
{ fullFileUrl: resolvePhotoDisplayUrl }
))
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)
zip.file(job.zipPath, blob)
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 ||
((b: Blob, name: string) => {
FileSaver.saveAs(b, name)
})
await save(blob, zipName)
return { okCount, failCount, zipName }
}
注意:将文件顶部的 import 合并到文件开头(不要重复 import 块)。CertRead 等类型 import 保持 Task 1 已有内容。
- Step 4: 跑测试确认通过
Run:
cd web-admin && pnpm exec tsx --test src/utils/pl-vehicle-cert-zip.test.ts
Expected: PASS
- Step 5: Commit
git add web-admin/src/utils/pl-vehicle-cert-zip.ts web-admin/src/utils/pl-vehicle-cert-zip.test.ts
git commit -m "$(cat <<'EOF'
feat(pl): pack selected vehicle cert attachments into zip.
Add JSZip builder with concurrency, injectable fetch/save for tests, and multi-vehicle folders.
EOF
)"
Task 4: 工具栏接入车辆列表页
Files:
-
Modify:
web-admin/src/views/pl/vehicle/index.vue -
Step 1: 在导出组件后插入工具栏按钮
在 ListExportDropdown 与 ListToolbarEndActions 之间插入:
<el-button
class="btn-ghost"
:disabled="selectedRows.length === 0"
:loading="certZipLoading"
@click="handleBatchDownloadCertZip"
>
<el-icon><Download /></el-icon>批量下载证书 ZIP
</el-button>
Download 已在该文件 icons import 中,勿重复添加。
- Step 2: 增加状态与 handler
在 selectedRows 附近增加:
const certZipLoading = ref(false)
在 script 中增加 import:
import { buildAndSaveVehicleCertZip } from '@/utils/pl-vehicle-cert-zip'
增加处理函数(放在 handleSelectionChange 附近):
async function handleBatchDownloadCertZip() {
if (!selectedRows.value.length || certZipLoading.value) return
certZipLoading.value = true
try {
const result = await buildAndSaveVehicleCertZip(selectedRows.value)
if (!result.zipName && result.okCount === 0 && result.failCount === 0) {
ElMessage.warning('无可下载附件')
return
}
if (!result.zipName && result.failCount > 0) {
ElMessage.error('下载失败,请稍后重试')
return
}
if (result.failCount > 0) {
ElMessage.warning(`已打包 ${result.okCount} 个文件,${result.failCount} 个失败`)
return
}
ElMessage.success(`下载成功(${result.okCount} 个文件)`)
} catch (e) {
console.error(e)
ElMessage.error('下载失败,请稍后重试')
} finally {
certZipLoading.value = false
}
}
确认页面已使用 ElMessage(Element Plus auto-import 或既有 import);若该文件已有 ElMessage 用法则直接复用。
- Step 3: 本地手测清单
- 打开
/#/pl/base-info/vehicle - 未勾选 → 按钮禁用
- 勾选 1 辆有证书附件 → ZIP 名
车辆证书_*.zip,根目录平铺文件 - 勾选多辆有附件 →
车辆证书批量_N辆_yyyyMMdd.zip,按车牌子目录 - 勾选仅无附件车辆 → 「无可下载附件」
- 列内单个 ↓ 仍可单独下载
- Step 4: Commit
git add web-admin/src/views/pl/vehicle/index.vue
git commit -m "$(cat <<'EOF'
feat(pl): add toolbar batch ZIP download for vehicle certs.
Wire selected rows to frontend JSZip packing on the third-party vehicle list.
EOF
)"
Task 5: 图谱更新(若本会话改了代码)
Files:
-
无业务代码;运行仓库约定脚本
-
Step 1: 更新前端 graphify
Run:
./scripts/update-graph.sh frontend
若脚本不存在或失败,可改为:
cd web-admin && graphify update .
- Step 2: 若 graph 有变更则单独 commit
git add web-admin/graphify-out graphify-out 2>/dev/null || true
git status
# 仅当有 graph 变更时:
git commit -m "$(cat <<'EOF'
chore: update web-admin graphify after vehicle cert zip utils.
EOF
)"
Spec 覆盖自检
| Spec 要求 | Task |
|---|---|
| 工具栏入口、禁用/loading | Task 4 |
| 仅证书附件列、跳过无附件、全无提示 | Task 1 + Task 3 + Task 4 |
| 单车平铺 / 多车子目录命名 | Task 1 + Task 3 |
主·/挂· 与列对齐、重名 _2 |
Task 1 |
| 预签名拉取 + JSZip + file-saver | Task 2 + Task 3 |
| 部分失败仍打包、全失败不空 ZIP | Task 3 + Task 4 |
| 单文件 ↓ 不变、无后端 | Task 4(不改列组件) |
| 并发限流 | Task 3 concurrency 默认 4 |
执行交接
Plan complete and saved to docs/superpowers/plans/2026-09-08-vehicle-cert-batch-zip.md. Two execution options:
1. Subagent-Driven (recommended) — 每个 Task 派一个新 subagent,Task 间复查,迭代快
2. Inline Execution — 本会话用 executing-plans 按 Task 顺序执行,带检查点
Which approach?