Selaa lähdekoodia

docs: add vehicle cert batch ZIP download design and demo.

Capture the approved frontend JSZip approach for third-party vehicle list bulk certificate attachment download.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 1 kuukausi sitten
vanhempi
commit
53a1bea890

+ 346 - 0
docs/superpowers/demos/2026-09-08-vehicle-cert-batch-zip-demo.html

@@ -0,0 +1,346 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1" />
+  <title>三方车辆 · 证书附件批量 ZIP 下载 Demo</title>
+  <style>
+    :root {
+      --primary: #10b981;
+      --primary-dark: #059669;
+      --primary-bg: #ecfdf5;
+      --border: #d9e8e2;
+      --text: #1e293b;
+      --muted: #64748b;
+      --page: #f1f5f9;
+    }
+    * { box-sizing: border-box; }
+    body {
+      margin: 0;
+      font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
+      background: var(--page);
+      color: var(--text);
+      line-height: 1.5;
+      padding: 24px;
+    }
+    h1 { font-size: 22px; margin: 0 0 6px; }
+    .sub { color: var(--muted); font-size: 14px; margin-bottom: 20px; }
+    .panel {
+      background: #fff;
+      border: 1px solid var(--border);
+      border-radius: 10px;
+      overflow: hidden;
+      margin-bottom: 20px;
+    }
+    .toolbar {
+      display: flex;
+      flex-wrap: wrap;
+      gap: 8px;
+      align-items: center;
+      padding: 12px 14px;
+      border-bottom: 1px solid #e2e8f0;
+    }
+    .btn {
+      height: 32px;
+      padding: 0 12px;
+      border-radius: 8px;
+      border: 1px solid var(--border);
+      background: #fff;
+      color: var(--text);
+      font-size: 13px;
+      cursor: pointer;
+    }
+    .btn:disabled { color: #94a3b8; cursor: not-allowed; }
+    .btn-main { border: none; background: var(--primary); color: #fff; font-weight: 600; }
+    .btn-zip {
+      border-color: #a7f3d0;
+      background: var(--primary-bg);
+      color: var(--primary-dark);
+      font-weight: 600;
+    }
+    .btn-zip:disabled { opacity: 0.45; }
+    .sel { margin-left: auto; font-size: 12px; color: var(--muted); }
+    .sel b { color: var(--primary); }
+    table { width: 100%; border-collapse: collapse; font-size: 13px; }
+    th, td { padding: 12px; text-align: left; border-top: 1px solid #e2e8f0; vertical-align: middle; }
+    th { background: #f8fafc; color: var(--muted); font-weight: 600; border-top: none; }
+    tr.selected { background: var(--primary-bg); }
+    a.plate { color: var(--primary); text-decoration: none; font-weight: 600; }
+    .thumb {
+      position: relative;
+      width: 72px;
+      height: 42px;
+      border: 1px solid var(--border);
+      border-radius: 6px;
+      background: var(--primary-bg);
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      color: var(--primary);
+      font-size: 11px;
+    }
+    .thumb.empty { border-style: dashed; background: #f8fafc; color: #94a3b8; font-size: 10px; }
+    .thumb .dl {
+      position: absolute; right: 2px; top: 2px; width: 14px; height: 14px;
+      border-radius: 50%; background: #fff; border: 1px solid #e2e8f0;
+      font-size: 9px; line-height: 12px; text-align: center;
+    }
+    .thumb .cap {
+      position: absolute; left: 0; right: 0; bottom: 0;
+      background: rgba(15,23,42,.62); color: #fff; font-size: 9px; text-align: center; padding: 1px 0;
+    }
+    .certs { display: flex; gap: 8px; flex-wrap: wrap; }
+    .tag {
+      display: inline-block; padding: 2px 8px; border-radius: 4px;
+      background: var(--primary-dark); color: #fff; font-size: 12px;
+    }
+    .tag.warn { background: #d97706; }
+    .toast {
+      display: none;
+      margin: 0 14px 14px;
+      padding: 12px 14px;
+      border-radius: 10px;
+      border: 1px solid #a7f3d0;
+      background: var(--primary-bg);
+      color: #065f46;
+      font-size: 13px;
+    }
+    .toast.show { display: block; }
+    .toast.warn { border-color: #fcd34d; background: #fffbeb; color: #92400e; }
+    .toast pre {
+      margin: 6px 0 0;
+      font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+      font-size: 12px;
+      line-height: 1.55;
+      white-space: pre-wrap;
+    }
+    .options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
+    @media (max-width: 900px) { .options { grid-template-columns: 1fr; } }
+    .opt {
+      border: 2px solid var(--border);
+      border-radius: 10px;
+      padding: 14px;
+      background: #fff;
+      cursor: pointer;
+    }
+    .opt.active { border-color: var(--primary); background: var(--primary-bg); }
+    .opt h3 { margin: 0 0 6px; font-size: 15px; }
+    .opt p { margin: 0; font-size: 13px; color: var(--muted); }
+    .letter {
+      display: inline-flex; width: 24px; height: 24px; border-radius: 50%;
+      align-items: center; justify-content: center;
+      background: var(--primary); color: #fff; font-size: 12px; font-weight: 700;
+      margin-bottom: 8px;
+    }
+    .split { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+    @media (max-width: 700px) { .split { grid-template-columns: 1fr; } }
+    .card {
+      background: #fff; border: 1px solid var(--border); border-radius: 10px; padding: 14px;
+    }
+    .card h3 { margin: 0 0 8px; font-size: 14px; color: var(--muted); font-weight: 600; }
+    .card pre { margin: 0; font-size: 12px; line-height: 1.6; color: #334155; }
+    .hint { font-size: 13px; color: var(--muted); margin-bottom: 10px; }
+  </style>
+</head>
+<body>
+  <h1>三方车辆 · 证书附件批量 ZIP 下载 Demo</h1>
+  <p class="sub">勾选车辆 → 点「批量下载证书 ZIP」→ 下方展示模拟包结构。单个附件仍可在缩略图右上角单独下载。</p>
+
+  <div class="panel">
+    <div class="toolbar" id="toolbar">
+      <span style="font-size:12px;color:var(--muted)">承运商 ▾</span>
+      <span class="btn" style="color:#94a3b8;cursor:default">车牌号</span>
+      <button class="btn" type="button">快速识别创建</button>
+      <button class="btn btn-main" type="button">+ 新增车辆</button>
+      <button class="btn" type="button" disabled>编辑</button>
+      <button class="btn" type="button" disabled style="border-color:#fecaca;color:#fca5a5">删除</button>
+      <button class="btn" type="button">导出 ▾</button>
+      <button class="btn btn-zip" id="btn-batch-zip" type="button" onclick="demoDownload()">⬇ 批量下载证书 ZIP</button>
+      <span class="sel">已选 <b id="sel-count">2</b> 项</span>
+    </div>
+
+    <table>
+      <thead>
+        <tr>
+          <th style="width:40px"><input type="checkbox" id="check-all" onchange="toggleAll(this)" /></th>
+          <th style="width:48px">序号</th>
+          <th>车牌号</th>
+          <th>
+            证书附件
+            <button class="btn btn-zip" id="btn-col-zip" type="button" onclick="demoDownload()" style="display:none;height:24px;padding:0 8px;font-size:11px;margin-left:8px">批量 ZIP</button>
+          </th>
+          <th>车辆状态</th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr class="selected">
+          <td><input type="checkbox" checked data-plate="皖A·D12345" data-files="主·行驶证.jpg,挂·行驶证.pdf" onchange="syncSel()" /></td>
+          <td>1</td>
+          <td><a class="plate" href="#">皖A·D12345 / 挂A8888</a></td>
+          <td>
+            <div class="certs">
+              <div class="thumb">📷<span class="dl">↓</span><span class="cap">主·行驶证</span></div>
+              <div class="thumb">📷<span class="dl">↓</span><span class="cap">挂·行驶证</span></div>
+              <div class="thumb empty">未上传<span class="cap">主·道路运输</span></div>
+            </div>
+          </td>
+          <td><span class="tag">正常</span></td>
+        </tr>
+        <tr class="selected">
+          <td><input type="checkbox" checked data-plate="皖B·F67890" data-files="主·行驶证.jpg,主·营运证.pdf" onchange="syncSel()" /></td>
+          <td>2</td>
+          <td><a class="plate" href="#">皖B·F67890</a></td>
+          <td>
+            <div class="certs">
+              <div class="thumb">📷<span class="dl">↓</span><span class="cap">主·行驶证</span></div>
+              <div class="thumb">📷<span class="dl">↓</span><span class="cap">主·营运证</span></div>
+            </div>
+          </td>
+          <td><span class="tag">正常</span></td>
+        </tr>
+        <tr>
+          <td><input type="checkbox" data-plate="皖C·H11111" data-files="" onchange="syncSel()" /></td>
+          <td>3</td>
+          <td><a class="plate" href="#">皖C·H11111</a></td>
+          <td style="color:#94a3b8">—</td>
+          <td><span class="tag warn">停用</span></td>
+        </tr>
+      </tbody>
+    </table>
+
+    <div class="toast" id="toast">
+      <div id="toast-title" style="font-weight:600"></div>
+      <pre id="toast-tree"></pre>
+    </div>
+  </div>
+
+  <p class="hint">入口位置偏好(点击切换上方 Demo 按钮位置):</p>
+  <div class="options" style="margin-bottom:20px">
+    <div class="opt active" data-mode="a" onclick="pickEntry(this)">
+      <div class="letter">A</div>
+      <h3>工具栏按钮(推荐)</h3>
+      <p>勾选后点工具栏「批量下载证书 ZIP」,与导出同级。</p>
+    </div>
+    <div class="opt" data-mode="b" onclick="pickEntry(this)">
+      <div class="letter">B</div>
+      <h3>证书附件列头小按钮</h3>
+      <p>入口紧贴附件列,语义更近。</p>
+    </div>
+    <div class="opt" data-mode="c" onclick="pickEntry(this)">
+      <div class="letter">C</div>
+      <h3>两处都有</h3>
+      <p>发现成本最低,略冗余。</p>
+    </div>
+  </div>
+
+  <div class="split">
+    <div class="card">
+      <h3>勾选 1 辆</h3>
+      <pre>车辆证书_皖A·D12345.zip
+├── 主·行驶证.jpg
+├── 挂·行驶证.pdf
+└── (无附件证书跳过)</pre>
+    </div>
+    <div class="card">
+      <h3>勾选多辆</h3>
+      <pre>车辆证书批量_2辆_20260908.zip
+├── 皖A·D12345/
+│   ├── 主·行驶证.jpg
+│   └── 挂·行驶证.pdf
+└── 皖B·F67890/
+    ├── 主·行驶证.jpg
+    └── 主·营运证.pdf</pre>
+    </div>
+  </div>
+
+  <script>
+    function syncSel() {
+      const boxes = Array.from(document.querySelectorAll('tbody input[type=checkbox]'));
+      boxes.forEach((b) => {
+        b.closest('tr').classList.toggle('selected', b.checked);
+      });
+      const checked = boxes.filter((b) => b.checked);
+      document.getElementById('sel-count').textContent = String(checked.length);
+      const btn = document.getElementById('btn-batch-zip');
+      const col = document.getElementById('btn-col-zip');
+      const disabled = checked.length === 0;
+      btn.disabled = disabled;
+      col.disabled = disabled;
+      document.getElementById('check-all').checked = checked.length === boxes.length;
+      document.getElementById('check-all').indeterminate = checked.length > 0 && checked.length < boxes.length;
+    }
+
+    function toggleAll(el) {
+      document.querySelectorAll('tbody input[type=checkbox]').forEach((b) => {
+        b.checked = el.checked;
+      });
+      syncSel();
+    }
+
+    function demoDownload() {
+      const boxes = Array.from(document.querySelectorAll('tbody input[type=checkbox]:checked'));
+      const toast = document.getElementById('toast');
+      const title = document.getElementById('toast-title');
+      const tree = document.getElementById('toast-tree');
+      toast.classList.add('show');
+      if (!boxes.length) {
+        toast.classList.add('warn');
+        title.textContent = '请先勾选至少一辆车';
+        tree.textContent = '';
+        return;
+      }
+      toast.classList.remove('warn');
+      const rows = boxes.map((b) => ({
+        plate: b.getAttribute('data-plate'),
+        files: String(b.getAttribute('data-files') || '').split(',').filter(Boolean)
+      }));
+      const withFiles = rows.filter((r) => r.files.length);
+      if (!withFiles.length) {
+        toast.classList.add('warn');
+        title.textContent = '所选车辆均无证书附件,无法打包';
+        tree.textContent = '';
+        return;
+      }
+      if (withFiles.length === 1) {
+        const r = withFiles[0];
+        title.textContent = '模拟下载成功 · 单车 ZIP';
+        tree.textContent =
+          '车辆证书_' + r.plate + '.zip\n' +
+          r.files.map((f, i) => (i === r.files.length - 1 ? '└── ' : '├── ') + f).join('\n');
+      } else {
+        title.textContent = '模拟下载成功 · 多车 ZIP(按车牌分子目录)';
+        let out = '车辆证书批量_' + withFiles.length + '辆_20260908.zip\n';
+        withFiles.forEach((r, i) => {
+          const last = i === withFiles.length - 1;
+          out += (last ? '└── ' : '├── ') + r.plate + '/\n';
+          r.files.forEach((f, j) => {
+            const lastF = j === r.files.length - 1;
+            out += (last ? '    ' : '│   ') + (lastF ? '└── ' : '├── ') + f + '\n';
+          });
+        });
+        tree.textContent = out.trimEnd();
+      }
+    }
+
+    function pickEntry(el) {
+      document.querySelectorAll('.opt').forEach((o) => o.classList.remove('active'));
+      el.classList.add('active');
+      const mode = el.getAttribute('data-mode');
+      const toolbarBtn = document.getElementById('btn-batch-zip');
+      const colBtn = document.getElementById('btn-col-zip');
+      if (mode === 'a') {
+        toolbarBtn.style.display = 'inline-block';
+        colBtn.style.display = 'none';
+      } else if (mode === 'b') {
+        toolbarBtn.style.display = 'none';
+        colBtn.style.display = 'inline-block';
+      } else {
+        toolbarBtn.style.display = 'inline-block';
+        colBtn.style.display = 'inline-block';
+      }
+    }
+
+    syncSel();
+  </script>
+</body>
+</html>

+ 102 - 0
docs/superpowers/specs/2026-09-08-vehicle-cert-batch-zip-design.md

@@ -0,0 +1,102 @@
+# 三方车辆 · 证书附件批量 ZIP 下载
+
+**日期:** 2026-09-08  
+**状态:** 已确认设计,待写实现计划  
+**范围:** `web-admin` 三方车辆列表(`/#/pl/base-info/vehicle`)  
+**实现路径:** 前端 JSZip(不改后端)
+
+## 背景
+
+列表「证书附件」列已支持单个附件下载(`PlCertListAttachmentCell` → `downloadAttachmentFile`)。用户需要:勾选一辆或多辆车后,将该车辆证书附件批量打成 ZIP 下载。
+
+## 已确认决策
+
+| 项 | 决策 |
+|----|------|
+| 入口 | 工具栏「批量下载证书 ZIP」(方案 A) |
+| 附件范围 | 仅「证书附件」列车辆证书(主车/挂车等),不含照片、不含关联人员证书 |
+| 无附件处理 | 跳过无附件车辆;若全部无附件则提示「无可下载附件」 |
+| 实现方式 | 前端 JSZip + 现有预签名下载链路(方案 1) |
+
+## UI 与交互
+
+### 入口
+
+- 位置:`web-admin/src/views/pl/vehicle/index.vue` 工具栏,靠近「导出」
+- 样式:描边按钮(`btn-ghost`),带 `Download` 图标,文案「批量下载证书 ZIP」
+- 未勾选:按钮禁用
+- 勾选 ≥1:可点;点击后按钮 `loading`,防重复提交
+
+### 单文件下载
+
+- 列内缩略图右上角 ↓ 保持现状,本期不改
+
+### 点击批量下载后
+
+1. 从 `selectedRows` 收集证书附件(逻辑对齐列组件:主·/挂·前缀 + `attachmentUrls`)
+2. 无附件车辆跳过;全部无附件 → `ElMessage.warning('无可下载附件')`,结束
+3. 有附件 → 拉取文件并打包 → 浏览器下载 ZIP → success 提示
+4. 部分文件失败 → 尽量打出 ZIP,并提示成功数/失败数
+
+## ZIP 命名与目录
+
+- **有效车辆 1 辆:** `车辆证书_{车牌展示名}.zip`,文件平铺在 ZIP 根目录
+- **有效车辆 ≥2:** `车辆证书批量_{N}辆_{yyyyMMdd}.zip`,每车一个子目录(车牌展示名与列表一致,如含挂车)
+- **包内文件名:** 与列 caption 同源(如 `主·行驶证.jpg`);同名冲突追加 `_2`、`_3`
+- **目录名安全:** 车牌中的 `/`、`\` 等文件系统不安全字符替换为 `_`
+
+## 架构与模块
+
+仅前端改动,不新增后端接口。
+
+| 位置 | 职责 |
+|------|------|
+| `views/pl/vehicle/index.vue` | 工具栏按钮、勾选校验、调用打包、loading / Message |
+| `utils/pl-vehicle-cert-zip.ts`(新建) | 从 `certList` 收集附件、命名、目录结构、JSZip 打包 |
+| `utils/download-attachment.ts`(小幅扩展) | 新增「解析预签名 URL 并拉取 Blob」能力(不直接触发 `<a>` 下载) |
+| 依赖 | 已有 `jszip`、`file-saver` |
+
+### 数据流
+
+```
+selectedRows
+  → 解析每行 certList(对齐 PlCertListAttachmentCell)
+  → 过滤无 URL 的项/车辆
+  → 每个文件:fetchAttachmentDownloadUrl → fetch(blob)
+  → JSZip 写入(单车平铺 / 多车子目录)
+  → FileSaver.saveAs(zipBlob, filename)
+```
+
+### 约束
+
+- 只用当前列表行已有的 `certList`,不额外请求车辆详情
+- 并发拉取限流(建议同时 3~5 个),避免一次勾选过多打爆浏览器
+- 仅对当前勾选行打包(与现有 table selection 行为一致,含翻页后的 selection 语义)
+
+## 异常与边界
+
+| 场景 | 行为 |
+|------|------|
+| 未勾选 | 按钮禁用,不弹提示 |
+| 勾选但全部无证书附件 | `warning`「无可下载附件」 |
+| 部分文件预签名/拉取失败 | 跳过失败项,其余入 ZIP;结束提示成功数/失败数 |
+| 全部文件失败 | `error`「下载失败,请稍后重试」,不生成空 ZIP |
+| 打包中再次点击 | loading 防重复 |
+
+## 验收标准
+
+1. 勾选 1 辆有附件车辆 → 下载 `车辆证书_{车牌}.zip`,内含该车证书列全部附件
+2. 勾选多辆有附件车辆 → 一个 ZIP,按车牌分子目录
+3. 勾选含「无附件」车辆 → 跳过无附件车,其余正常;全无附件则提示
+4. 列内单个 ↓ 下载行为不变
+5. 不新增后端接口
+
+## 非目标(本期不做)
+
+- 车辆照片、关联人员证书
+- 后端 ZIP / 超大批量流式下载
+- 进度条百分比(仅按钮 loading + 结果 Message)
+
+## Demo 参考
+
+- 交互 Demo:`docs/superpowers/demos/2026-09-08-vehicle-cert-batch-zip-demo.html`