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