From b4e59ab16bb9f861d3ee9bc0bbf660f6be6290fc Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Tue, 8 Sep 2026 20:37:53 +0800 Subject: [PATCH] docs: add plan for cert ZIP on self-vehicle and personnel lists. Cover shared pack core, personnel naming, and toolbar wiring for both pages. Co-authored-by: Cursor --- ...8-cert-batch-zip-personnel-self-vehicle.md | 528 ++++++++++++++++++ 1 file changed, 528 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-08-cert-batch-zip-personnel-self-vehicle.md diff --git a/docs/superpowers/plans/2026-09-08-cert-batch-zip-personnel-self-vehicle.md b/docs/superpowers/plans/2026-09-08-cert-batch-zip-personnel-self-vehicle.md new file mode 100644 index 000000000..8f9c7844c --- /dev/null +++ b/docs/superpowers/plans/2026-09-08-cert-batch-zip-personnel-self-vehicle.md @@ -0,0 +1,528 @@ +# 证书批量 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」,与已上线的三方车辆行为一致。 + +**Architecture:** 在现有 `pl-vehicle-cert-zip.ts` 上抽出通用打包核心(按 `folderLabel` + files 打 ZIP);车辆 API 保持不变;新增人员收集/命名与 `buildAndSavePersonnelCertZip`;两页工具栏接线复用三方车辆 handler 模式。 + +**Tech Stack:** Vue 3、Element Plus、`jszip`、`file-saver`、`node:test` + `pnpm exec tsx --test` + +**Spec:** `docs/superpowers/specs/2026-09-08-cert-batch-zip-personnel-self-vehicle-design.md` + +--- + +## 文件结构 + +| 操作 | 文件 | 职责 | +|------|------|------| +| Modify | `web-admin/src/utils/pl-vehicle-cert-zip.ts` | 通用打包核心;人员 collect/filename/buildAndSave;`plateFolder` 兼容别名 `folderLabel` | +| Modify | `web-admin/src/utils/pl-vehicle-cert-zip.test.ts` | 人员用例 + 回归车辆 | +| Modify | `web-admin/src/views/pl/self-vehicle/index.vue` | 工具栏按钮 + handler(调车辆 ZIP) | +| Modify | `web-admin/src/views/pl/personnel/index.vue` | 工具栏按钮 + handler(调人员 ZIP) | + +**参考:** `web-admin/src/views/pl/vehicle/index.vue`(按钮与 `handleBatchDownloadCertZip`) + +--- + +### Task 1: 通用打包核心 + 人员纯函数(TDD) + +**Files:** +- Modify: `web-admin/src/utils/pl-vehicle-cert-zip.ts` +- Modify: `web-admin/src/utils/pl-vehicle-cert-zip.test.ts` + +- [ ] **Step 1: 追加人员相关失败测试** + +在 `pl-vehicle-cert-zip.test.ts` 现有 import 中增加: + +```ts + collectPersonnelCertZipEntries, + buildPersonnelCertZipFilename, + buildAndSavePersonnelCertZip +``` + +文件末尾追加: + +```ts +describe('collectPersonnelCertZipEntries', () => { + it('uses personnel name folders; dedupes same names with _2; skips empty', () => { + const entries = collectPersonnelCertZipEntries([ + { + id: 'p1', + personnelName: '张三', + certList: [ + { + id: 'c1', + ownerType: 'personnel', + ownerId: 'p1', + status: '1', + certName: '驾驶证', + attachmentUrls: 'https://cdn.example/d.jpg' + } + ] + }, + { + id: 'p2', + personnelName: '张三', + certList: [ + { + id: 'c2', + ownerType: 'personnel', + ownerId: 'p2', + status: '1', + certName: '从业资格证', + attachmentUrls: 'https://cdn.example/q.pdf' + } + ] + }, + { + id: 'p3', + personnelName: '李四', + certList: [] + } + ]) + assert.equal(entries.length, 2) + assert.equal(entries[0].folderLabel, '张三') + assert.equal(entries[1].folderLabel, '张三_2') + assert.equal(entries[0].files[0].baseName, '驾驶证') + assert.equal(entries[1].files[0].baseName, '从业资格证') + }) + + it('does not add 主·/挂· for personnel certs', () => { + const entries = collectPersonnelCertZipEntries([ + { + id: 'p1', + personnelName: '王五', + certList: [ + { + id: 'c1', + ownerType: 'personnel', + ownerId: 'p1', + status: '1', + certName: '驾驶证', + vehiclePart: 'trailer', + attachmentUrls: 'https://cdn.example/a.jpg' + } + ] + } + ]) + assert.equal(entries[0].files[0].baseName, '驾驶证') + }) +}) + +describe('buildPersonnelCertZipFilename', () => { + it('names single and multi personnel zips', () => { + assert.equal( + buildPersonnelCertZipFilename([{ folderLabel: '张三' }], '20260908'), + '人员证书_张三.zip' + ) + assert.equal( + buildPersonnelCertZipFilename( + [{ folderLabel: '张三' }, { folderLabel: '张三_2' }], + '20260908' + ), + '人员证书批量_2人_20260908.zip' + ) + }) +}) + +describe('buildAndSavePersonnelCertZip', () => { + it('packs multi personnel into name subfolders', async () => { + const paths: string[] = [] + const result = await buildAndSavePersonnelCertZip( + [ + { + id: 'p1', + personnelName: '张三', + certList: [ + { + id: 'c1', + ownerType: 'personnel', + ownerId: 'p1', + status: '1', + certName: '驾驶证', + attachmentUrls: 'https://cdn.example/a.jpg' + } + ] + }, + { + id: 'p2', + personnelName: '李四', + certList: [ + { + id: 'c2', + ownerType: 'personnel', + ownerId: 'p2', + status: '1', + certName: '身份证', + attachmentUrls: 'https://cdn.example/b.png' + } + ] + } + ], + { + yyyymmdd: '20260908', + fetchBlob: async () => new Blob(['x']), + saveAs: async () => {}, + onFileAdded: (p) => paths.push(p) + } + ) + assert.equal(result.zipName, '人员证书批量_2人_20260908.zip') + assert.deepEqual(paths.sort(), ['张三/驾驶证.jpg', '李四/身份证.png'].sort()) + }) +}) +``` + +- [ ] **Step 2: 跑测试确认新用例失败** + +```bash +cd web-admin && pnpm exec tsx --test src/utils/pl-vehicle-cert-zip.test.ts +``` + +Expected: FAIL(人员导出缺失) + +- [ ] **Step 3: 实现通用核心与人员 API(保持车辆导出兼容)** + +在 `pl-vehicle-cert-zip.ts` 中: + +1. 将 `VehicleCertZipEntry` 调整为同时带 `folderLabel`(主字段)与兼容读取:现有 `plateFolder` 改为写入时同时设置,或把类型改为: + +```ts +export type CertZipFile = { + fileUrl: string + baseName: string + ext: string +} + +export type CertZipEntry = { + id: string + folderLabel: string + files: CertZipFile[] +} + +/** @deprecated alias — vehicle collectors still expose plateFolder via mapping */ +export type VehicleCertZipFile = CertZipFile +export type VehicleCertZipEntry = { + vehicleId: string + plateFolder: string + files: VehicleCertZipFile[] +} +``` + +更稳妥的最小改动: + +- 新增内部类型 `CertZipPackEntry = { folderLabel: string; files: VehicleCertZipFile[] }` +- 抽出: + +```ts +export type BuildCertZipOptions = BuildVehicleCertZipOptions +export type BuildCertZipResult = BuildVehicleCertZipResult + +export async function packAndSaveCertZip( + entries: Array<{ folderLabel: string; files: VehicleCertZipFile[] }>, + zipName: string, + options: BuildCertZipOptions = {} +): Promise { + if (!entries.length) { + return { okCount: 0, failCount: 0, zipName: '' } + } + const multi = entries.length > 1 + const concurrency = options.concurrency ?? 4 + const used = new Set() + const jobs: Array<{ zipPath: string; fileUrl: string; fileName: string }> = [] + for (const entry of entries) { + for (const file of entry.files) { + const leaf = `${file.baseName}${file.ext ? `.${file.ext}` : ''}` + const rawPath = multi ? `${entry.folderLabel}/${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 out = await zip.generateAsync({ type: 'blob' }) + const save = options.saveAs ?? defaultSaveAs + await save(out, zipName) + return { okCount, failCount, zipName } +} +``` + +2. 将 `buildAndSaveVehicleCertZip` 改为: + +```ts +export async function buildAndSaveVehicleCertZip( + vehicles: VehicleCertZipSource[], + options: BuildVehicleCertZipOptions = {} +): Promise { + const collected = collectVehicleCertZipEntries(vehicles) + if (!collected.length) return { okCount: 0, failCount: 0, zipName: '' } + const yyyymmdd = options.yyyymmdd || todayYyyymmdd() + const zipName = buildVehicleCertZipFilename(collected, yyyymmdd) + return packAndSaveCertZip( + collected.map((e) => ({ folderLabel: e.plateFolder, files: e.files })), + zipName, + options + ) +} +``` + +3. 新增人员类型与函数: + +```ts +export type PersonnelCertZipSource = { + id: string + personnelName?: string | null + certList?: CertRead[] | null +} + +export type PersonnelCertZipEntry = { + personnelId: string + folderLabel: string + files: VehicleCertZipFile[] +} + +export function collectPersonnelCertZipEntries( + people: PersonnelCertZipSource[] +): PersonnelCertZipEntry[] { + const result: PersonnelCertZipEntry[] = [] + const usedFolders = new Set() + + for (const person of people) { + const certs = person.certList || [] + const files: VehicleCertZipFile[] = [] + certs.forEach((cert) => { + const certName = String(cert.certName || '').trim() || '证书' + parseUrls(cert.attachmentUrls).forEach((fileUrl) => { + files.push({ + fileUrl, + baseName: certName, + ext: getFileExt(fileUrl) + }) + }) + }) + if (!files.length) continue + const rawName = String(person.personnelName || '').trim() + const baseFolder = sanitizeZipPathSegment(rawName || person.id) + const folderLabel = uniqueZipPath(usedFolders, baseFolder) + result.push({ + personnelId: person.id, + folderLabel, + files + }) + } + return result +} + +export function buildPersonnelCertZipFilename( + entries: Array<{ folderLabel: string }>, + yyyymmdd: string +): string { + if (entries.length === 1) { + return `人员证书_${entries[0].folderLabel}.zip` + } + return `人员证书批量_${entries.length}人_${yyyymmdd}.zip` +} + +export async function buildAndSavePersonnelCertZip( + people: PersonnelCertZipSource[], + options: BuildCertZipOptions = {} +): Promise { + const collected = collectPersonnelCertZipEntries(people) + if (!collected.length) return { okCount: 0, failCount: 0, zipName: '' } + const yyyymmdd = options.yyyymmdd || todayYyyymmdd() + const zipName = buildPersonnelCertZipFilename(collected, yyyymmdd) + return packAndSaveCertZip( + collected.map((e) => ({ folderLabel: e.folderLabel, files: e.files })), + zipName, + options + ) +} +``` + +**重要:** 现有车辆测试必须仍全部 PASS(`plateFolder` API 不变)。 + +- [ ] **Step 4: 跑全文件测试** + +```bash +cd web-admin && pnpm exec tsx --test src/utils/pl-vehicle-cert-zip.test.ts +``` + +Expected: 全部 PASS(原 9 + 新人员用例) + +- [ ] **Step 5: Commit** + +```bash +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 personnel cert zip packing and shared pack core. + +Extract packAndSaveCertZip for reuse; keep vehicle API; name folders with _2 on collisions. +EOF +)" +``` + +--- + +### Task 2: 山峡自有车辆工具栏接线 + +**Files:** +- Modify: `web-admin/src/views/pl/self-vehicle/index.vue` + +- [ ] **Step 1: 对照三方车辆插入按钮** + +在 `ListExportDropdown` 与 `ListToolbarEndActions` 之间插入(与 `vehicle/index.vue` 相同): + +```vue + + 批量下载证书 ZIP + +``` + +- [ ] **Step 2: script 接线** + +确认 `Download` 已在 icons import 中;增加: + +```ts +import { buildAndSaveVehicleCertZip } from '@/utils/pl-vehicle-cert-zip' +``` + +在 `selectedRows` 附近: + +```ts +const certZipLoading = ref(false) +``` + +在 `handleSelectionChange` 附近复制三方车辆的 `handleBatchDownloadCertZip`(调用 `buildAndSaveVehicleCertZip(selectedRows.value)`)。 + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/views/pl/self-vehicle/index.vue +git commit -m "$(cat <<'EOF' +feat(pl): add cert batch ZIP download on self-vehicle list. + +Reuse vehicle cert zip packing on the Shanxia owned-vehicle toolbar. +EOF +)" +``` + +--- + +### Task 3: 驾押人员工具栏接线(三方 + 山峡分册) + +**Files:** +- Modify: `web-admin/src/views/pl/personnel/index.vue` + +- [ ] **Step 1: 插入工具栏按钮** + +同样插在 `ListExportDropdown` 与 `ListToolbarEndActions` 之间。 + +- [ ] **Step 2: script** + +```ts +import { buildAndSavePersonnelCertZip } from '@/utils/pl-vehicle-cert-zip' +``` + +若 `Download` 未导入则加入 `@element-plus/icons-vue` import。 + +```ts +const certZipLoading = ref(false) + +async function handleBatchDownloadCertZip() { + if (!selectedRows.value.length || certZipLoading.value) return + certZipLoading.value = true + try { + const result = await buildAndSavePersonnelCertZip(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 + } +} +``` + +两分册共用同一页面,无需分支。 + +- [ ] **Step 3: Commit** + +```bash +git add web-admin/src/views/pl/personnel/index.vue +git commit -m "$(cat <<'EOF' +feat(pl): add cert batch ZIP download on personnel lists. + +Wire third-party and Shanxia escort/driver lists to personnel cert zip packing. +EOF +)" +``` + +--- + +### Task 4: graphify 更新 + +- [ ] **Step 1:** `./scripts/update-graph.sh frontend` +- [ ] **Step 2:** 若有 graph 变更则 commit: + +```bash +git add web-admin/graphify-out graphify-out 2>/dev/null || true +git commit -m "$(cat <<'EOF' +chore: update graphify after personnel/self-vehicle cert zip. +EOF +)" +``` + +--- + +## Spec 覆盖自检 + +| Spec | Task | +|------|------| +| 自有车辆工具栏 + 车辆 ZIP | Task 2 | +| 人员工具栏 + 人员命名/同名 `_2` | Task 1 + Task 3 | +| 通用核心、车辆不回归 | Task 1 | +| 无后端、单文件 ↓ 不变 | Task 2/3 仅改工具栏 | + +--- + +## 执行交接 + +Plan complete and saved to `docs/superpowers/plans/2026-09-08-cert-batch-zip-personnel-self-vehicle.md`. + +**1. Subagent-Driven (recommended)** — 每 Task 独立 subagent + 复查 +**2. Inline Execution** — 本会话按 Task 执行 + +Which approach?