Files
shanxia2.0/docs/superpowers/plans/2026-09-08-vehicle-cert-batch-zip.md
T
wuzilongandCursor 65af6e6163 docs: add implementation plan for vehicle cert batch ZIP download.
Break the frontend JSZip approach into TDD tasks for helpers, blob fetch, packing, and toolbar wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 20:17:05 +08:00

23 KiB
Raw Blame History

三方车辆证书附件批量 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」,将勾选车辆的「证书附件」列文件在前端打成 ZIP 下载。

Architecture: 纯前端。从列表行 certList 收集 URL(命名对齐 PlCertListAttachmentCell),经现有预签名接口拉取 Blob,用已有 jszip + file-saver 打包;不新增后端接口。

Tech Stack: Vue 3、Element Plus、jszip、file-saver、node:test + pnpm exec tsx --test

Spec: docs/superpowers/specs/2026-09-08-vehicle-cert-batch-zip-design.md


文件结构

操作 文件 职责
Create web-admin/src/utils/pl-vehicle-cert-zip.ts 收集附件、安全命名、ZIP 目录结构、打包与保存
Create web-admin/src/utils/pl-vehicle-cert-zip.test.ts 纯函数与打包逻辑单测
Modify web-admin/src/utils/download-attachment.ts 新增 fetchAttachmentBlob(预签名 → Blob,不触发 <a>)
Modify web-admin/src/views/pl/vehicle/index.vue 工具栏按钮、loading、Message、调用打包

不变: 后端 API、PlCertListAttachmentCell 单文件下载、车辆照片/人员证书。

参考:

  • 列组件命名:web-admin/src/components/business/pl-cert-list-attachment-cell/index.vue
  • 车牌展示:formatPlVehiclePlateDisplay(web-admin/src/utils/pl-display.ts)
  • 单文件下载:downloadAttachmentFile(web-admin/src/utils/download-attachment.ts)
  • 主·/挂·:normalizeVehicleCertPart / PL_VEHICLE_CERT_PART_TRAILER(web-admin/src/utils/pl-vehicle-cert.ts)

Task 1: 纯函数 — 收集附件与命名

Files:

  • Create: web-admin/src/utils/pl-vehicle-cert-zip.ts

  • Create: web-admin/src/utils/pl-vehicle-cert-zip.test.ts

  • Step 1: 写失败测试(收集与命名)

创建 web-admin/src/utils/pl-vehicle-cert-zip.test.ts:

import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
  collectVehicleCertZipEntries,
  buildVehicleCertZipFilename,
  uniqueZipPath,
  sanitizeZipPathSegment
} from './pl-vehicle-cert-zip'

describe('sanitizeZipPathSegment', () => {
  it('replaces unsafe path chars', () => {
    assert.equal(sanitizeZipPathSegment('皖A/D12\\345'), '皖A_D12_345')
    assert.equal(sanitizeZipPathSegment('  皖A  '), '皖A')
  })
})

