diff --git a/docs/superpowers/specs/2026-08-21-web-admin-list-export-design.md b/docs/superpowers/specs/2026-08-21-web-admin-list-export-design.md new file mode 100644 index 000000000..c4e46d084 --- /dev/null +++ b/docs/superpowers/specs/2026-08-21-web-admin-list-export-design.md @@ -0,0 +1,140 @@ +# web-admin 列表统一后端导出 + +**日期:** 2026-08-21 +**状态:** 已确认 +**范围:** `web-admin` 所有带筛选/分页的业务列表(系统管理、物流、报销、CRM、地磅等);小程序不在本期。 + +## 背景 + +多数列表页缺少导出;少数页(CRM 等)已有后端 Excel 导出,Demo 商品等则用前端 `ArtExcelExport` 分页拉全量再生成。筛选结果达数万行时,前端全量拉取不合理。需要统一为**后端生成 xlsx、前端下载**,并在工具栏提供一致交互。 + +## 决策摘要 + +| 项 | 结论 | +|----|------| +| 覆盖范围 | 所有带筛选/分页的业务列表;已有导出逻辑保留,仅对齐交互 | +| 实现方式 | 后端导出(方案 2:共享基建 + 各页注册) | +| 导出列 | 与当前列表可见列一致;中文表头;状态等为文案非 code | +| 主操作 | 导出筛选结果 | +| 次操作 | 导出已选(仅有多选勾选的列表;下拉菜单) | +| 无多选列表 | 仅单个「导出」按钮(等同导出筛选结果) | +| 大数据 | 同步流式下载;硬上限 50000 行 | +| 非目标 | 异步任务、导出历史、邮件、导入、小程序 | + +## 架构 + +### 后端 + +- 复用并扩展 `backend/app/utils/excel_export.py`(`ExportColumn`、`DictLabelResolver`、`export_rows_to_xlsx_response`)。 +- 各资源仍暴露独立 `POST …/export`(已有 GET 的保持兼容);**不做**全局万能导出 API。 +- 共享约定: + - 查询复用对应列表(租户隔离、数据权限、软删、筛选条件一致) + - 可选 `ids`:有则只导出这些行(仍叠加权限过滤) + - 可选 `columns`:前端传入当前可见列 `prop`;缺省用服务端默认列(对齐列表默认可见列) + - 硬上限 **50000**;0 条返回业务错误,不生成空文件 +- 二进制响应路径须在 `API_ENCRYPT_EXCLUDE_PATHS` 中(与现有 CRM 导出一致)。 + +### 前端 + +- 新组件 `ListExportDropdown`(中性 `btn-ghost` + `Download` 图标): + - `hasSelection=true`:下拉——主按钮「导出筛选结果」,菜单项「导出已选」(无选中则禁用) + - `hasSelection=false`:单按钮「导出」 +- 位置:列表工具栏主操作之后、`ListToolbarEndActions` 之前。 +- 统一 blob 下载工具(解析 `Content-Disposition`、loading、错误提示)。 +- 有列显隐时,请求携带当前 `visibleColumns`。 + +### 数据流 + +1. **导出筛选结果**:当前筛选参数 + 可选 `columns`,不传 `ids` → 后端查全量筛选结果 → xlsx → 下载。 +2. **导出已选**:同上 + `ids: selectedRowIds`;未选中时前端禁用,不发请求。 + +```text +[工具栏 ListExportDropdown] + │ filterParams + columns? + ids? + ▼ +[POST /api/.../export] ──同列表权限──► [查询行] ──ExportColumn──► [openpyxl] ──► StreamingResponse + ▲ │ + └──────────────────────── blob download + Message ──────────────────────────────┘ +``` + +## 接口约定 + +### 请求 + +```ts +{ + // 与对应列表相同的筛选字段 + ids?: string[] // 导出已选 + columns?: string[] // 当前可见列 prop;省略则服务端默认列 +} +``` + +### 响应 + +- 成功:xlsx 流;`Content-Disposition` 含中文文件名(沿用 `workbook_to_streaming_response`)。 +- 失败(建议): + +| 场景 | 处理 | +|------|------| +| 0 条可导出 | 400「没有可导出的数据」 | +| 超过 50000 | 400「导出超过 50000 条,请缩小筛选范围」 | +| 无权限 | 与列表一致 401/403 | +| `columns` 含未知 prop | 忽略未知项;仅导出已登记字段 | +| `ids` 权限过滤后为空 | 同 0 条 | + +### 列定义 + +- 使用 `ExportColumn(title, field, dict_type?, formatter?)`。 +- 树形路径等展示规则与列表/规范一致(全路径、` / ` 分隔)。 +- 「可见列」:有 `usePersistedListColumns` 时跟当前勾选;否则跟页面表格列定义(排除 selection/index/操作列)。 + +### 已有导出 + +- **已有后端导出**(CRM 等):保留既有列与查询;按需补 `ids` / `columns`;前端改为 `ListExportDropdown`(或等价交互)。 +- **现有前端 `ArtExcelExport` 页**(如 Demo 商品):批处理时迁为后端 `/export` + `ListExportDropdown`,不再用浏览器拉全量生成。 + +## 前端组件草案 + +**`ListExportDropdown` props** + +| Prop | 说明 | +|------|------| +| `exportFn` | `(payload) => Promise` | +| `getFilterParams` | `() => Record` | +| `selectedIds` | 已选 id;无多选可不传 | +| `hasSelection` | 是否支持多选 UI | +| `visibleColumns` | 可选可见列 | +| `filename` | 兜底文件名(优先响应头) | +| `disabled` / `loading` | 外部控制 | + +页面接入:声明导出 API、列与表格对齐、挂组件;有勾选则传 `selectedIds`。 + +## 落地节奏 + +1. **基建**:后端上限/空数据约定文档化进工具函数;前端组件 + 下载工具。 +2. **样板页**:选一标准列表(如 `system/user` 或 `demo/unit`)端到端打通。 +3. **批处理**:按模块为缺导出页补 `/export` + 挂组件;已有导出页对齐下拉。 +4. **回归**:筛选一致性、可见列、已选、空数据、超限、加密排除路径。 + +## 权限与安全 + +- 导出 SHALL 与列表同一鉴权、租户、数据权限;MUST NOT 因导出绕过范围。 +- `ids` MUST 仍叠加租户 + 数据权限,禁止仅凭 id 跨范围导出。 +- `columns` 仅允许该资源已登记 field。 + +## 验收标准 + +- [ ] 目标列表工具栏均有导出入口(中性按钮 + 图标)。 +- [ ] 有多选:下拉含「导出筛选结果」「导出已选」;未选中时后者禁用。 +- [ ] 无多选:仅「导出」单按钮。 +- [ ] 导出结果与当前筛选一致;可见列与 Excel 列一致;状态为中文文案。 +- [ ] 0 条 / 超 50000 有明确提示且不下载损坏文件。 +- [ ] 已有导出页交互对齐,业务列含义不回退。 +- [ ] 导出路径不受 API 加密中间件破坏二进制。 + +## 明确不做 + +- 前端拉全量再生成 Excel(新接入页) +- 异步导出任务、导出历史、邮件通知 +- 自定义列模板编辑器、导入 +- 小程序端导出