|
|
@@ -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?
|