describe('collectVehicleCertZipEntries', () => {
  it('skips vehicles and certs without urls; prefixes 主·/挂· when trailer certs exist', () => {
    const entries = collectVehicleCertZipEntries([
      {
        id: 'v1',
        licensePlate: '皖AD12345',
        trailerNo: '挂A8888',
        certList: [
          {
            id: 'c1',
            ownerType: 'vehicle',
            ownerId: 'v1',
            status: '1',
            certName: '行驶证',
            vehiclePart: 'head',
            attachmentUrls: 'https://cdn.example/a.jpg'
          },
          {
            id: 'c2',
            ownerType: 'vehicle',
            ownerId: 'v1',
            status: '1',
            certName: '道路运输证',
            vehiclePart: 'head',
            attachmentUrls: ''
          },
          {
            id: 'c3',
            ownerType: 'vehicle',
            ownerId: 'v1',
            status: '1',
            certName: '行驶证',
            vehiclePart: 'trailer',
            attachmentUrls: 'https://cdn.example/b.pdf'
          }
        ]
      },
      {
        id: 'v2',
        licensePlate: '皖BF67890',
        certList: []
      }
    ])

    assert.equal(entries.length, 1)
    assert.equal(entries[0].vehicleId, 'v1')
    assert.equal(entries[0].plateFolder, '皖AD12345挂A8888')
    assert.equal(entries[0].files.length, 2)
    assert.equal(entries[0].files[0].baseName, '主·行驶证')
    assert.equal(entries[0].files[0].ext, 'jpg')
    assert.equal(entries[0].files[1].baseName, '挂·行驶证')
    assert.equal(entries[0].files[1].ext, 'pdf')
  })

  it('omits 主·/挂· prefix when no trailer certs on that vehicle', () => {
    const entries = collectVehicleCertZipEntries([
      {
        id: 'v1',
        licensePlate: '皖BF67890',
        certList: [
          {
            id: 'c1',
            ownerType: 'vehicle',
            ownerId: 'v1',
            status: '1',
            certName: '行驶证',
            vehiclePart: 'head',
            attachmentUrls: 'https://cdn.example/a.jpg,https://cdn.example/a2.png'
          }
        ]
      }
    ])
    assert.equal(entries[0].files.length, 2)
    assert.equal(entries[0].files[0].baseName, '行驶证')
    assert.equal(entries[0].files[1].baseName, '行驶证')
  })
})

describe('buildVehicleCertZipFilename', () => {
  it('names single-vehicle zip with plate', () => {
    assert.equal(
      buildVehicleCertZipFilename([{ plateFolder: '皖AD12345挂A8888' }], '20260908'),
      '车辆证书_皖AD12345挂A8888.zip'
    )
  })

  it('names multi-vehicle zip with count and date', () => {
    assert.equal(
      buildVehicleCertZipFilename(
        [{ plateFolder: 'A' }, { plateFolder: 'B' }],
        '20260908'
      ),
      '车辆证书批量_2辆_20260908.zip'
    )
  })
})

describe('uniqueZipPath', () => {
  it('appends _2 _3 on collisions', () => {
    const used = new Set<string>()
    assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证.jpg')
    assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证_2.jpg')
    assert.equal(uniqueZipPath(used, '行驶证.jpg'), '行驶证_3.jpg')
  })
})
  • Step 2: 跑测试确认失败

Run:

cd web-admin && pnpm exec tsx --test src/utils/pl-vehicle-cert-zip.test.ts

Expected: FAIL(模块不存在或导出缺失)

  • Step 3: 实现纯函数

创建 web-admin/src/utils/pl-vehicle-cert-zip.ts(本 Task 只实现下列导出;ZIP 下载函数放 Task 3):

import type { CertRead } from '@/api/pl'
import { normalizeAttachmentUrls } from '@/utils/download-attachment'
import { formatPlVehiclePlateDisplay } from '@/utils/pl-display'
import {
  PL_VEHICLE_CERT_PART_TRAILER,
  normalizeVehicleCertPart
} from '@/utils/pl-vehicle-cert'

export type VehicleCertZipSource = {
  id: string
  licensePlate?: string | null
  trailerNo?: string | null
  certList?: CertRead[] | null
}

export type VehicleCertZipFile = {
  fileUrl: string
  baseName: string
  ext: string
}

export type VehicleCertZipEntry = {
  vehicleId: string
  plateFolder: string
  files: VehicleCertZipFile[]
}

