Jelajahi Sumber

feat: 待办列表改造及首页、我的流程、菜单优化

- 我的待办由卡片改为飞书多维表格风格列表,动态展示流程表单字段
- 我的待办支持按流程名称下拉筛选
- 列表列顺序调整:到达时间、剩余时间置于操作列前
- 我的流程支持批量撤回和批量删除
- 首页按权限动态展示统计卡片和快捷入口
- 隐藏侧边栏菜单中的流程明细
- 修复 transferTo 类型错误
ye-zhaojia 1 Minggu lalu
induk
melakukan
7a9ed7f894

+ 8 - 0
src/api/flow/instance.ts

@@ -36,10 +36,18 @@ export function revokeInstance(id: number): Promise<void> {
   return request.post(`/flow/instance/${id}/revoke`)
 }
 
+export function batchRevokeInstance(ids: number[]): Promise<void> {
+  return request.post('/flow/instance/batch-revoke', ids)
+}
+
 export function deleteInstance(id: number): Promise<void> {
   return request.delete(`/flow/instance/${id}`)
 }
 
+export function batchDeleteInstance(ids: number[]): Promise<void> {
+  return request.post('/flow/instance/batch-delete', ids)
+}
+
 export function getInstanceAttachments(id: number): Promise<Attachment[]> {
   return request.get(`/flow/instance/${id}/attachments`)
 }

+ 6 - 0
src/router/index.ts

@@ -146,6 +146,12 @@ const router = createRouter({
           name: 'FlowExecute',
           component: () => import('@/views/flow/execute/index.vue'),
           meta: { title: '流程执行', icon: 'Pointer' }
+        },
+        {
+          path: 'all',
+          name: 'AllInstance',
+          component: () => import('@/views/flow/instance/all.vue'),
+          meta: { title: '流程明细', icon: 'Document', hidden: true }
         }
       ]
     }

+ 2 - 0
src/types/flow.ts

