From e72bcc67bce41337e7671b88c89cbbde7df7dc59 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 21 Aug 2026 17:39:20 +0800 Subject: [PATCH] feat(workflow): add Excel export for process and task lists Support filtered and selected rows for process defs, pending/completed tasks, and my applications. Co-authored-by: Cursor --- backend/app/api/workflow.py | 233 ++++++++++++++++++ web-admin/src/api/workflow.ts | 32 +++ .../system/workflow/my-applications/index.vue | 28 ++- .../views/system/workflow/process/index.vue | 27 +- .../system/workflow/task-completed/index.vue | 27 +- .../system/workflow/task-pending/index.vue | 26 +- 6 files changed, 369 insertions(+), 4 deletions(-) diff --git a/backend/app/api/workflow.py b/backend/app/api/workflow.py index 155c72252..3b491c000 100644 --- a/backend/app/api/workflow.py +++ b/backend/app/api/workflow.py @@ -1,4 +1,5 @@ """工作流管理接口""" +from datetime import datetime from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, Query @@ -12,6 +13,13 @@ from app.core.response import success_response, error_response from app.crud import workflow as wf_crud from app.models.models import SysUser, WfNode, WfProcessDef, WfProcessInstance, WfProcessVersion, WfTask from app.services.workflow_engine import WorkflowEngine +from app.utils.excel_export import ( + EXPORT_MAX_ROWS, + ExportColumn, + ExportValidationError, + export_rows_to_xlsx_response, + filter_export_columns, +) router = APIRouter(prefix="/web/admin/workflow", tags=["工作流管理"]) @@ -30,6 +38,12 @@ def _business_type_label(code: Optional[str]) -> str: return _BUSINESS_TYPE_LABELS.get(code, code) +def _fmt_latest_version(v: Any) -> str: + if v is None or v == "": + return "--" + return f"V{v}" + + def _portal_user_info(user: dict) -> dict: dept_id = (user.get("region_id") or user.get("dept_id") or "").strip() return { @@ -189,6 +203,59 @@ def list_process_defs( return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) +PROCESS_DEF_EXPORT_COLUMNS = [ + ExportColumn(title="流程编码", field="processCode"), + ExportColumn(title="流程名称", field="processName"), + ExportColumn(title="状态", field="status", dict_type="sys_status"), + ExportColumn(title="最新版本", field="latestVersion", formatter=_fmt_latest_version), + ExportColumn(title="创建时间", field="createTime"), +] + + +class ProcessDefExportBody(BaseModel): + keyword: Optional[str] = None + status: Optional[str] = None + ids: Optional[List[str]] = None + columns: Optional[List[str]] = None + + +@router.post("/defs/export") +def export_process_defs( + body: ProcessDefExportBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + """导出流程定义列表(当前筛选全量,或 ids 已选)。""" + try: + tenant_no = user.get("tenant_no", "shanxia2.0") + items, _ = wf_crud.list_process_defs( + db, + page=1, + page_size=EXPORT_MAX_ROWS, + keyword=body.keyword, + status=body.status, + tenant_no=tenant_no, + user_info=user, + ) + if body.ids: + idset = set(body.ids) + items = [r for r in items if r.get("id") in idset] + cols = filter_export_columns(PROCESS_DEF_EXPORT_COLUMNS, body.columns) + date_str = datetime.now().strftime("%Y%m%d") + return export_rows_to_xlsx_response( + db, + tenant_no=tenant_no, + sheet_title="流程定义", + columns=cols, + rows=items, + 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)) + + @router.post("/defs") def create_process_def( body: ProcessDefCreateBody, @@ -379,6 +446,62 @@ def list_my_instances( return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) +MY_INSTANCE_EXPORT_COLUMNS = [ + ExportColumn(title="流程名称", field="processName"), + ExportColumn(title="业务类型", field="businessTypeLabel"), + ExportColumn(title="业务单号", field="businessId"), + ExportColumn(title="状态", field="status", dict_type="wf_process_status"), + ExportColumn(title="发起时间", field="createTime"), +] + + +class MyInstanceExportBody(BaseModel): + keyword: Optional[str] = None + status: Optional[str] = None + ids: Optional[List[str]] = None + columns: Optional[List[str]] = None + + +@router.post("/instances/my/export") +def export_my_instances( + body: MyInstanceExportBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + """导出我的申请列表(当前筛选全量,或 ids 已选)。""" + try: + user_id = user.get("user_id") or user.get("id") + tenant_no = user.get("tenant_no", "shanxia2.0") + items, _ = wf_crud.list_my_instances( + db, + user_id, + page=1, + page_size=EXPORT_MAX_ROWS, + keyword=body.keyword, + status=body.status, + tenant_no=tenant_no, + user_info=user, + ) + _enrich_task_list(db, items) + if body.ids: + idset = set(body.ids) + items = [r for r in items if r.get("id") in idset] + cols = filter_export_columns(MY_INSTANCE_EXPORT_COLUMNS, body.columns) + date_str = datetime.now().strftime("%Y%m%d") + return export_rows_to_xlsx_response( + db, + tenant_no=tenant_no, + sheet_title="我的申请", + columns=cols, + rows=items, + 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)) + + @router.get("/instances/{instance_id}") def get_instance_detail( instance_id: str, @@ -461,6 +584,60 @@ def list_pending_tasks( return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) +PENDING_TASK_EXPORT_COLUMNS = [ + ExportColumn(title="流程名称", field="processName"), + ExportColumn(title="业务类型", field="businessTypeLabel"), + ExportColumn(title="业务单号", field="businessId"), + ExportColumn(title="发起人", field="starterUserName"), + ExportColumn(title="到达时间", field="createTime"), +] + + +class PendingTaskExportBody(BaseModel): + keyword: Optional[str] = None + ids: Optional[List[str]] = None + columns: Optional[List[str]] = None + + +@router.post("/tasks/pending/export") +def export_pending_tasks( + body: PendingTaskExportBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + """导出待办任务列表(当前筛选全量,或 ids 已选)。""" + try: + user_id = user.get("user_id") or user.get("id") + tenant_no = user.get("tenant_no", "shanxia2.0") + items, _ = wf_crud.list_pending_tasks( + db, + user_id, + page=1, + page_size=EXPORT_MAX_ROWS, + keyword=body.keyword, + tenant_no=tenant_no, + user_info=user, + ) + _enrich_task_list(db, items) + if body.ids: + idset = set(body.ids) + items = [r for r in items if r.get("id") in idset] + cols = filter_export_columns(PENDING_TASK_EXPORT_COLUMNS, body.columns) + date_str = datetime.now().strftime("%Y%m%d") + return export_rows_to_xlsx_response( + db, + tenant_no=tenant_no, + sheet_title="待办任务", + columns=cols, + rows=items, + 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)) + + @router.get("/tasks/completed") def list_completed_tasks( page: int = Query(1, ge=1), @@ -479,6 +656,62 @@ def list_completed_tasks( return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp}) +COMPLETED_TASK_EXPORT_COLUMNS = [ + ExportColumn(title="流程名称", field="processName"), + ExportColumn(title="业务类型", field="businessTypeLabel"), + ExportColumn(title="业务单号", field="businessId"), + ExportColumn(title="发起人", field="starterUserName"), + ExportColumn(title="审批结果", field="status", dict_type="wf_process_status"), + ExportColumn(title="审批意见", field="commentText"), + ExportColumn(title="审批时间", field="updateTime"), +] + + +class CompletedTaskExportBody(BaseModel): + keyword: Optional[str] = None + ids: Optional[List[str]] = None + columns: Optional[List[str]] = None + + +@router.post("/tasks/completed/export") +def export_completed_tasks( + body: CompletedTaskExportBody, + user: dict = Depends(require_admin_or_super_admin), + db: Session = Depends(get_db), +): + """导出已办任务列表(当前筛选全量,或 ids 已选)。""" + try: + user_id = user.get("user_id") or user.get("id") + tenant_no = user.get("tenant_no", "shanxia2.0") + items, _ = wf_crud.list_completed_tasks( + db, + user_id, + page=1, + page_size=EXPORT_MAX_ROWS, + keyword=body.keyword, + tenant_no=tenant_no, + user_info=user, + ) + _enrich_task_list(db, items) + if body.ids: + idset = set(body.ids) + items = [r for r in items if r.get("id") in idset] + cols = filter_export_columns(COMPLETED_TASK_EXPORT_COLUMNS, body.columns) + date_str = datetime.now().strftime("%Y%m%d") + return export_rows_to_xlsx_response( + db, + tenant_no=tenant_no, + sheet_title="已办任务", + columns=cols, + rows=items, + 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)) + + # --------------------------------------------------------------------------- # 审批 / 驳回 # --------------------------------------------------------------------------- diff --git a/web-admin/src/api/workflow.ts b/web-admin/src/api/workflow.ts index cde6c1bb1..dd4f36ff0 100644 --- a/web-admin/src/api/workflow.ts +++ b/web-admin/src/api/workflow.ts @@ -22,6 +22,14 @@ export function fetchProcessDefList(params?: { }) } +export function fetchExportProcessDefs(data: Record) { + return request.post({ + url: '/web/admin/workflow/defs/export', + data, + responseType: 'blob' + }) +} + export function fetchCreateProcessDef(data: { processCode: string processName: string @@ -144,6 +152,14 @@ export function fetchPendingTasks(params?: { page?: number; pageSize?: number; k }) } +export function fetchExportPendingTasks(data: Record) { + return request.post({ + url: '/web/admin/workflow/tasks/pending/export', + data, + responseType: 'blob' + }) +} + export function fetchCompletedTasks(params?: { page?: number pageSize?: number @@ -161,6 +177,14 @@ export function fetchCompletedTasks(params?: { }) } +export function fetchExportCompletedTasks(data: Record) { + return request.post({ + url: '/web/admin/workflow/tasks/completed/export', + data, + responseType: 'blob' + }) +} + export function fetchApproveTask(taskId: string, data?: { commentText?: string }) { return request.post({ url: `/web/admin/workflow/tasks/${taskId}/approve`, @@ -195,6 +219,14 @@ export function fetchMyInstances(params?: { }) } +export function fetchExportMyInstances(data: Record) { + return request.post({ + url: '/web/admin/workflow/instances/my/export', + data, + responseType: 'blob' + }) +} + export function fetchBusinessAllTasks(businessType: string, businessId: string) { return request.get({ url: `/web/admin/workflow/instances/by-business/${businessType}/${businessId}` diff --git a/web-admin/src/views/system/workflow/my-applications/index.vue b/web-admin/src/views/system/workflow/my-applications/index.vue index a0186a879..5d6eda899 100644 --- a/web-admin/src/views/system/workflow/my-applications/index.vue +++ b/web-admin/src/views/system/workflow/my-applications/index.vue @@ -48,6 +48,16 @@ @search="handleQuickSearch" @clear="handleQuickSearchClear" /> + + + import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue' + import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue' import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue' import { usePersistedListColumns } from '@/composables/usePersistedListColumns' - import { fetchMyInstances, fetchBusinessAllTasks } from '@/api/workflow' + import { fetchMyInstances, fetchBusinessAllTasks, fetchExportMyInstances } from '@/api/workflow' import ProcessStatusTag from '../components/ProcessStatusTag.vue' import { Document, Search, RefreshLeft, Close } from '@element-plus/icons-vue' @@ -252,6 +263,21 @@ const searchForm = reactive<{ keyword: string; status: string }>({ keyword: '', status: '' }) const quickSearchKeyword = ref('') + + function getMyApplicationsExportFilterParams() { + const quickKeyword = quickSearchKeyword.value?.trim() + return { + keyword: quickKeyword || searchForm.keyword || undefined, + status: searchForm.status || undefined + } + } + + const myApplicationsExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('processName')) cols.unshift('processName') + return cols + }) + const tableData = ref([]) const loading = ref(false) const selectedRows = ref([]) diff --git a/web-admin/src/views/system/workflow/process/index.vue b/web-admin/src/views/system/workflow/process/index.vue index 005f7f70d..b9e873ce9 100644 --- a/web-admin/src/views/system/workflow/process/index.vue +++ b/web-admin/src/views/system/workflow/process/index.vue @@ -78,6 +78,15 @@ 批量删除 + + import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue' +import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue' import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue' import { usePersistedListColumns } from '@/composables/usePersistedListColumns' import { @@ -294,7 +304,8 @@ import { usePersistedListColumns } from '@/composables/usePersistedListColumns' fetchProcessDefList, fetchCreateProcessDef, fetchUpdateProcessDef, - fetchDeleteProcessDef + fetchDeleteProcessDef, + fetchExportProcessDefs } from '@/api/workflow' import FlowDesigner from '../designer/FlowDesigner.vue' import SystemViewDialog, { @@ -321,6 +332,20 @@ import { usePersistedListColumns } from '@/composables/usePersistedListColumns' const searchForm = reactive<{ keyword: string }>({ keyword: '' }) const quickSearchKeyword = ref('') + + function getProcessExportFilterParams() { + const quickKeyword = quickSearchKeyword.value?.trim() + return { + keyword: quickKeyword || searchForm.keyword || undefined + } + } + + const processExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('processName')) cols.unshift('processName') + return cols + }) + const tableData = ref([]) const selectedRows = ref([]) const loading = ref(false) diff --git a/web-admin/src/views/system/workflow/task-completed/index.vue b/web-admin/src/views/system/workflow/task-completed/index.vue index df11aee2e..60e6be347 100644 --- a/web-admin/src/views/system/workflow/task-completed/index.vue +++ b/web-admin/src/views/system/workflow/task-completed/index.vue @@ -36,6 +36,16 @@ @search="handleQuickSearch" @clear="handleQuickSearchClear" /> + + + import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue' + import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue' import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue' import { usePersistedListColumns } from '@/composables/usePersistedListColumns' - import { fetchCompletedTasks, fetchInstanceDetail } from '@/api/workflow' + import { fetchCompletedTasks, fetchInstanceDetail, fetchExportCompletedTasks } from '@/api/workflow' import ProcessStatusTag from '../components/ProcessStatusTag.vue' import { Document, Search, RefreshLeft, Close } from '@element-plus/icons-vue' @@ -259,6 +270,20 @@ const searchForm = reactive<{ keyword: string }>({ keyword: '' }) const quickSearchKeyword = ref('') + + function getCompletedExportFilterParams() { + const quickKeyword = quickSearchKeyword.value?.trim() + return { + keyword: quickKeyword || searchForm.keyword || undefined + } + } + + const completedExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('processName')) cols.unshift('processName') + return cols + }) + const tableData = ref([]) const loading = ref(false) const selectedRows = ref([]) diff --git a/web-admin/src/views/system/workflow/task-pending/index.vue b/web-admin/src/views/system/workflow/task-pending/index.vue index c5e0be121..d698b7a53 100644 --- a/web-admin/src/views/system/workflow/task-pending/index.vue +++ b/web-admin/src/views/system/workflow/task-pending/index.vue @@ -46,6 +46,15 @@ 审批 + + import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue' +import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue' import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue' import { usePersistedListColumns } from '@/composables/usePersistedListColumns' - import { fetchPendingTasks, fetchInstanceDetail } from '@/api/workflow' + import { fetchPendingTasks, fetchInstanceDetail, fetchExportPendingTasks } from '@/api/workflow' import ApprovalDialog from '../components/ApprovalDialog.vue' import ProcessStatusTag from '../components/ProcessStatusTag.vue' import { Document, EditPen, Search, RefreshLeft, Close } from '@element-plus/icons-vue' @@ -267,6 +277,20 @@ import { usePersistedListColumns } from '@/composables/usePersistedListColumns' const searchForm = reactive<{ keyword: string }>({ keyword: '' }) const quickSearchKeyword = ref('') + + function getPendingExportFilterParams() { + const quickKeyword = quickSearchKeyword.value?.trim() + return { + keyword: quickKeyword || searchForm.keyword || undefined + } + } + + const pendingExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('processName')) cols.unshift('processName') + return cols + }) + const tableData = ref([]) const selectedRows = ref([]) const loading = ref(false)