Files
shanxia2.0/docs/superpowers/plans/2026-08-21-web-admin-list-export.md
T
zlwuandCursor 6dc8cfb2c1 docs: add implementation plan for web-admin list Excel export
Detail shared helpers, ListExportDropdown, user pilot, CRM align,
and per-module batch waves covering remaining list pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:50:11 +08:00

831 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 必须先完成。