export function sanitizeZipPathSegment(raw: string): string {
  return String(raw || '')
    .trim()
    .replace(/[\\/:*?"<>|]+/g, '_')
    .replace(/\s+/g, ' ')
    .trim() || '未命名'
}

function parseUrls(raw?: string | null): string[] {
  const normalized = normalizeAttachmentUrls(raw)
  if (!normalized) return []
  return normalized
    .split(',')
    .map((item) => item.trim())
    .filter(Boolean)
}

function getFileExt(url: string): string {
  const name =
    String(url || '')
      .split('?')[0]
      .split('/')
      .pop() || ''
  const lastDot = name.lastIndexOf('.')
  if (lastDot === -1) return ''
  return name.slice(lastDot + 1).toLowerCase()
}

function partPrefix(cert: CertRead, enablePart: boolean): string {
  if (!enablePart) return ''
  return normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER
    ? '挂·'
    : '主·'
}

/** 对齐 PlCertListAttachmentCell:有挂车证书时加主·/挂·前缀;仅收集有 URL 的项 */
export function collectVehicleCertZipEntries(
  vehicles: VehicleCertZipSource[]
): VehicleCertZipEntry[] {
  const result: VehicleCertZipEntry[] = []

  for (const vehicle of vehicles) {
    const certs = vehicle.certList || []
    const enablePart = certs.some(
      (cert) => normalizeVehicleCertPart(cert.vehiclePart) === PL_VEHICLE_CERT_PART_TRAILER
    )
    const files: VehicleCertZipFile[] = []

    certs.forEach((cert) => {
      const certName = String(cert.certName || '').trim() || '证书'
      const prefix = partPrefix(cert, enablePart)
      const baseName = `${prefix}${certName}`
      parseUrls(cert.attachmentUrls).forEach((fileUrl) => {
        files.push({
          fileUrl,
          baseName,
          ext: getFileExt(fileUrl)
        })
      })
    })

    if (!files.length) continue

    const plateRaw = formatPlVehiclePlateDisplay(vehicle.licensePlate, vehicle.trailerNo)
    result.push({
      vehicleId: vehicle.id,
      plateFolder: sanitizeZipPathSegment(plateRaw === '—' ? vehicle.id : plateRaw),
      files
    })
  }

  return result
}

export function buildVehicleCertZipFilename(
  entries: Array<{ plateFolder: string }>,
  yyyymmdd: string
): string {
  if (entries.length === 1) {
    return `车辆证书_${entries[0].plateFolder}.zip`
  }
  return `车辆证书批量_${entries.length}辆_${yyyymmdd}.zip`
}

/** 同目录下重名追加 _2、_3(在扩展名之前) */
export function uniqueZipPath(used: Set<string>, relativePath: string): string {
  if (!used.has(relativePath)) {
    used.add(relativePath)
    return relativePath
  }
  const lastSlash = relativePath.lastIndexOf('/')
  const dir = lastSlash >= 0 ? relativePath.slice(0, lastSlash + 1) : ''
  const name = lastSlash >= 0 ? relativePath.slice(lastSlash + 1) : relativePath
  const lastDot = name.lastIndexOf('.')
  const stem = lastDot > 0 ? name.slice(0, lastDot) : name
  const ext = lastDot > 0 ? name.slice(lastDot) : ''
  let i = 2
  let candidate = `${dir}${stem}_${i}${ext}`
  while (used.has(candidate)) {
    i += 1
    candidate = `${dir}${stem}_${i}${ext}`
  }
  used.add(candidate)
  return candidate
}
  • Step 4: 跑测试确认通过

Run:

cd web-admin && pnpm exec tsx --test src/utils/pl-vehicle-cert-zip.test.ts

Expected: PASS(本文件全部用例)

  • Step 5: Commit
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 vehicle cert zip naming and collect helpers.

Pure functions for toolbar batch ZIP: plate folders, 主·/挂· labels, and collision-safe paths.
EOF
)"

Task 2: 预签名拉取 Blob

Files:

  • Modify: web-admin/src/utils/download-attachment.ts

  • Step 1: 在 downloadAttachmentFile 之后追加 fetchAttachmentBlob

/** 附件内容:预签名 URL → Blob(供 ZIP 打包);预签名失败时回退直连。 */
export async function fetchAttachmentBlob(
  file: AttachmentDownloadItem,
  options?: { fullFileUrl?: (url: string) => string }
): Promise<Blob> {
  const fileUrl = String(file.fileUrl || '').trim()
  if (!fileUrl) {
    throw new Error('empty fileUrl')
  }
  const fileName = String(file.fileName || '').trim() || fileUrl.split('/').pop() || 'download'

  let href = ''
  try {
    const res = await fetchAttachmentDownloadUrl({ fileUrl, fileName })
    if (res?.downloadUrl) href = res.downloadUrl
  } catch {
    // fall through
  }

  if (!href) {
    const resolveUrl = options?.fullFileUrl || ((url: string) => url)
    href = resolveUrl(fileUrl)
  }

  if (!href) {
    throw new Error('无法获取文件下载地址')
  }

  const resp = await fetch(href)
  if (!resp.ok) {
    throw new Error(`download failed: ${resp.status}`)
  }
  return resp.blob()
}
  • Step 2: 确认单文件下载函数签名未改、无语法错误

目视检查:downloadAttachmentFile 行为不变,仍走 triggerAnchorDownload。

  • Step 3: Commit
git add web-admin/src/utils/download-attachment.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add fetchAttachmentBlob for zip packing.

Reuse attachment download-url flow to load file bytes without triggering anchor download.
EOF
)"