@@ -70,6 +70,8 @@ export interface FlowTask {
   timeoutTime?: string
   urgency?: number // 0-正常,1-即将超时,2-已超时
   remainingMinutes?: number
+  formData?: string
+  formSchema?: string
 }
 
 export interface FlowNodeConfig {

+ 110 - 103
src/views/dashboard/index.vue

@@ -1,98 +1,50 @@
 <template>
   <div class="dashboard">
-    <el-row :gutter="20">
-      <el-col :span="6">
-        <el-card class="stat-card" shadow="hover" @click="$router.push('/flow/definition')">
+    <!-- 统计概览 -->
+    <el-row :gutter="16">
+      <el-col v-for="card in visibleStatCards" :key="card.key" :xs="24" :sm="12" :md="12" :lg="6" :xl="6">
+        <el-card class="stat-card" shadow="hover" @click="$router.push(card.path)">
           <div class="stat-item">
-            <div class="stat-icon" style="background: #409EFF;">
-              <el-icon><Document /></el-icon>
+            <div class="stat-icon" :style="{ background: card.color }">
+              <el-icon><component :is="card.icon" /></el-icon>
             </div>
             <div class="stat-info">
-              <div class="stat-value">{{ stats.definitionCount }}</div>
-              <div class="stat-label">流程管理</div>
-            </div>
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :span="6">
-        <el-card class="stat-card" shadow="hover" @click="$router.push('/approval/todo')">
-          <div class="stat-item">
-            <div class="stat-icon" style="background: #67C23A;">
-              <el-icon><Stamp /></el-icon>
-            </div>
-            <div class="stat-info">
-              <div class="stat-value">{{ stats.todoCount }}</div>
-              <div class="stat-label">我的待办</div>
-            </div>
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :span="6">
-        <el-card class="stat-card" shadow="hover" @click="$router.push('/approval/mine')">
-          <div class="stat-item">
-            <div class="stat-icon" style="background: #E6A23C;">
-              <el-icon><Connection /></el-icon>
-            </div>
-            <div class="stat-info">
-              <div class="stat-value">{{ stats.instanceCount }}</div>
-              <div class="stat-label">我的发起</div>
-            </div>
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :span="6">
-        <el-card class="stat-card" shadow="hover" @click="$router.push('/system/user')">
-          <div class="stat-item">
-            <div class="stat-icon" style="background: #F56C6C;">
-              <el-icon><UserFilled /></el-icon>
-            </div>
-            <div class="stat-info">
-              <div class="stat-value">{{ stats.userCount }}</div>
-              <div class="stat-label">系统用户</div>
+              <div class="stat-value">{{ card.value }}</div>
+              <div class="stat-label">{{ card.label }}</div>
             </div>
           </div>
         </el-card>
       </el-col>
     </el-row>
 
-    <el-row :gutter="20" style="margin-top: 20px;">
-      <el-col :span="12">
+    <el-row :gutter="16" style="margin-top: 16px;">
+      <!-- 快捷入口 -->
+      <el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
         <el-card class="quick-card">
           <template #header>
             <div class="quick-header">
-              <span>快捷操作</span>
+              <span>快捷入口</span>
             </div>
           </template>
           <div class="quick-grid">
-            <div class="quick-item" @click="$router.push('/approval/todo')">
-              <div class="quick-icon" style="background: #67C23A;">
-                <el-icon><Bell /></el-icon>
-                <el-badge v-if="stats.todoCount > 0" :value="stats.todoCount" class="todo-badge" />
-              </div>
-              <div class="quick-text">处理待办</div>
-            </div>
-            <div v-if="isAdmin" class="quick-item" @click="$router.push('/flow/designer')">
-              <div class="quick-icon" style="background: #409EFF;">
-                <el-icon><EditPen /></el-icon>
-              </div>
-              <div class="quick-text">设计流程</div>
-            </div>
-            <div class="quick-item" @click="$router.push('/flow/definition')">
-              <div class="quick-icon" style="background: #E6A23C;">
-                <el-icon><Document /></el-icon>
-              </div>
-              <div class="quick-text">流程管理</div>
-            </div>
-            <div class="quick-item" @click="$router.push('/approval/execute')">
-              <div class="quick-icon" style="background: #909399;">
-                <el-icon><Pointer /></el-icon>
+            <div
+              v-for="item in visibleQuickActions"
+              :key="item.key"
+              class="quick-item"
+              @click="$router.push(item.path)"
+            >
+              <div class="quick-icon" :style="{ background: item.color }">
+                <el-icon><component :is="item.icon" /></el-icon>
+                <el-badge v-if="item.badge && stats.todoCount > 0" :value="stats.todoCount" class="todo-badge" />
               </div>
-              <div class="quick-text">流程执行</div>
+              <div class="quick-text">{{ item.label }}</div>
             </div>
           </div>
         </el-card>
       </el-col>
-      <el-col :span="12">
+
+      <!-- 常用流程 -->
+      <el-col :xs="24" :sm="24" :md="12" :lg="12" :xl="12">
         <el-card class="quick-card">
           <template #header>
             <div class="quick-header">
@@ -180,29 +132,66 @@ import { ref, onMounted, reactive, computed } from 'vue'
 import { useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
 import type { FormInstance, FormRules } from 'element-plus'
-import { Document, Stamp, Connection, UserFilled, Bell, EditPen, Pointer, Promotion } from '@element-plus/icons-vue'
-import { listTodo } from '@/api/flow/task'
+import {
+  Bell, CircleCheck, Connection, UserFilled, Message,
+  Document, TrendCharts, Pointer, EditPen, User, Promotion
+} from '@element-plus/icons-vue'
+import { listTodo, listHandled, listCc } from '@/api/flow/task'
 import { listMyInstance, startInstance } from '@/api/flow/instance'
 import { listDefinition, listEnabled } from '@/api/flow/definition'
 import { listUser } from '@/api/system/user'
-import { useUserStore } from '@/stores/user-store'
+import { getOverview } from '@/api/analysis'
 import { uploadFile } from '@/api/file'
 import { beforeFileUpload, collectAttachmentUrls, getUploadUrl } from '@/utils/file'
 import type { FlowDefinition } from '@/types/flow'
+import { useUserStore } from '@/stores/user-store'
 import FlowFormFields from '@/components/FlowFormFields/index.vue'
 import FilePreview from '@/components/FilePreview/index.vue'
 
 const router = useRouter()
 const userStore = useUserStore()
-const isAdmin = computed(() => userStore.userInfo?.employeeType === 'super_admin' || userStore.userInfo?.employeeType === 'flow_manager')
+
+const employeeType = computed(() => userStore.userInfo?.employeeType || 'common_user')
+
+const isFlowAdmin = computed(() => ['flow_manager', 'super_admin'].includes(employeeType.value))
+const isSystemAdmin = computed(() => ['dept_manager', 'super_admin'].includes(employeeType.value))
 
 const stats = ref({
-  definitionCount: 0,
   todoCount: 0,
+  handledCount: 0,
   instanceCount: 0,
-  userCount: 0
+  ccCount: 0,
+  definitionCount: 0,
+  userCount: 0,
+  analysisTotal: 0,
+  analysisRunning: 0
 })
 
+const statCardConfigs = computed(() => [
+  { key: 'todo', label: '我的待办', value: stats.value.todoCount, color: '#67C23A', icon: Bell, path: '/approval/todo', visible: true },
+  { key: 'handled', label: '我的已办', value: stats.value.handledCount, color: '#409EFF', icon: CircleCheck, path: '/approval/handled', visible: true },
+  { key: 'mine', label: '我的流程', value: stats.value.instanceCount, color: '#E6A23C', icon: Connection, path: '/approval/mine', visible: true },
+  { key: 'cc', label: '我的抄送', value: stats.value.ccCount, color: '#909399', icon: Message, path: '/approval/cc', visible: true },
+  { key: 'definition', label: '流程管理', value: stats.value.definitionCount, color: '#409EFF', icon: Document, path: '/flow/definition', visible: isFlowAdmin.value },
+  { key: 'analysis', label: '数据看板', value: stats.value.analysisRunning, color: '#13C2C2', icon: TrendCharts, path: '/analysis/flow', visible: isFlowAdmin.value },
+  { key: 'user', label: '系统用户', value: stats.value.userCount, color: '#F56C6C', icon: UserFilled, path: '/system/user', visible: isSystemAdmin.value }
+])
+
+const visibleStatCards = computed(() => statCardConfigs.value.filter(c => c.visible))
+
+const quickActionConfigs = computed(() => [
+  { key: 'todo', label: '处理待办', color: '#67C23A', icon: Bell, path: '/approval/todo', badge: true },
+  { key: 'execute', label: '流程执行', color: '#909399', icon: Pointer, path: '/approval/execute' },
+  { key: 'mine', label: '我的流程', color: '#E6A23C', icon: Connection, path: '/approval/mine' },
+  { key: 'cc', label: '我的抄送', color: '#909399', icon: Message, path: '/approval/cc' },
+  { key: 'designer', label: '设计流程', color: '#409EFF', icon: EditPen, path: '/flow/designer', visible: isFlowAdmin.value },
+  { key: 'definition', label: '流程管理', color: '#E6A23C', icon: Document, path: '/flow/definition', visible: isFlowAdmin.value },
+  { key: 'user', label: '用户管理', color: '#F56C6C', icon: User, path: '/system/user', visible: isSystemAdmin.value },
+  { key: 'analysis', label: '数据看板', color: '#13C2C2', icon: TrendCharts, path: '/analysis/flow', visible: isFlowAdmin.value }
+])
+
+const visibleQuickActions = computed(() => quickActionConfigs.value.filter(c => c.visible !== false))
+
 const loadingDefinitions = ref(false)
 const commonFlows = ref<FlowDefinition[]>([])
 
@@ -250,18 +239,33 @@ const startFormRules: FormRules = {
 
 async function loadStats() {
   try {
-    const [defRes, todoRes, instRes, userRes] = await Promise.all([
-      // 统计卡片查询失败时静默处理,避免无权限用户进入首页即弹“权限不足”
-      listDefinition({ pageNum: 1, pageSize: 1 }, { silent: true }),
+    // 所有用户可见的统计
+    const [todoRes, handledRes, instRes, ccRes] = await Promise.all([
       listTodo({ pageNum: 1, pageSize: 1 }),
+      listHandled({ pageNum: 1, pageSize: 1 }),
       listMyInstance({ pageNum: 1, pageSize: 1 }),
-      listUser({ pageNum: 1, pageSize: 1 }, { silent: true })
+      listCc({ pageNum: 1, pageSize: 1 })
     ])
-    stats.value = {
-      definitionCount: defRes.total ?? 0,
-      todoCount: todoRes.total ?? 0,
-      instanceCount: instRes.total ?? 0,
-      userCount: userRes.total ?? 0
+    stats.value.todoCount = todoRes.total ?? 0
+    stats.value.handledCount = handledRes.total ?? 0
+    stats.value.instanceCount = instRes.total ?? 0
+    stats.value.ccCount = ccRes.total ?? 0
+
+    // 流程管理员 / 超级管理员可见
+    if (isFlowAdmin.value) {
+      const [defRes, overviewRes] = await Promise.all([
+        listDefinition({ pageNum: 1, pageSize: 1 }, { silent: true }),
+        getOverview({}, { silent: true })
+      ])
+      stats.value.definitionCount = defRes.total ?? 0
+      stats.value.analysisTotal = overviewRes?.totalInstances ?? 0
+      stats.value.analysisRunning = overviewRes?.runningCount ?? 0
+    }
+
+    // 部门经理 / 超级管理员可见
+    if (isSystemAdmin.value) {
+      const userRes = await listUser({ pageNum: 1, pageSize: 1 }, { silent: true })
+      stats.value.userCount = userRes.total ?? 0
     }
   } catch {
     // ignore
@@ -305,7 +309,6 @@ async function submitStart() {
   if (!valid) return
   starting.value = true
   try {
-    // 校验动态表单字段;无动态字段时视为校验通过
     const dynamicValid = flowFormFieldsRef.value?.validate() ?? true
     if (dynamicValid === false) return
 
@@ -313,7 +316,6 @@ async function submitStart() {
     if (selectedDefinition.value?.formSchema) {
       formData = { ...dynamicFormData.value }
     } else if (startForm.formData.trim()) {
-      // 兼容旧版无表单字段配置的流程
       formData = {}
       startForm.formData.split('\n').forEach(line => {
         const idx = line.indexOf('=')
@@ -346,9 +348,13 @@ onMounted(() => {
 </script>
 
 <style scoped>
+.dashboard {
+  padding: 4px;
+}
 .stat-card {
   cursor: pointer;
   transition: all 0.2s ease;
+  margin-bottom: 16px;
 }
 .stat-card:hover {
   transform: translateY(-2px);
@@ -357,30 +363,31 @@ onMounted(() => {
 .stat-item {
   display: flex;
   align-items: center;
-  gap: 16px;
+  gap: 14px;
 }
 .stat-icon {
-  width: 60px;
-  height: 60px;
-  border-radius: 8px;
+  width: 52px;
+  height: 52px;
+  border-radius: 10px;
   display: flex;
   align-items: center;
   justify-content: center;
   color: #fff;
-  font-size: 28px;
+  font-size: 26px;
 }
 .stat-value {
-  font-size: 28px;
+  font-size: 26px;
   font-weight: bold;
   color: #303133;
+  line-height: 1.2;
 }
 .stat-label {
-  font-size: 14px;
+  font-size: 13px;
   color: #909399;
   margin-top: 4px;
 }
 .quick-card {
-  min-height: 260px;
+  min-height: 280px;
 }
 .quick-header {
   display: flex;
@@ -390,13 +397,13 @@ onMounted(() => {
 .quick-grid {
   display: grid;
   grid-template-columns: repeat(2, 1fr);
-  gap: 16px;
+  gap: 12px;
 }
 .quick-item {
   display: flex;
   align-items: center;
   gap: 12px;
-  padding: 16px;
+  padding: 14px;
   border-radius: 8px;
   background: #f5f7fa;
   cursor: pointer;
@@ -408,14 +415,14 @@ onMounted(() => {
 }
 .quick-icon {
   position: relative;
-  width: 48px;
-  height: 48px;
+  width: 44px;
+  height: 44px;
   border-radius: 8px;
   display: flex;
   align-items: center;
   justify-content: center;
   color: #fff;
-  font-size: 24px;
+  font-size: 22px;
 }
 .todo-badge :deep(.el-badge__content) {
   position: absolute;

+ 105 - 2
src/views/flow/instance/mine.vue

@@ -25,7 +25,28 @@
         </el-form-item>
       </el-form>
 
-      <el-table v-loading="loading" :data="tableData" border>
+      <!-- 批量操作工具条 -->
+      <div v-if="selectedIds.length > 0" class="batch-toolbar">
+        <div class="batch-info">
+          <el-checkbox :model-value="true" @change="clearSelection" />
+          <span>已选择 <strong>{{ selectedIds.length }}</strong> 项</span>
+        </div>
+        <div class="batch-actions">
+          <el-button type="warning" :icon="RefreshLeft" size="small" @click="handleBatchRevoke">批量撤回</el-button>
+          <el-button type="danger" :icon="Delete" size="small" @click="handleBatchDelete">批量删除</el-button>
+          <el-button size="small" @click="clearSelection">取消选择</el-button>
+        </div>
+      </div>
+
+      <el-table
+        ref="tableRef"
+        v-loading="loading"
+        :data="tableData"
+        border
+        row-key="id"
+        @selection-change="handleSelectionChange"
+      >
+        <el-table-column type="selection" width="48" />
         <el-table-column type="index" label="序号" width="60" />
         <el-table-column prop="definitionName" label="流程名称" />
         <el-table-column label="业务编号">
@@ -103,13 +124,15 @@
 
 <script setup lang="ts">
 import { ref, reactive, onMounted } from 'vue'
-import { listMyInstance, revokeInstance, deleteInstance } from '@/api/flow/instance'
+import { listMyInstance, revokeInstance, deleteInstance, batchRevokeInstance, batchDeleteInstance } from '@/api/flow/instance'
 import { listEnabled, getDefinition } from '@/api/flow/definition'
 import InstanceDetail from '@/views/flow/execute/InstanceDetail.vue'
 import { instanceStatusText, instanceStatusTagType } from '@/utils/flow'
 import { downloadTemplate as downloadFlowTemplate, importFlow } from '@/api/flow/import'
 import type { FlowInstance, FlowDefinition } from '@/types/flow'
 import { ElMessage, ElMessageBox } from 'element-plus'
+import { RefreshLeft, Delete } from '@element-plus/icons-vue'
+import type { TableInstance } from 'element-plus'
 
 const loading = ref(false)
 const tableData = ref<FlowInstance[]>([])
@@ -118,6 +141,8 @@ const detailVisible = ref(false)
 const selectedInstanceId = ref<number>(0)
 const definitionList = ref<FlowDefinition[]>([])
 const currentNodeOptions = ref<string[]>([])
+const tableRef = ref<TableInstance>()
+const selectedIds = ref<number[]>([])
 
 const queryParams = reactive({
   pageNum: 1,
@@ -126,6 +151,15 @@ const queryParams = reactive({
   currentNodeName: undefined as string | undefined
 })
 
+function handleSelectionChange(selection: FlowInstance[]) {
+  selectedIds.value = selection.map(row => row.id)
+}
+
+function clearSelection() {
+  selectedIds.value = []
+  tableRef.value?.clearSelection()
+}
+
 function onProcessNameChange() {
   queryParams.currentNodeName = undefined
   const def = definitionList.value.find(d => d.name === queryParams.processName)
@@ -168,6 +202,54 @@ async function handleDelete(row: FlowInstance) {
   }
 }
 
+function getRevocableIds(): number[] {
+  return selectedIds.value.filter(id => {
+    const row = tableData.value.find(r => r.id === id)
+    return row && (row.status === 0 || row.status === 1 || row.status === 4)
+  })
+}
+
+function getDeletableIds(): number[] {
+  return selectedIds.value.filter(id => {
+    const row = tableData.value.find(r => r.id === id)
+    return row && row.status === 6
+  })
+}
+
+async function handleBatchRevoke() {
+  const ids = getRevocableIds()
+  if (ids.length === 0) {
+    ElMessage.warning('请选择可撤回的流程(运行中或已回退)')
+    return
+  }
+  try {
+    await ElMessageBox.confirm(`确认批量撤回 ${ids.length} 个流程吗?`, '提示', { type: 'warning' })
+    await batchRevokeInstance(ids)
+    ElMessage.success('批量撤回成功')
+    clearSelection()
+    loadData()
+  } catch {
+    // cancel
+  }
+}
+
+async function handleBatchDelete() {
+  const ids = getDeletableIds()
+  if (ids.length === 0) {
+    ElMessage.warning('请选择可删除的流程(已撤回状态)')
+    return
+  }
+  try {
+    await ElMessageBox.confirm(`确认批量删除 ${ids.length} 个流程吗?删除后不可恢复。`, '提示', { type: 'error' })
+    await batchDeleteInstance(ids)
+    ElMessage.success('批量删除成功')
+    clearSelection()
+    loadData()
+  } catch {
+    // cancel
+  }
+}
+
 function handleQuery() {
   queryParams.pageNum = 1
   loadData()
@@ -187,6 +269,8 @@ async function loadData() {
     const res = await listMyInstance(queryParams)
     tableData.value = res.list
     total.value = res.total
+    const pageIds = new Set(res.list.map(r => r.id))
+    selectedIds.value = selectedIds.value.filter(id => pageIds.has(id))
   } finally {
     loading.value = false
   }
@@ -252,4 +336,23 @@ onMounted(() => {
 .card-header { display: flex; justify-content: space-between; align-items: center; }
 .pagination { margin-top: 20px; justify-content: flex-end; }
 .search-form { margin-bottom: 20px; }
+.batch-toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  background: #f5f7fa;
+  padding: 12px 16px;
+  border-radius: 8px;
+  margin-bottom: 16px;
+}
+.batch-info {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  color: #606266;
+}
+.batch-actions {
+  display: flex;
+  gap: 10px;
+}
 </style>

+ 265 - 144
src/views/flow/task/todo.vue

@@ -10,7 +10,9 @@
 
       <el-form :inline="true" :model="queryParams" class="search-form">
         <el-form-item label="流程名称">
-          <el-input v-model="queryParams.processName" placeholder="请输入流程名称" clearable />
+          <el-select v-model="queryParams.processName" placeholder="全部流程" clearable style="width: 200px;">
+            <el-option v-for="def in definitionList" :key="def.id" :label="def.name" :value="def.name" />
+          </el-select>
         </el-form-item>
         <el-form-item>
           <el-button type="primary" @click="handleQuery">查询</el-button>
@@ -38,74 +40,124 @@
         </div>
       </div>
 
-      <div v-loading="loading" class="task-card-list">
-        <el-empty v-if="!loading && tableData.length === 0" description="暂无待办任务" />
-        <el-card
-          v-for="row in filteredTableData"
-          :key="row.id"
-          class="task-card"
-          shadow="hover"
-          :class="{ 'is-selected': isSelected(row.id) }"
+      <!-- 飞书多维表格风格列表 -->
+      <div v-loading="loading" class="todo-table-wrapper">
+        <el-empty v-if="!loading && filteredTableData.length === 0" description="暂无待办任务" />
+        <el-table
+          v-else
+          ref="tableRef"
+          :data="filteredTableData"
+          class="todo-table"
+          row-key="id"
+          @selection-change="handleSelectionChange"
         >
-          <div class="task-card-main" @click="toggleSelect(row)">
-            <div class="task-card-header">
-              <el-checkbox
-                :model-value="isSelected(row.id)"
-                @click.stop
-                @change="(val: any) => handleSelectChange(val, row)"
-              />
-              <div class="task-title">{{ row.definitionName || '未命名流程' }}</div>
-              <el-tag v-if="row.urgency === 2" type="danger" size="small" effect="dark">已超时</el-tag>
-              <el-tag v-else-if="row.urgency === 1" type="warning" size="small" effect="dark">即将超时</el-tag>
-              <el-tag v-else-if="row.timeoutTime" type="info" size="small">正常</el-tag>
-            </div>
-            <div class="task-card-body">
-              <div class="task-meta">
-                <span class="meta-label">当前节点</span>
-                <el-tag type="warning" size="small">{{ row.nodeName }}</el-tag>
-              </div>
-              <div class="task-meta">
-                <span class="meta-label">到达时间</span>
-                <span>{{ row.createTime }}</span>
-              </div>
-              <div v-if="row.timeoutTime" class="task-meta">
-                <span class="meta-label">剩余时间</span>
-                <span :class="{ 'text-danger': row.urgency === 2, 'text-warning': row.urgency === 1 }">
-                  {{ formatRemaining(row) }}
-                </span>
-              </div>
-              <div v-if="row.instanceTitle" class="task-meta">
-                <span class="meta-label">实例标题</span>
-                <el-tooltip :content="row.instanceTitle" placement="top">
-                  <span class="meta-value ellipsis">{{ row.instanceTitle }}</span>
-                </el-tooltip>
+          <el-table-column type="selection" width="48" fixed="left" />
+
+          <el-table-column label="流程名称" min-width="200" fixed="left" show-overflow-tooltip>
+            <template #default="{ row }">
+              <div class="process-cell">
+                <div class="process-title">
+                  <el-tag
+                    :type="urgencyType(row.urgency)"
+                    size="small"
+                    effect="dark"
+                    class="urgency-tag"
+                  >
+                    {{ urgencyText(row.urgency) }}
+                  </el-tag>
+                  <span class="process-name">{{ row.definitionName || '未命名流程' }}</span>
+                </div>
+                <div v-if="row.instanceTitle" class="process-subtitle">
+                  {{ row.instanceTitle }}
+                </div>
               </div>
-              <div v-if="row.instanceNo" class="task-meta">
-                <span class="meta-label">实例编号</span>
-                <span class="meta-value">{{ row.instanceNo }}</span>
+            </template>
+          </el-table-column>
+
+          <el-table-column label="当前节点" width="140" show-overflow-tooltip>
+            <template #default="{ row }">
+              <el-tag type="warning" size="small">{{ row.nodeName }}</el-tag>
+            </template>
+          </el-table-column>
+
+          <el-table-column label="实例编号" width="170" show-overflow-tooltip>
+            <template #default="{ row }">
+              <span class="text-muted">{{ row.instanceNo || '—' }}</span>
+            </template>
+          </el-table-column>
+
+          <!-- 动态流程表单字段列 -->
+          <el-table-column
+            v-for="field in dynamicFields"
+            :key="field.name"
+            :label="field.label"
+            :prop="field.name"
+            min-width="140"
+            show-overflow-tooltip
+          >
+            <template #default="{ row }">
+              <span class="field-value">{{ getFieldDisplayValue(row, field) }}</span>
+            </template>
+          </el-table-column>
+
+          <el-table-column label="到达时间" width="160">
+            <template #default="{ row }">
+              <span class="time-text">{{ row.createTime }}</span>
+            </template>
+          </el-table-column>
+
+          <el-table-column label="剩余时间" width="120">
+            <template #default="{ row }">
+              <span v-if="row.timeoutTime" :class="{ 'text-danger': row.urgency === 2, 'text-warning': row.urgency === 1 }">
+                {{ formatRemaining(row) }}
+              </span>
+              <span v-else class="text-muted">—</span>
+            </template>
+          </el-table-column>
+
+          <el-table-column label="操作" width="220" fixed="right">
+            <template #default="{ row }">
+              <div class="operation-cell">
+                <el-button
+                  link
+                  type="success"
+                  size="small"
+                  :icon="Check"
+                  @click.stop="openQuickApprove(row, 'pass')"
+                >
+                  通过
+                </el-button>
+                <el-button
+                  link
+                  type="danger"
+                  size="small"
+                  :icon="Close"
+                  @click.stop="openQuickApprove(row, 'reject')"
+                >
+                  拒绝
+                </el-button>
+                <el-dropdown trigger="click" @command="(cmd: string) => handleMoreAction(cmd, row)">
+                  <el-button link type="primary" size="small">
+                    更多<el-icon class="el-icon--right"><arrow-down /></el-icon>
+                  </el-button>
+                  <template #dropdown>
+                    <el-dropdown-menu>
+                      <el-dropdown-item command="transfer">
+                        <el-icon><Switch /></el-icon>转办
+                      </el-dropdown-item>
+                      <el-dropdown-item command="addSign">
+                        <el-icon><Plus /></el-icon>加签
+                      </el-dropdown-item>
+                      <el-dropdown-item command="detail" divided>
+                        <el-icon><View /></el-icon>详情
+                      </el-dropdown-item>
+                    </el-dropdown-menu>
+                  </template>
+                </el-dropdown>
               </div>
-            </div>
-          </div>
-
-          <!-- 悬浮快捷操作 -->
-          <div class="task-card-actions">
-            <el-tooltip content="通过" placement="top">
-              <el-button circle type="success" size="small" :icon="Check" @click.stop="openQuickApprove(row, 'pass')" />
-            </el-tooltip>
-            <el-tooltip content="拒绝" placement="top">
-              <el-button circle type="danger" size="small" :icon="Close" @click.stop="openQuickApprove(row, 'reject')" />
-            </el-tooltip>
-            <el-tooltip content="转办" placement="top">
-              <el-button circle type="warning" size="small" :icon="Switch" @click.stop="openTransfer(row)" />
-            </el-tooltip>
-            <el-tooltip content="加签" placement="top">
-              <el-button circle type="info" size="small" :icon="Plus" @click.stop="handleAddSign(row)" />
-            </el-tooltip>
-            <el-tooltip content="详情" placement="top">
-              <el-button circle type="primary" size="small" :icon="View" @click.stop="handleDetail(row)" />
-            </el-tooltip>
-          </div>
-        </el-card>
+            </template>
+          </el-table-column>
+        </el-table>
       </div>
 
       <el-pagination
@@ -220,16 +272,18 @@
 <script setup lang="ts">
 import { ref, reactive, onMounted, computed } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
-import { Check, Close, Switch, Plus, View } from '@element-plus/icons-vue'
+import { Check, Close, Switch, Plus, View, ArrowDown } from '@element-plus/icons-vue'
 import { listTodo, approveTask, rejectTask, transferTask, addSignTask, batchApproveTask, batchRejectTask, listNextNodes } from '@/api/flow/task'
+import { listEnabled } from '@/api/flow/definition'
 import { listUsersByRole } from '@/api/system/role'
 import { listUser } from '@/api/system/user'
 import { uploadFile } from '@/api/file'
 import { getInstanceAttachments } from '@/api/flow/instance'
 import { beforeFileUpload, getUploadUrl, getFileName } from '@/utils/file'
-import type { FlowTask, ApprovalAction, Attachment, NextNode } from '@/types/flow'
+import type { FlowTask, ApprovalAction, Attachment, NextNode, FormField, FormFieldOption, FlowDefinition } from '@/types/flow'
 import { useUserStore } from '@/stores/user-store'
 import type { User } from '@/types/system'
+import type { TableInstance } from 'element-plus'
 import InstanceDetail from '@/views/flow/execute/InstanceDetail.vue'
 import FilePreview from '@/components/FilePreview/index.vue'
 
@@ -237,6 +291,8 @@ const userStore = useUserStore()
 const loading = ref(false)
 const tableData = ref<FlowTask[]>([])
 const total = ref(0)
+const tableRef = ref<TableInstance>()
+const definitionList = ref<FlowDefinition[]>([])
 const queryParams = reactive({
   pageNum: 1,
   pageSize: 10,
@@ -275,38 +331,86 @@ const filteredTableData = computed(() => {
   return tableData.value.filter(row => row.urgency === map[activeUrgency.value])
 })
 
-const userList = ref<User[]>([])
-const nextNodes = ref<NextNode[]>([])
+// 动态表单字段列:从当前页所有任务的 formSchema 聚合
+const dynamicFields = computed<FormField[]>(() => {
+  const fieldMap = new Map<string, FormField>()
+  filteredTableData.value.forEach(row => {
+    if (!row.formSchema) return
+    try {
+      const parsed = JSON.parse(row.formSchema)
+      if (Array.isArray(parsed)) {
+        parsed.forEach((field: FormField) => {
+          if (field.name && !fieldMap.has(field.name)) {
+            fieldMap.set(field.name, field)
+          }
+        })
+      }
+    } catch {
+      // ignore
+    }
+  })
+  return Array.from(fieldMap.values())
+})
 
-// 选择相关
-const selectedIds = ref<number[]>([])
+function parseFormData(row: FlowTask): Record<string, unknown> {
+  if (!row.formData) return {}
+  try {
+    return JSON.parse(row.formData)
+  } catch {
+    return {}
+  }
+}
 
-function isSelected(id: number) {
-  return selectedIds.value.includes(id)
+function normalizeOptions(options?: FormFieldOption[] | string[]): FormFieldOption[] {
+  if (!options || !Array.isArray(options)) return []
+  return options.map((opt: any) => {
+    if (typeof opt === 'string') {
+      return { label: opt, value: opt }
+    }
+    return { label: String(opt.label ?? opt.value), value: opt.value }
+  })
 }
 
-function toggleSelect(row: FlowTask) {
-  const idx = selectedIds.value.indexOf(row.id)
-  if (idx > -1) {
-    selectedIds.value.splice(idx, 1)
-  } else {
-    selectedIds.value.push(row.id)
-  }
+function formatValue(value: unknown): string {
+  if (value === undefined || value === null) return ''
+  if (typeof value === 'boolean') return value ? '是' : '否'
+  return String(value)
 }
 
-function handleSelectChange(val: boolean, row: FlowTask) {
-  if (val) {
-    if (!selectedIds.value.includes(row.id)) {
-      selectedIds.value.push(row.id)
-    }
-  } else {
-    const idx = selectedIds.value.indexOf(row.id)
-    if (idx > -1) selectedIds.value.splice(idx, 1)
+function formatSelectValue(value: unknown, field: FormField): string {
+  const options = normalizeOptions(field.options)
+  if (!options.length) return formatValue(value)
+  const list = field.multiple && Array.isArray(value) ? value : [value]
+  const labels = list.map(v => {
+    const found = options.find(opt => String(opt.value) === String(v))
+    return found ? found.label : formatValue(v)
+  })
+  return labels.join(',')
+}
+
+function getFieldDisplayValue(row: FlowTask, field: FormField): string {
+  const data = parseFormData(row)
+  const value = data[field.name]
+  if (value === undefined || value === null) return '—'
+  if (field.type === 'select') {
+    return formatSelectValue(value, field)
   }
+  return formatValue(value)
+}
+
+const userList = ref<User[]>([])
+const nextNodes = ref<NextNode[]>([])
+
+// 选择相关
+const selectedIds = ref<number[]>([])
+
+function handleSelectionChange(selection: FlowTask[]) {
+  selectedIds.value = selection.map(row => row.id)
 }
 
 function clearSelection() {
   selectedIds.value = []
+  tableRef.value?.clearSelection()
 }
 
 // 抽屉审批相关
@@ -352,7 +456,7 @@ function buildActionData(): ApprovalAction {
     comment: form.comment
   }
   if (form.action === 'transfer' && form.transferTo) {
-    data.transferToUserId = Number(form.transferTo)
+    data.transferTo = Number(form.transferTo)
   }
   if (form.action === 'pass' && form.targetNodeId) {
     data.targetNodeId = form.targetNodeId
@@ -529,6 +633,20 @@ async function submitAddSign() {
   }
 }
 
+function handleMoreAction(cmd: string, row: FlowTask) {
+  switch (cmd) {
+    case 'transfer':
+      openTransfer(row)
+      break
+    case 'addSign':
+      handleAddSign(row)
+      break
+    case 'detail':
+      handleDetail(row)
+      break
+  }
+}
+
 async function loadData() {
   loading.value = true
   try {
@@ -565,9 +683,18 @@ function resetQuery() {
   loadData()
 }
 
+async function loadDefinitions() {
+  try {
+    definitionList.value = await listEnabled()
+  } catch {
+    definitionList.value = []
+  }
+}
+
 onMounted(() => {
   loadData()
   loadUsers()
+  loadDefinitions()
 })
 </script>
 
@@ -590,6 +717,13 @@ onMounted(() => {
 .text-warning {
   color: #e6a23c;
 }
+.text-muted {
+  color: #909399;
+}
+.time-text {
+  color: #606266;
+  font-size: 13px;
+}
 .batch-toolbar {
   display: flex;
   justify-content: space-between;
@@ -609,81 +743,68 @@ onMounted(() => {
   display: flex;
   gap: 10px;
 }
-.task-card-list {
-  display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
-  gap: 16px;
-}
-.task-card {
-  position: relative;
-  transition: all 0.2s ease;
-  cursor: pointer;
+
+/* 飞书多维表格风格列表 */
+.todo-table-wrapper {
+  border: 1px solid #e4e7ed;
+  border-radius: 8px;
   overflow: hidden;
 }
-.task-card:hover {
-  transform: translateY(-2px);
-  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+.todo-table {
+  width: 100%;
 }
-.task-card.is-selected {
-  border-color: #409eff;
-  background: #f0f9ff;
+.todo-table :deep(.el-table__header-wrapper th) {
+  background-color: #f5f7fa;
+  color: #606266;
+  font-weight: 600;
+  font-size: 13px;
+  padding: 12px 0;
 }
-.task-card-main {
-  padding-right: 48px;
+.todo-table :deep(.el-table__row) {
+  transition: background-color 0.2s ease;
 }
-.task-card-header {
-  display: flex;
-  align-items: center;
-  gap: 10px;
-  margin-bottom: 12px;
+.todo-table :deep(.el-table__row:hover) {
+  background-color: #f5f7ff !important;
 }
-.task-title {
-  font-size: 16px;
-  font-weight: bold;
-  color: #303133;
-  flex: 1;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
+.todo-table :deep(.el-table__cell) {
+  padding: 12px 0;
 }
-.task-card-body {
+.process-cell {
   display: flex;
   flex-direction: column;
-  gap: 8px;
+  gap: 4px;
 }
-.task-meta {
+.process-title {
   display: flex;
   align-items: center;
-  font-size: 13px;
-  color: #606266;
+  gap: 8px;
 }
-.meta-label {
-  color: #909399;
-  width: 70px;
+.urgency-tag {
   flex-shrink: 0;
 }
-.meta-value {
-  flex: 1;
+.process-name {
+  font-weight: 600;
+  color: #303133;
+  font-size: 14px;
+}
+.process-subtitle {
+  color: #909399;
+  font-size: 12px;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
 }
-.task-card-actions {
-  position: absolute;
-  top: 12px;
-  right: 12px;
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-  opacity: 0.3;
-  transition: opacity 0.2s ease;
+.field-value {
+  color: #303133;
+  font-size: 13px;
 }
-.task-card:hover .task-card-actions,
-.task-card.is-selected .task-card-actions {
-  opacity: 1;
+.operation-cell {
+  display: flex;
+  align-items: center;
+  gap: 4px;
 }
-.task-card-actions .el-button {
-  margin: 0;
+.operation-cell .el-button {
+  padding: 4px 6px;
 }
 .pagination {
   margin-top: 20px;

+ 1 - 1
src/views/workbench/index.vue

@@ -388,7 +388,7 @@ function resetApproveForm() {
 function buildActionData(): ApprovalAction {
   const data: ApprovalAction = { action: form.action, comment: form.comment }
   if (form.action === 'transfer' && form.transferTo) {
-    data.transferToUserId = Number(form.transferTo)
+    data.transferTo = Number(form.transferTo)
   }
   if (form.action === 'pass' && form.targetNodeId) {
     data.targetNodeId = form.targetNodeId