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

28 KiB
Raw Blame History

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:

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: 跑测确认失败
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 之后)追加:

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))(若调用方已校验可保留双重保险,或仅在本函数内校验一次)。推荐仅在本函数内调用一次:

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: 跑测确认通过
cd backend && source venv/bin/activate && pytest tests/test_excel_export_helpers.py -v

Expected: PASS

  • Step 5: Commit
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):

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} 之前注册):

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(角色名拼接),避免上面的重建逻辑:

ExportColumn(title="角色", field="roles"),
# ...
d["roles"] = "、".join(names)
  • Step 3: 手工或脚本冒烟(可选)
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
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:

/** 从 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:

<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)

页面将使用:

import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue'
  • Step 4: Commit
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 附近追加:

export function fetchExportUsers(data: Record<string, any>) {
  return request.post({
    url: '/web/admin/users/export',
    data,
    responseType: 'blob'
  })
}
  • Step 2: 用户页挂组件

在工具栏删除按钮之后、ListToolbarEndActions 之前插入:

<ListExportDropdown
  :export-fn="fetchExportUsers"
  :get-filter-params="getUserExportFilterParams"
  :has-selection="true"
  :selected-ids="selectedRows.map((r) => r.id)"
  :visible-columns="visibleColumns"
  filename="用户列表.xlsx"
/>

script 中:

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
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 增加可选字段

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: 前端替换导出按钮

将:

<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

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: 模块提交
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。
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 必须先完成。