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:
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?
|
||||
Reference in new issue
Block a user