Преглед на файлове

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 <cursoragent@cursor.com>
wuzilong преди 1 месец
родител
ревизия
b4e59ab16b
променени са 1 файла, в които са добавени 528 реда и са изтрити 0 реда
  1. 528 0
      docs/superpowers/plans/2026-09-08-cert-batch-zip-personnel-self-vehicle.md

+ 528 - 0
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<BuildCertZipResult> {
+  if (!entries.length) {
+    return { okCount: 0, failCount: 0, zipName: '' }
+  }
+  const multi = entries.length > 1
+  const concurrency = options.concurrency ?? 4
+  const used = new Set<string>()
+  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<BuildVehicleCertZipResult> {
+  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<string>()
+
+  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<BuildCertZipResult> {
+  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
+          <el-button
+            class="btn-ghost"
+            :disabled="selectedRows.length === 0"
+            :loading="certZipLoading"
+            @click="handleBatchDownloadCertZip"
+          >
+            <el-icon><Download /></el-icon>批量下载证书 ZIP
+          </el-button>
+```
+
+- [ ] **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?