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>
This commit is contained in:
wuzilongandCursor committed 2026-09-08 20:14:32 +08:00
1 parent b6485f3b6c
commit 53a1bea890
2 files changed
+448

No files matched your search

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