Task 3: JSZip 打包与保存

Files:

  • Modify: web-admin/src/utils/pl-vehicle-cert-zip.ts

  • Modify: web-admin/src/utils/pl-vehicle-cert-zip.test.ts

  • Step 1: 追加打包结果类型测试(可注入 fetchBlob)

在 pl-vehicle-cert-zip.test.ts 末尾追加:

import { buildAndSaveVehicleCertZip } from './pl-vehicle-cert-zip'

describe('buildAndSaveVehicleCertZip', () => {
  it('packs single vehicle flat; skips failed blobs; returns counts', async () => {
    const saved: { name: string; size: number }[] = []
    const result = await buildAndSaveVehicleCertZip(
      [
        {
          id: 'v1',
          licensePlate: '皖AD12345',
          certList: [
            {
              id: 'c1',
              ownerType: 'vehicle',
              ownerId: 'v1',
              status: '1',
              certName: '行驶证',
              vehiclePart: 'head',
              attachmentUrls: 'https://cdn.example/ok.jpg,https://cdn.example/bad.jpg'
            }
          ]
        }
      ],
      {
        yyyymmdd: '20260908',
        fetchBlob: async (fileUrl) => {
          if (fileUrl.includes('bad')) throw new Error('fail')
          return new Blob([fileUrl], { type: 'application/octet-stream' })
        },
        saveAs: async (blob, name) => {
          saved.push({ name, size: blob.size })
        }
      }
    )

    assert.equal(result.okCount, 1)
    assert.equal(result.failCount, 1)
    assert.equal(result.zipName, '车辆证书_皖AD12345.zip')
    assert.equal(saved.length, 1)
    assert.equal(saved[0].name, '车辆证书_皖AD12345.zip')
  })

  it('uses subfolders for multiple vehicles', async () => {
    const paths: string[] = []
    await buildAndSaveVehicleCertZip(
      [
        {
          id: 'v1',
          licensePlate: '皖A1',
          certList: [
            {
              id: 'c1',
              ownerType: 'vehicle',
              ownerId: 'v1',
              status: '1',
              certName: '行驶证',
              vehiclePart: 'head',
              attachmentUrls: 'https://cdn.example/a.jpg'
            }
          ]
        },
        {
          id: 'v2',
          licensePlate: '皖B2',
          certList: [
            {
              id: 'c2',
              ownerType: 'vehicle',
              ownerId: 'v2',
              status: '1',
              certName: '营运证',
              vehiclePart: 'head',
              attachmentUrls: 'https://cdn.example/b.pdf'
            }
          ]
        }
      ],
      {
        yyyymmdd: '20260908',
        fetchBlob: async () => new Blob(['x']),
        saveAs: async () => {},
        onFileAdded: (zipPath) => paths.push(zipPath)
      }
    )
    assert.deepEqual(paths.sort(), ['皖A1/行驶证.jpg', '皖B2/营运证.pdf'].sort())
  })

  it('returns empty when no attachments', async () => {
    const result = await buildAndSaveVehicleCertZip(
      [{ id: 'v1', licensePlate: '皖A1', certList: [] }],
      {
        yyyymmdd: '20260908',
        fetchBlob: async () => new Blob(['x']),
        saveAs: async () => {}
      }
    )
    assert.equal(result.okCount, 0)
    assert.equal(result.failCount, 0)
    assert.equal(result.zipName, '')
  })
})
  • Step 2: 跑测试确认新用例失败

