Просмотр исходного кода

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 <cursoragent@cursor.com>
zlwu 2 месяцев назад
Родитель
Сommit
e72bcc67bc

+ 233 - 0
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))
+
+
 # ---------------------------------------------------------------------------
 # 审批 / 驳回
 # ---------------------------------------------------------------------------

+ 32 - 0
web-admin/src/api/workflow.ts

@@ -22,6 +22,14 @@ export function fetchProcessDefList(params?: {
   })
 }
 
+export function fetchExportProcessDefs(data: Record<string, any>) {
+  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<string, any>) {
+  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<string, any>) {
+  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<string, any>) {
+  return request.post({
+    url: '/web/admin/workflow/instances/my/export',
+    data,
+    responseType: 'blob'
+  })
+}
+
 export function fetchBusinessAllTasks(businessType: string, businessId: string) {
   return request.get<Api.Workflow.BusinessAllTasks>({
     url: `/web/admin/workflow/instances/by-business/${businessType}/${businessId}`

+ 27 - 1
web-admin/src/views/system/workflow/my-applications/index.vue

@@ -48,6 +48,16 @@
             @search="handleQuickSearch"
             @clear="handleQuickSearchClear"
           />
+
+          <ListExportDropdown
+            :export-fn="fetchExportMyInstances"
+            :get-filter-params="getMyApplicationsExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="myApplicationsExportColumns"
+            filename="我的申请.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -227,9 +237,10 @@
 
 <script setup lang="ts">
   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<Api.Workflow.MyInstanceItem[]>([])
   const loading = ref(false)
   const selectedRows = ref<Api.Workflow.MyInstanceItem[]>([])

+ 26 - 1
web-admin/src/views/system/workflow/process/index.vue

@@ -78,6 +78,15 @@
             批量删除
           </el-button>
 
+          <ListExportDropdown
+            :export-fn="fetchExportProcessDefs"
+            :get-filter-params="getProcessExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="processExportColumns"
+            filename="流程定义.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -270,6 +279,7 @@
 
 <script setup lang="ts">
 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<Api.Workflow.ProcessDefItem[]>([])
   const selectedRows = ref<Api.Workflow.ProcessDefItem[]>([])
   const loading = ref(false)

+ 26 - 1
web-admin/src/views/system/workflow/task-completed/index.vue

@@ -36,6 +36,16 @@
             @search="handleQuickSearch"
             @clear="handleQuickSearchClear"
           />
+
+          <ListExportDropdown
+            :export-fn="fetchExportCompletedTasks"
+            :get-filter-params="getCompletedExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="completedExportColumns"
+            filename="已办任务.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -232,9 +242,10 @@
 
 <script setup lang="ts">
   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<Api.Workflow.TaskItem[]>([])
   const loading = ref(false)
   const selectedRows = ref<Api.Workflow.TaskItem[]>([])

+ 25 - 1
web-admin/src/views/system/workflow/task-pending/index.vue

@@ -46,6 +46,15 @@
             审批
           </el-button>
 
+          <ListExportDropdown
+            :export-fn="fetchExportPendingTasks"
+            :get-filter-params="getPendingExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="pendingExportColumns"
+            filename="待办任务.xlsx"
+          />
+
           <ListToolbarEndActions
             :loading="loading"
             :column-defs="listColumnDefs"
@@ -241,9 +250,10 @@
 
 <script setup lang="ts">
 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<Api.Workflow.TaskItem[]>([])
   const selectedRows = ref<Api.Workflow.TaskItem[]>([])
   const loading = ref(false)