|
|
@@ -0,0 +1,830 @@
|
|
|
+# web-admin 列表统一后端导出 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:** 为 web-admin 所有带筛选/分页的业务列表提供统一的后端 Excel 导出(筛选全量 / 已选),工具栏交互一致。
|
|
|
+
|
|
|
+**Architecture:** 扩展 `excel_export.py` 共享校验与列裁剪;各资源独立 `POST …/export` 复用列表查询与权限;前端 `ListExportDropdown` + `downloadExcelBlob`;样板打通后按模块批处理;已有 CRM 导出保留列逻辑并改挂组件。
|
|
|
+
|
|
|
+**Tech Stack:** FastAPI · openpyxl · `excel_export.ExportColumn` · Vue 3 · Element Plus `el-dropdown` · Axios `responseType: 'blob'`
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-08-21-web-admin-list-export-design.md`
|
|
|
+
|
|
|
+**加密说明:** `api_crypto_middleware` 对非 JSON 响应(xlsx)已直接透传,无需为每个 `/export` 追加 `API_ENCRYPT_EXCLUDE_PATHS`(与现有 CRM 导出一致)。错误仍走 JSON,前端 blob 拦截器会解析 JSON 错误体。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## File map
|
|
|
+
|
|
|
+| File | Responsibility |
|
|
|
+|------|----------------|
|
|
|
+| `backend/app/utils/excel_export.py` | 上限常量、空数据/超限异常、按 `columns` 裁剪列、统一导出入口 |
|
|
|
+| `backend/tests/test_excel_export_helpers.py` | 裁剪列 / 空数据 / 超限单测(无 DB) |
|
|
|
+| `backend/app/api/admin.py` | 样板:`POST /users/export` |
|
|
|
+| `backend/app/schemas/user.py`(或 admin 内联 Body) | 用户导出请求体(筛选 + ids + columns) |
|
|
|
+| `web-admin/src/utils/list-export-download.ts` | 解析文件名并触发 blob 下载 |
|
|
|
+| `web-admin/src/components/business/list-export-dropdown/index.vue` | 导出下拉 / 单按钮 |
|
|
|
+| `web-admin/src/api/admin.ts` | `fetchExportUsers` |
|
|
|
+| `web-admin/src/views/system/user/index.vue` | 样板页接入 |
|
|
|
+| 各模块 list 页 + 对应 API | 批处理补 `/export` 并挂组件 |
|
|
|
+| 已有 CRM 导出页 | 改挂 `ListExportDropdown`,按需补 `ids`/`columns` |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: 后端导出辅助函数 + 单测
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/utils/excel_export.py`
|
|
|
+- Create: `backend/tests/test_excel_export_helpers.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败单测**
|
|
|
+
|
|
|
+创建 `backend/tests/test_excel_export_helpers.py`:
|
|
|
+
|
|
|
+```python
|
|
|
+import pytest
|
|
|
+
|
|
|
+from app.utils.excel_export import (
|
|
|
+ EXPORT_MAX_ROWS,
|
|
|
+ ExportColumn,
|
|
|
+ ExportValidationError,
|
|
|
+ filter_export_columns,
|
|
|
+ assert_export_row_count,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+COLS = [
|
|
|
+ ExportColumn(title="账号", field="userNo"),
|
|
|
+ ExportColumn(title="姓名", field="nickName"),
|
|
|
+ ExportColumn(title="状态", field="status", dict_type="sys_status"),
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+def test_filter_export_columns_keeps_order_and_ignores_unknown():
|
|
|
+ out = filter_export_columns(COLS, ["status", "unknown", "userNo"])
|
|
|
+ assert [c.field for c in out] == ["status", "userNo"]
|
|
|
+
|
|
|
+
|
|
|
+def test_filter_export_columns_none_returns_all():
|
|
|
+ out = filter_export_columns(COLS, None)
|
|
|
+ assert [c.field for c in out] == ["userNo", "nickName", "status"]
|
|
|
+
|
|
|
+
|
|
|
+def test_assert_export_row_count_empty():
|
|
|
+ with pytest.raises(ExportValidationError) as ei:
|
|
|
+ assert_export_row_count(0)
|
|
|
+ assert "没有可导出的数据" in str(ei.value)
|
|
|
+
|
|
|
+
|
|
|
+def test_assert_export_row_count_over_limit():
|
|
|
+ with pytest.raises(ExportValidationError) as ei:
|
|
|
+ assert_export_row_count(EXPORT_MAX_ROWS + 1)
|
|
|
+ assert "50000" in str(ei.value)
|
|
|
+
|
|
|
+
|
|
|
+def test_assert_export_row_count_ok():
|
|
|
+ assert_export_row_count(1)
|
|
|
+ assert_export_row_count(EXPORT_MAX_ROWS)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 跑测确认失败**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_excel_export_helpers.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: FAIL(缺少 `ExportValidationError` / `filter_export_columns` / `assert_export_row_count` / `EXPORT_MAX_ROWS`)
|
|
|
+
|
|
|
+- [ ] **Step 3: 实现辅助函数**
|
|
|
+
|
|
|
+在 `backend/app/utils/excel_export.py` 末尾(或 `ExportColumn` 之后)追加:
|
|
|
+
|
|
|
+```python
|
|
|
+EXPORT_MAX_ROWS = 50000
|
|
|
+
|
|
|
+
|
|
|
+class ExportValidationError(ValueError):
|
|
|
+ """导出业务校验失败(空数据 / 超限等),路由层转为 400。"""
|
|
|
+
|
|
|
+
|
|
|
+def filter_export_columns(
|
|
|
+ all_columns: Sequence[ExportColumn],
|
|
|
+ requested: Optional[Sequence[str]],
|
|
|
+) -> List[ExportColumn]:
|
|
|
+ """按前端可见列 prop 裁剪;未知 field 忽略;requested 为空则返回全部默认列。"""
|
|
|
+ if not requested:
|
|
|
+ return list(all_columns)
|
|
|
+ by_field = {c.field: c for c in all_columns}
|
|
|
+ out: List[ExportColumn] = []
|
|
|
+ for key in requested:
|
|
|
+ col = by_field.get(str(key))
|
|
|
+ if col is not None:
|
|
|
+ out.append(col)
|
|
|
+ return out if out else list(all_columns)
|
|
|
+
|
|
|
+
|
|
|
+def assert_export_row_count(count: int) -> None:
|
|
|
+ if count <= 0:
|
|
|
+ raise ExportValidationError("没有可导出的数据")
|
|
|
+ if count > EXPORT_MAX_ROWS:
|
|
|
+ raise ExportValidationError(f"导出超过 {EXPORT_MAX_ROWS} 条,请缩小筛选范围")
|
|
|
+```
|
|
|
+
|
|
|
+并扩展 `export_rows_to_xlsx_response`:在构建 workbook 前调用 `assert_export_row_count(len(rows))`(若调用方已校验可保留双重保险,或仅在本函数内校验一次)。推荐**仅在本函数内**调用一次:
|
|
|
+
|
|
|
+```python
|
|
|
+def export_rows_to_xlsx_response(
|
|
|
+ db: Session,
|
|
|
+ *,
|
|
|
+ tenant_no: Optional[str],
|
|
|
+ sheet_title: str,
|
|
|
+ columns: Sequence[ExportColumn],
|
|
|
+ rows: Sequence[Mapping[str, Any]],
|
|
|
+ filename_stem: str,
|
|
|
+ include_legacy_filename: bool = True,
|
|
|
+) -> StreamingResponse:
|
|
|
+ assert_export_row_count(len(rows))
|
|
|
+ if not columns:
|
|
|
+ raise ExportValidationError("没有可导出的列")
|
|
|
+ resolver = DictLabelResolver(db, columns, tenant_no=tenant_no)
|
|
|
+ wb = _build_workbook(sheet_title, columns, rows, resolver)
|
|
|
+ return workbook_to_streaming_response(
|
|
|
+ wb,
|
|
|
+ filename_stem=filename_stem,
|
|
|
+ include_legacy_filename=include_legacy_filename,
|
|
|
+ )
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 跑测确认通过**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && pytest tests/test_excel_export_helpers.py -v
|
|
|
+```
|
|
|
+
|
|
|
+Expected: PASS
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/utils/excel_export.py backend/tests/test_excel_export_helpers.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(backend): add list export column filter and row-count guards
|
|
|
+
|
|
|
+Shared helpers enforce empty/over-limit export rules and allow
|
|
|
+clients to request visible columns by field key.
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: 样板后端 `POST /web/admin/users/export`
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `backend/app/api/admin.py`(在 `list_users` 附近新增导出)
|
|
|
+- Modify: `backend/app/schemas/user.py`(若项目习惯 Body 放 schemas;否则在 `admin.py` 内联 Pydantic 模型)
|
|
|
+
|
|
|
+- [ ] **Step 1: 增加请求体模型**
|
|
|
+
|
|
|
+在 `backend/app/schemas/user.py` 追加(若无该文件合适位置则放 `admin.py`):
|
|
|
+
|
|
|
+```python
|
|
|
+from typing import List, Optional
|
|
|
+from pydantic import BaseModel, Field
|
|
|
+
|
|
|
+
|
|
|
+class UserExportBody(BaseModel):
|
|
|
+ keyword: Optional[str] = None
|
|
|
+ deptId: Optional[str] = None
|
|
|
+ status: Optional[str] = None
|
|
|
+ ids: Optional[List[str]] = None
|
|
|
+ columns: Optional[List[str]] = None
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 实现导出路由**
|
|
|
+
|
|
|
+在 `backend/app/api/admin.py` 的 `list_users` **之后**、`get_user_by_id` **之前**插入(注意路径顺序:`/users/export` 必须在 `/users/{user_id}` 之前注册):
|
|
|
+
|
|
|
+```python
|
|
|
+from datetime import datetime
|
|
|
+from pydantic import BaseModel, Field
|
|
|
+from app.utils.excel_export import (
|
|
|
+ ExportColumn,
|
|
|
+ ExportValidationError,
|
|
|
+ export_rows_to_xlsx_response,
|
|
|
+ filter_export_columns,
|
|
|
+)
|
|
|
+
|
|
|
+USER_EXPORT_COLUMNS = [
|
|
|
+ ExportColumn(title="用户账号", field="userNo"),
|
|
|
+ ExportColumn(title="用户名", field="nickName"),
|
|
|
+ ExportColumn(title="所属组织", field="deptName"),
|
|
|
+ ExportColumn(title="角色", field="roleNames"),
|
|
|
+ ExportColumn(title="手机号", field="phoneNo"),
|
|
|
+ ExportColumn(title="状态", field="status", dict_type="sys_status"),
|
|
|
+ ExportColumn(title="创建时间", field="createTime"),
|
|
|
+]
|
|
|
+
|
|
|
+
|
|
|
+class UserExportBody(BaseModel):
|
|
|
+ keyword: Optional[str] = None
|
|
|
+ deptId: Optional[str] = Field(None, alias="deptId")
|
|
|
+ status: Optional[str] = None
|
|
|
+ ids: Optional[List[str]] = None
|
|
|
+ columns: Optional[List[str]] = None
|
|
|
+
|
|
|
+ model_config = {"populate_by_name": True}
|
|
|
+
|
|
|
+
|
|
|
+@router.post("/users/export")
|
|
|
+def export_users(
|
|
|
+ body: UserExportBody,
|
|
|
+ user: dict = Depends(require_menu_code("system:user:list")),
|
|
|
+ db: Session = Depends(get_db),
|
|
|
+):
|
|
|
+ """导出用户列表(当前筛选全量,或 ids 已选)。"""
|
|
|
+ try:
|
|
|
+ tenant_no = _get_tenant_no(db, user)
|
|
|
+ query = db.query(SysUser).filter(
|
|
|
+ SysUser.del_flag == 0,
|
|
|
+ SysUser.tenant_no == tenant_no,
|
|
|
+ )
|
|
|
+ kw = (body.keyword or "").strip()
|
|
|
+ if kw:
|
|
|
+ like = f"%{kw}%"
|
|
|
+ query = query.filter(
|
|
|
+ or_(
|
|
|
+ SysUser.user_no.like(like),
|
|
|
+ SysUser.nick_name.like(like),
|
|
|
+ SysUser.phone_no.like(like),
|
|
|
+ )
|
|
|
+ )
|
|
|
+ if body.deptId:
|
|
|
+ query = query.filter(SysUser.dept_id == body.deptId)
|
|
|
+ if body.status:
|
|
|
+ query = query.filter(SysUser.status == body.status)
|
|
|
+ if body.ids:
|
|
|
+ query = query.filter(SysUser.id.in_(body.ids))
|
|
|
+ query = apply_data_scope(query, SysUser, user, db, tenant_no)
|
|
|
+ rows_orm = query.order_by(SysUser.create_time.desc()).all()
|
|
|
+ dept_ids = list({r.dept_id for r in rows_orm if r.dept_id})
|
|
|
+ dept_map: dict[str, str] = {}
|
|
|
+ if dept_ids:
|
|
|
+ for d in db.query(SysDept).filter(SysDept.id.in_(dept_ids), SysDept.del_flag == 0).all():
|
|
|
+ dept_map[d.id] = d.dept_name or ""
|
|
|
+ result_list = [
|
|
|
+ user_svc._row_to_dict(r, dept_name=dept_map.get(r.dept_id or "", "")) for r in rows_orm
|
|
|
+ ]
|
|
|
+ user_ids = [d["id"] for d in result_list if d.get("id")]
|
|
|
+ role_map: dict[str, list[str]] = {uid: [] for uid in user_ids}
|
|
|
+ role_name_by_id: dict[str, str] = {}
|
|
|
+ if user_ids:
|
|
|
+ for ur in (
|
|
|
+ db.query(SysUserRole)
|
|
|
+ .filter(SysUserRole.user_id.in_(user_ids), SysUserRole.del_flag == 0)
|
|
|
+ .all()
|
|
|
+ ):
|
|
|
+ role_map.setdefault(ur.user_id, []).append(ur.role_id)
|
|
|
+ role_ids = list({rid for ids in role_map.values() for rid in ids})
|
|
|
+ if role_ids:
|
|
|
+ from app.models.models import SysRole
|
|
|
+ for role in db.query(SysRole).filter(SysRole.id.in_(role_ids), SysRole.del_flag == 0).all():
|
|
|
+ role_name_by_id[role.id] = role.role_name or role.id
|
|
|
+ for d in result_list:
|
|
|
+ names = [role_name_by_id.get(rid, rid) for rid in role_map.get(d["id"], [])]
|
|
|
+ d["roleNames"] = "、".join(names)
|
|
|
+ cols = filter_export_columns(USER_EXPORT_COLUMNS, body.columns)
|
|
|
+ # 列表「角色」列 prop 为 roles,映射到导出 field roleNames
|
|
|
+ if body.columns and "roles" in body.columns and "roleNames" not in [c.field for c in cols]:
|
|
|
+ roles_col = next((c for c in USER_EXPORT_COLUMNS if c.field == "roleNames"), None)
|
|
|
+ if roles_col:
|
|
|
+ # 按前端可见列顺序插入:用 roles 占位替换
|
|
|
+ rebuilt: list = []
|
|
|
+ by_req = {c.field: c for c in cols}
|
|
|
+ for key in body.columns:
|
|
|
+ if key == "roles":
|
|
|
+ rebuilt.append(roles_col)
|
|
|
+ elif key in by_req:
|
|
|
+ rebuilt.append(by_req[key])
|
|
|
+ elif key == "roleNames":
|
|
|
+ rebuilt.append(roles_col)
|
|
|
+ cols = rebuilt or cols
|
|
|
+ date_str = datetime.now().strftime("%Y%m%d")
|
|
|
+ return export_rows_to_xlsx_response(
|
|
|
+ db,
|
|
|
+ tenant_no=tenant_no,
|
|
|
+ sheet_title="用户列表",
|
|
|
+ columns=cols,
|
|
|
+ rows=result_list,
|
|
|
+ filename_stem=f"用户列表_{date_str}",
|
|
|
+ )
|
|
|
+ except ExportValidationError as e:
|
|
|
+ return error_response(400, str(e))
|
|
|
+ except Exception as e:
|
|
|
+ return error_response(500, str(e))
|
|
|
+```
|
|
|
+
|
|
|
+**列 prop 映射约定(样板简化):** 前端 `visibleColumns` 含 `roles` 时,后端 `filter_export_columns` 的登记 field 使用 `roles` 别名更干净。实现时优先把 `USER_EXPORT_COLUMNS` 的角色列改为 `field="roles"`,行数据同时写 `roles`(角色名拼接),避免上面的重建逻辑:
|
|
|
+
|
|
|
+```python
|
|
|
+ExportColumn(title="角色", field="roles"),
|
|
|
+# ...
|
|
|
+d["roles"] = "、".join(names)
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 手工或脚本冒烟(可选)**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd backend && source venv/bin/activate && python -c "from app.api.admin import export_users; print(export_users.__name__)"
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 打印 `export_users`
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add backend/app/api/admin.py backend/app/schemas/user.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(admin): add users list Excel export endpoint
|
|
|
+
|
|
|
+Reuse list filters, tenant and data scope; support ids and visible columns.
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: 前端下载工具 + `ListExportDropdown`
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `web-admin/src/utils/list-export-download.ts`
|
|
|
+- Create: `web-admin/src/components/business/list-export-dropdown/index.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: 实现下载工具**
|
|
|
+
|
|
|
+创建 `web-admin/src/utils/list-export-download.ts`:
|
|
|
+
|
|
|
+```typescript
|
|
|
+/** 从 Content-Disposition 解析文件名;失败则用 fallback */
|
|
|
+export function filenameFromContentDisposition(
|
|
|
+ header: string | undefined,
|
|
|
+ fallback: string
|
|
|
+): string {
|
|
|
+ if (!header) return fallback
|
|
|
+ const utf8 = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header)
|
|
|
+ if (utf8?.[1]) {
|
|
|
+ try {
|
|
|
+ return decodeURIComponent(utf8[1].trim().replace(/["']/g, ''))
|
|
|
+ } catch {
|
|
|
+ /* ignore */
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const plain = /filename\s*=\s*("?)([^";]+)\1/i.exec(header)
|
|
|
+ if (plain?.[2]) return plain[2].trim()
|
|
|
+ return fallback
|
|
|
+}
|
|
|
+
|
|
|
+export function triggerBlobDownload(blob: Blob, filename: string): void {
|
|
|
+ const url = URL.createObjectURL(blob)
|
|
|
+ const link = document.createElement('a')
|
|
|
+ link.href = url
|
|
|
+ link.download = filename
|
|
|
+ link.click()
|
|
|
+ URL.revokeObjectURL(url)
|
|
|
+}
|
|
|
+
|
|
|
+/** Axios 成功时若拦截器已把 data 设为 Blob,直接用;否则兼容 { data: Blob, headers } */
|
|
|
+export function pickBlobFromExportResponse(res: any): { blob: Blob; filenameHeader?: string } {
|
|
|
+ if (res instanceof Blob) {
|
|
|
+ return { blob: res }
|
|
|
+ }
|
|
|
+ if (res?.data instanceof Blob) {
|
|
|
+ return {
|
|
|
+ blob: res.data,
|
|
|
+ filenameHeader: res.headers?.['content-disposition'] || res.headers?.['Content-Disposition']
|
|
|
+ }
|
|
|
+ }
|
|
|
+ throw new Error('导出响应不是文件')
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 实现组件**
|
|
|
+
|
|
|
+创建 `web-admin/src/components/business/list-export-dropdown/index.vue`:
|
|
|
+
|
|
|
+```vue
|
|
|
+<template>
|
|
|
+ <el-dropdown
|
|
|
+ v-if="hasSelection"
|
|
|
+ split-button
|
|
|
+ class="list-export-dropdown"
|
|
|
+ :disabled="disabled || exporting"
|
|
|
+ trigger="click"
|
|
|
+ @click="handleExportFiltered"
|
|
|
+ @command="handleCommand"
|
|
|
+ >
|
|
|
+ <span class="list-export-dropdown__main">
|
|
|
+ <el-icon><Download /></el-icon>
|
|
|
+ 导出筛选结果
|
|
|
+ </span>
|
|
|
+ <template #dropdown>
|
|
|
+ <el-dropdown-menu>
|
|
|
+ <el-dropdown-item command="selected" :disabled="!selectedIds?.length">
|
|
|
+ 导出已选
|
|
|
+ </el-dropdown-item>
|
|
|
+ </el-dropdown-menu>
|
|
|
+ </template>
|
|
|
+ </el-dropdown>
|
|
|
+
|
|
|
+ <el-button
|
|
|
+ v-else
|
|
|
+ class="btn-ghost"
|
|
|
+ :disabled="disabled"
|
|
|
+ :loading="exporting"
|
|
|
+ @click="handleExportFiltered"
|
|
|
+ >
|
|
|
+ <el-icon><Download /></el-icon>
|
|
|
+ 导出
|
|
|
+ </el-button>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+ import { ref } from 'vue'
|
|
|
+ import { Download } from '@element-plus/icons-vue'
|
|
|
+ import { ElMessage } from 'element-plus'
|
|
|
+ import {
|
|
|
+ filenameFromContentDisposition,
|
|
|
+ pickBlobFromExportResponse,
|
|
|
+ triggerBlobDownload
|
|
|
+ } from '@/utils/list-export-download'
|
|
|
+
|
|
|
+ defineOptions({ name: 'ListExportDropdown' })
|
|
|
+
|
|
|
+ export type ListExportPayload = Record<string, any> & {
|
|
|
+ ids?: string[]
|
|
|
+ columns?: string[]
|
|
|
+ }
|
|
|
+
|
|
|
+ const props = withDefaults(
|
|
|
+ defineProps<{
|
|
|
+ exportFn: (payload: ListExportPayload) => Promise<any>
|
|
|
+ getFilterParams: () => Record<string, any>
|
|
|
+ selectedIds?: string[]
|
|
|
+ hasSelection?: boolean
|
|
|
+ visibleColumns?: string[]
|
|
|
+ filename?: string
|
|
|
+ disabled?: boolean
|
|
|
+ }>(),
|
|
|
+ {
|
|
|
+ selectedIds: () => [],
|
|
|
+ hasSelection: false,
|
|
|
+ visibleColumns: undefined,
|
|
|
+ filename: '导出.xlsx',
|
|
|
+ disabled: false
|
|
|
+ }
|
|
|
+ )
|
|
|
+
|
|
|
+ const exporting = ref(false)
|
|
|
+
|
|
|
+ function buildPayload(withIds: boolean): ListExportPayload {
|
|
|
+ const payload: ListExportPayload = { ...props.getFilterParams() }
|
|
|
+ if (props.visibleColumns?.length) {
|
|
|
+ payload.columns = [...props.visibleColumns]
|
|
|
+ }
|
|
|
+ if (withIds) {
|
|
|
+ payload.ids = [...(props.selectedIds || [])]
|
|
|
+ }
|
|
|
+ return payload
|
|
|
+ }
|
|
|
+
|
|
|
+ async function runExport(withIds: boolean) {
|
|
|
+ if (exporting.value || props.disabled) return
|
|
|
+ if (withIds && !(props.selectedIds?.length > 0)) return
|
|
|
+ exporting.value = true
|
|
|
+ try {
|
|
|
+ const res = await props.exportFn(buildPayload(withIds))
|
|
|
+ const { blob, filenameHeader } = pickBlobFromExportResponse(res)
|
|
|
+ const name = filenameFromContentDisposition(filenameHeader, props.filename)
|
|
|
+ triggerBlobDownload(blob, name)
|
|
|
+ ElMessage.success('导出成功')
|
|
|
+ } catch (e: any) {
|
|
|
+ const msg = e?.message || e?.msg || '导出失败'
|
|
|
+ ElMessage.error(msg)
|
|
|
+ } finally {
|
|
|
+ exporting.value = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleExportFiltered() {
|
|
|
+ void runExport(false)
|
|
|
+ }
|
|
|
+
|
|
|
+ function handleCommand(cmd: string) {
|
|
|
+ if (cmd === 'selected') void runExport(true)
|
|
|
+ }
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+ .list-export-dropdown {
|
|
|
+ :deep(.el-button) {
|
|
|
+ /* 对齐 btn-ghost:由页面工具栏全局样式覆盖时可删 */
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .list-export-dropdown__main {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 6px;
|
|
|
+ }
|
|
|
+</style>
|
|
|
+```
|
|
|
+
|
|
|
+**样式注意:** Element Plus `split-button` 默认主色;实现后对照 Fresh Mint,用 `class="btn-ghost"` / 深度选择器把 split 两侧改成中性描边(与规范「导入导出为中性按钮」一致)。若 split-button 难控样式,可改为「主 `el-button.btn-ghost` + 旁侧 `el-dropdown` 箭头」结构,语义不变。
|
|
|
+
|
|
|
+- [ ] **Step 3: 确认组件可被引用(无强制自动注册则页面手动 import)**
|
|
|
+
|
|
|
+页面将使用:
|
|
|
+
|
|
|
+```ts
|
|
|
+import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/utils/list-export-download.ts web-admin/src/components/business/list-export-dropdown/index.vue
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(web-admin): add ListExportDropdown and Excel blob download helper
|
|
|
+
|
|
|
+Support filtered-result primary action and optional export-selected menu.
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: 样板页接入 — 系统用户
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/api/admin.ts`
|
|
|
+- Modify: `web-admin/src/views/system/user/index.vue`
|
|
|
+
|
|
|
+- [ ] **Step 1: API**
|
|
|
+
|
|
|
+在 `web-admin/src/api/admin.ts` 的 `fetchUserList` 附近追加:
|
|
|
+
|
|
|
+```typescript
|
|
|
+export function fetchExportUsers(data: Record<string, any>) {
|
|
|
+ return request.post({
|
|
|
+ url: '/web/admin/users/export',
|
|
|
+ data,
|
|
|
+ responseType: 'blob'
|
|
|
+ })
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 用户页挂组件**
|
|
|
+
|
|
|
+在工具栏删除按钮之后、`ListToolbarEndActions` 之前插入:
|
|
|
+
|
|
|
+```vue
|
|
|
+<ListExportDropdown
|
|
|
+ :export-fn="fetchExportUsers"
|
|
|
+ :get-filter-params="getUserExportFilterParams"
|
|
|
+ :has-selection="true"
|
|
|
+ :selected-ids="selectedRows.map((r) => r.id)"
|
|
|
+ :visible-columns="visibleColumns"
|
|
|
+ filename="用户列表.xlsx"
|
|
|
+/>
|
|
|
+```
|
|
|
+
|
|
|
+script 中:
|
|
|
+
|
|
|
+```typescript
|
|
|
+import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
|
|
|
+import { fetchExportUsers } from '@/api/admin'
|
|
|
+
|
|
|
+function getUserExportFilterParams() {
|
|
|
+ const keyword = quickSearchKeyword.value.trim() || searchForm.keyword || undefined
|
|
|
+ return {
|
|
|
+ keyword: keyword || undefined,
|
|
|
+ status: searchForm.status || undefined,
|
|
|
+ deptId: currentDeptId.value || undefined // 若页内变量名不同,改为实际组织树选中 id
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+核对 `user/index.vue` 中组织树选中字段名(常见 `selectedDeptId` / `currentDeptId`),与 `loadData` 传给列表的 `deptId` **保持一致**。
|
|
|
+
|
|
|
+- [ ] **Step 3: 手工验收清单**
|
|
|
+
|
|
|
+1. 打开 `/#/system/user`(或实际路由),工具栏出现下拉导出。
|
|
|
+2. 无勾选点「导出筛选结果」→ 下载 xlsx,列与可见列一致,状态为「正常/停用」文案。
|
|
|
+3. 勾选若干行 →「导出已选」仅含这些行。
|
|
|
+4. 筛到 0 条 → 提示「没有可导出的数据」,无文件。
|
|
|
+5. 隐藏「手机号」列再导出 → Excel 无该列。
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/api/admin.ts web-admin/src/views/system/user/index.vue
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(web-admin): wire list export on system user page
|
|
|
+
|
|
|
+Pilot filtered and selected Excel export via ListExportDropdown.
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: 对齐已有 CRM 导出交互(产品页为范例)
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `web-admin/src/views/crm/product/index.vue`
|
|
|
+- Modify: `backend/app/api/crm.py`(`product_export`:补 `ids`/`columns` 支持)
|
|
|
+- Modify: `backend/app/schemas/crm/product.py`(`ProductListBody` 或新建 ExportBody)
|
|
|
+
|
|
|
+- [ ] **Step 1: 后端 `ProductListBody` 增加可选字段**
|
|
|
+
|
|
|
+```python
|
|
|
+ids: Optional[List[str]] = None
|
|
|
+columns: Optional[List[str]] = None
|
|
|
+```
|
|
|
+
|
|
|
+在 `product_export` 中:若 `body.ids` 有值则过滤产品 id;用 `filter_export_columns(PRODUCT_EXPORT_COLUMNS, body.columns)`;捕获 `ExportValidationError` → `error_response(400, …)`。将原 `page_size=10000` 改为不分页拉全量(或 `page_size=EXPORT_MAX_ROWS`),依赖 `assert_export_row_count`。
|
|
|
+
|
|
|
+- [ ] **Step 2: 前端替换导出按钮**
|
|
|
+
|
|
|
+将:
|
|
|
+
|
|
|
+```vue
|
|
|
+<el-button class="btn-ghost" @click="handleExport">
|
|
|
+ <el-icon><Download /></el-icon>
|
|
|
+ 导出
|
|
|
+</el-button>
|
|
|
+```
|
|
|
+
|
|
|
+改为 `ListExportDropdown`(`has-selection=true`,`export-fn` 包一层 `fetchExportProducts`,`getFilterParams` 复用原 `handleExport` 参数逻辑,`selected-ids` / `visible-columns` 接上)。删除仅调用旧 `handleExport` 的冗余代码,或让 `handleExport` 委托给组件。
|
|
|
+
|
|
|
+- [ ] **Step 3: 验收** — 产品列表筛选导出、已选导出、原列含义不回退。
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add web-admin/src/views/crm/product/index.vue backend/app/api/crm.py backend/app/schemas/crm/product.py
|
|
|
+git commit -m "$(cat <<'EOF'
|
|
|
+feat(crm): align product export with ListExportDropdown
|
|
|
+
|
|
|
+Add ids/columns to product export and unify toolbar UX.
|
|
|
+EOF
|
|
|
+)"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: 批处理配方(每个缺导出列表页重复本配方)
|
|
|
+
|
|
|
+对清单中**每一个**列表页执行下列步骤(不要跳过登记表)。
|
|
|
+
|
|
|
+**配方步骤(复制到每个页面的实现笔记):**
|
|
|
+
|
|
|
+1. **后端**:新增 `POST …/export`(路径在 `{id}` 路由之前)。
|
|
|
+ - Body = 列表筛选字段 + `ids?: string[]` + `columns?: string[]`
|
|
|
+ - 权限 / 租户 / `apply_data_scope` 与 list 相同
|
|
|
+ - `ExportColumn[]` 对齐表格业务列(中文 title;状态 `dict_type` 或 formatter)
|
|
|
+ - `cols = filter_export_columns(ALL_COLS, body.columns)`
|
|
|
+ - `return export_rows_to_xlsx_response(...)`;`except ExportValidationError`
|
|
|
+2. **前端 API**:`request.post({ url, data, responseType: 'blob' })`
|
|
|
+3. **页面**:挂 `ListExportDropdown`;有 `type="selection"` → `has-selection=true`;否则 `false`
|
|
|
+4. **`getFilterParams`**:与当前 `loadData`/`getList` 筛选参数一致(含快捷搜索)
|
|
|
+5. **Commit**:按页或按模块小提交
|
|
|
+
|
|
|
+**列登记:** `ExportColumn.field` 必须等于前端 `visibleColumns` 的 `prop`(及表格 `prop`)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 7: 模块波次 — 系统管理
|
|
|
+
|
|
|
+按 Task 6 配方处理(跳过已完成的 `system/user`):
|
|
|
+
|
|
|
+| 页面 | 前端 | 后端路由模块(按现网核对) |
|
|
|
+|------|------|---------------------------|
|
|
|
+| 租户 | `views/system/tenant/index.vue` | `admin.py` / tenant |
|
|
|
+| 角色 | `views/system/role/index.vue` | role |
|
|
|
+| 岗位 | `views/system/post/index.vue` | post |
|
|
|
+| 站点 | `views/system/site/index.vue` | site |
|
|
|
+| 字典 | `views/system/dict/index.vue` | dict |
|
|
|
+| 请假 | `views/system/leave/index.vue` | leave |
|
|
|
+| 操作日志 | `views/system/operlog/index.vue` | operlog |
|
|
|
+| 部门 | `views/system/dept/index.vue` | dept(树表:导出扁平行或当前筛选) |
|
|
|
+| 菜单 | `views/system/menu/index.vue` | menu(同上) |
|
|
|
+| 流程定义 | `views/system/workflow/process/index.vue` | workflow |
|
|
|
+| 待办 | `views/system/workflow/task-pending/index.vue` | workflow |
|
|
|
+| 已办 | `views/system/workflow/task-completed/index.vue` | workflow |
|
|
|
+| 我的申请 | `views/system/workflow/my-applications/index.vue` | workflow |
|
|
|
+
|
|
|
+- [ ] **Step 1:** 逐页完成配方并自测导出
|
|
|
+- [ ] **Step 2:** 模块提交
|
|
|
+
|
|
|
+```bash
|
|
|
+git commit -m "feat(system): add list Excel export for system admin pages"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 8: 模块波次 — 物流 pl
|
|
|
+
|
|
|
+| 页面 | 前端 |
|
|
|
+|------|------|
|
|
|
+| 计划 | `views/pl/plan/index.vue` |
|
|
|
+| 计划归档 | `views/pl/plan-archive/index.vue` |
|
|
|
+| 计划合同 | `views/pl/plan-contract/index.vue` |
|
|
|
+| 计划证件 | `views/pl/plan-cert/index.vue` |
|
|
|
+| 运输 | `views/pl/transport/index.vue` |
|
|
|
+| 运输地址 | `views/pl/transport-address/index.vue` |
|
|
|
+| 承运商 | `views/pl/carrier/index.vue` |
|
|
|
+| 车辆 | `views/pl/vehicle/index.vue` |
|
|
|
+| 自有车 | `views/pl/self-vehicle/index.vue` |
|
|
|
+| 司机 | `views/pl/driver/index.vue` |
|
|
|
+| 人员 | `views/pl/personnel/index.vue` |
|
|
|
+| 维保申请/过程 | `vehicle-maintenance-*.vue` |
|
|
|
+| 充值申请/记录 | `vehicle-recharge-*.vue` |
|
|
|
+| 尿素申请/发放 | `vehicle-urea-*.vue` |
|
|
|
+| 库存台账等 | `inventory/ledger.vue` 等 |
|
|
|
+| 进出场共享列表 | `pl/_shared/IoOrderListPage.vue` |
|
|
|
+| 运费各 Panel | `pl/freight/components/*.vue`(有 list-toolbar 的面板) |
|
|
|
+
|
|
|
+- [ ] **Step 1:** 逐页配方 + 自测
|
|
|
+- [ ] **Step 2:** `git commit -m "feat(pl): add list Excel export for logistics pages"`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 9: 模块波次 — 报销 bx
|
|
|
+
|
|
|
+| 页面 | 前端 |
|
|
|
+|------|------|
|
|
|
+| 预支申请/审批/发放 | `bx/advance/**` |
|
|
|
+| 报销申请/审批 | `bx/reimburse/**` |
|
|
|
+| 付款 | `bx/payment/index.vue` |
|
|
|
+| 发票夹 | `bx/invoice-wallet/**` |
|
|
|
+| 费用科目 | `bx/expense-item/index.vue` |
|
|
|
+| 视图 all/mine/todo | `bx/view/**` |
|
|
|
+| 工作流配置页 | `bx/workflow/**`(若为列表) |
|
|
|
+
|
|
|
+已有 `advance-issues/export` 等:只改挂 `ListExportDropdown`,并补 `ids`/`columns`。
|
|
|
+
|
|
|
+- [ ] **Step 1–2:** 配方 + commit `feat(bx): add list Excel export for reimbursement pages`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 10: 模块波次 — CRM 剩余 + 地磅 + Demo
|
|
|
+
|
|
|
+**CRM(已有 export 的对齐交互;缺的补接口)**
|
|
|
+
|
|
|
+- 已有:clue / customer / product / opportunity / quote / contract* / supplier — 对齐 `ListExportDropdown` + `ids`/`columns`
|
|
|
+- 缺导出:`customer/pool`、`RuleIndex`、其它带 list-toolbar 的 CRM 子列表
|
|
|
+
|
|
|
+**地磅 db**
|
|
|
+
|
|
|
+- `views/db/plans/index.vue`、`inventory/index.vue`、`weighing/index.vue`(已有导出的对齐下拉)
|
|
|
+
|
|
|
+**Demo**
|
|
|
+
|
|
|
+- `demo/unit`、`demo/order`、`demo/product`、`demo/category`:迁离 `ArtExcelExport`,改为后端 `/export` + 组件
|
|
|
+
|
|
|
+- [ ] **Step 1–2:** 配方 + commits(可按 crm / db / demo 拆提交)
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 11: 回归与收尾
|
|
|
+
|
|
|
+- [ ] **Step 1:** 抽样验收(每模块至少 1 页):筛选一致、可见列、已选禁用态、空数据提示、超限文案(可用单测或构造 total)。
|
|
|
+- [ ] **Step 2:** 确认无新页再引入 `ArtExcelExport` 作列表导出。
|
|
|
+- [ ] **Step 3:** 更新设计文档状态为「已实现」(可选)并最终 commit。
|
|
|
+
|
|
|
+```bash
|
|
|
+git commit -m "docs: mark web-admin list export design as implemented"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Spec coverage(自审)
|
|
|
+
|
|
|
+| Spec 要求 | Task |
|
|
|
+|-----------|------|
|
|
|
+| 后端共享基建 / 上限 / 空数据 | Task 1 |
|
|
|
+| 独立 `/export`、复用列表权限 | Task 2, 6–10 |
|
|
|
+| `ids` + `columns` | Task 2, 5, 6 |
|
|
|
+| `ListExportDropdown` 双模式 | Task 3 |
|
|
|
+| 可见列 | Task 3–4 |
|
|
|
+| 样板页 | Task 4 |
|
|
|
+| 已有导出对齐 | Task 5, 9–10 |
|
|
|
+| ArtExcelExport 迁移 | Task 10 Demo |
|
|
|
+| 全量业务列表 | Task 7–10 |
|
|
|
+| 加密不破坏 xlsx | 说明(中间件已透传) |
|
|
|
+| 不做异步/小程序 | 未列入任务 |
|
|
|
+
|
|
|
+## 执行说明
|
|
|
+
|
|
|
+波次 Task 7–10 体量大,建议 **subagent-driven**:每页或每半模块一个子代理,按 Task 6 配方执行,父代理做验收。基建 Task 1–5 必须先完成。
|