Run:

cd web-admin && pnpm exec tsx --test src/utils/pl-vehicle-cert-zip.test.ts

Expected: FAIL(buildAndSaveVehicleCertZip 未导出)

  • Step 3: 实现 buildAndSaveVehicleCertZip

在 pl-vehicle-cert-zip.ts 末尾追加:

import JSZip from 'jszip'
import FileSaver from 'file-saver'
import { fetchAttachmentBlob } from '@/utils/download-attachment'
import { resolvePhotoDisplayUrl } from '@/utils/pl-photo'

export type BuildVehicleCertZipOptions = {
  yyyymmdd?: string
  concurrency?: number
  fetchBlob?: (fileUrl: string, fileName: string) => Promise<Blob>
  saveAs?: (blob: Blob, filename: string) => void | Promise<void>
  onFileAdded?: (zipPath: string) => void
}

export type BuildVehicleCertZipResult = {
  okCount: number
  failCount: number
  zipName: string
}

function todayYyyymmdd(): string {
  const d = new Date()
  const y = d.getFullYear()
  const m = String(d.getMonth() + 1).padStart(2, '0')
  const day = String(d.getDate()).padStart(2, '0')
  return `${y}${m}${day}`
}

async function mapPool<T, R>(
  items: T[],
  concurrency: number,
  worker: (item: T) => Promise<R>
): Promise<R[]> {
  const results: R[] = new Array(items.length)
  let next = 0
  const runners = Array.from({ length: Math.min(concurrency, items.length) || 0 }, async () => {
    while (next < items.length) {
      const idx = next
      next += 1
      results[idx] = await worker(items[idx])
    }
  })
  await Promise.all(runners)
  return results
}

export async function buildAndSaveVehicleCertZip(
  vehicles: VehicleCertZipSource[],
  options: BuildVehicleCertZipOptions = {}
): Promise<BuildVehicleCertZipResult> {
  const entries = collectVehicleCertZipEntries(vehicles)
  if (!entries.length) {
    return { okCount: 0, failCount: 0, zipName: '' }
  }

  const yyyymmdd = options.yyyymmdd || todayYyyymmdd()
  const zipName = buildVehicleCertZipFilename(entries, yyyymmdd)
  const multi = entries.length > 1
  const concurrency = options.concurrency ?? 4

  type Job = {
    zipPath: string
    fileUrl: string
    fileName: string
  }

  const used = new Set<string>()
  const jobs: Job[] = []
  for (const entry of entries) {
    for (const file of entry.files) {
      const leaf = `${file.baseName}${file.ext ? `.${file.ext}` : ''}`
      const rawPath = multi ? `${entry.plateFolder}/${leaf}` : leaf
      const zipPath = uniqueZipPath(used, rawPath)
      jobs.push({
        zipPath,
        fileUrl: file.fileUrl,
        fileName: leaf
      })
    }
  }

  const fetchBlob =
    options.fetchBlob ||
    ((fileUrl: string, fileName: string) =>
      fetchAttachmentBlob(
        { fileUrl, fileName },
        { fullFileUrl: resolvePhotoDisplayUrl }
      ))

  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)
      zip.file(job.zipPath, blob)
      options.onFileAdded?.(job.zipPath)
      okCount += 1
    } catch {
      failCount += 1
    }
  })

  if (okCount === 0) {
    return { okCount: 0, failCount, zipName: '' }
  }

  const blob = await zip.generateAsync({ type: 'blob' })
  const save =
    options.saveAs ||
    ((b: Blob, name: string) => {
      FileSaver.saveAs(b, name)
    })
  await save(blob, zipName)

  return { okCount, failCount, zipName }
}

