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>
This commit is contained in:
wuzilongandCursor committed 2026-09-08 20:37:53 +08:00
1 parent b84eec3c90
commit b4e59ab16b
1 file changed
+528
@@ -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?