注意:将文件顶部的 import 合并到文件开头(不要重复 import 块)。CertRead 等类型 import 保持 Task 1 已有内容。

  • Step 4: 跑测试确认通过

Run:

cd web-admin && pnpm exec tsx --test src/utils/pl-vehicle-cert-zip.test.ts

Expected: PASS

  • Step 5: Commit
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): pack selected vehicle cert attachments into zip.

Add JSZip builder with concurrency, injectable fetch/save for tests, and multi-vehicle folders.
EOF
)"

Task 4: 工具栏接入车辆列表页

Files:

  • Modify: web-admin/src/views/pl/vehicle/index.vue

  • Step 1: 在导出组件后插入工具栏按钮

在 ListExportDropdown 与 ListToolbarEndActions 之间插入:

          <el-button
            class="btn-ghost"
            :disabled="selectedRows.length === 0"
            :loading="certZipLoading"
            @click="handleBatchDownloadCertZip"
          >
            <el-icon><Download /></el-icon>批量下载证书 ZIP
          </el-button>

Download 已在该文件 icons import 中,勿重复添加。

  • Step 2: 增加状态与 handler

在 selectedRows 附近增加:

const certZipLoading = ref(false)

在 script 中增加 import:

import { buildAndSaveVehicleCertZip } from '@/utils/pl-vehicle-cert-zip'

增加处理函数(放在 handleSelectionChange 附近):

async function handleBatchDownloadCertZip() {
  if (!selectedRows.value.length || certZipLoading.value) return
  certZipLoading.value = true
  try {
    const result = await buildAndSaveVehicleCertZip(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
  }
}

确认页面已使用 ElMessage(Element Plus auto-import 或既有 import);若该文件已有 ElMessage 用法则直接复用。

  • Step 3: 本地手测清单
  1. 打开 /#/pl/base-info/vehicle
  2. 未勾选 → 按钮禁用
  3. 勾选 1 辆有证书附件 → ZIP 名 车辆证书_*.zip,根目录平铺文件
  4. 勾选多辆有附件 → 车辆证书批量_N辆_yyyyMMdd.zip,按车牌子目录
  5. 勾选仅无附件车辆 → 「无可下载附件」
  6. 列内单个 ↓ 仍可单独下载
  • Step 4: Commit
git add web-admin/src/views/pl/vehicle/index.vue
git commit -m "$(cat <<'EOF'
feat(pl): add toolbar batch ZIP download for vehicle certs.

Wire selected rows to frontend JSZip packing on the third-party vehicle list.
EOF
)"

Task 5: 图谱更新(若本会话改了代码)

Files:

  • 无业务代码;运行仓库约定脚本

  • Step 1: 更新前端 graphify

Run:

./scripts/update-graph.sh frontend

若脚本不存在或失败,可改为:

cd web-admin && graphify update .
  • Step 2: 若 graph 有变更则单独 commit
git add web-admin/graphify-out graphify-out 2>/dev/null || true
git status
# 仅当有 graph 变更时:
git commit -m "$(cat <<'EOF'
chore: update web-admin graphify after vehicle cert zip utils.
EOF
)"

Spec 覆盖自检

Spec 要求 Task
工具栏入口、禁用/loading Task 4
仅证书附件列、跳过无附件、全无提示 Task 1 + Task 3 + Task 4
单车平铺 / 多车子目录命名 Task 1 + Task 3
主·/挂· 与列对齐、重名 _2 Task 1
预签名拉取 + JSZip + file-saver Task 2 + Task 3
部分失败仍打包、全失败不空 ZIP Task 3 + Task 4
单文件 ↓ 不变、无后端 Task 4(不改列组件)
并发限流 Task 3 concurrency 默认 4

执行交接

Plan complete and saved to docs/superpowers/plans/2026-09-08-vehicle-cert-batch-zip.md. Two execution options:

1. Subagent-Driven (recommended) — 每个 Task 派一个新 subagent,Task 间复查,迭代快

2. Inline Execution — 本会话用 executing-plans 按 Task 顺序执行,带检查点

Which approach?