瀏覽代碼

Merge branch 'feature/flow-design'

wuwenyi 6 天之前
父節點
當前提交
4804ca92aa
共有 69 個文件被更改,包括 6134 次插入3339 次删除
  1. 30 0
      .eslintrc.cjs
  2. 1 0
      .gitignore
  3. 3 0
      .prettierignore
  4. 9 0
      .prettierrc
  5. 1 1
      Dockerfile
  6. 47 57
      README.md
  7. 875 55
      package-lock.json
  8. 17 1
      package.json
  9. 16 18
      src/api/analysis.ts
  10. 6 6
      src/api/auth.ts
  11. 2 33
      src/api/file.ts
  12. 14 11
      src/api/flow/definition.ts
  13. 39 0
      src/api/flow/import.ts
  14. 40 11
      src/api/flow/instance.ts
  15. 28 16
      src/api/flow/task.ts
  16. 134 37
      src/api/request.ts
  17. 6 6
      src/api/system/dept.ts
  18. 3 3
      src/api/system/notification-config.ts
  19. 10 9
      src/api/system/role.ts
  20. 15 8
      src/api/system/user.ts
  21. 26 0
      src/api/wecom.ts
  22. 45 0
      src/components/AddSignDialog/index.vue
  23. 144 0
      src/components/ApprovalDrawer/index.vue
  24. 14 4
      src/components/FlowFormFields/index.vue
  25. 5 26
      src/components/Sidebar/index.vue
  26. 51 0
      src/composables/useAddSign.ts
  27. 186 0
      src/composables/useApproval.ts
  28. 62 0
      src/composables/useFileUpload.ts
  29. 2 2
      src/main.ts
  30. 65 100
      src/router/index.ts
  31. 8 9
      src/stores/user-store.ts
  32. 14 0
      src/types/flow.ts
  33. 1 6
      src/types/system.ts
  34. 1 1
      src/utils/auth.ts
  35. 43 8
      src/utils/file.ts
  36. 16 4
      src/utils/flow.ts
  37. 4 11
      src/utils/format.ts
  38. 38 0
      src/utils/permission.ts
  39. 28 0
      src/views/analysis/EfficiencyChart.vue
  40. 133 0
      src/views/analysis/KpiCards.vue
  41. 19 0
      src/views/analysis/NodeCountChart.vue
  42. 129 0
      src/views/analysis/StuckInstanceTable.vue
  43. 18 0
      src/views/analysis/TimeoutChart.vue
  44. 42 0
      src/views/analysis/TrendChart.vue
  45. 62 657
      src/views/analysis/index.vue
  46. 518 0
      src/views/analysis/useAnalysis.ts
  47. 122 134
      src/views/dashboard/index.vue
  48. 102 3
      src/views/flow/definition/index.vue
  49. 122 0
      src/views/flow/designer/DesignerCanvas.vue
  50. 173 0
      src/views/flow/designer/DesignerPropertyPanel.vue
  51. 176 0
      src/views/flow/designer/DesignerSaveDialog.vue
  52. 82 909
      src/views/flow/designer/index.vue
  53. 841 0
      src/views/flow/designer/useFlowDesigner.ts
  54. 185 118
      src/views/flow/execute/InstanceDetail.vue
  55. 157 236
      src/views/flow/execute/index.vue
  56. 136 0
      src/views/flow/instance/all.vue
  57. 221 32
      src/views/flow/instance/mine.vue
  58. 16 10
      src/views/flow/task/handled.vue
  59. 444 407
      src/views/flow/task/todo.vue
  60. 3 19
      src/views/login/index.vue
  61. 1 1
      src/views/system/notification-config/index.vue
  62. 20 124
      src/views/system/role/index.vue
  63. 191 23
      src/views/system/user/index.vue
  64. 3 3
      src/views/workbench/components/TaskCard.vue
  65. 24 220
      src/views/workbench/index.vue
  66. 63 0
      tests/unit/permission.spec.ts
  67. 18 0
      tests/unit/request.spec.ts
  68. 43 0
      tests/unit/useApproval.spec.ts
  69. 21 0
      vitest.config.ts

+ 30 - 0
.eslintrc.cjs

@@ -0,0 +1,30 @@
+/* eslint-env node */
+module.exports = {
+  root: true,
+  env: {
+    browser: true,
+    es2021: true,
+    node: true
+  },
+  parser: 'vue-eslint-parser',
+  parserOptions: {
+    parser: '@typescript-eslint/parser',
+    ecmaVersion: 'latest',
+    sourceType: 'module',
+    extraFileExtensions: ['.vue']
+  },
+  extends: [
+    'eslint:recommended',
+    'plugin:@typescript-eslint/recommended',
+    'plugin:vue/vue3-recommended',
+    'plugin:prettier/recommended'
+  ],
+  plugins: ['@typescript-eslint', 'vue'],
+  rules: {
+    'vue/multi-word-component-names': 'off',
+    '@typescript-eslint/no-explicit-any': 'warn',
+    '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
+    'no-console': 'warn'
+  },
+  ignorePatterns: ['dist', 'node_modules', '*.d.ts']
+}

+ 1 - 0
.gitignore

@@ -17,6 +17,7 @@ dist-ssr/
 .idea/
 
 # 日志
+*.log
 npm-debug.log*
 yarn-debug.log*
 yarn-error.log*

+ 3 - 0
.prettierignore

@@ -0,0 +1,3 @@
+node_modules
+dist
+*.log

+ 9 - 0
.prettierrc

@@ -0,0 +1,9 @@
+{
+  "semi": false,
+  "singleQuote": true,
+  "trailingComma": "es5",
+  "printWidth": 120,
+  "tabWidth": 2,
+  "useTabs": false,
+  "endOfLine": "auto"
+}

+ 1 - 1
Dockerfile

@@ -5,7 +5,7 @@ RUN npm config set registry https://registry.npmmirror.com
 COPY package*.json ./
 RUN --mount=type=cache,target=/root/.npm npm ci --legacy-peer-deps
 # BUILD_BUSTER 每次改值可破 Docker 缓存(COPY . . 之后会重新执行)
-ARG BUILD_BUSTER=1
+ARG BUILD_BUSTER=2
 COPY . .
 RUN npx vite build
 

+ 47 - 57
README.md

@@ -4,18 +4,12 @@
 
 ## 技术栈
 
-- Vue 3.4 + Vite 4.5
-- TypeScript 5
+- Vue 3.4 + Vite 4.5 + TypeScript 5
 - Element Plus 2.7
-- Pinia
-- Vue Router 4
+- Pinia + Vue Router 4
+- LogicFlow 1.2(流程设计器,v1.x 无 destroy 方法)
+- ECharts 6 + vue-echarts(分析看板)
 - Axios
-- LogicFlow 1.2(流程设计器)
-
-## 环境要求
-
-- Node.js 18+
-- npm 9+
 
 ## 快速启动
 
@@ -25,67 +19,63 @@ npm install
 npm run dev
 ```
 
-开发服务器默认启动在 `http://localhost:3000`,Vite 会自动代理 `/api` 请求到后端 `http://localhost:8080`。
+开发服务器:`http://localhost:3000`,自动代理 `/api` 到 `http://localhost:8080`。
 
 ## 构建部署
 
 ```bash
-# 生产构建
-npm run build
-
-# 预览生产构建
-npm run preview
+npm run build     # 输出到 dist/
+npm run preview   # 预览构建产物
 ```
 
-构建产物输出到 `dist/` 目录
+Docker 构建时如遇缓存问题,修改 `Dockerfile` 中的 `ARG BUILD_BUSTER` 值破缓存。
 
-## 开发说明
+## 默认账号
 
-### 代理配置
+密码均为 `123456a`:
 
-开发环境的 API 代理配置在 `vite.config.ts`:
+| 账号 | 可见菜单 |
+|------|---------|
+| admin | 全部(首页、审批中心、流程管理、系统管理、统计分析) |
+| zhangsan | 首页、审批中心 |
+| lisi | 首页、审批中心、系统管理 |
+| wangwu | 首页、审批中心、流程管理、统计分析 |
 
-```ts
-server: {
-  port: 3000,
-  proxy: {
-    '/api': {
-      target: 'http://localhost:8080',
-      changeOrigin: true,
-      rewrite: (path) => path.replace(/^\/api/, '')
-    }
-  }
-}
-```
+菜单可见性由 `employeeType` 控制(`super_admin` / `flow_manager` / `dept_manager` / `common_user`)。
+
+## 页面说明
 
-如果后端运行在其他地址/端口,请修改 `vite.config.ts` 中的 `target`。
+| 路由 | 页面 | 说明 |
+|------|------|------|
+| `/dashboard` | 首页 | 统计卡片 + 快捷入口 |
+| `/approval/todo` | 待办任务 | 审批通过/拒绝/转办/加签、分支选择 |
+| `/approval/mine` | 我的发起 | 已发起流程列表、筛选 |
+| `/flow/definition` | 流程管理 | 流程 CRUD、发布/停用、编辑表单和导入配置 |
+| `/flow/designer` | 流程设计器 | 拖拽设计审批流程(管理员可见) |
+| `/flow/execute` | 流程执行 | 发起流程(选择已发布流程) |
+| `/flow/instance/mine` | 我的发起 | 流程实例列表 + 批量导入 |
+| `/flow/instance/all` | 流程明细 | 全部实例(管理员) |
+| `/analysis` | 分析看板 | KPI、趋势、效率、节点统计、流程明细 |
+| `/system/user` | 用户管理 | 系统用户 CRUD + 分配角色 |
+| `/system/role` | 审批角色 | 角色 CRUD + 企微配置 |
 
-### 项目结构
+## 项目结构
 
 ```
 src/
-├── api/           # API 接口封装
-├── components/    # 公共组件(Layout、Navbar、Sidebar)
-├── router/        # 路由配置
-├── stores/        # Pinia 状态管理
-├── types/         # TypeScript 类型定义
-├── utils/         # 工具函数
-├── views/         # 页面视图
-│   ├── dashboard/
-│   ├── login/
-│   ├── flow/      # 流程模块(定义、设计器、审批、执行)
-│   └── system/    # 系统模块(用户、角色、部门、菜单)
-├── App.vue
+├── api/                # API 接口层
+│   └── flow/           # 流程相关(task、definition、instance、import)
+├── components/         # 公共组件(Layout、Sidebar、FlowFormFields、FilePreview)
+├── router/             # 路由 + 权限守卫
+├── stores/             # Pinia(user-store)
+├── types/              # TypeScript 类型定义
+├── utils/              # 工具函数(auth、file、flow、format)
+├── views/
+│   ├── dashboard/      # 首页
+│   ├── login/          # 登录
+│   ├── workbench/      # 审批中心(待办/已办/抄送/我的发起)
+│   ├── flow/           # 流程设计器、定义、执行、实例
+│   ├── system/         # 用户管理、审批角色
+│   └── analysis/       # 数据分析看板
 └── main.ts
 ```
-
-### 登录
-
-默认管理员账号:`admin` / `admin123`
-
-支持"记住密码"功能,勾选后账号密码会保存到浏览器 localStorage。
-
-## 注意事项
-
-1. 开发服务器端口 3000 被占用时,Vite 会自动切换到下一个可用端口
-2. 确保后端服务已启动(`http://localhost:8080`),否则 API 请求会失败

File diff suppressed because it is too large
+ 875 - 55
package-lock.json


+ 17 - 1
package.json

@@ -6,7 +6,12 @@
   "scripts": {
     "dev": "vite",
     "build": "vue-tsc --noEmit && vite build",
-    "preview": "vite preview"
+    "preview": "vite preview",
+    "lint": "eslint src",
+    "lint:fix": "eslint src --fix",
+    "format": "prettier --write \"src/**/*.{ts,vue}\"",
+    "test": "vitest run",
+    "test:coverage": "vitest run --coverage"
   },
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.1",
@@ -26,9 +31,20 @@
   },
   "devDependencies": {
     "@types/js-cookie": "^3.0.6",
+    "@typescript-eslint/eslint-plugin": "^6.21.0",
+    "@typescript-eslint/parser": "^6.21.0",
     "@vitejs/plugin-vue": "^4.5.0",
+    "@vitest/coverage-v8": "^0.34.6",
+    "@vue/test-utils": "^2.4.1",
+    "eslint": "^8.57.0",
+    "eslint-config-prettier": "^9.1.0",
+    "eslint-plugin-prettier": "^5.1.3",
+    "eslint-plugin-vue": "^9.27.0",
+    "jsdom": "^22.1.0",
+    "prettier": "^3.3.3",
     "typescript": "^5.5.3",
     "vite": "^4.5.3",
+    "vitest": "^0.34.6",
     "vue-tsc": "^2.0.26"
   }
 }

+ 16 - 18
src/api/analysis.ts

@@ -1,4 +1,4 @@
-import request from '@/api/request'
+import { http } from '@/api/request'
 import type { PageQuery, PageResult } from '@/types/api'
 import type { AxiosRequestConfig } from 'axios'
 import type {
@@ -10,30 +10,24 @@ import type {
   Trend,
   EfficiencyQuery,
   OverviewQuery,
-  StuckInstanceQuery
+  StuckInstanceQuery,
 } from '@/types/analysis'
 
-export function getOverview(
-  params?: OverviewQuery,
-  config?: AxiosRequestConfig
-): Promise<AnalysisOverview> {
-  return request.get('/analysis/overview', { params, ...config })
+export function getOverview(params?: OverviewQuery, config?: AxiosRequestConfig): Promise<AnalysisOverview> {
+  return http.get('/analysis/overview', { params, ...config })
 }
 
 export function getStatusDistribution(
   params?: { processDefinitionId?: number; startTime?: string; endTime?: string },
   config?: AxiosRequestConfig
 ): Promise<StatusDistribution[]> {
-  return request.get('/analysis/status-distribution', { params, ...config })
+  return http.get('/analysis/status-distribution', { params, ...config })
 }
 
-export function getTrend(
-  processDefinitionId?: number,
-  config?: AxiosRequestConfig
-): Promise<Trend[]> {
-  return request.get('/analysis/trend', {
+export function getTrend(processDefinitionId?: number, config?: AxiosRequestConfig): Promise<Trend[]> {
+  return http.get('/analysis/trend', {
     params: processDefinitionId !== undefined ? { processDefinitionId } : undefined,
-    ...config
+    ...config,
   })
 }
 
@@ -41,16 +35,16 @@ export function getCompletedEfficiency(
   params: EfficiencyQuery,
   config?: AxiosRequestConfig
 ): Promise<ProcessEfficiency[]> {
-  return request.get('/analysis/completed-efficiency', { params, ...config })
+  return http.get('/analysis/completed-efficiency', { params, ...config })
 }
 
 export function getInProgressByNode(
   processDefinitionId?: number,
   config?: AxiosRequestConfig
 ): Promise<NodeStayStat[]> {
-  return request.get('/analysis/in-progress-by-node', {
+  return http.get('/analysis/in-progress-by-node', {
     params: processDefinitionId !== undefined ? { processDefinitionId } : undefined,
-    ...config
+    ...config,
   })
 }
 
@@ -58,5 +52,9 @@ export function getStuckInstances(
   params: StuckInstanceQuery & PageQuery,
   config?: AxiosRequestConfig
 ): Promise<PageResult<StuckInstance>> {
-  return request.get('/analysis/stuck-instances', { params, ...config })
+  return http.get('/analysis/stuck-instances', { params, ...config })
+}
+
+export function getNodeCount(processDefinitionId: number): Promise<{ nodeName: string; count: number }[]> {
+  return http.get('/analysis/node-count', { params: { processDefinitionId } })
 }

+ 6 - 6
src/api/auth.ts

@@ -1,19 +1,19 @@
-import request from './request'
-import type { ApiResponse, LoginData, LoginResult } from '@/types'
+import { http } from './request'
+import type { LoginData, LoginResult } from '@/types'
 import type { User } from '@/types/system'
 
 export function login(data: LoginData): Promise<LoginResult> {
-  return request.post('/auth/login', data)
+  return http.post('/auth/login', data)
 }
 
 export function logout(): Promise<void> {
-  return request.post('/auth/logout')
+  return http.post('/auth/logout')
 }
 
 export function getUserInfo(): Promise<User> {
-  return request.get('/auth/info')
+  return http.get('/auth/info')
 }
 
 export function changePassword(oldPassword: string, newPassword: string): Promise<void> {
-  return request.post('/auth/change-password', { oldPassword, newPassword })
+  return http.post('/auth/change-password', { oldPassword, newPassword })
 }

+ 2 - 33
src/api/file.ts

@@ -1,38 +1,7 @@
-import request from '@/api/request'
+import { http } from '@/api/request'
 
 export function uploadFile(file: File): Promise<string> {
   const formData = new FormData()
   formData.append('file', file)
-  return request.post('/file/upload', formData)
-}
-
-/**
- * 解析 Excel 文件,返回表单数据
- * @param file Excel 文件
- * @param definitionId 流程定义 ID
- * @param mappings Excel 列名 -> 系统字段名 映射
- */
-export function parseExcel(file: File, definitionId: number, mappings: Record<string, string>): Promise<Record<string, unknown>> {
-  const formData = new FormData()
-  formData.append('file', file)
-  formData.append('definitionId', String(definitionId))
-  formData.append('mappings', JSON.stringify(mappings))
-  return request.post('/file/parse-excel', formData)
-}
-
-/**
- * 下载流程表单 Excel 模板
- */
-export async function downloadTemplate(definitionId: number): Promise<void> {
-  const blob = (await request.get(`/file/template/${definitionId}`, {
-    responseType: 'blob'
-  })) as unknown as Blob
-  const url = window.URL.createObjectURL(blob)
-  const link = document.createElement('a')
-  link.href = url
-  link.download = 'template.xlsx'
-  document.body.appendChild(link)
-  link.click()
-  document.body.removeChild(link)
-  window.URL.revokeObjectURL(url)
+  return http.post('/file/upload', formData)
 }

+ 14 - 11
src/api/flow/definition.ts

@@ -1,40 +1,43 @@
-import request from '@/api/request'
+import { http } from '@/api/request'
 import type { FlowDefinition } from '@/types/flow'
 import type { PageQuery, PageResult } from '@/types/api'
 import type { AxiosRequestConfig } from 'axios'
 
-export function listDefinition(params?: PageQuery & Partial<FlowDefinition>, config?: AxiosRequestConfig): Promise<PageResult<FlowDefinition>> {
-  return request.get('/flow/definition/page', { params, ...config })
+export function listDefinition(
+  params?: PageQuery & Partial<FlowDefinition>,
+  config?: AxiosRequestConfig
+): Promise<PageResult<FlowDefinition>> {
+  return http.get('/flow/definition/page', { params, ...config })
 }
 
 export function getDefinition(id: number): Promise<FlowDefinition> {
-  return request.get(`/flow/definition/${id}`)
+  return http.get(`/flow/definition/${id}`)
 }
 
 export function addDefinition(data: Partial<FlowDefinition>): Promise<void> {
-  return request.post('/flow/definition', data)
+  return http.post('/flow/definition', data)
 }
 
 export function updateDefinition(data: Partial<FlowDefinition>): Promise<number> {
-  return request.put('/flow/definition', data)
+  return http.put('/flow/definition', data)
 }
 
 export function deleteDefinition(id: number): Promise<void> {
-  return request.delete(`/flow/definition/${id}`)
+  return http.delete(`/flow/definition/${id}`)
 }
 
 export function publishDefinition(id: number): Promise<void> {
-  return request.post(`/flow/definition/${id}/publish`)
+  return http.post(`/flow/definition/${id}/publish`)
 }
 
 export function stopDefinition(id: number): Promise<void> {
-  return request.post(`/flow/definition/${id}/stop`)
+  return http.post(`/flow/definition/${id}/stop`)
 }
 
 export function enableDefinition(id: number): Promise<void> {
-  return request.post(`/flow/definition/${id}/enable`)
+  return http.post(`/flow/definition/${id}/enable`)
 }
 
 export function listEnabled(): Promise<FlowDefinition[]> {
-  return request.get('/flow/definition/list-enabled')
+  return http.get('/flow/definition/list-enabled')
 }

+ 39 - 0
src/api/flow/import.ts

@@ -0,0 +1,39 @@
+import { http } from '@/api/request'
+
+/** 下载流程 Excel 导入模板 */
+export async function downloadTemplate(definitionId: number, filename?: string): Promise<void> {
+  const blob = await http.get<Blob>(`/flow/import/template/${definitionId}`, {
+    responseType: 'blob',
+    timeout: 120000,
+  })
+  const url = window.URL.createObjectURL(blob)
+  const link = document.createElement('a')
+  link.href = url
+  link.download = (filename || 'template') + '.xlsx'
+  document.body.appendChild(link)
+  link.click()
+  document.body.removeChild(link)
+  window.URL.revokeObjectURL(url)
+}
+
+/** 批量导入流程 */
+export function importFlow(
+  file: File,
+  definitionId: number,
+  mappings: Record<string, string>
+): Promise<{
+  total: number
+  successCount: number
+  updateCount: number
+  instanceIds: number[]
+}> {
+  const formData = new FormData()
+  formData.append('file', file)
+  formData.append('definitionId', String(definitionId))
+  formData.append('mappings', JSON.stringify(mappings))
+  return http.post<{ total: number; successCount: number; updateCount: number; instanceIds: number[] }>(
+    '/flow/import/execute',
+    formData,
+    { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120000 }
+  )
+}

+ 40 - 11
src/api/flow/instance.ts

@@ -1,41 +1,70 @@
-import request from '@/api/request'
+import { http } from '@/api/request'
 import type { Attachment, FlowInstance, ProcessProgress } from '@/types/flow'
 import type { PageQuery, PageResult } from '@/types/api'
 import type { AxiosRequestConfig } from 'axios'
 
 export function listMyInstance(params?: PageQuery): Promise<PageResult<FlowInstance>> {
-  return request.get('/flow/instance/mine', { params })
+  return http.get('/flow/instance/mine', { params })
 }
 
-export function startInstance(processDefinitionId: number, title?: string, formData?: Record<string, unknown>, attachmentUrls?: string): Promise<void> {
-  return request.post('/flow/instance/start', {
+export function listInstance(
+  params?: PageQuery & { applicantName?: string; processName?: string; definitionName?: string; currentNodeName?: string }
+): Promise<PageResult<FlowInstance>> {
+  return http.get('/flow/instance/page', { params })
+}
+
+export function startInstance(
+  processDefinitionId: number,
+  title?: string,
+  formData?: Record<string, unknown>,
+  attachmentUrls?: string
+): Promise<void> {
+  return http.post('/flow/instance/start', {
     processDefinitionId,
     title,
     formData: formData ? JSON.stringify(formData) : undefined,
-    attachmentUrls
+    attachmentUrls,
   })
 }
 
 export function getInstance(id: number): Promise<FlowInstance> {
-  return request.get(`/flow/instance/${id}`)
+  return http.get(`/flow/instance/${id}`)
 }
 
 export function getProgress(id: number, config?: AxiosRequestConfig): Promise<ProcessProgress> {
-  return request.get(`/flow/instance/${id}/progress`, config)
+  return http.get(`/flow/instance/${id}/progress`, config)
 }
 
 export function participatedList(params?: PageQuery): Promise<PageResult<FlowInstance>> {
-  return request.get('/flow/instance/participated', { params })
+  return http.get('/flow/instance/participated', { params })
 }
 
 export function revokeInstance(id: number): Promise<void> {
-  return request.post(`/flow/instance/${id}/revoke`)
+  return http.post(`/flow/instance/${id}/revoke`)
+}
+
+export function batchRevokeInstance(ids: number[]): Promise<void> {
+  return http.post('/flow/instance/batch-revoke', ids)
 }
 
 export function deleteInstance(id: number): Promise<void> {
-  return request.delete(`/flow/instance/${id}`)
+  return http.delete(`/flow/instance/${id}`)
+}
+
+export function batchDeleteInstance(ids: number[]): Promise<void> {
+  return http.post('/flow/instance/batch-delete', ids)
 }
 
 export function getInstanceAttachments(id: number): Promise<Attachment[]> {
-  return request.get(`/flow/instance/${id}/attachments`)
+  return http.get(`/flow/instance/${id}/attachments`)
+}
+
+export function updateInstanceFormData(id: number, formData: Record<string, unknown>): Promise<void> {
+  return http.post(`/flow/instance/${id}/form-data`, {
+    formData: JSON.stringify(formData),
+  })
+}
+
+export function migrateInstances(fromDefinitionId: number, toDefinitionId: number): Promise<{ migratedCount: number }> {
+  return http.post('/flow/instance/migrate-all', null, { params: { fromDefinitionId, toDefinitionId } })
 }

+ 28 - 16
src/api/flow/task.ts

@@ -1,55 +1,67 @@
-import request from '@/api/request'
-import type { FlowTask, ApprovalAction } from '@/types/flow'
+import { http } from '@/api/request'
+import type { FlowTask, ApprovalAction, NextNode } from '@/types/flow'
 import type { PageQuery, PageResult } from '@/types/api'
+import type { User } from '@/types/system'
 
-export function listTodo(params?: PageQuery): Promise<PageResult<FlowTask>> {
-  return request.get('/flow/task/todo', { params })
+export function listTodo(
+  params?: PageQuery & { processName?: string; formFilters?: string }
+): Promise<PageResult<FlowTask>> {
+  return http.get('/flow/task/todo', { params })
 }
 
 export function listHandled(params?: PageQuery): Promise<PageResult<FlowTask>> {
-  return request.get('/flow/task/handled', { params })
+  return http.get('/flow/task/handled', { params })
 }
 
 export function getTask(id: number): Promise<FlowTask> {
-  return request.get(`/flow/task/${id}`)
+  return http.get(`/flow/task/${id}`)
 }
 
 export function approveTask(id: number, data: ApprovalAction): Promise<void> {
-  return request.post(`/flow/task/${id}/approve`, data)
+  return http.post(`/flow/task/${id}/approve`, data)
 }
 
 export function rejectTask(id: number, data: ApprovalAction): Promise<void> {
-  return request.post(`/flow/task/${id}/reject`, data)
+  return http.post(`/flow/task/${id}/reject`, data)
 }
 
 export function batchApproveTask(taskIds: number[], data: ApprovalAction): Promise<void> {
-  return request.post('/flow/task/batch-approve', { taskIds, comment: data.comment, attachmentUrls: data.attachmentUrls })
+  return http.post('/flow/task/batch-approve', { taskIds, comment: data.comment, attachmentUrls: data.attachmentUrls })
 }
 
 export function batchRejectTask(taskIds: number[], data: ApprovalAction): Promise<void> {
-  return request.post('/flow/task/batch-reject', { taskIds, comment: data.comment, attachmentUrls: data.attachmentUrls })
+  return http.post('/flow/task/batch-reject', { taskIds, comment: data.comment, attachmentUrls: data.attachmentUrls })
 }
 
 export function returnTask(id: number, data: ApprovalAction): Promise<void> {
-  return request.post(`/flow/task/${id}/return`, data)
+  return http.post(`/flow/task/${id}/return`, data)
 }
 
 export function transferTask(id: number, data: ApprovalAction): Promise<void> {
-  return request.post(`/flow/task/${id}/transfer`, data)
+  return http.post(`/flow/task/${id}/transfer`, data)
 }
 
 export function todoCount(): Promise<number> {
-  return request.get('/flow/task/todo-count')
+  return http.get('/flow/task/todo-count')
 }
 
 export function addSignTask(taskId: number, assigneeId: number): Promise<void> {
-  return request.post(`/flow/task/${taskId}/add-sign?assigneeId=${assigneeId}`)
+  return http.post(`/flow/task/${taskId}/add-sign?assigneeId=${assigneeId}`)
 }
 
 export function listCc(params?: PageQuery): Promise<PageResult<FlowTask>> {
-  return request.get('/flow/task/cc', { params })
+  return http.get('/flow/task/cc', { params })
 }
 
 export function readCc(taskId: number): Promise<void> {
-  return request.post(`/flow/task/cc/${taskId}/read`)
+  return http.post(`/flow/task/cc/${taskId}/read`)
+}
+
+export function listNextNodes(taskId: number): Promise<NextNode[]> {
+  return http.get(`/flow/task/${taskId}/next-nodes`)
+}
+
+/** 获取可转办/加签的用户列表(按当前节点审批角色过滤) */
+export function getTransferableUsers(taskId: number): Promise<User[]> {
+  return http.get(`/flow/task/${taskId}/transferable-users`)
 }

+ 134 - 37
src/api/request.ts

@@ -1,9 +1,15 @@
-import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from 'axios'
+import axios, {
+  type AxiosInstance,
+  type AxiosError,
+  type AxiosRequestConfig,
+  type AxiosResponse,
+  type InternalAxiosRequestConfig,
+} from 'axios'
 import { ElMessage } from 'element-plus'
-import { getToken, removeToken } from '@/utils/auth'
+import { getToken, setToken, removeToken } from '@/utils/auth'
 import type { ApiResponse } from '@/types'
 
-// 扩展 axios 请求配置,支持静默模式(页面初始化查询失败时不弹全局错误提示)
+// 扩展 axios 请求配置,支持静默模式与请求取消
 declare module 'axios' {
   interface AxiosRequestConfig {
     silent?: boolean
@@ -14,7 +20,13 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
 
 const request: AxiosInstance = axios.create({
   baseURL: API_BASE_URL,
-  timeout: 10000
+  timeout: 10000,
+})
+
+// 独立的刷新请求客户端,避免拦截器递归触发刷新
+const refreshClient: AxiosInstance = axios.create({
+  baseURL: API_BASE_URL,
+  timeout: 10000,
 })
 
 function getRequestTimeout(config: InternalAxiosRequestConfig): number {
@@ -30,48 +42,117 @@ function getRequestTimeout(config: InternalAxiosRequestConfig): number {
   return config.timeout || 10000
 }
 
-request.interceptors.request.use(
-  (config: InternalAxiosRequestConfig) => {
-    const token = getToken()
-    if (token && config.headers) {
-      config.headers.Authorization = `Bearer ${token}`
-    }
-    config.timeout = getRequestTimeout(config)
-    return config
-  },
-  (error: AxiosError) => {
-    return Promise.reject(error)
+function attachToken(config: InternalAxiosRequestConfig) {
+  const token = getToken()
+  if (token && config.headers) {
+    config.headers.Authorization = `Bearer ${token}`
   }
-)
+  config.timeout = getRequestTimeout(config)
+  return config
+}
+
+request.interceptors.request.use(attachToken, (error: AxiosError) => Promise.reject(error))
+refreshClient.interceptors.request.use(attachToken, (error: AxiosError) => Promise.reject(error))
 
 let hasShownAuthError = false
+let isRefreshing = false
+let refreshPromise: Promise<string | null> | null = null
+
+function handleUnauthorized() {
+  if (hasShownAuthError) return
+  hasShownAuthError = true
+  removeToken()
+  setTimeout(() => {
+    window.location.href = '/login'
+    hasShownAuthError = false
+  }, 500)
+}
+
+async function syncStoreToken(token: string) {
+  try {
+    const { useUserStore } = await import('@/stores/user-store')
+    const userStore = useUserStore()
+    userStore.setToken(token)
+  } catch {
+    // store 未初始化时忽略
+  }
+}
+
+async function doRefresh(): Promise<string | null> {
+  const token = getToken()
+  if (!token) return null
+  try {
+    const res = await refreshClient.post<ApiResponse<{ token: string }>>('/auth/refresh')
+    if (res.data.code === 200 && res.data.data?.token) {
+      const newToken = res.data.data.token
+      setToken(newToken)
+      await syncStoreToken(newToken)
+      return newToken
+    }
+    return null
+  } catch {
+    return null
+  }
+}
+
+async function tryRefresh(): Promise<string | null> {
+  if (!isRefreshing) {
+    isRefreshing = true
+    refreshPromise = doRefresh().finally(() => {
+      isRefreshing = false
+      refreshPromise = null
+    })
+  }
+  return refreshPromise!
+}
+
+async function retryWithNewToken(config: InternalAxiosRequestConfig, newToken: string): Promise<unknown> {
+  const headers = { ...config.headers, Authorization: `Bearer ${newToken}` }
+  return request({ ...config, headers })
+}
+
+async function handleTokenExpired(config?: InternalAxiosRequestConfig): Promise<never> {
+  const newToken = await tryRefresh()
+  if (!newToken) {
+    handleUnauthorized()
+    return Promise.reject(new Error('登录已过期,请重新登录'))
+  }
+  if (config) {
+    return retryWithNewToken(config, newToken) as Promise<never>
+  }
+  return Promise.reject(new Error('登录已过期,请重新登录'))
+}
 
 request.interceptors.response.use(
-  (response) => {
+  ((response: AxiosResponse<unknown>) => {
     // 二进制流直接返回,不做统一 code 校验
     if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
-      return response.data as any
+      return response.data
     }
+
     const res = response.data as ApiResponse<unknown>
     if (res.code !== 200) {
-      // 业务码非 200 时,如果请求标记为 silent 则不弹全局提示,由调用方处理
-      if (!response.config.silent) {
+      const silent = response.config.silent ?? false
+      if (!silent) {
         ElMessage.error(res.msg || '请求失败')
       }
       if (res.code === 401) {
-        handleUnauthorized()
+        return handleTokenExpired(response.config)
       }
       return Promise.reject(new Error(res.msg || '请求失败'))
     }
-    return res.data as any
-  },
-  (error: AxiosError) => {
+    return res.data
+  }) as any,
+  (async (error: AxiosError) => {
     const status = error.response?.status
-    const data = error.response?.data as any
+    const data = error.response?.data as { msg?: string } | undefined
     const silent = error.config?.silent ?? false
     let msg = data?.msg || error.message || '网络错误'
 
     if (status === 401) {
+      if (error.config) {
+        return handleTokenExpired(error.config)
+      }
       msg = '登录已过期,请重新登录'
       handleUnauthorized()
     } else if (status === 403) {
@@ -82,25 +163,41 @@ request.interceptors.response.use(
       msg = '请求超时,请稍后重试'
     }
 
-    // silent 模式下不弹全局 Message,把错误信息附加到 error 上供调用方决定
     if (!silent && (!hasShownAuthError || status !== 401)) {
       ElMessage.error(msg)
     }
     if (silent && error.response) {
-      ;(error as any).displayMsg = msg
+      error.displayMsg = msg
     }
     return Promise.reject(error)
-  }
+  }) as any
 )
 
-function handleUnauthorized() {
-  if (hasShownAuthError) return
-  hasShownAuthError = true
-  removeToken()
-  setTimeout(() => {
-    window.location.href = '/login'
-    hasShownAuthError = false
-  }, 500)
+export interface RequestConfig extends AxiosRequestConfig {
+  silent?: boolean
+}
+
+export const http = {
+  get<T = unknown>(url: string, config?: RequestConfig): Promise<T> {
+    return request.get<never, T>(url, config)
+  },
+  post<T = unknown>(url: string, data?: unknown, config?: RequestConfig): Promise<T> {
+    return request.post<never, T>(url, data, config)
+  },
+  put<T = unknown>(url: string, data?: unknown, config?: RequestConfig): Promise<T> {
+    return request.put<never, T>(url, data, config)
+  },
+  delete<T = unknown>(url: string, config?: RequestConfig): Promise<T> {
+    return request.delete<never, T>(url, config)
+  },
+}
+
+export function createRequestController() {
+  return new AbortController()
 }
 
-export default request
+declare module 'axios' {
+  interface AxiosError {
+    displayMsg?: string
+  }
+}

+ 6 - 6
src/api/system/dept.ts

@@ -1,22 +1,22 @@
-import request from '@/api/request'
+import { http } from '@/api/request'
 import type { Dept } from '@/types/system'
 
 export function listDept(): Promise<Dept[]> {
-  return request.get('/system/dept/list')
+  return http.get('/system/dept/list')
 }
 
 export function getDept(id: number): Promise<Dept> {
-  return request.get(`/system/dept/${id}`)
+  return http.get(`/system/dept/${id}`)
 }
 
 export function addDept(data: Partial<Dept>): Promise<void> {
-  return request.post('/system/dept', data)
+  return http.post('/system/dept', data)
 }
 
 export function updateDept(data: Partial<Dept>): Promise<void> {
-  return request.put('/system/dept', data)
+  return http.put('/system/dept', data)
 }
 
 export function deleteDept(id: number): Promise<void> {
-  return request.delete(`/system/dept/${id}`)
+  return http.delete(`/system/dept/${id}`)
 }

+ 3 - 3
src/api/system/notification-config.ts

@@ -1,4 +1,4 @@
-import request from '@/api/request'
+import { http } from '@/api/request'
 
 export interface WeComConfig {
   corpId?: string
@@ -8,9 +8,9 @@ export interface WeComConfig {
 }
 
 export function getWeComConfig(): Promise<WeComConfig> {
-  return request.get('/system/notification-config/wecom')
+  return http.get('/system/notification-config/wecom')
 }
 
 export function saveWeComConfig(data: WeComConfig): Promise<void> {
-  return request.put('/system/notification-config/wecom', data)
+  return http.put('/system/notification-config/wecom', data)
 }

+ 10 - 9
src/api/system/role.ts

@@ -1,27 +1,28 @@
-import request from '@/api/request'
-import type { Role } from '@/types/system'
+import { http } from '@/api/request'
+import type { Role, User } from '@/types/system'
 import type { PageQuery, PageResult } from '@/types/api'
 
 export function listRole(params?: PageQuery & Partial<Role> & { deptId?: number }): Promise<PageResult<Role>> {
-  return request.get('/system/role/list', { params })
+  return http.get('/system/role/list', { params })
 }
 
 export function getRole(id: number): Promise<Role> {
-  return request.get(`/system/role/${id}`)
+  return http.get(`/system/role/${id}`)
 }
 
 export function addRole(data: Partial<Role>): Promise<void> {
-  return request.post('/system/role', data)
+  return http.post('/system/role', data)
 }
 
 export function updateRole(data: Partial<Role>): Promise<void> {
-  return request.put('/system/role', data)
+  return http.put('/system/role', data)
 }
 
 export function deleteRole(id: number): Promise<void> {
-  return request.delete(`/system/role/${id}`)
+  return http.delete(`/system/role/${id}`)
 }
 
-export function bindRoleWeCom(id: number, data: { wecomUserId?: string; wecomRemindEnabled?: number }): Promise<void> {
-  return request.put(`/system/role/${id}/wecom`, data)
+/** 查询拥有某角色的用户列表(转办选人) */
+export function listUsersByRole(roleId: number): Promise<User[]> {
+  return http.get(`/system/role/${roleId}/users`)
 }

+ 15 - 8
src/api/system/user.ts

@@ -1,28 +1,35 @@
-import request from '@/api/request'
+import { http } from '@/api/request'
 import type { User } from '@/types/system'
 import type { PageQuery, PageResult } from '@/types/api'
 import type { AxiosRequestConfig } from 'axios'
 
-export function listUser(params?: PageQuery & Partial<User> & { deptId?: number }, config?: AxiosRequestConfig): Promise<PageResult<User>> {
-  return request.get('/system/user/list', { params, ...config })
+export function listUser(
+  params?: PageQuery & Partial<User> & { deptId?: number },
+  config?: AxiosRequestConfig
+): Promise<PageResult<User>> {
+  return http.get('/system/user/list', { params, ...config })
 }
 
 export function getUser(id: number): Promise<User> {
-  return request.get(`/system/user/${id}`)
+  return http.get(`/system/user/${id}`)
 }
 
 export function addUser(data: Partial<User>): Promise<void> {
-  return request.post('/system/user', data)
+  return http.post('/system/user', data)
 }
 
 export function updateUser(data: Partial<User>): Promise<void> {
-  return request.put('/system/user', data)
+  return http.put('/system/user', data)
 }
 
 export function deleteUser(id: number): Promise<void> {
-  return request.delete(`/system/user/${id}`)
+  return http.delete(`/system/user/${id}`)
+}
+
+export function assignUserRoles(id: number, roleIds: number[]): Promise<void> {
+  return http.put(`/system/user/${id}/roles`, { roleIds })
 }
 
 export function bindWeCom(id: number, data: { wecomUserId?: string; wecomRemindEnabled?: number }): Promise<void> {
-  return request.put(`/system/user/${id}/wecom`, data)
+  return http.put(`/system/user/${id}/wecom`, data)
 }

+ 26 - 0
src/api/wecom.ts

@@ -0,0 +1,26 @@
+import { http } from '@/api/request'
+
+export interface WecomDepartment {
+  id: number
+  name: string
+  parentId: number
+}
+
+export interface WecomMember {
+  id: number
+  userid: string
+  name: string
+  departmentId: number
+}
+
+export function listSubDepartments(parentId: number = 1): Promise<WecomDepartment[]> {
+  return http.get('/wecom/contact/departments', { params: { parentId } })
+}
+
+export function listDepartmentMembers(departmentId: number): Promise<WecomMember[]> {
+  return http.get('/wecom/contact/members', { params: { departmentId } })
+}
+
+export function getMemberByUserid(userid: string): Promise<WecomMember> {
+  return http.get('/wecom/contact/member', { params: { userid } })
+}

+ 45 - 0
src/components/AddSignDialog/index.vue

@@ -0,0 +1,45 @@
+<template>
+  <el-dialog v-model="addSign.visible" title="任务加签" width="400px">
+    <el-form label-width="80px">
+      <el-form-item label="加签人">
+        <el-select
+          v-model="addSign.userId"
+          filterable
+          placeholder="请选择加签人"
+          style="width: 100%"
+        >
+          <el-option
+            v-for="u in addSign.userList"
+            :key="u.id"
+            :label="u.realName || u.username"
+            :value="u.id"
+          />
+        </el-select>
+      </el-form-item>
+    </el-form>
+    <template #footer>
+      <el-button @click="addSign.visible = false">取消</el-button>
+      <el-button type="primary" :loading="addSign.loading" @click="handleSubmit">确认</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { reactive } from 'vue'
+import { useAddSign } from '@/composables/useAddSign'
+
+const emit = defineEmits<{
+  (e: 'success'): void
+}>()
+
+const addSign = reactive(useAddSign())
+
+function handleSubmit() {
+  addSign.submit(() => emit('success'))
+}
+
+defineExpose({
+  open: addSign.open,
+  reset: addSign.reset
+})
+</script>

+ 144 - 0
src/components/ApprovalDrawer/index.vue

@@ -0,0 +1,144 @@
+<template>
+  <el-drawer
+    v-model="approval.visible"
+    :title="approval.title"
+    direction="rtl"
+    size="420px"
+    :destroy-on-close="true"
+    @closed="approval.reset"
+  >
+    <div class="drawer-content">
+      <el-alert
+        v-if="approval.alertTitle"
+        :title="approval.alertTitle"
+        type="info"
+        :closable="false"
+        show-icon
+        class="batch-alert"
+      />
+      <el-form :model="approval.form" label-width="80px">
+        <el-form-item
+          v-if="approval.form.action === 'pass' && approval.nextNodes.length > 0"
+          label="选择分支"
+        >
+          <el-select
+            v-model="approval.form.targetNodeId"
+            placeholder="请选择下一节点"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="n in approval.nextNodes"
+              :key="n.nodeId"
+              :label="n.branchName || n.nodeName"
+              :value="n.nodeId"
+            />
+          </el-select>
+        </el-form-item>
+        <!-- TODO: 接入文件上传后重新启用 -->
+        <el-form-item v-if="false" label="附件">
+          <div v-if="approval.previousAttachments.length > 0" class="previous-attachments">
+            <div class="previous-title">历史附件</div>
+            <div
+              v-for="att in approval.previousAttachments"
+              :key="att.id"
+              class="previous-item"
+            >
+              <el-link type="primary" size="small" @click="approval.openPreview(att.fileUrl)">
+                {{ att.fileName }}
+              </el-link>
+              <span class="previous-meta">{{ att.nodeName || '发起' }} · {{ att.uploaderName || '未知' }}</span>
+            </div>
+          </div>
+          <el-upload
+            v-model:file-list="approval.attachmentList"
+            action="#"
+            :http-request="approval.handleUpload"
+            :before-upload="beforeFileUpload"
+            :before-remove="() => true"
+            :on-preview="approval.handleFilePreview"
+            multiple
+          >
+            <el-button type="primary" size="small">上传附件</el-button>
+          </el-upload>
+        </el-form-item>
+        <el-form-item label="审批意见">
+          <el-input
+            v-model="approval.form.comment"
+            type="textarea"
+            :rows="4"
+            placeholder="请输入审批意见"
+          />
+        </el-form-item>
+      </el-form>
+      <div class="drawer-footer">
+        <el-button @click="approval.visible = false">取消</el-button>
+        <el-button type="primary" :loading="approval.submitting" @click="approval.submit">
+          确认{{ approval.actionText }}
+        </el-button>
+      </div>
+    </div>
+
+    <FilePreview v-model="approval.previewVisible" :url="approval.previewUrl" />
+  </el-drawer>
+</template>
+
+<script setup lang="ts">
+import { reactive } from 'vue'
+import { useApproval } from '@/composables/useApproval'
+import { beforeFileUpload } from '@/utils/file'
+import FilePreview from '@/components/FilePreview/index.vue'
+import type { FlowTask } from '@/types/flow'
+
+const emit = defineEmits<{
+  (e: 'success'): void
+}>()
+
+const approval = reactive(useApproval({
+  onSuccess: () => emit('success')
+}))
+
+defineExpose({
+  open: approval.open,
+  reset: approval.reset
+})
+</script>
+
+<style scoped>
+.drawer-content {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+.batch-alert {
+  margin-bottom: 16px;
+}
+.drawer-footer {
+  margin-top: auto;
+  padding-top: 20px;
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+}
+.previous-attachments {
+  margin-bottom: 10px;
+  padding: 10px 12px;
+  background: #f5f7fa;
+  border-radius: 4px;
+}
+.previous-title {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 6px;
+}
+.previous-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  flex-wrap: wrap;
+  margin-bottom: 4px;
+}
+.previous-meta {
+  font-size: 12px;
+  color: #909399;
+}
+</style>

+ 14 - 4
src/components/FlowFormFields/index.vue

@@ -92,7 +92,8 @@
 import { computed, watch } from 'vue'
 import { ElMessage } from 'element-plus'
 import type { FormField, FormFieldOption, FlowDefinition } from '@/types/flow'
-import { parseExcel, downloadTemplate } from '@/api/file'
+import { downloadTemplate as downloadFlowTemplate } from '@/api/flow/import'
+import { http } from '@/api/request'
 
 const props = defineProps<{
   definition: FlowDefinition | null | undefined
@@ -112,7 +113,10 @@ const fields = computed<FormField[]>(() => {
 })
 
 // 切换流程定义时重置数据;同时为多选下拉设置数组默认值
-watch(() => props.definition?.id, () => {
+watch(() => props.definition?.id, (newId, oldId) => {
+  if (newId === oldId) return
+  // 保留已有数据(如回退后补充材料场景)
+  if (Object.keys(formData.value || {}).length > 0) return
   formData.value = {}
   for (const field of fields.value) {
     if (field.type === 'select' && field.multiple) {
@@ -153,7 +157,7 @@ function validate(): boolean {
 async function handleDownloadTemplate() {
   if (!props.definition?.id) return
   try {
-    await downloadTemplate(props.definition.id)
+    await downloadFlowTemplate(props.definition.id)
   } catch (e: any) {
     ElMessage.error(e?.message || '模板下载失败')
   }
@@ -180,7 +184,13 @@ async function handleExcelUpload(options: any) {
       mappings[field.label] = field.name
       mappings[`${field.label}(${field.name})`] = field.name
     }
-    const data = await parseExcel(options.file, props.definition.id, mappings)
+    const formData2 = new FormData()
+    formData2.append('file', options.file)
+    formData2.append('definitionId', String(props.definition.id))
+    formData2.append('mappings', JSON.stringify(mappings))
+    const data = await http.post<Record<string, unknown>>('/flow/import/parse', formData2, {
+      headers: { 'Content-Type': 'multipart/form-data' }
+    })
     formData.value = { ...formData.value, ...data }
     ElMessage.success('Excel 数据已填充')
     options.onSuccess?.()

+ 5 - 26
src/components/Sidebar/index.vue

@@ -49,6 +49,7 @@ import { computed } from 'vue'
 import { useRoute } from 'vue-router'
 import router from '@/router'
 import { useUserStore } from '@/stores/user-store'
+import { getAllowedMenuRoutes } from '@/utils/permission'
 
 const currentRoute = useRoute()
 const userStore = useUserStore()
@@ -64,34 +65,12 @@ function resolvePath(parentPath: string, childPath: string) {
   return parentPath + '/' + childPath
 }
 
-// 根据员工类型控制可见菜单
-const menuPermissions: Record<string, string[]> = {
-  common_user: ['/', '/approval'],
-  dept_manager: ['/', '/system', '/approval'],
-  flow_manager: ['/', '/flow', '/approval', '/analysis'],
-  super_admin: ['/', '/system', '/flow', '/approval', '/analysis']
-}
-
 // 路由表运行时不会变化,一次性计算即可,避免每次路由切换重复构造菜单
-const menuRoutes = router.getRoutes()
-  .filter(r => r.path !== '/login' && r.meta?.hidden !== true)
-  .filter(r => r.children && r.children.length > 0)
-  .map(r => ({
-    path: r.path,
-    meta: r.meta,
-    children: r.children!.filter(c => !c.meta?.hidden)
-  }))
-  .filter(r => r.children.length > 0)
+const menuRoutes = getAllowedMenuRoutes(router.getRoutes(), userStore.userInfo?.employeeType)
 
-const filteredMenuRoutes = computed(() => {
-  // 角色用户只显示首页和审批中心
-  if (userStore.isRoleUser) {
-    return menuRoutes.filter(r => ['/', '/approval'].includes(r.path))
-  }
-  const empType = userStore.userInfo?.employeeType || 'common_user'
-  const allowedPaths = menuPermissions[empType] || menuPermissions.common_user
-  return menuRoutes.filter(r => allowedPaths.includes(r.path))
-})
+const filteredMenuRoutes = computed(() =>
+  getAllowedMenuRoutes(router.getRoutes(), userStore.userInfo?.employeeType)
+)
 </script>
 
 <style scoped>

+ 51 - 0
src/composables/useAddSign.ts

@@ -0,0 +1,51 @@
+import { ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { addSignTask, getTransferableUsers } from '@/api/flow/task'
+import type { User } from '@/types/system'
+
+export function useAddSign() {
+  const visible = ref(false)
+  const userId = ref<number | undefined>()
+  const loading = ref(false)
+  const userList = ref<User[]>([])
+  let currentTaskId = 0
+
+  async function open(taskId: number) {
+    currentTaskId = taskId
+    userId.value = undefined
+    loading.value = false
+    try {
+      userList.value = await getTransferableUsers(taskId)
+    } catch {
+      userList.value = []
+    }
+    visible.value = true
+  }
+
+  async function submit(onSuccess?: () => void) {
+    if (!userId.value) {
+      ElMessage.warning('请选择加签人')
+      return
+    }
+    loading.value = true
+    try {
+      await addSignTask(currentTaskId, userId.value)
+      ElMessage.success('加签成功')
+      visible.value = false
+      userId.value = undefined
+      onSuccess?.()
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function reset() {
+    visible.value = false
+    userId.value = undefined
+    loading.value = false
+    userList.value = []
+    currentTaskId = 0
+  }
+
+  return { visible, userId, loading, userList, open, submit, reset }
+}

+ 186 - 0
src/composables/useApproval.ts

@@ -0,0 +1,186 @@
+import { ref, reactive, computed } from 'vue'
+import { ElMessage } from 'element-plus'
+import { approveTask, rejectTask, returnTask, batchApproveTask, batchRejectTask, listNextNodes } from '@/api/flow/task'
+import { getInstanceAttachments } from '@/api/flow/instance'
+import { useFileUpload } from './useFileUpload'
+import type { FlowTask, ApprovalAction, NextNode } from '@/types/flow'
+
+export interface UseApprovalOptions {
+  onSuccess?: () => void
+}
+
+export function useApproval(options?: UseApprovalOptions) {
+  const visible = ref(false)
+  const form = reactive<ApprovalAction>({
+    action: 'pass',
+    comment: '',
+  })
+  const submitting = ref(false)
+  const nextNodes = ref<NextNode[]>([])
+  const currentTask = ref<FlowTask | null>(null)
+  const currentTasks = ref<FlowTask[]>([])
+  const batchMode = ref(false)
+
+  const file = useFileUpload()
+
+  const actionText = computed(() => {
+    const map: Record<string, string> = { pass: '通过', reject: '拒绝', rollback: '回退' }
+    return map[form.action] || '审批'
+  })
+
+  const title = computed(() => {
+    if (batchMode.value) return `批量${actionText.value}`
+    return `${actionText.value}审批`
+  })
+
+  const alertTitle = computed(() => {
+    if (batchMode.value) {
+      return `将对 ${currentTasks.value.length} 个待办任务执行「${actionText.value}」操作`
+    }
+    if (currentTask.value) {
+      return `流程:${currentTask.value.definitionName} · 节点:${currentTask.value.nodeName}`
+    }
+    return ''
+  })
+
+  function buildActionData(): ApprovalAction {
+    const data: ApprovalAction = {
+      action: form.action,
+      comment: form.comment,
+    }
+    if (form.action === 'pass' && form.targetNodeId) {
+      data.targetNodeId = form.targetNodeId
+    }
+    if (file.attachmentList.value.length > 0) {
+      data.attachmentUrls = JSON.stringify(
+        file.attachmentList.value.map((f: any) => f.response || f.url).filter(Boolean)
+      )
+    }
+    return data
+  }
+
+  async function loadNextNodes() {
+    const task = batchMode.value ? currentTasks.value[0] : currentTask.value
+    if (!task) {
+      nextNodes.value = []
+      return
+    }
+    try {
+      nextNodes.value = await listNextNodes(task.id)
+    } catch {
+      nextNodes.value = []
+    }
+  }
+
+  async function loadPreviousAttachments() {
+    if (batchMode.value || !currentTask.value?.instanceId) {
+      await file.loadPreviousAttachments()
+      return
+    }
+    const instanceId = currentTask.value.instanceId
+    await file.loadPreviousAttachments(() => getInstanceAttachments(instanceId))
+  }
+
+  async function open(tasks: FlowTask | FlowTask[], action: 'pass' | 'reject' | 'rollback') {
+    reset()
+    if (Array.isArray(tasks)) {
+      currentTasks.value = tasks
+      batchMode.value = true
+    } else {
+      currentTask.value = tasks
+      batchMode.value = false
+    }
+    form.action = action
+    if (action === 'pass') {
+      await loadNextNodes()
+    }
+    await loadPreviousAttachments()
+    visible.value = true
+  }
+
+  async function submit() {
+    if (batchMode.value) {
+      await submitBatch()
+      return
+    }
+    await submitSingle()
+  }
+
+  async function submitSingle() {
+    const task = currentTask.value
+    if (!task) return
+    if (form.action === 'pass' && nextNodes.value.length > 0 && !form.targetNodeId) {
+      ElMessage.warning('当前节点存在多个下游分支,请选择一个目标节点')
+      return
+    }
+    submitting.value = true
+    try {
+      const data = buildActionData()
+      switch (form.action) {
+        case 'pass':
+          await approveTask(task.id, data)
+          break
+        case 'reject':
+          await rejectTask(task.id, data)
+          break
+        case 'rollback':
+          await returnTask(task.id, data)
+          break
+      }
+      ElMessage.success('审批成功')
+      visible.value = false
+      options?.onSuccess?.()
+    } finally {
+      submitting.value = false
+    }
+  }
+
+  async function submitBatch() {
+    const taskIds = currentTasks.value.map((t) => t.id)
+    if (taskIds.length === 0) return
+    submitting.value = true
+    try {
+      const data = buildActionData()
+      if (form.action === 'pass') {
+        await batchApproveTask(taskIds, data)
+      } else if (form.action === 'reject') {
+        await batchRejectTask(taskIds, data)
+      }
+      ElMessage.success('批量审批成功')
+      visible.value = false
+      options?.onSuccess?.()
+    } finally {
+      submitting.value = false
+    }
+  }
+
+  function reset() {
+    form.action = 'pass'
+    form.comment = ''
+    form.targetNodeId = undefined
+    submitting.value = false
+    nextNodes.value = []
+    currentTask.value = null
+    currentTasks.value = []
+    batchMode.value = false
+    file.resetFileUpload()
+  }
+
+  return {
+    visible,
+    form,
+    submitting,
+    nextNodes,
+    currentTask,
+    currentTasks,
+    batchMode,
+    actionText,
+    title,
+    alertTitle,
+    buildActionData,
+    open,
+    submit,
+    reset,
+    ...file,
+  }
+}

+ 62 - 0
src/composables/useFileUpload.ts

@@ -0,0 +1,62 @@
+import { ref } from 'vue'
+import { uploadFile } from '@/api/file'
+import { getUploadUrl } from '@/utils/file'
+import type { Attachment } from '@/types/flow'
+
+export function useFileUpload() {
+  const attachmentList = ref<any[]>([])
+  const previousAttachments = ref<Attachment[]>([])
+  const previewVisible = ref(false)
+  const previewUrl = ref('')
+
+  async function handleUpload(options: any) {
+    try {
+      const res = await uploadFile(options.file)
+      options.onSuccess?.(res)
+    } catch (e: any) {
+      options.onError?.(e)
+    }
+  }
+
+  async function loadPreviousAttachments(loader?: () => Promise<Attachment[]>) {
+    if (!loader) {
+      previousAttachments.value = []
+      return
+    }
+    try {
+      previousAttachments.value = await loader()
+    } catch {
+      previousAttachments.value = []
+    }
+  }
+
+  function openPreview(url: string) {
+    if (!url) return
+    previewUrl.value = url
+    previewVisible.value = true
+  }
+
+  function handleFilePreview(file: any) {
+    const url = getUploadUrl(file)
+    if (url) openPreview(url)
+  }
+
+  function resetFileUpload() {
+    attachmentList.value = []
+    previousAttachments.value = []
+    previewUrl.value = ''
+    previewVisible.value = false
+  }
+
+  return {
+    attachmentList,
+    previousAttachments,
+    previewVisible,
+    previewUrl,
+    handleUpload,
+    loadPreviousAttachments,
+    openPreview,
+    handleFilePreview,
+    resetFileUpload,
+  }
+}

+ 2 - 2
src/main.ts

@@ -22,7 +22,7 @@ import {
   TrendCharts,
   User,
   UserFilled,
-  Warning
+  Warning,
 } from '@element-plus/icons-vue'
 import App from './App.vue'
 import router from './router'
@@ -51,7 +51,7 @@ const icons = {
   TrendCharts,
   User,
   UserFilled,
-  Warning
+  Warning,
 }
 for (const [key, component] of Object.entries(icons)) {
   app.component(key, component)

+ 65 - 100
src/router/index.ts

@@ -1,7 +1,7 @@
 import { createRouter, createWebHistory } from 'vue-router'
 import { useUserStore } from '@/stores/user-store'
 import { getToken } from '@/utils/auth'
-
+import { isRoutePathAllowed } from '@/utils/permission'
 
 const router = createRouter({
   history: createWebHistory(),
@@ -10,7 +10,7 @@ const router = createRouter({
       path: '/login',
       name: 'Login',
       component: () => import('@/views/login/index.vue'),
-      meta: { hidden: true }
+      meta: { hidden: true },
     },
     {
       path: '/profile',
@@ -21,15 +21,15 @@ const router = createRouter({
           path: '',
           name: 'Profile',
           component: () => import('@/views/profile/index.vue'),
-          meta: { title: '个人中心' }
-        }
-      ]
+          meta: { title: '个人中心' },
+        },
+      ],
     },
     {
       path: '/:pathMatch(.*)*',
       name: 'NotFound',
       component: () => import('@/views/error/404.vue'),
-      meta: { hidden: true }
+      meta: { hidden: true },
     },
     {
       path: '/',
@@ -40,76 +40,63 @@ const router = createRouter({
           path: 'dashboard',
           name: 'Dashboard',
           component: () => import('@/views/dashboard/index.vue'),
-          meta: { title: '首页', icon: 'HomeFilled' }
-        }
-      ]
-    },
-    {
-      path: '/workbench',
-      component: () => import('@/components/Layout/index.vue'),
-      meta: { title: '工作台', icon: 'Monitor' },
-      children: [
-        {
-          path: '',
-          name: 'Workbench',
-          component: () => import('@/views/workbench/index.vue'),
-          meta: { title: '智能工作台', icon: 'Monitor' }
-        }
-      ]
+          meta: { title: '首页', icon: 'HomeFilled' },
+        },
+      ],
     },
     {
       path: '/analysis',
       component: () => import('@/components/Layout/index.vue'),
       redirect: '/analysis/flow',
-      meta: { title: '数据看板', icon: 'TrendCharts' },
+      meta: { title: '数据看板', icon: 'TrendCharts', permissions: ['dept_manager', 'flow_manager', 'super_admin'] },
       children: [
         {
           path: 'flow',
           name: 'FlowAnalysis',
           component: () => import('@/views/analysis/index.vue'),
-          meta: { title: '流程分析看板', icon: 'TrendCharts' }
-        }
-      ]
+          meta: { title: '流程分析看板', icon: 'TrendCharts', permissions: ['dept_manager', 'flow_manager', 'super_admin'] },
+        },
+      ],
     },
     {
       path: '/system',
       component: () => import('@/components/Layout/index.vue'),
       redirect: '/system/user',
-      meta: { title: '系统管理', icon: 'Setting' },
+      meta: { title: '系统管理', icon: 'Setting', permissions: ['dept_manager', 'super_admin'] },
       children: [
         {
           path: 'user',
           name: 'User',
           component: () => import('@/views/system/user/index.vue'),
-          meta: { title: '管理员管理', icon: 'UserFilled' }
+          meta: { title: '用户管理', icon: 'UserFilled', permissions: ['dept_manager', 'super_admin'] },
         },
         {
           path: 'role',
           name: 'Role',
           component: () => import('@/views/system/role/index.vue'),
-          meta: { title: '员工管理', icon: 'User' }
+          meta: { title: '审批角色', icon: 'User', permissions: ['dept_manager', 'super_admin'] },
         },
-      ]
+      ],
     },
     {
       path: '/flow',
       component: () => import('@/components/Layout/index.vue'),
       redirect: '/flow/definition',
-      meta: { title: '流程管理', icon: 'Connection' },
+      meta: { title: '流程管理', icon: 'Connection', permissions: ['flow_manager', 'super_admin'] },
       children: [
         {
           path: 'designer',
           name: 'FlowDesigner',
           component: () => import('@/views/flow/designer/index.vue'),
-          meta: { title: '流程设计器', icon: 'EditPen' }
+          meta: { title: '流程设计器', icon: 'EditPen', permissions: ['flow_manager', 'super_admin'] },
         },
         {
           path: 'definition',
           name: 'FlowDefinition',
           component: () => import('@/views/flow/definition/index.vue'),
-          meta: { title: '流程管理', icon: 'Document' }
-        }
-      ]
+          meta: { title: '流程管理', icon: 'Document', permissions: ['flow_manager', 'super_admin'] },
+        },
+      ],
     },
     {
       path: '/approval',
@@ -121,108 +108,86 @@ const router = createRouter({
           path: 'todo',
           name: 'Todo',
           component: () => import('@/views/flow/task/todo.vue'),
-          meta: { title: '我的待办', icon: 'Bell' }
+          meta: { title: '我的待办', icon: 'Bell' },
         },
         {
           path: 'handled',
           name: 'Handled',
           component: () => import('@/views/flow/task/handled.vue'),
-          meta: { title: '我的已办', icon: 'CircleCheck' }
+          meta: { title: '我的已办', icon: 'CircleCheck' },
         },
         {
           path: 'cc',
           name: 'Cc',
           component: () => import('@/views/flow/task/cc.vue'),
-          meta: { title: '我的抄送', icon: 'Message' }
+          meta: { title: '我的抄送', icon: 'Message' },
         },
         {
           path: 'mine',
           name: 'MyInstance',
           component: () => import('@/views/flow/instance/mine.vue'),
-          meta: { title: '我的流程', icon: 'List' }
+          meta: { title: '我的发起', icon: 'List' },
         },
         {
           path: 'execute',
           name: 'FlowExecute',
           component: () => import('@/views/flow/execute/index.vue'),
-          meta: { title: '流程执行', icon: 'Pointer' }
-        }
-      ]
-    }
-  ]
+          meta: { title: '流程执行', icon: 'Pointer' },
+        },
+        {
+          path: 'all',
+          name: 'AllInstance',
+          component: () => import('@/views/flow/instance/all.vue'),
+          meta: { title: '流程明细', icon: 'Document', hidden: true },
+        },
+      ],
+    },
+  ],
 })
 
 const publicPaths = new Set(['/login'])
 
-const roleAllowedPaths = new Set(['/', '/dashboard', '/login', '/profile', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc'])
-
-// 按员工类型限制的路径
-const permissionPaths: Record<string, Set<string>> = {
-  common_user: new Set(['/', '/dashboard', '/login', '/profile', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc']),
-  dept_manager: new Set(['/', '/dashboard', '/login', '/profile', '/system/user', '/system/role', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc']),
-  flow_manager: new Set(['/', '/dashboard', '/login', '/profile', '/flow/designer', '/flow/definition', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc', '/analysis']),
-  super_admin: new Set(['/', '/dashboard', '/login', '/profile', '/system/user', '/system/role', '/flow/designer', '/flow/definition', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc', '/analysis'])
-}
-
-function isPathAllowed(allowedSet: Set<string>, path: string): boolean {
-  if (allowedSet.has(path)) return true
-  // 允许子路径,但父路径必须以 / 结尾或精确匹配
-  for (const allowed of allowedSet) {
-    if (allowed !== '/' && path.startsWith(allowed + '/')) {
-      return true
-    }
-  }
-  return false
-}
-
 // 防止快速切换 tab 时并发多次拉取用户信息
 let fetchingUserInfo: Promise<unknown> | null = null
 
 router.beforeEach(async (to, from, next) => {
-  const token = getToken()
   // 404、登录页等公开页面直接放行
   if (publicPaths.has(to.path) || to.name === 'NotFound') {
     next()
     return
   }
-  if (token) {
-    const userStore = useUserStore()
-    if (!userStore.userInfo) {
-      try {
-        if (!fetchingUserInfo) {
-          fetchingUserInfo = userStore.fetchUserInfo().catch(async (err) => {
-            await userStore.logoutAction()
-            throw err
-          })
-        }
-        await fetchingUserInfo
-      } catch {
-        next(`/login?redirect=${to.path}`)
-        return
-      } finally {
-        fetchingUserInfo = null
-      }
-    }
-    // 角色用户路由限制
-    if (userStore.isRoleUser) {
-      if (!isPathAllowed(roleAllowedPaths, to.path)) {
-        next('/approval/todo')
-        return
+
+  const token = getToken()
+  if (!token) {
+    next(`/login?redirect=${to.path}`)
+    return
+  }
+
+  const userStore = useUserStore()
+  if (!userStore.userInfo) {
+    try {
+      if (!fetchingUserInfo) {
+        fetchingUserInfo = userStore.fetchUserInfo().catch(async (err) => {
+          await userStore.logoutAction()
+          throw err
+        })
       }
-      next()
+      await fetchingUserInfo
+    } catch {
+      next(`/login?redirect=${to.path}`)
       return
+    } finally {
+      fetchingUserInfo = null
     }
-    // 普通用户按 employeeType 限制
-    const employeeType = userStore.userInfo?.employeeType || 'common_user'
-    const allowedPaths = permissionPaths[employeeType] || permissionPaths['common_user']
-    if (!isPathAllowed(allowedPaths, to.path)) {
-      next('/dashboard')
-      return
-    }
-    next()
-  } else {
-    next(`/login?redirect=${to.path}`)
   }
+
+  const employeeType = userStore.userInfo?.employeeType || 'common_user'
+  if (!isRoutePathAllowed(to, employeeType)) {
+    next('/dashboard')
+    return
+  }
+
+  next()
 })
 
 export default router

+ 8 - 9
src/stores/user-store.ts

@@ -1,16 +1,14 @@
 import { defineStore } from 'pinia'
 import { ref, computed } from 'vue'
-import { getToken, setToken, removeToken } from '@/utils/auth'
+import { getToken, setToken as setTokenCookie, removeToken } from '@/utils/auth'
 import { login, logout, getUserInfo } from '@/api/auth'
 import type { LoginData, User } from '@/types/system'
 
 export const useUserStore = defineStore('user', () => {
   const token = ref<string | undefined>(getToken())
   const userInfo = ref<User | null>(null)
-  const userType = ref<string>('SYSTEM')
 
   const isLoggedIn = computed(() => !!token.value)
-  const isRoleUser = computed(() => userType.value === 'ROLE')
 
   async function loginAction(data: LoginData) {
     const res = await login(data)
@@ -22,10 +20,14 @@ export const useUserStore = defineStore('user', () => {
   async function fetchUserInfo() {
     const res = await getUserInfo()
     userInfo.value = res
-    userType.value = res.userType || 'SYSTEM'
     return res
   }
 
+  function setToken(newToken: string) {
+    token.value = newToken
+    setTokenCookie(newToken)
+  }
+
   async function logoutAction() {
     try {
       await logout()
@@ -34,21 +36,18 @@ export const useUserStore = defineStore('user', () => {
     }
     token.value = undefined
     userInfo.value = null
-    userType.value = 'SYSTEM'
     removeToken()
     localStorage.removeItem('login_username')
     localStorage.removeItem('login_remember')
-    localStorage.removeItem('login_type')
   }
 
   return {
     token,
     userInfo,
-    userType,
     isLoggedIn,
-    isRoleUser,
     loginAction,
     fetchUserInfo,
-    logoutAction
+    logoutAction,
+    setToken,
   }
 })

+ 14 - 0
src/types/flow.ts

@@ -23,6 +23,9 @@ export interface FlowDefinition {
   status: number // 0-草稿 1-已发布 2-停用
   formSchema?: string
   formFields?: FormField[]
+  nodeFieldName?: string
+  ownerFieldName?: string
+  bizFieldName?: string
   flowJson?: string
   createTime?: string
   updateTime?: string
@@ -33,6 +36,7 @@ export interface FlowInstance {
   definitionId: number
   definitionName?: string
   title?: string
+  bizValue?: string
   instanceNo?: string
   businessKey?: string
   status: number // 0-待接收, 1-运行中, 2-已通过, 3-已拒绝, 4-已回退, 5-已完成, 6-已撤回, 7-已终止
@@ -66,6 +70,8 @@ export interface FlowTask {
   timeoutTime?: string
   urgency?: number // 0-正常,1-即将超时,2-已超时
   remainingMinutes?: number
+  formData?: string
+  formSchema?: string
 }
 
 export interface FlowNodeConfig {
@@ -84,6 +90,14 @@ export interface FlowEdgeConfig {
   properties?: Record<string, unknown>
 }
 
+export interface NextNode {
+  nodeId: string
+  nodeName: string
+  nodeType: string
+  branchName?: string
+  condition?: string
+}
+
 export interface ApprovalAction {
   action: 'pass' | 'reject' | 'rollback' | 'transfer'
   comment?: string

+ 1 - 6
src/types/system.ts

@@ -13,6 +13,7 @@ export interface User {
   roleIds?: number[]
   password?: string
   wecomUserId?: string
+  wecomUserName?: string
   wecomRemindEnabled?: number
   createTime?: string
 }
@@ -21,16 +22,11 @@ export interface Role {
   id: number
   roleCode: string
   roleName: string
-  username?: string
-  password?: string
   roleScope?: string
-  phone?: string
   parentId?: number
   deptId?: number
   deptName?: string
   status: number // 0-禁用 1-正常
-  wecomUserId?: string
-  wecomRemindEnabled?: number
   createTime?: string
 }
 
@@ -50,7 +46,6 @@ export interface Dept {
 export interface LoginData {
   username: string
   password: string
-  loginType?: string // SYSTEM / ROLE
 }
 
 export interface LoginResult {

+ 1 - 1
src/utils/auth.ts

@@ -12,7 +12,7 @@ export function setToken(token: string): void {
   Cookies.set(TokenKey, token, {
     secure: isSecure,
     sameSite: 'Strict',
-    expires: 1
+    expires: 1,
   })
 }
 

+ 43 - 8
src/utils/file.ts

@@ -2,16 +2,48 @@ import { ElMessage } from 'element-plus'
 import type { UploadRawFile } from 'element-plus'
 
 const ALLOWED_EXTENSIONS = new Set([
-  'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp',
-  'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
-  'txt', 'md', 'csv',
-  'zip', 'rar', '7z',
-  'mp4', 'mp3', 'wav', 'ogg'
+  'jpg',
+  'jpeg',
+  'png',
+  'gif',
+  'bmp',
+  'webp',
+  'pdf',
+  'doc',
+  'docx',
+  'xls',
+  'xlsx',
+  'ppt',
+  'pptx',
+  'txt',
+  'md',
+  'csv',
+  'zip',
+  'rar',
+  '7z',
+  'mp4',
+  'mp3',
+  'wav',
+  'ogg',
 ])
 
 const BLOCKED_EXTENSIONS = new Set([
-  'jsp', 'jspx', 'php', 'asp', 'aspx', 'sh', 'bat', 'cmd',
-  'exe', 'dll', 'jar', 'war', 'ear', 'html', 'htm', 'js'
+  'jsp',
+  'jspx',
+  'php',
+  'asp',
+  'aspx',
+  'sh',
+  'bat',
+  'cmd',
+  'exe',
+  'dll',
+  'jar',
+  'war',
+  'ear',
+  'html',
+  'htm',
+  'js',
 ])
 
 const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
@@ -105,7 +137,10 @@ export function parseAttachments(urlsStr?: string): string[] {
     }
   } catch {
     if (typeof urlsStr === 'string' && urlsStr.includes(',')) {
-      return urlsStr.split(',').map(s => s.trim()).filter(isSafeUrl)
+      return urlsStr
+        .split(',')
+        .map((s) => s.trim())
+        .filter(isSafeUrl)
     }
   }
   return isSafeUrl(urlsStr) ? [urlsStr] : []

+ 16 - 4
src/utils/flow.ts

@@ -1,16 +1,28 @@
 export function instanceStatusText(status?: number): string {
   if (status === undefined) return '未知'
   const map: Record<number, string> = {
-    0: '待接收', 1: '运行中', 2: '已通过', 3: '已拒绝',
-    4: '已回退', 5: '已完成', 6: '已撤回', 7: '已终止'
+    0: '待接收',
+    1: '运行中',
+    2: '已通过',
+    3: '已拒绝',
+    4: '已回退',
+    5: '已完成',
+    6: '已撤回',
+    7: '已终止',
   }
   return map[status] || '未知'
 }
 
 export function instanceStatusTagType(status?: number): string {
   const map: Record<number, string> = {
-    0: 'info', 1: 'primary', 2: 'success', 3: 'danger',
-    4: 'warning', 5: 'success', 6: 'info', 7: 'danger'
+    0: 'info',
+    1: 'primary',
+    2: 'success',
+    3: 'danger',
+    4: 'warning',
+    5: 'success',
+    6: 'info',
+    7: 'danger',
   }
   return map[status ?? 0] || 'info'
 }

+ 4 - 11
src/utils/format.ts

@@ -1,15 +1,8 @@
 const employeeTypeMap: Record<string, string> = {
   common_user: '普通用户',
-  dept_manager: '部门经理',
-  flow_manager: '流程管理员',
-  super_admin: '超级管理员'
-}
-
-const adminEmployeeTypeMap: Record<string, string> = {
-  common_user: '普通管理员',
-  dept_manager: '部门管理员',
-  flow_manager: '流程管理员',
-  super_admin: '系统管理员'
+  dept_manager: '部门运维',
+  flow_manager: '流程运维',
+  super_admin: '管理员',
 }
 
 export function employeeTypeLabel(type?: string): string {
@@ -17,5 +10,5 @@ export function employeeTypeLabel(type?: string): string {
 }
 
 export function adminEmployeeTypeLabel(type?: string): string {
-  return adminEmployeeTypeMap[type || ''] || type || '-'
+  return employeeTypeMap[type || ''] || type || '-'
 }

+ 38 - 0
src/utils/permission.ts

@@ -0,0 +1,38 @@
+import type { RouteRecordNormalized, RouteRecordRaw, RouteLocationNormalized } from 'vue-router'
+
+export type EmployeeType = 'common_user' | 'dept_manager' | 'flow_manager' | 'super_admin'
+
+export function hasPermission(required: EmployeeType[] | undefined, employeeType?: string): boolean {
+  if (!required || required.length === 0) return true
+  return required.includes(employeeType as EmployeeType)
+}
+
+export function isRouteAllowed(
+  route: RouteRecordRaw | RouteRecordNormalized | RouteLocationNormalized,
+  employeeType?: string
+): boolean {
+  const permissions = route.meta?.permissions as EmployeeType[] | undefined
+  return hasPermission(permissions, employeeType)
+}
+
+export function isRoutePathAllowed(to: { matched: readonly RouteRecordNormalized[] }, employeeType?: string): boolean {
+  return to.matched.every((route) => isRouteAllowed(route, employeeType))
+}
+
+export interface MenuRoute {
+  path: string
+  meta: RouteRecordNormalized['meta']
+  children: RouteRecordRaw[]
+}
+
+export function getAllowedMenuRoutes(routes: RouteRecordNormalized[], employeeType?: string): MenuRoute[] {
+  return routes
+    .filter((r) => r.path !== '/login' && r.meta?.hidden !== true)
+    .filter((r) => r.children && r.children.length > 0)
+    .map((r) => ({
+      path: r.path,
+      meta: r.meta,
+      children: r.children!.filter((c) => c.meta?.hidden !== true && isRouteAllowed(c, employeeType)),
+    }))
+    .filter((r) => isRouteAllowed({ meta: r.meta } as RouteRecordRaw, employeeType) || r.children.length > 0)
+}

+ 28 - 0
src/views/analysis/EfficiencyChart.vue

@@ -0,0 +1,28 @@
+<template>
+  <el-card shadow="hover" v-loading="loading">
+    <template #header>
+      <span>已完成流程效率</span>
+      <span class="sub-title">按平均耗时(分钟)</span>
+    </template>
+    <v-chart v-if="option" :option="option" autoresize style="height: 320px" />
+    <el-empty v-else description="暂无数据" />
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import VChart from 'vue-echarts'
+
+defineProps<{
+  option: any
+  loading: boolean
+}>()
+</script>
+
+<style scoped>
+.sub-title {
+  margin-left: 8px;
+  font-size: 12px;
+  color: #909399;
+  font-weight: normal;
+}
+</style>

+ 133 - 0
src/views/analysis/KpiCards.vue

@@ -0,0 +1,133 @@
+<template>
+  <el-row :gutter="16" class="kpi-row">
+    <el-col :xs="24" :sm="12" :md="8" :lg="4">
+      <el-card shadow="hover" v-loading="loading" class="kpi-card kpi-total">
+        <div class="kpi-icon"><el-icon><DataAnalysis /></el-icon></div>
+        <div class="kpi-content">
+          <div class="kpi-label">流程总数</div>
+          <el-statistic :value="overview.totalInstances" />
+        </div>
+      </el-card>
+    </el-col>
+    <el-col :xs="24" :sm="12" :md="8" :lg="4">
+      <el-card shadow="hover" v-loading="loading" class="kpi-card kpi-completed">
+        <div class="kpi-icon"><el-icon><CircleCheck /></el-icon></div>
+        <div class="kpi-content">
+          <div class="kpi-label">已完成</div>
+          <el-statistic :value="overview.completedCount" />
+        </div>
+      </el-card>
+    </el-col>
+    <el-col :xs="24" :sm="12" :md="8" :lg="4">
+      <el-card shadow="hover" v-loading="loading" class="kpi-card kpi-running">
+        <div class="kpi-icon"><el-icon><Monitor /></el-icon></div>
+        <div class="kpi-content">
+          <div class="kpi-label">进行中</div>
+          <el-statistic :value="overview.runningCount" />
+        </div>
+      </el-card>
+    </el-col>
+    <el-col :xs="24" :sm="12" :md="8" :lg="4">
+      <el-card shadow="hover" v-loading="loading" class="kpi-card kpi-timeout">
+        <div class="kpi-icon"><el-icon><Warning /></el-icon></div>
+        <div class="kpi-content">
+          <div class="kpi-label">超时率</div>
+          <el-statistic :value="overview.timeoutRate" :precision="1" suffix="%" />
+        </div>
+      </el-card>
+    </el-col>
+    <el-col :xs="24" :sm="12" :md="8" :lg="4">
+      <el-card shadow="hover" v-loading="loading" class="kpi-card kpi-avg">
+        <div class="kpi-icon"><el-icon><Clock /></el-icon></div>
+        <div class="kpi-content">
+          <div class="kpi-label">平均耗时</div>
+          <div class="kpi-value">{{ formatMinutes(overview.avgDurationMinutes) }}</div>
+        </div>
+      </el-card>
+    </el-col>
+  </el-row>
+</template>
+
+<script setup lang="ts">
+import { DataAnalysis, CircleCheck, Monitor, Warning, Clock } from '@element-plus/icons-vue'
+import type { AnalysisOverview } from '@/types/analysis'
+
+defineProps<{
+  overview: AnalysisOverview
+  loading: boolean
+  formatMinutes: (minutes: number) => string
+}>()
+</script>
+
+<style scoped>
+.kpi-row {
+  margin-bottom: 16px;
+}
+.kpi-card {
+  border-left: 4px solid transparent;
+  transition: transform 0.2s;
+}
+.kpi-card :deep(.el-card__body) {
+  display: flex;
+  align-items: center;
+  padding: 12px;
+}
+.kpi-card:hover {
+  transform: translateY(-2px);
+}
+.kpi-icon {
+  width: 48px;
+  height: 48px;
+  border-radius: 10px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  margin-right: 12px;
+  color: #fff;
+}
+.kpi-total {
+  border-left-color: #5470c6;
+}
+.kpi-total .kpi-icon {
+  background: linear-gradient(135deg, #5470c6, #8ba1e6);
+}
+.kpi-completed {
+  border-left-color: #67c23a;
+}
+.kpi-completed .kpi-icon {
+  background: linear-gradient(135deg, #67c23a, #9fe07a);
+}
+.kpi-running {
+  border-left-color: #409eff;
+}
+.kpi-running .kpi-icon {
+  background: linear-gradient(135deg, #409eff, #7ec2ff);
+}
+.kpi-timeout {
+  border-left-color: #f56c6c;
+}
+.kpi-timeout .kpi-icon {
+  background: linear-gradient(135deg, #f56c6c, #f9a0a0);
+}
+.kpi-avg {
+  border-left-color: #e6a23c;
+}
+.kpi-avg .kpi-icon {
+  background: linear-gradient(135deg, #e6a23c, #f2c97d);
+}
+.kpi-content {
+  flex: 1;
+}
+.kpi-label {
+  font-size: 13px;
+  color: #909399;
+  margin-bottom: 4px;
+}
+.kpi-value {
+  font-size: 24px;
+  font-weight: bold;
+  color: #303133;
+  line-height: 1.2;
+}
+</style>

+ 19 - 0
src/views/analysis/NodeCountChart.vue

@@ -0,0 +1,19 @@
+<template>
+  <el-card shadow="hover" v-loading="loading" style="height: 100%;">
+    <template #header>
+      <span>各节点统计</span>
+    </template>
+    <v-chart v-if="dataLength > 0" :option="option" autoresize style="height: 60px;" :style="{ height: Math.max(dataLength * 36, 200) + 'px' }" />
+    <el-empty v-else description="请选择流程" />
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import VChart from 'vue-echarts'
+
+defineProps<{
+  option: any
+  loading: boolean
+  dataLength: number
+}>()
+</script>

+ 129 - 0
src/views/analysis/StuckInstanceTable.vue

@@ -0,0 +1,129 @@
+<template>
+  <el-card shadow="hover" class="detail-card" v-loading="loading">
+    <template #header>
+      <div class="detail-header">
+        <span>流程明细</span>
+        <div class="detail-actions">
+          <el-select v-model="localQuery.processDefinitionId" placeholder="流程名称" clearable style="width: 160px;" @change="onProcessChange">
+            <el-option v-for="def in definitionList" :key="def.id" :label="def.name" :value="def.id" />
+          </el-select>
+          <el-input v-model="localQuery.applicantName" placeholder="归属用户" clearable style="width: 140px; margin-left: 8px;" @change="onSearch" />
+          <el-select v-model="localQuery.currentNodeName" placeholder="当前节点" clearable style="width: 140px; margin-left: 8px;" @change="onSearch">
+            <el-option v-for="node in currentNodeOptions" :key="node" :label="node" :value="node" />
+          </el-select>
+          <el-button type="primary" @click="onSearch" style="margin-left: 8px;">查询</el-button>
+        </div>
+      </div>
+    </template>
+
+    <el-table :data="instanceList" stripe>
+      <el-table-column prop="definitionName" label="流程名称" min-width="140" show-overflow-tooltip />
+      <el-table-column label="业务编号" min-width="140">
+        <template #default="{ row }">
+          {{ row.bizValue || row.instanceNo || '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column prop="applicantName" label="发起人" min-width="100" />
+      <el-table-column prop="status" label="状态" min-width="90">
+        <template #default="{ row }">
+          <el-tag :type="instanceStatusTagType(row.status)">{{ instanceStatusText(row.status) }}</el-tag>
+        </template>
+      </el-table-column>
+      <el-table-column prop="currentNodeName" label="当前节点" min-width="120">
+        <template #default="{ row }">
+          {{ row.currentNodeName || row.currentNode || '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column prop="startTime" label="发起时间" min-width="160" />
+      <el-table-column label="操作" min-width="80" fixed="right">
+        <template #default="{ row }">
+          <el-button link type="primary" size="small" @click="emit('detail', row.id)">详情</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+
+    <el-pagination
+      v-model:current-page="localQuery.pageNum"
+      v-model:page-size="localQuery.pageSize"
+      :total="instanceTotal"
+      :page-sizes="[10, 20, 50]"
+      layout="total, sizes, prev, pager, next, jumper"
+      class="pagination"
+      @size-change="onSearch"
+      @current-change="onSearch"
+    />
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import { reactive, watch } from 'vue'
+import { instanceStatusText, instanceStatusTagType } from '@/utils/flow'
+import type { FlowInstance, FlowDefinition } from '@/types/flow'
+
+const props = defineProps<{
+  instanceList: FlowInstance[]
+  instanceTotal: number
+  loading: boolean
+  query: {
+    pageNum: number
+    pageSize: number
+    processDefinitionId?: number
+    processName: string
+    applicantName: string
+    currentNodeName: string
+  }
+  definitionList: FlowDefinition[]
+  currentNodeOptions: string[]
+}>()
+
+const emit = defineEmits<{
+  'update:query': [typeof props.query]
+  'process-change': []
+  'search': []
+  'detail': [number]
+}>()
+
+const localQuery = reactive({ ...props.query })
+
+watch(
+  () => props.query,
+  (val) => {
+    Object.assign(localQuery, val)
+  },
+  { deep: true, immediate: true }
+)
+
+function updateQuery() {
+  emit('update:query', { ...localQuery })
+}
+
+function onProcessChange() {
+  updateQuery()
+  emit('process-change')
+}
+
+function onSearch() {
+  updateQuery()
+  emit('search')
+}
+</script>
+
+<style scoped>
+.detail-card {
+  margin-bottom: 16px;
+}
+.detail-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.detail-actions {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+.el-pagination {
+  margin-top: 16px;
+  justify-content: flex-end;
+}
+</style>

+ 18 - 0
src/views/analysis/TimeoutChart.vue

@@ -0,0 +1,18 @@
+<template>
+  <el-card shadow="hover" v-loading="loading">
+    <template #header>
+      <span>流程状态分布</span>
+    </template>
+    <v-chart v-if="option" :option="option" autoresize style="height: 320px" />
+    <el-empty v-else description="暂无数据" />
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import VChart from 'vue-echarts'
+
+defineProps<{
+  option: any
+  loading: boolean
+}>()
+</script>

+ 42 - 0
src/views/analysis/TrendChart.vue

@@ -0,0 +1,42 @@
+<template>
+  <el-card shadow="hover" v-loading="loading">
+    <template #header>
+      <span>进行中节点停留统计</span>
+      <span class="sub-title">点击柱形可下钻明细</span>
+    </template>
+    <v-chart
+      v-if="option"
+      :option="option"
+      autoresize
+      style="height: 320px"
+      @click="onChartClick"
+    />
+    <el-empty v-else description="暂无数据" />
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import VChart from 'vue-echarts'
+
+const props = defineProps<{
+  option: any
+  loading: boolean
+}>()
+
+const emit = defineEmits<{
+  nodeClick: [params: any]
+}>()
+
+function onChartClick(params: any) {
+  emit('nodeClick', params)
+}
+</script>
+
+<style scoped>
+.sub-title {
+  margin-left: 8px;
+  font-size: 12px;
+  color: #909399;
+  font-weight: normal;
+}
+</style>

+ 62 - 657
src/views/analysis/index.vue

@@ -35,591 +35,89 @@
       </el-form>
     </el-card>
 
-    <!-- KPI 概览卡片 -->
-    <el-row :gutter="16" class="kpi-row">
-      <el-col :xs="24" :sm="12" :md="8" :lg="4">
-        <el-card shadow="hover" v-loading="loadingOverview" class="kpi-card kpi-total">
-          <div class="kpi-icon"><el-icon><DataAnalysis /></el-icon></div>
-          <div class="kpi-content">
-            <div class="kpi-label">流程总数</div>
-            <el-statistic :value="overview.totalInstances" />
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :xs="24" :sm="12" :md="8" :lg="4">
-        <el-card shadow="hover" v-loading="loadingOverview" class="kpi-card kpi-completed">
-          <div class="kpi-icon"><el-icon><CircleCheck /></el-icon></div>
-          <div class="kpi-content">
-            <div class="kpi-label">已完成</div>
-            <el-statistic :value="overview.completedCount" />
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :xs="24" :sm="12" :md="8" :lg="4">
-        <el-card shadow="hover" v-loading="loadingOverview" class="kpi-card kpi-running">
-          <div class="kpi-icon"><el-icon><Monitor /></el-icon></div>
-          <div class="kpi-content">
-            <div class="kpi-label">进行中</div>
-            <el-statistic :value="overview.runningCount" />
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :xs="24" :sm="12" :md="8" :lg="4">
-        <el-card shadow="hover" v-loading="loadingOverview" class="kpi-card kpi-timeout">
-          <div class="kpi-icon"><el-icon><Warning /></el-icon></div>
-          <div class="kpi-content">
-            <div class="kpi-label">超时率</div>
-            <el-statistic :value="overview.timeoutRate" :precision="1" suffix="%" />
-          </div>
-        </el-card>
-      </el-col>
-      <el-col :xs="24" :sm="12" :md="8" :lg="4">
-        <el-card shadow="hover" v-loading="loadingOverview" class="kpi-card kpi-avg">
-          <div class="kpi-icon"><el-icon><Clock /></el-icon></div>
-          <div class="kpi-content">
-            <div class="kpi-label">平均耗时</div>
-            <div class="kpi-value">{{ formatMinutes(overview.avgDurationMinutes) }}</div>
-          </div>
-        </el-card>
-      </el-col>
-    </el-row>
+    <KpiCards :overview="overview" :loading="loadingOverview" :format-minutes="formatMinutes" />
 
-    <!-- 状态分布 + 趋势 -->
     <el-row :gutter="16" class="chart-row">
       <el-col :span="12">
-        <el-card shadow="hover" v-loading="loadingStatus">
-          <template #header>
-            <span>流程状态分布</span>
-          </template>
-          <v-chart v-if="statusChartOption" :option="statusChartOption" autoresize style="height: 320px" />
-          <el-empty v-else description="暂无数据" />
-        </el-card>
+        <TimeoutChart :option="statusChartOption" :loading="loadingStatus" />
       </el-col>
       <el-col :span="12">
-        <el-card shadow="hover" v-loading="loadingTrend">
-          <template #header>
-            <span>近30天趋势</span>
-          </template>
-          <v-chart v-if="trendChartOption" :option="trendChartOption" autoresize style="height: 320px" />
-          <el-empty v-else description="暂无数据" />
-        </el-card>
+        <NodeCountChart :option="nodeCountOption" :loading="loadingNodeCount" :data-length="nodeCountData.length" />
       </el-col>
     </el-row>
 
-    <!-- 效率 + 节点停留 -->
     <el-row :gutter="16" class="chart-row">
       <el-col :span="12">
-        <el-card shadow="hover" v-loading="loadingEfficiency">
-          <template #header>
-            <span>已完成流程效率</span>
-            <span class="sub-title">按平均耗时(分钟)</span>
-          </template>
-          <v-chart v-if="efficiencyChartOption" :option="efficiencyChartOption" autoresize style="height: 320px" />
-          <el-empty v-else description="暂无数据" />
-        </el-card>
+        <EfficiencyChart :option="efficiencyChartOption" :loading="loadingEfficiency" />
       </el-col>
       <el-col :span="12">
-        <el-card shadow="hover" v-loading="loadingNode">
-          <template #header>
-            <span>进行中节点停留统计</span>
-            <span class="sub-title">点击柱形可下钻明细</span>
-          </template>
-          <v-chart
-            v-if="nodeChartOption"
-            :option="nodeChartOption"
-            autoresize
-            style="height: 320px"
-            @click="onNodeChartClick"
-          />
-          <el-empty v-else description="暂无数据" />
-        </el-card>
+        <TrendChart :option="nodeChartOption" :loading="loadingNode" @node-click="onNodeChartClick" />
       </el-col>
     </el-row>
 
-    <el-card shadow="hover" class="detail-card" v-loading="loadingStuck">
-      <template #header>
-        <div class="detail-header">
-          <span>节点下钻明细(卡住流程)</span>
-          <div class="detail-actions">
-            <el-input-number
-              v-model="stuckQuery.minStayMinutes"
-              :min="0"
-              placeholder="最小停留分钟"
-              controls-position="right"
-              style="width: 160px"
-              @change="loadStuck(1)"
-            />
-            <el-button type="primary" @click="loadStuck(1)">查询</el-button>
-          </div>
-        </div>
-      </template>
-
-      <el-table :data="stuckList" stripe>
-        <el-table-column prop="instanceNo" label="实例编号" min-width="160" show-overflow-tooltip />
-        <el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
-        <el-table-column prop="processName" label="流程" min-width="140" show-overflow-tooltip />
-        <el-table-column prop="nodeName" label="当前节点" min-width="140" show-overflow-tooltip />
-        <el-table-column prop="applicantName" label="申请人" min-width="100" />
-        <el-table-column prop="stayMinutes" label="已停留" min-width="120">
-          <template #default="{ row }">
-            <el-tag :type="row.stayMinutes > 1440 ? 'danger' : row.stayMinutes > 240 ? 'warning' : 'info'">
-              {{ formatMinutes(row.stayMinutes) }}
-            </el-tag>
-          </template>
-        </el-table-column>
-        <el-table-column prop="taskCreateTime" label="任务到达时间" min-width="160" />
-        <el-table-column label="操作" width="100" fixed="right">
-          <template #default="{ row }">
-            <el-link type="primary" @click="goInstance(row.instanceId)">查看</el-link>
-          </template>
-        </el-table-column>
-      </el-table>
+    <StuckInstanceTable
+      :instance-list="instanceList"
+      :instance-total="instanceTotal"
+      :loading="loadingInstanceList"
+      :query="instanceQuery"
+      :definition-list="definitionList"
+      :current-node-options="currentNodeOptions"
+      @update:query="onInstanceQueryUpdate"
+      @process-change="onInstanceProcessChange"
+      @search="loadInstanceList"
+      @detail="openInstanceDetail"
+    />
+
+    <InstanceDetail v-model="instanceDetailVisible" :instance-id="instanceDetailId" />
 
-      <el-pagination
-        v-model:current-page="stuckQuery.pageNum"
-        v-model:page-size="stuckQuery.pageSize"
-        :total="stuckTotal"
-        layout="total, sizes, prev, pager, next"
-        :page-sizes="[10, 20, 50]"
-        @size-change="loadStuck(1)"
-        @current-change="loadStuck"
-      />
-    </el-card>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, computed, onMounted, nextTick } from 'vue'
-import { useRouter } from 'vue-router'
-import { ElMessage } from 'element-plus'
-import { use } from 'echarts/core'
-import { CanvasRenderer } from 'echarts/renderers'
-import { BarChart, PieChart, LineChart } from 'echarts/charts'
-import { GridComponent, TooltipComponent, LegendComponent, TitleComponent, DataZoomComponent } from 'echarts/components'
-import VChart from 'vue-echarts'
-import {
-  getCompletedEfficiency,
-  getInProgressByNode,
-  getStuckInstances,
-  getOverview,
-  getStatusDistribution,
-  getTrend
-} from '@/api/analysis'
-import { listEnabled } from '@/api/flow/definition'
-import type {
-  ProcessEfficiency,
-  NodeStayStat,
-  StuckInstance,
-  AnalysisOverview,
-  StatusDistribution,
-  Trend
-} from '@/types/analysis'
-import type { FlowDefinition } from '@/types/flow'
-
-use([
-  CanvasRenderer,
-  BarChart,
-  PieChart,
-  LineChart,
-  GridComponent,
-  TooltipComponent,
-  LegendComponent,
-  TitleComponent,
-  DataZoomComponent
-])
-
-const router = useRouter()
-
-const colorPalette = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc']
-
-const now = new Date()
-const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
-const defaultStart = formatDateTime(thirtyDaysAgo)
-const defaultEnd = formatDateTime(now)
-
-const dateRange = ref<[string, string]>([defaultStart, defaultEnd])
-const query = reactive({
-  startTime: defaultStart as string | undefined,
-  endTime: defaultEnd as string | undefined,
-  processDefinitionId: undefined as number | undefined
-})
-
-const definitionOptions = ref<FlowDefinition[]>([])
-
-const loadingOverview = ref(false)
-const overview = ref<AnalysisOverview>({
-  totalInstances: 0,
-  completedCount: 0,
-  runningCount: 0,
-  rejectedCount: 0,
-  revokedCount: 0,
-  timeoutCount: 0,
-  timeoutRate: 0,
-  avgDurationMinutes: 0
-})
-
-const loadingStatus = ref(false)
-const statusList = ref<StatusDistribution[]>([])
-const statusChartOption = computed(() => buildStatusOption(statusList.value))
-
-const loadingTrend = ref(false)
-const trendList = ref<Trend[]>([])
-const trendChartOption = computed(() => buildTrendOption(trendList.value))
-
-const loadingEfficiency = ref(false)
-const efficiencyList = ref<ProcessEfficiency[]>([])
-const efficiencyChartOption = computed(() => buildEfficiencyOption(efficiencyList.value))
-
-const loadingNode = ref(false)
-const nodeList = ref<NodeStayStat[]>([])
-const nodeChartOption = computed(() => buildNodeOption(nodeList.value))
-
-const stuckQuery = reactive({
-  nodeId: undefined as string | undefined,
-  processDefinitionId: undefined as number | undefined,
-  minStayMinutes: 60,
-  pageNum: 1,
-  pageSize: 10
-})
-const loadingStuck = ref(false)
-const stuckList = ref<StuckInstance[]>([])
-const stuckTotal = ref(0)
-
-// 防止快速切换Tab或连续刷新时并发触发大量请求
-const loadingAll = ref(false)
-
-function formatDateTime(d: Date): string {
-  const pad = (n: number) => String(n).padStart(2, '0')
-  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
-}
-
-function onDateRangeChange(val: [string, string] | null) {
-  if (val && val.length === 2) {
-    query.startTime = val[0]
-    query.endTime = val[1]
-  } else {
-    query.startTime = undefined
-    query.endTime = undefined
-  }
-  loadAll()
-}
-
-async function loadDefinitions() {
-  try {
-    definitionOptions.value = await listEnabled()
-  } catch {
-    definitionOptions.value = []
-  }
-}
-
-async function loadOverview() {
-  loadingOverview.value = true
-  try {
-    overview.value = await getOverview({
-      startTime: query.startTime,
-      endTime: query.endTime,
-      processDefinitionId: query.processDefinitionId
-    })
-  } catch {
-    // keep default zeros
-  } finally {
-    loadingOverview.value = false
-  }
-}
-
-async function loadStatusDistribution() {
-  loadingStatus.value = true
-  try {
-    statusList.value = await getStatusDistribution({
-      startTime: query.startTime,
-      endTime: query.endTime,
-      processDefinitionId: query.processDefinitionId
-    })
-  } finally {
-    loadingStatus.value = false
-  }
-}
-
-async function loadTrend() {
-  loadingTrend.value = true
-  try {
-    trendList.value = await getTrend(query.processDefinitionId)
-  } finally {
-    loadingTrend.value = false
-  }
-}
-
-async function loadEfficiency() {
-  loadingEfficiency.value = true
-  try {
-    efficiencyList.value = await getCompletedEfficiency({
-      startTime: query.startTime,
-      endTime: query.endTime,
-      processDefinitionId: query.processDefinitionId
-    })
-  } finally {
-    loadingEfficiency.value = false
-  }
-}
-
-async function loadNodes() {
-  loadingNode.value = true
-  try {
-    nodeList.value = await getInProgressByNode(query.processDefinitionId)
-  } finally {
-    loadingNode.value = false
-  }
-}
-
-async function loadStuck(pageNum = stuckQuery.pageNum) {
-  loadingStuck.value = true
-  stuckQuery.pageNum = pageNum
-  stuckQuery.processDefinitionId = query.processDefinitionId
-  try {
-    const res = await getStuckInstances({
-      nodeId: stuckQuery.nodeId,
-      processDefinitionId: stuckQuery.processDefinitionId,
-      minStayMinutes: stuckQuery.minStayMinutes,
-      pageNum: stuckQuery.pageNum,
-      pageSize: stuckQuery.pageSize
-    })
-    stuckList.value = res.list || []
-    stuckTotal.value = res.total || 0
-  } finally {
-    loadingStuck.value = false
-  }
-}
-
-async function loadAll() {
-  if (loadingAll.value) return
-  loadingAll.value = true
-  try {
-    await Promise.all([
-      loadOverview(),
-      loadStatusDistribution(),
-      loadTrend(),
-      loadEfficiency(),
-      loadNodes()
-    ])
-    await loadStuck(1)
-  } finally {
-    loadingAll.value = false
-  }
-}
-
-function onNodeChartClick(params: any) {
-  const idx = params?.dataIndex
-  if (idx == null) return
-  const node = nodeList.value[idx]
-  if (!node) return
-  stuckQuery.nodeId = node.nodeId
-  nextTick(() => loadStuck(1))
-  ElMessage.info(`已切换至节点「${node.nodeName}」明细`)
-}
-
-function goInstance(id: number) {
-  router.push(`/approval/execute?id=${id}`)
-}
-
-function formatMinutes(minutes: number): string {
-  if (minutes < 60) return `${minutes}分钟`
-  const hours = Math.floor(minutes / 60)
-  const mins = minutes % 60
-  if (hours < 24) return `${hours}小时${mins ? mins + '分钟' : ''}`
-  const days = Math.floor(hours / 24)
-  const remHours = hours % 24
-  return `${days}天${remHours ? remHours + '小时' : ''}`
-}
-
-function buildStatusOption(list: StatusDistribution[]) {
-  if (!list.length) return null
-  return {
-    color: colorPalette,
-    tooltip: {
-      trigger: 'item',
-      formatter: (params: any) => {
-        return `${params.name}<br/>数量:${params.value} 个<br/>占比:${params.percent}%`
-      }
-    },
-    legend: {
-      bottom: 0,
-      type: 'scroll'
-    },
-    series: [
-      {
-        name: '流程状态',
-        type: 'pie',
-        radius: ['40%', '70%'],
-        center: ['50%', '45%'],
-        avoidLabelOverlap: true,
-        itemStyle: {
-          borderRadius: 8,
-          borderColor: '#fff',
-          borderWidth: 2
-        },
-        label: {
-          formatter: '{b}: {c} ({d}%)'
-        },
-        data: list.map((i) => ({ name: i.statusName || `状态${i.status}`, value: i.count }))
-      }
-    ]
-  }
-}
-
-function buildTrendOption(list: Trend[]) {
-  if (!list.length) return null
-  const dates = list.map((i) => i.date)
-  return {
-    color: colorPalette,
-    tooltip: {
-      trigger: 'axis',
-      formatter: (params: any[]) => {
-        let html = params[0]?.axisValue + '<br/>'
-        params.forEach((p) => {
-          html += `${p.marker} ${p.seriesName}:${p.value} 个<br/>`
-        })
-        return html
-      }
-    },
-    legend: {
-      data: ['发起数', '完成数', '拒绝数'],
-      bottom: 0
-    },
-    grid: { left: '3%', right: '4%', bottom: '12%', top: '10%', containLabel: true },
-    xAxis: {
-      type: 'category',
-      boundaryGap: false,
-      data: dates,
-      axisLabel: { rotate: 30 }
-    },
-    yAxis: { type: 'value', name: '个' },
-    series: [
-      {
-        name: '发起数',
-        type: 'line',
-        smooth: true,
-        data: list.map((i) => i.startedCount),
-        areaStyle: {
-          color: {
-            type: 'linear',
-            x: 0,
-            y: 0,
-            x2: 0,
-            y2: 1,
-            colorStops: [
-              { offset: 0, color: 'rgba(84,112,198,0.4)' },
-              { offset: 1, color: 'rgba(84,112,198,0.05)' }
-            ]
-          }
-        }
-      },
-      {
-        name: '完成数',
-        type: 'line',
-        smooth: true,
-        data: list.map((i) => i.completedCount)
-      },
-      {
-        name: '拒绝数',
-        type: 'line',
-        smooth: true,
-        data: list.map((i) => i.rejectedCount)
-      }
-    ]
-  }
+import { useAnalysis } from './useAnalysis'
+import KpiCards from './KpiCards.vue'
+import TimeoutChart from './TimeoutChart.vue'
+import NodeCountChart from './NodeCountChart.vue'
+import EfficiencyChart from './EfficiencyChart.vue'
+import TrendChart from './TrendChart.vue'
+import StuckInstanceTable from './StuckInstanceTable.vue'
+import InstanceDetail from '@/views/flow/execute/InstanceDetail.vue'
+
+const {
+  dateRange,
+  query,
+  definitionOptions,
+  overview,
+  loadingOverview,
+  loadingStatus,
+  statusChartOption,
+  loadingEfficiency,
+  efficiencyChartOption,
+  loadingNode,
+  nodeChartOption,
+  nodeCountData,
+  loadingNodeCount,
+  nodeCountOption,
+  instanceList,
+  instanceTotal,
+  loadingInstanceList,
+  instanceQuery,
+  definitionList,
+  currentNodeOptions,
+  instanceDetailVisible,
+  instanceDetailId,
+  loadAll,
+  loadInstanceList,
+  onDateRangeChange,
+  onNodeChartClick,
+  onInstanceProcessChange,
+  openInstanceDetail,
+  formatMinutes
+} = useAnalysis()
+
+function onInstanceQueryUpdate(newQuery: typeof instanceQuery) {
+  Object.assign(instanceQuery, newQuery)
 }
-
-function buildEfficiencyOption(list: ProcessEfficiency[]) {
-  if (!list.length) return null
-  return {
-    color: colorPalette,
-    tooltip: {
-      trigger: 'axis',
-      axisPointer: { type: 'shadow' },
-      formatter: (params: any[]) => {
-        const p = params[0]
-        const item = list[p.dataIndex]
-        return `${p.name}<br/>实例数:${item.instanceCount} 个<br/>平均耗时:${formatMinutes(Math.round(item.avgDurationMinutes))}<br/>最大:${formatMinutes(item.maxDurationMinutes)}<br/>最小:${formatMinutes(item.minDurationMinutes)}`
-      }
-    },
-    grid: { left: '3%', right: '4%', bottom: list.length > 10 ? '18%' : '3%', containLabel: true },
-    dataZoom:
-      list.length > 10
-        ? [
-            {
-              type: 'slider',
-              xAxisIndex: 0,
-              start: 0,
-              end: Math.min(100, Math.round((10 / list.length) * 100)),
-              height: 20,
-              bottom: 0
-            }
-          ]
-        : [],
-    xAxis: { type: 'category', data: list.map((i) => i.processName), axisLabel: { interval: 0, rotate: 20 } },
-    yAxis: { type: 'value', name: '分钟' },
-    series: [
-      {
-        name: '平均耗时',
-        type: 'bar',
-        data: list.map((i) => i.avgDurationMinutes),
-        itemStyle: { color: colorPalette[0], borderRadius: [6, 6, 0, 0] }
-      }
-    ]
-  }
-}
-
-function buildNodeOption(list: NodeStayStat[]) {
-  if (!list.length) return null
-  const top = list.slice(0, 20)
-  return {
-    color: colorPalette,
-    tooltip: {
-      trigger: 'axis',
-      axisPointer: { type: 'shadow' },
-      formatter: (params: any[]) => {
-        const p = params[0]
-        const item = top[p.dataIndex]
-        return `${item.processName || ''} / ${item.nodeName}<br/>任务数:${item.taskCount} 个<br/>平均停留:${formatMinutes(Math.round(item.avgStayMinutes))}<br/>最大停留:${formatMinutes(item.maxStayMinutes)}`
-      }
-    },
-    grid: { left: '3%', right: '4%', bottom: top.length > 10 ? '18%' : '3%', containLabel: true },
-    dataZoom:
-      top.length > 10
-        ? [
-            {
-              type: 'slider',
-              xAxisIndex: 0,
-              start: 0,
-              end: Math.min(100, Math.round((10 / top.length) * 100)),
-              height: 20,
-              bottom: 0
-            }
-          ]
-        : [],
-    xAxis: { type: 'category', data: top.map((i) => i.nodeName), axisLabel: { interval: 0, rotate: 25 } },
-    yAxis: { type: 'value', name: '分钟' },
-    series: [
-      {
-        name: '平均停留',
-        type: 'bar',
-        data: top.map((i) => i.avgStayMinutes),
-        itemStyle: {
-          color: (p: any) => {
-            const v = top[p.dataIndex].avgStayMinutes
-            return v > 1440 ? '#F56C6C' : v > 240 ? '#E6A23C' : '#67C23A'
-          },
-          borderRadius: [6, 6, 0, 0]
-        }
-      }
-    ]
-  }
-}
-
-onMounted(() => {
-  loadDefinitions()
-  loadAll()
-})
 </script>
 
 <style scoped>
@@ -629,100 +127,7 @@ onMounted(() => {
 .filter-card {
   margin-bottom: 16px;
 }
-.kpi-row {
-  margin-bottom: 16px;
-}
-.kpi-card {
-  border-left: 4px solid transparent;
-  transition: transform 0.2s;
-}
-.kpi-card :deep(.el-card__body) {
-  display: flex;
-  align-items: center;
-  padding: 12px;
-}
-.kpi-card:hover {
-  transform: translateY(-2px);
-}
-.kpi-icon {
-  width: 48px;
-  height: 48px;
-  border-radius: 10px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 24px;
-  margin-right: 12px;
-  color: #fff;
-}
-.kpi-total {
-  border-left-color: #5470c6;
-}
-.kpi-total .kpi-icon {
-  background: linear-gradient(135deg, #5470c6, #8ba1e6);
-}
-.kpi-completed {
-  border-left-color: #67c23a;
-}
-.kpi-completed .kpi-icon {
-  background: linear-gradient(135deg, #67c23a, #9fe07a);
-}
-.kpi-running {
-  border-left-color: #409eff;
-}
-.kpi-running .kpi-icon {
-  background: linear-gradient(135deg, #409eff, #7ec2ff);
-}
-.kpi-timeout {
-  border-left-color: #f56c6c;
-}
-.kpi-timeout .kpi-icon {
-  background: linear-gradient(135deg, #f56c6c, #f9a0a0);
-}
-.kpi-avg {
-  border-left-color: #e6a23c;
-}
-.kpi-avg .kpi-icon {
-  background: linear-gradient(135deg, #e6a23c, #f2c97d);
-}
-.kpi-content {
-  flex: 1;
-}
-.kpi-label {
-  font-size: 13px;
-  color: #909399;
-  margin-bottom: 4px;
-}
-.kpi-value {
-  font-size: 24px;
-  font-weight: bold;
-  color: #303133;
-  line-height: 1.2;
-}
 .chart-row {
   margin-bottom: 16px;
 }
-.sub-title {
-  margin-left: 8px;
-  font-size: 12px;
-  color: #909399;
-  font-weight: normal;
-}
-.detail-card {
-  margin-bottom: 16px;
-}
-.detail-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-.detail-actions {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-.el-pagination {
-  margin-top: 16px;
-  justify-content: flex-end;
-}
 </style>

+ 518 - 0
src/views/analysis/useAnalysis.ts

@@ -0,0 +1,518 @@
+import { ref, reactive, computed, onMounted, nextTick } from 'vue'
+import { ElMessage } from 'element-plus'
+import { use } from 'echarts/core'
+import { CanvasRenderer } from 'echarts/renderers'
+import { BarChart, PieChart } from 'echarts/charts'
+import { GridComponent, TooltipComponent, LegendComponent, TitleComponent, DataZoomComponent } from 'echarts/components'
+import {
+  getCompletedEfficiency,
+  getInProgressByNode,
+  getStuckInstances,
+  getOverview,
+  getStatusDistribution,
+  getNodeCount,
+} from '@/api/analysis'
+import { listEnabled } from '@/api/flow/definition'
+import { listInstance } from '@/api/flow/instance'
+import type { FlowInstance, FlowDefinition } from '@/types/flow'
+import type {
+  ProcessEfficiency,
+  NodeStayStat,
+  StuckInstance,
+  AnalysisOverview,
+  StatusDistribution,
+} from '@/types/analysis'
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+use([
+  CanvasRenderer,
+  BarChart,
+  PieChart,
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  TitleComponent,
+  DataZoomComponent,
+])
+
+const colorPalette = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc']
+
+function formatDateTime(d: Date): string {
+  const pad = (n: number) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
+
+function formatMinutes(minutes: number): string {
+  if (minutes < 60) return `${minutes}分钟`
+  const hours = Math.floor(minutes / 60)
+  const mins = minutes % 60
+  if (hours < 24) return `${hours}小时${mins ? mins + '分钟' : ''}`
+  const days = Math.floor(hours / 24)
+  const remHours = hours % 24
+  return `${days}天${remHours ? remHours + '小时' : ''}`
+}
+
+export function useAnalysis() {
+  const now = new Date()
+  const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
+  const defaultStart = formatDateTime(thirtyDaysAgo)
+  const defaultEnd = formatDateTime(now)
+
+  const dateRange = ref<[string, string]>([defaultStart, defaultEnd])
+  const query = reactive({
+    startTime: defaultStart as string | undefined,
+    endTime: defaultEnd as string | undefined,
+    processDefinitionId: undefined as number | undefined,
+  })
+
+  const definitionOptions = ref<FlowDefinition[]>([])
+
+  const loadingOverview = ref(false)
+  const overview = ref<AnalysisOverview>({
+    totalInstances: 0,
+    completedCount: 0,
+    runningCount: 0,
+    rejectedCount: 0,
+    revokedCount: 0,
+    timeoutCount: 0,
+    timeoutRate: 0,
+    avgDurationMinutes: 0,
+  })
+
+  const loadingStatus = ref(false)
+  const statusList = ref<StatusDistribution[]>([])
+  const statusChartOption = computed(() => buildStatusOption(statusList.value))
+
+  const loadingEfficiency = ref(false)
+  const efficiencyList = ref<ProcessEfficiency[]>([])
+  const efficiencyChartOption = computed(() => buildEfficiencyOption(efficiencyList.value))
+
+  const loadingNode = ref(false)
+  const nodeList = ref<NodeStayStat[]>([])
+  const nodeChartOption = computed(() => buildNodeOption(nodeList.value))
+
+  const stuckQuery = reactive({
+    nodeId: undefined as string | undefined,
+    processDefinitionId: undefined as number | undefined,
+    minStayMinutes: 60,
+    pageNum: 1,
+    pageSize: 10,
+  })
+  const loadingStuck = ref(false)
+  const stuckList = ref<StuckInstance[]>([])
+  const stuckTotal = ref(0)
+
+  const loadingAll = ref(false)
+
+  // 流程明细
+  const instanceQuery = reactive({ pageNum: 1, pageSize: 10, processDefinitionId: undefined as number | undefined, processName: '', applicantName: '', currentNodeName: '' })
+  const instanceList = ref<FlowInstance[]>([])
+  const instanceTotal = ref(0)
+  const loadingInstanceList = ref(false)
+  const definitionList = ref<FlowDefinition[]>([])
+  const currentNodeOptions = ref<string[]>([])
+
+  const instanceDetailVisible = ref(false)
+  const instanceDetailId = ref(0)
+
+  // 各节点统计图
+  const nodeCountData = ref<{ nodeName: string; count: number }[]>([])
+  const loadingNodeCount = ref(false)
+  const nodeCountOption = computed(() => ({
+    tooltip: { trigger: 'axis' as const, axisPointer: { type: 'shadow' as const } },
+    grid: { left: 120, right: 40, top: 10, bottom: 10 },
+    xAxis: { type: 'value' as const, minInterval: 1 },
+    yAxis: { type: 'category' as const, data: nodeCountData.value.map((d) => d.nodeName), inverse: true },
+    series: [
+      {
+        type: 'bar',
+        data: nodeCountData.value.map((d, i) => ({
+          value: d.count,
+          itemStyle: { color: colorPalette[i % colorPalette.length] },
+        })),
+        barMaxWidth: 30,
+        label: { show: true, position: 'right' as const },
+      },
+    ],
+  }))
+
+  function buildStatusOption(list: StatusDistribution[]) {
+    if (!list.length) return null
+    return {
+      color: colorPalette,
+      tooltip: {
+        trigger: 'item',
+        formatter: (params: any) => {
+          return `${params.name}<br/>数量:${params.value} 个<br/>占比:${params.percent}%`
+        },
+      },
+      legend: {
+        bottom: 0,
+        type: 'scroll',
+      },
+      series: [
+        {
+          name: '流程状态',
+          type: 'pie',
+          radius: ['40%', '70%'],
+          center: ['50%', '45%'],
+          avoidLabelOverlap: true,
+          itemStyle: {
+            borderRadius: 8,
+            borderColor: '#fff',
+            borderWidth: 2,
+          },
+          label: {
+            formatter: '{b}: {c} ({d}%)',
+          },
+          data: list.map((i) => ({ name: i.statusName || `状态${i.status}`, value: i.count })),
+        },
+      ],
+    }
+  }
+
+  function buildEfficiencyOption(list: ProcessEfficiency[]) {
+    if (!list.length) return null
+    return {
+      color: colorPalette,
+      tooltip: {
+        trigger: 'axis',
+        axisPointer: { type: 'shadow' },
+        formatter: (params: any[]) => {
+          const p = params[0]
+          const item = list[p.dataIndex]
+          return `${p.name}<br/>实例数:${item.instanceCount} 个<br/>平均耗时:${formatMinutes(Math.round(item.avgDurationMinutes))}<br/>最大:${formatMinutes(item.maxDurationMinutes)}<br/>最小:${formatMinutes(item.minDurationMinutes)}`
+        },
+      },
+      grid: { left: '3%', right: '4%', bottom: list.length > 10 ? '18%' : '3%', containLabel: true },
+      dataZoom:
+        list.length > 10
+          ? [
+              {
+                type: 'slider',
+                xAxisIndex: 0,
+                start: 0,
+                end: Math.min(100, Math.round((10 / list.length) * 100)),
+                height: 20,
+                bottom: 0,
+              },
+            ]
+          : [],
+      xAxis: { type: 'category', data: list.map((i) => i.processName), axisLabel: { interval: 0, rotate: 20 } },
+      yAxis: { type: 'value', name: '分钟' },
+      series: [
+        {
+          name: '平均耗时',
+          type: 'bar',
+          data: list.map((i) => i.avgDurationMinutes),
+          barMaxWidth: 48,
+          itemStyle: { color: colorPalette[0], borderRadius: [6, 6, 0, 0] },
+        },
+      ],
+    }
+  }
+
+  function buildNodeOption(list: NodeStayStat[]) {
+    if (!list.length) return null
+    const top = list.slice(0, 20)
+    return {
+      color: colorPalette,
+      tooltip: {
+        trigger: 'axis',
+        axisPointer: { type: 'shadow' },
+        formatter: (params: any[]) => {
+          const p = params[0]
+          const item = top[p.dataIndex]
+          return `${item.processName || ''} / ${item.nodeName}<br/>任务数:${item.taskCount} 个<br/>平均停留:${formatMinutes(Math.round(item.avgStayMinutes))}<br/>最大停留:${formatMinutes(item.maxStayMinutes)}`
+        },
+      },
+      grid: { left: '3%', right: '4%', bottom: top.length > 10 ? '18%' : '3%', containLabel: true },
+      dataZoom:
+        top.length > 10
+          ? [
+              {
+                type: 'slider',
+                xAxisIndex: 0,
+                start: 0,
+                end: Math.min(100, Math.round((10 / top.length) * 100)),
+                height: 20,
+                bottom: 0,
+              },
+            ]
+          : [],
+      xAxis: { type: 'category', data: top.map((i) => i.nodeName), axisLabel: { interval: 0, rotate: 25 } },
+      yAxis: { type: 'value', name: '分钟' },
+      series: [
+        {
+          name: '平均停留',
+          type: 'bar',
+          data: top.map((i) => i.avgStayMinutes),
+          barMaxWidth: 48,
+          itemStyle: {
+            color: (p: any) => {
+              const v = top[p.dataIndex].avgStayMinutes
+              return v > 1440 ? '#F56C6C' : v > 240 ? '#E6A23C' : '#67C23A'
+            },
+            borderRadius: [6, 6, 0, 0],
+          },
+        },
+      ],
+    }
+  }
+
+  function onDateRangeChange(val: [string, string] | null) {
+    if (val && val.length === 2) {
+      query.startTime = val[0]
+      query.endTime = val[1]
+    } else {
+      query.startTime = undefined
+      query.endTime = undefined
+    }
+    loadAll()
+  }
+
+  async function loadDefinitions() {
+    try {
+      definitionOptions.value = await listEnabled()
+      if (definitionOptions.value.length > 0 && !query.processDefinitionId) {
+        query.processDefinitionId = definitionOptions.value[definitionOptions.value.length - 1].id
+      }
+    } catch {
+      definitionOptions.value = []
+    }
+  }
+
+  async function loadOverview() {
+    loadingOverview.value = true
+    try {
+      overview.value = await getOverview({
+        startTime: query.startTime,
+        endTime: query.endTime,
+        processDefinitionId: query.processDefinitionId,
+      })
+    } catch {
+      // keep default zeros
+    } finally {
+      loadingOverview.value = false
+    }
+  }
+
+  async function loadStatusDistribution() {
+    loadingStatus.value = true
+    try {
+      statusList.value = await getStatusDistribution({
+        startTime: query.startTime,
+        endTime: query.endTime,
+        processDefinitionId: query.processDefinitionId,
+      })
+    } finally {
+      loadingStatus.value = false
+    }
+  }
+
+  async function loadEfficiency() {
+    loadingEfficiency.value = true
+    try {
+      efficiencyList.value = await getCompletedEfficiency({
+        startTime: query.startTime,
+        endTime: query.endTime,
+        processDefinitionId: query.processDefinitionId,
+      })
+    } finally {
+      loadingEfficiency.value = false
+    }
+  }
+
+  async function loadNodes() {
+    loadingNode.value = true
+    try {
+      nodeList.value = await getInProgressByNode(query.processDefinitionId)
+    } finally {
+      loadingNode.value = false
+    }
+  }
+
+  async function loadStuck(pageNum = stuckQuery.pageNum) {
+    loadingStuck.value = true
+    stuckQuery.pageNum = pageNum
+    stuckQuery.processDefinitionId = query.processDefinitionId
+    try {
+      const res = await getStuckInstances({
+        nodeId: stuckQuery.nodeId,
+        processDefinitionId: stuckQuery.processDefinitionId,
+        minStayMinutes: stuckQuery.minStayMinutes,
+        pageNum: stuckQuery.pageNum,
+        pageSize: stuckQuery.pageSize,
+      })
+      stuckList.value = res.list || []
+      stuckTotal.value = res.total || 0
+    } finally {
+      loadingStuck.value = false
+    }
+  }
+
+  async function loadAll() {
+    if (loadingAll.value) return
+    loadingAll.value = true
+    try {
+      await Promise.all([loadOverview(), loadStatusDistribution(), loadEfficiency(), loadNodes(), loadNodeCount()])
+      await loadStuck(1)
+    } finally {
+      loadingAll.value = false
+    }
+  }
+
+  function onNodeChartClick(params: any) {
+    const idx = params?.dataIndex
+    if (idx == null) return
+    const node = nodeList.value[idx]
+    if (!node) return
+    instanceQuery.currentNodeName = node.nodeName
+    instanceQuery.processName = node.processName || ''
+    instanceQuery.pageNum = 1
+    loadInstanceList()
+    ElMessage.info(`已筛选节点「${node.nodeName}」`)
+  }
+
+  function openInstanceDetail(id: number) {
+    instanceDetailId.value = id
+    instanceDetailVisible.value = true
+  }
+
+  function onInstanceProcessChange() {
+    instanceQuery.currentNodeName = ''
+    const def = definitionList.value.find((d) => d.name === instanceQuery.processName)
+    if (def?.flowJson) {
+      try {
+        currentNodeOptions.value = JSON.parse(def.flowJson)
+          .nodes.filter((n: any) => n.type !== 'start' && n.type !== 'end' && n.type !== 'condition' && n.type !== 'cc')
+          .map((n: any) => n.name)
+      } catch {
+        currentNodeOptions.value = []
+      }
+    } else {
+      currentNodeOptions.value = []
+    }
+    loadInstanceList()
+  }
+
+  async function loadInstanceList() {
+    loadingInstanceList.value = true
+    try {
+      const res = await listInstance({
+        pageNum: instanceQuery.pageNum,
+        pageSize: instanceQuery.pageSize,
+        processDefinitionId: instanceQuery.processDefinitionId,
+        definitionName: instanceQuery.processName || undefined,
+        applicantName: instanceQuery.applicantName || undefined,
+        currentNodeName: instanceQuery.currentNodeName || undefined,
+      })
+      instanceList.value = res.list
+      instanceTotal.value = res.total
+    } finally {
+      loadingInstanceList.value = false
+    }
+  }
+
+  async function loadNodeCount() {
+    if (!query.processDefinitionId) {
+      nodeCountData.value = []
+      return
+    }
+    loadingNodeCount.value = true
+    try {
+      nodeCountData.value = await getNodeCount(query.processDefinitionId)
+    } catch {
+      nodeCountData.value = []
+    } finally {
+      loadingNodeCount.value = false
+    }
+  }
+
+  async function loadDefinitionsFull() {
+    try {
+      definitionList.value = await listEnabled()
+    } catch {
+      /* ignore */
+    }
+  }
+
+  onMounted(() => {
+    loadDefinitions()
+    loadAll()
+    loadDefinitionsFull()
+    loadInstanceList()
+  })
+
+  return {
+    // filters
+    dateRange,
+    query,
+    definitionOptions,
+    instanceQuery,
+
+    // overview
+    loadingOverview,
+    overview,
+
+    // status distribution
+    loadingStatus,
+    statusList,
+    statusChartOption,
+
+    // efficiency
+    loadingEfficiency,
+    efficiencyList,
+    efficiencyChartOption,
+
+    // node stay
+    loadingNode,
+    nodeList,
+    nodeChartOption,
+
+    // stuck
+    stuckQuery,
+    loadingStuck,
+    stuckList,
+    stuckTotal,
+
+    // instance list
+    instanceList,
+    instanceTotal,
+    loadingInstanceList,
+    definitionList,
+    currentNodeOptions,
+
+    // node count
+    nodeCountData,
+    loadingNodeCount,
+    nodeCountOption,
+
+    // detail
+    instanceDetailVisible,
+    instanceDetailId,
+
+    // loading guard
+    loadingAll,
+
+    // actions
+    loadAll,
+    loadInstanceList,
+    loadStuck,
+    loadDefinitions,
+    loadOverview,
+    loadStatusDistribution,
+    loadEfficiency,
+    loadNodes,
+    loadNodeCount,
+    loadDefinitionsFull,
+    onDateRangeChange,
+    onNodeChartClick,
+    onInstanceProcessChange,
+    openInstanceDetail,
+    formatMinutes,
+    buildStatusOption,
+    buildEfficiencyOption,
+    buildNodeOption,
+  }
+}

+ 122 - 134
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 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">
@@ -140,14 +92,14 @@
         </el-form-item>
         <el-form-item label="附件">
           <el-upload
-            v-model:file-list="attachmentList"
+            v-model:file-list="fileUpload.attachmentList"
             action="#"
-            :http-request="handleUpload"
+            :http-request="fileUpload.handleUpload"
             :before-upload="beforeFileUpload"
             :before-remove="() => true"
             :limit="5"
             multiple
-            :on-preview="handleFilePreview"
+            :on-preview="fileUpload.handleFilePreview"
           >
             <el-button type="primary" size="small">上传附件</el-button>
             <template #tip>
@@ -156,7 +108,7 @@
             <template #file="{ file }">
               <div class="upload-file-item">
                 <el-icon><Document /></el-icon>
-                <el-link type="primary" @click="openPreview(getUploadUrl(file))">
+                <el-link type="primary" @click="fileUpload.openPreview(file.url || file.response)">
                   {{ file.name }}
                 </el-link>
               </div>
@@ -171,7 +123,7 @@
     </el-dialog>
 
     <!-- 附件预览 -->
-    <FilePreview v-model="previewVisible" :url="previewUrl" />
+    <FilePreview v-model="fileUpload.previewVisible" :url="fileUpload.previewUrl" />
   </div>
 </template>
 
@@ -180,26 +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 { uploadFile } from '@/api/file'
-import { beforeFileUpload, collectAttachmentUrls, getUploadUrl } from '@/utils/file'
+import { getOverview } from '@/api/analysis'
+import { beforeFileUpload, collectAttachmentUrls } from '@/utils/file'
+import { useFileUpload } from '@/composables/useFileUpload'
 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 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[]>([])
 
@@ -226,20 +218,7 @@ const hasFormFields = computed(() => {
     return false
   }
 })
-const attachmentList = ref<any[]>([])
-const previewVisible = ref(false)
-const previewUrl = ref('')
-
-function openPreview(url: string) {
-  if (!url) return
-  previewUrl.value = url
-  previewVisible.value = true
-}
-
-function handleFilePreview(file: any) {
-  const url = getUploadUrl(file)
-  if (url) openPreview(url)
-}
+const fileUpload = reactive(useFileUpload())
 
 const startFormRules: FormRules = {
   title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
@@ -247,18 +226,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
@@ -284,25 +278,15 @@ function handleStart(def: FlowDefinition) {
   startForm.formData = ''
   dynamicFormData.value = {}
   selectedDefinition.value = def
-  attachmentList.value = []
+  fileUpload.resetFileUpload()
   startDialogVisible.value = true
 }
 
-async function handleUpload(options: any) {
-  try {
-    const res = await uploadFile(options.file)
-    options.onSuccess(res)
-  } catch (e) {
-    options.onError(e)
-  }
-}
-
 async function submitStart() {
   const valid = await startFormRef.value?.validate().catch(() => false)
   if (!valid) return
   starting.value = true
   try {
-    // 校验动态表单字段;无动态字段时视为校验通过
     const dynamicValid = flowFormFieldsRef.value?.validate() ?? true
     if (dynamicValid === false) return
 
@@ -310,7 +294,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('=')
@@ -324,8 +307,8 @@ async function submitStart() {
         }
       })
     }
-    const attachmentUrls = attachmentList.value.length > 0
-      ? collectAttachmentUrls(attachmentList.value)
+    const attachmentUrls = fileUpload.attachmentList.length > 0
+      ? collectAttachmentUrls(fileUpload.attachmentList)
       : undefined
     await startInstance(startForm.processDefinitionId, startForm.title || undefined, formData, attachmentUrls)
     ElMessage.success('流程发起成功')
@@ -343,9 +326,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);
@@ -354,30 +341,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;
@@ -387,13 +375,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;
@@ -405,14 +393,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;

+ 102 - 3
src/views/flow/definition/index.vue

@@ -4,7 +4,10 @@
       <template #header>
         <div class="card-header">
           <span>流程管理</span>
-          <el-button type="primary" @click="handleAdd">新增流程</el-button>
+          <div>
+            <el-button type="warning" @click="openMigrateDialog" style="margin-right: 8px;">流程迁移</el-button>
+            <el-button type="primary" @click="handleAdd">新增流程</el-button>
+          </div>
         </div>
       </template>
 
@@ -16,7 +19,7 @@
           <el-input v-model="queryParams.name" placeholder="请输入流程名称" clearable />
         </el-form-item>
         <el-form-item label="状态">
-          <el-select v-model="queryParams.status" placeholder="全部" clearable>
+          <el-select v-model="queryParams.status" placeholder="全部" clearable style="width: auto; min-width: 120px;">
             <el-option label="草稿" :value="0" />
             <el-option label="已发布" :value="1" />
             <el-option label="已停用" :value="2" />
@@ -90,7 +93,7 @@
 
     <!-- 编辑流程 - 基本信息弹窗 -->
     <el-dialog v-model="editDialogVisible" title="编辑流程信息" width="700px">
-      <el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="100px">
+      <el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="120px">
         <el-form-item label="流程编码">
           <el-input v-model="editForm.code" disabled />
         </el-form-item>
@@ -103,6 +106,27 @@
         <el-form-item label="描述">
           <el-input v-model="editForm.description" type="textarea" />
         </el-form-item>
+        <el-form-item>
+          <template #label>
+            节点名称字段
+            <el-tooltip placement="top" content="导入流程时,按此字段的值匹配对应的流程节点" raw-content>
+              <el-icon style="margin-left: 4px; cursor: help;"><QuestionFilled /></el-icon>
+            </el-tooltip>
+          </template>
+          <el-input v-model="editForm.nodeFieldName" placeholder="如:节点名称,对应 Excel 中的列名" />
+        </el-form-item>
+        <el-form-item>
+          <template #label>
+            归属人字段
+            <el-tooltip placement="top" content="导入流程时,按此字段的值匹配 sys_user 表中的用户,匹配成功则该流程属于该用户" raw-content>
+              <el-icon style="margin-left: 4px; cursor: help;"><QuestionFilled /></el-icon>
+            </el-tooltip>
+          </template>
+          <el-input v-model="editForm.ownerFieldName" placeholder="如:发起人,对应 Excel 中的列名" />
+        </el-form-item>
+        <el-form-item label="业务编号字段">
+          <el-input v-model="editForm.bizFieldName" placeholder="如:customerName,列表中显示为该字段的值" />
+        </el-form-item>
       </el-form>
 
       <div class="form-fields-section">
@@ -198,6 +222,31 @@
         <el-button type="primary" @click="saveOptions">保存</el-button>
       </template>
     </el-dialog>
+
+    <!-- 流程迁移弹窗 -->
+    <el-dialog v-model="migrateDialogVisible" title="流程迁移" width="550px">
+      <el-form label-width="100px">
+        <el-form-item label="源流程版本">
+          <el-select v-model="migrateFromId" placeholder="请选择旧版本流程" style="width: 100%" @change="onMigrateFromChange">
+            <el-option v-for="def in migrateDefinitionList" :key="def.id" :label="`${def.name}(v${def.version})${def.status === 0 ? ' [设计]' : def.status === 1 ? ' [已发布]' : ' [已停用]'}`" :value="def.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="目标版本">
+          <el-select v-model="migrateToId" placeholder="请选择新版本流程" style="width: 100%" :disabled="!migrateFromId">
+            <el-option v-for="def in migrateTargetList" :key="def.id" :label="`${def.name}(v${def.version})${def.status === 0 ? ' [设计]' : def.status === 1 ? ' [已发布]' : ' [已停用]'}`" :value="def.id" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <el-alert type="warning" :closable="false" style="margin-bottom: 12px;">
+        <template #title>
+          迁移将把所有运行中的实例从源版本切换到目标版本,节点按名称匹配。不兼容的实例将被跳过。操作不可逆,请确认后执行。
+        </template>
+      </el-alert>
+      <template #footer>
+        <el-button @click="migrateDialogVisible = false">取消</el-button>
+        <el-button type="warning" :disabled="!migrateFromId || !migrateToId" :loading="migrating" @click="confirmMigrate">确认迁移</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -205,8 +254,10 @@
 import { ref, reactive, onMounted } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { ElMessage, ElMessageBox } from 'element-plus'
+import { QuestionFilled } from '@element-plus/icons-vue'
 import type { FormInstance, FormRules } from 'element-plus'
 import { listDefinition, getDefinition, updateDefinition, deleteDefinition, publishDefinition, stopDefinition, enableDefinition } from '@/api/flow/definition'
+import { migrateInstances } from '@/api/flow/instance'
 import type { FlowDefinition, FormField, FormFieldOption } from '@/types/flow'
 
 const router = useRouter()
@@ -473,6 +524,54 @@ async function handleDelete(row: FlowDefinition) {
   }
 }
 
+// 流程迁移
+const migrateDialogVisible = ref(false)
+const migrateFromId = ref<number | null>(null)
+const migrateToId = ref<number | null>(null)
+const migrateDefinitionList = ref<FlowDefinition[]>([])
+const migrateTargetList = ref<FlowDefinition[]>([])
+const migrating = ref(false)
+
+async function openMigrateDialog() {
+  migrateFromId.value = null
+  migrateToId.value = null
+  migrateTargetList.value = []
+  // 加载全部流程定义(不受分页限制)
+  try {
+    const res = await listDefinition({ pageNum: 1, pageSize: 999 })
+    migrateDefinitionList.value = res.list
+  } catch {
+    migrateDefinitionList.value = [...tableData.value]
+  }
+  migrateDialogVisible.value = true
+}
+
+function onMigrateFromChange() {
+  migrateToId.value = null
+  const from = migrateDefinitionList.value.find(d => d.id === migrateFromId.value)
+  if (!from) { migrateTargetList.value = []; return }
+  migrateTargetList.value = migrateDefinitionList.value.filter(d => d.id !== from.id)
+}
+
+async function confirmMigrate() {
+  if (!migrateFromId.value || !migrateToId.value) return
+  try {
+    await ElMessageBox.confirm(
+      `确认将所有运行中的实例从源版本 v${migrateDefinitionList.value.find(d => d.id === migrateFromId.value)?.version} 迁移到目标版本 v${migrateTargetList.value.find(d => d.id === migrateToId.value)?.version} 吗?此操作不可逆。`,
+      '确认迁移', { type: 'warning' }
+    )
+  } catch { return }
+  migrating.value = true
+  try {
+    const res = await migrateInstances(migrateFromId.value, migrateToId.value)
+    ElMessage.success(`迁移完成,共迁移 ${res.migratedCount} 个实例`)
+    migrateDialogVisible.value = false
+    loadData()
+  } catch (e: any) {
+    ElMessage.error(e?.message || '迁移失败')
+  } finally { migrating.value = false }
+}
+
 onMounted(loadData)
 </script>
 

+ 122 - 0
src/views/flow/designer/DesignerCanvas.vue

@@ -0,0 +1,122 @@
+<template>
+  <div class="designer-canvas">
+    <div class="node-panel">
+      <div class="panel-title">节点类型</div>
+      <div
+        v-for="node in nodeTypes"
+        :key="node.type"
+        class="node-item"
+        draggable="true"
+        @dragstart="handleDragStart($event, node)"
+      >
+        <div class="node-icon" :style="{ backgroundColor: node.color }">
+          <el-icon><component :is="node.icon" /></el-icon>
+        </div>
+        <div class="node-name">{{ node.label }}</div>
+      </div>
+    </div>
+
+    <div class="canvas-area" @drop.prevent="$emit('drop', $event)" @dragover.prevent>
+      <div :ref="setContainer" class="lf-container" />
+      <div class="toolbar">
+        <el-button type="primary" size="small" @click="$emit('save')">保存</el-button>
+        <el-button size="small" @click="$emit('clear')">清空</el-button>
+        <el-button size="small" @click="$emit('back')">返回</el-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { ComponentPublicInstance } from 'vue'
+import { VideoPlay, User, Message, Operation, CircleCheck } from '@element-plus/icons-vue'
+
+defineProps<{
+  setContainer: (el: Element | ComponentPublicInstance | null) => void
+}>()
+
+defineEmits<{
+  drop: [e: DragEvent]
+  save: []
+  clear: []
+  back: []
+}>()
+
+const nodeTypes = [
+  { type: 'start-node', label: '开始', icon: VideoPlay, color: '#67C23A' },
+  { type: 'approval-node', label: '审批节点', icon: User, color: '#409EFF' },
+  { type: 'cc-node', label: '抄送节点', icon: Message, color: '#E6A23C' },
+  { type: 'condition-node', label: '条件节点', icon: Operation, color: '#909399' },
+  { type: 'end-node', label: '结束', icon: CircleCheck, color: '#F56C6C' }
+]
+
+function handleDragStart(e: DragEvent, node: any) {
+  const serializable = { type: node.type, label: node.label }
+  e.dataTransfer?.setData('application/json', JSON.stringify(serializable))
+}
+</script>
+
+<style scoped>
+.designer-canvas {
+  flex: 1;
+  display: flex;
+  min-width: 0;
+  height: 100%;
+}
+.node-panel {
+  width: 200px;
+  border-right: 1px solid #dcdfe6;
+  padding: 16px;
+  background: #fafafa;
+}
+.panel-title {
+  font-size: 16px;
+  font-weight: bold;
+  margin-bottom: 16px;
+  color: #303133;
+}
+.node-item {
+  display: flex;
+  align-items: center;
+  padding: 12px;
+  margin-bottom: 8px;
+  border-radius: 6px;
+  cursor: move;
+  background: #fff;
+  border: 1px solid #ebeef5;
+  transition: all 0.2s;
+}
+.node-item:hover {
+  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
+  border-color: #c6e2ff;
+}
+.node-icon {
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+  margin-right: 12px;
+}
+.node-name {
+  font-size: 14px;
+  color: #606266;
+}
+.canvas-area {
+  flex: 1;
+  position: relative;
+  background: #f5f7fa;
+}
+.lf-container {
+  width: 100%;
+  height: 100%;
+}
+.toolbar {
+  position: absolute;
+  top: 16px;
+  right: 16px;
+  z-index: 10;
+}
+</style>

+ 173 - 0
src/views/flow/designer/DesignerPropertyPanel.vue

@@ -0,0 +1,173 @@
+<template>
+  <div v-if="selectedNode" class="property-panel">
+    <div class="panel-title">节点属性</div>
+    <el-form label-width="100px" size="small">
+      <el-form-item label="节点ID">
+        <el-input v-model="selectedNode.id" disabled />
+      </el-form-item>
+      <el-form-item label="节点名称">
+        <el-input v-model="nodeName" @blur="updateNodeName" />
+      </el-form-item>
+      <template v-if="selectedNode.type === 'approval-node'">
+        <el-form-item label="审批员工">
+          <el-select
+            v-model="nodeProps.assigneeValue"
+            filterable
+            clearable
+            placeholder="请选择审批角色中的成员"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="role in roleList"
+              :key="role.id"
+              :label="role.roleName"
+              :value="role.roleCode"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="审批方式">
+          <el-select v-model="nodeProps.approveMode" placeholder="请选择" style="width: 100%">
+            <el-option label="或签(一人通过即可)" value="or" />
+            <el-option label="会签(全部通过)" value="and" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <template #label>
+            审批时限
+            <el-tooltip placement="top" content="单位:小时,为空则不计算超时">
+              <el-icon style="margin-left: 4px; cursor: help;"><QuestionFilled /></el-icon>
+            </el-tooltip>
+          </template>
+          <el-input-number
+            v-model="nodeProps.timeoutHours"
+            :min="1"
+            :max="720"
+            :precision="0"
+            placeholder="不填表示无限制"
+            controls-position="right"
+            style="width: 100%"
+          />
+        </el-form-item>
+        <el-form-item label="超时动作">
+          <el-select v-model="nodeProps.timeoutAction" placeholder="请选择" style="width: 100%">
+            <el-option label="提醒" value="remind" />
+            <el-option label="无" value="" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <template #label>
+            审批时间字段
+            <el-tooltip placement="top" content="导入时按此字段的值作为该节点的审批时间,Excel 列名需与此一致">
+              <el-icon style="margin-left: 4px; cursor: help;"><QuestionFilled /></el-icon>
+            </el-tooltip>
+          </template>
+          <el-input v-model="nodeProps.approveTimeField" placeholder="如:审批时间" />
+        </el-form-item>
+      </template>
+      <template v-if="selectedNode.type === 'condition-node'">
+        <div class="panel-title">分支配置</div>
+        <el-table :data="outgoingEdges" size="small" border class="branch-table">
+          <el-table-column label="目标节点" min-width="100">
+            <template #default="{ row }">
+              {{ getEdgeTargetName(row) }}
+            </template>
+          </el-table-column>
+          <el-table-column label="默认分支" width="80" align="center">
+            <template #default="{ row }">
+              <el-switch v-model="row.properties.isDefault" @change="onDefaultChange(row)" />
+            </template>
+          </el-table-column>
+          <el-table-column label="分支名称" min-width="100">
+            <template #default="{ row }">
+              <el-input v-model="row.properties.branchName" size="small" placeholder="如:大额" @blur="onBranchBlur(row)" />
+            </template>
+          </el-table-column>
+          <el-table-column label="条件表达式" min-width="160">
+            <template #default="{ row }">
+              <el-input v-model="row.properties.condition" size="small" placeholder="如:amount >= 1000" :disabled="row.properties.isDefault" @blur="onBranchBlur(row)" />
+            </template>
+          </el-table-column>
+        </el-table>
+        <div class="form-tip">默认分支无需填写条件;有且只能有一个默认分支。</div>
+      </template>
+      <template v-if="selectedNode.type === 'cc-node'">
+        <el-form-item label="抄送人">
+          <el-select
+            v-model="nodeProps.ccUsers"
+            filterable
+            clearable
+            placeholder="请选择抄送人"
+            style="width: 100%"
+          >
+            <el-option
+              v-for="role in roleList"
+              :key="role.id"
+              :label="role.roleName"
+              :value="role.roleCode"
+            />
+          </el-select>
+        </el-form-item>
+      </template>
+    </el-form>
+  </div>
+  <div v-else class="property-panel empty">
+    <div class="panel-title">节点属性</div>
+    <el-empty description="请选择节点" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, toRefs } from 'vue'
+import { QuestionFilled } from '@element-plus/icons-vue'
+import type { Role } from '@/types/system'
+
+const props = defineProps<{
+  selectedNode: any
+  nodeProps: Record<string, any>
+  outgoingEdges: any[]
+  roleList: Role[]
+  getEdgeTargetName: (edge: any) => string
+  updateNodeName: () => void
+  onDefaultChange: (edge: any) => void
+  onBranchBlur: (edge: any) => void
+}>()
+
+const { selectedNode, outgoingEdges, roleList, nodeProps } = toRefs(props)
+const { getEdgeTargetName, updateNodeName, onDefaultChange, onBranchBlur } = props
+
+const nodeName = computed({
+  get: () => selectedNode.value?.text?.value || '',
+  set: (val: string) => {
+    if (selectedNode.value) {
+      selectedNode.value.text = { ...selectedNode.value.text, value: val }
+    }
+  }
+})
+</script>
+
+<style scoped>
+.property-panel {
+  width: 300px;
+  border-left: 1px solid #dcdfe6;
+  padding: 16px;
+  background: #fafafa;
+  overflow-y: auto;
+}
+.property-panel.empty {
+  display: flex;
+  flex-direction: column;
+}
+.panel-title {
+  font-size: 16px;
+  font-weight: bold;
+  margin-bottom: 16px;
+  color: #303133;
+}
+.branch-table {
+  margin-top: 8px;
+}
+.branch-table :deep(.el-input__wrapper) {
+  padding-left: 4px;
+  padding-right: 4px;
+}
+</style>

+ 176 - 0
src/views/flow/designer/DesignerSaveDialog.vue

@@ -0,0 +1,176 @@
+<template>
+  <!-- 保存确认弹窗(无模式时) -->
+  <el-dialog :model-value="visible" @update:model-value="$emit('update:visible', $event)" title="保存流程" width="600px">
+    <el-form ref="saveFormRef" :model="saveForm" :rules="saveFormRules" label-width="100px">
+      <el-form-item label="流程编码" prop="code">
+        <el-input v-model="saveForm.code" />
+      </el-form-item>
+      <el-form-item label="流程名称" prop="name">
+        <el-input v-model="saveForm.name" />
+      </el-form-item>
+      <el-form-item label="分类">
+        <el-input v-model="saveForm.category" />
+      </el-form-item>
+      <el-form-item label="描述">
+        <el-input v-model="saveForm.description" type="textarea" />
+      </el-form-item>
+    </el-form>
+
+    <div class="form-fields-section">
+      <div class="form-fields-header">
+        <span class="form-fields-title">流程表单字段</span>
+        <el-button type="primary" size="small" :icon="Delete" @click="$emit('addField')">添加字段</el-button>
+      </div>
+      <el-table :data="formFields" size="small" border style="width: 100%">
+        <el-table-column label="字段名" min-width="120">
+          <template #default="{ $index }">
+            <el-input v-model="formFields[$index].name" placeholder="英文标识" size="small" />
+          </template>
+        </el-table-column>
+        <el-table-column label="显示名" min-width="120">
+          <template #default="{ $index }">
+            <el-input v-model="formFields[$index].label" placeholder="中文显示名" size="small" />
+          </template>
+        </el-table-column>
+        <el-table-column label="类型" width="120">
+          <template #default="{ $index }">
+            <el-select v-model="formFields[$index].type" placeholder="类型" size="small" style="width: 100%">
+              <el-option v-for="opt in fieldTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+            </el-select>
+          </template>
+        </el-table-column>
+        <el-table-column label="必填" width="70" align="center">
+          <template #default="{ $index }">
+            <el-checkbox v-model="formFields[$index].required" />
+          </template>
+        </el-table-column>
+        <el-table-column label="多选" width="70" align="center">
+          <template #default="{ $index }">
+            <el-checkbox
+              v-model="formFields[$index].multiple"
+              :disabled="formFields[$index].type !== 'select'"
+            />
+          </template>
+        </el-table-column>
+        <el-table-column label="选项" width="90" align="center">
+          <template #default="{ $index }">
+            <el-button
+              link
+              type="primary"
+              size="small"
+              :disabled="formFields[$index].type !== 'select'"
+              @click="$emit('openOptionDialog', $index)"
+            >
+              配置选项
+            </el-button>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="70" align="center">
+          <template #default="{ $index }">
+            <el-button link type="danger" size="small" @click="$emit('removeField', $index)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-empty v-if="formFields.length === 0" description="暂无字段,点击上方按钮添加" :image-size="60" style="padding: 10px 0;" />
+    </div>
+
+    <template #footer>
+      <el-button @click="$emit('update:visible', false)">取消</el-button>
+      <el-button type="primary" @click="handleSubmit">确认保存</el-button>
+    </template>
+  </el-dialog>
+
+  <!-- 下拉选项配置弹窗 -->
+  <el-dialog :model-value="optionDialogVisible" @update:model-value="$emit('update:optionDialogVisible', $event)" title="配置下拉选项" width="500px">
+    <div v-if="optionEditingField" class="option-edit-tip">
+      字段:{{ optionEditingField.label || optionEditingField.name }}
+      <el-checkbox v-model="optionEditingField.multiple" style="margin-left: 16px;">允许多选</el-checkbox>
+    </div>
+    <el-table :data="optionList" size="small" border>
+      <el-table-column label="显示文本" min-width="140">
+        <template #default="{ $index }">
+          <el-input v-model="optionList[$index].label" placeholder="显示文本" size="small" />
+        </template>
+      </el-table-column>
+      <el-table-column label="选项值" min-width="140">
+        <template #default="{ $index }">
+          <el-input v-model="optionList[$index].value" placeholder="选项值" size="small" />
+        </template>
+      </el-table-column>
+      <el-table-column label="操作" width="70" align="center">
+        <template #default="{ $index }">
+          <el-button link type="danger" size="small" @click="$emit('removeOption', $index)">删除</el-button>
+        </template>
+      </el-table-column>
+    </el-table>
+    <el-button type="primary" size="small" style="margin-top: 12px;" @click="$emit('addOption')">添加选项</el-button>
+    <template #footer>
+      <el-button @click="$emit('update:optionDialogVisible', false)">取消</el-button>
+      <el-button type="primary" @click="$emit('saveOptions')">保存</el-button>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, toRefs } from 'vue'
+import { Delete } from '@element-plus/icons-vue'
+import type { FormInstance, FormRules } from 'element-plus'
+import type { FormField, FormFieldOption } from '@/types/flow'
+
+const props = defineProps<{
+  visible: boolean
+  optionDialogVisible: boolean
+  saveForm: {
+    code: string
+    name: string
+    category: string
+    description: string
+  }
+  saveFormRules: FormRules
+  formFields: FormField[]
+  fieldTypeOptions: { label: string; value: string }[]
+  optionEditingField: FormField | null
+  optionList: FormFieldOption[]
+}>()
+
+const emit = defineEmits<{
+  'update:visible': [value: boolean]
+  'update:optionDialogVisible': [value: boolean]
+  submit: []
+  addField: []
+  removeField: [index: number]
+  openOptionDialog: [index: number]
+  addOption: []
+  removeOption: [index: number]
+  saveOptions: []
+}>()
+
+const { visible, optionDialogVisible, saveForm, formFields, optionEditingField, optionList } = toRefs(props)
+const { saveFormRules, fieldTypeOptions } = props
+
+const saveFormRef = ref<FormInstance>()
+
+async function handleSubmit() {
+  const valid = await saveFormRef.value?.validate().catch(() => false)
+  if (!valid) return
+  emit('submit')
+}
+</script>
+
+<style scoped>
+.form-fields-section {
+  margin-top: 16px;
+  border-top: 1px solid #ebeef5;
+  padding-top: 16px;
+}
+.form-fields-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+.form-fields-title {
+  font-weight: bold;
+  color: #303133;
+}
+</style>

+ 82 - 909
src/views/flow/designer/index.vue

@@ -9,840 +9,95 @@
       <span v-if="flowCategory" class="flow-info-item"><strong>分类:</strong>{{ flowCategory }}</span>
     </div>
 
-    <div class="node-panel">
-      <div class="panel-title">节点类型</div>
-      <div
-        v-for="node in nodeTypes"
-        :key="node.type"
-        class="node-item"
-        draggable="true"
-        @dragstart="handleDragStart($event, node)"
-      >
-        <div class="node-icon" :style="{ backgroundColor: node.color }">
-          <el-icon><component :is="node.icon" /></el-icon>
-        </div>
-        <div class="node-name">{{ node.label }}</div>
-      </div>
-    </div>
-
-    <div class="canvas-area" @drop="handleDrop" @dragover.prevent>
-      <div ref="lfContainer" class="lf-container" />
-      <div class="toolbar">
-        <el-button type="primary" size="small" @click="handleSave">保存</el-button>
-        <el-button size="small" @click="handleClear">清空</el-button>
-        <el-button size="small" @click="goBack">返回</el-button>
-      </div>
-    </div>
-
-    <div class="property-panel" v-if="selectedNode">
-      <div class="panel-title">节点属性</div>
-      <el-form label-width="80px" size="small">
-        <el-form-item label="节点ID">
-          <el-input v-model="selectedNode.id" disabled />
-        </el-form-item>
-        <el-form-item label="节点名称">
-          <el-input v-model="nodeName" @blur="updateNodeName" />
-        </el-form-item>
-        <template v-if="selectedNode.type === 'approval-node'">
-          <el-form-item label="审批员工">
-            <el-select
-              v-model="nodeProps.assigneeValue"
-              filterable
-              clearable
-              placeholder="请选择员工管理中的成员"
-              style="width: 100%"
-            >
-              <el-option
-                v-for="role in roleList"
-                :key="role.id"
-                :label="role.roleName"
-                :value="role.roleCode"
-              />
-            </el-select>
-          </el-form-item>
-          <el-form-item label="审批方式">
-            <el-select v-model="nodeProps.approveMode" placeholder="请选择" style="width: 100%">
-              <el-option label="或签(一人通过即可)" value="or" />
-              <el-option label="会签(全部通过)" value="and" />
-            </el-select>
-          </el-form-item>
-          <el-form-item label="审批时限">
-            <el-input-number
-              v-model="nodeProps.timeoutHours"
-              :min="1"
-              :max="720"
-              :precision="0"
-              placeholder="不填表示无限制"
-              controls-position="right"
-              style="width: 100%"
-            />
-            <div class="form-tip">单位:小时,为空则不计算超时</div>
-          </el-form-item>
-          <el-form-item label="超时动作">
-            <el-select v-model="nodeProps.timeoutAction" placeholder="请选择" style="width: 100%">
-              <el-option label="提醒" value="remind" />
-              <el-option label="自动通过" value="pass" />
-              <el-option label="自动驳回" value="reject" />
-            </el-select>
-            <div class="form-tip">目前仅实现「提醒」,自动通过/驳回预留</div>
-          </el-form-item>
-        </template>
-        <template v-if="selectedNode.type === 'condition-node'">
-          <el-form-item label="条件表达式">
-            <el-input v-model="nodeProps.condition" type="textarea" placeholder="输入条件表达式" />
-          </el-form-item>
-        </template>
-        <template v-if="selectedNode.type === 'cc-node'">
-          <el-form-item label="抄送人">
-            <el-select
-              v-model="nodeProps.ccUsers"
-              filterable
-              clearable
-              placeholder="请选择抄送人"
-              style="width: 100%"
-            >
-              <el-option
-                v-for="role in roleList"
-                :key="role.id"
-                :label="role.roleName"
-                :value="role.roleCode"
-              />
-            </el-select>
-          </el-form-item>
-        </template>
-      </el-form>
-    </div>
-    <div class="property-panel empty" v-else>
-      <div class="panel-title">节点属性</div>
-      <el-empty description="请选择节点" />
-    </div>
-
-    <!-- 保存确认弹窗(无模式时) -->
-    <el-dialog v-model="saveDialogVisible" title="保存流程" width="600px">
-      <el-form ref="saveFormRef" :model="saveForm" :rules="saveFormRules" label-width="100px">
-        <el-form-item label="流程编码" prop="code">
-          <el-input v-model="saveForm.code" />
-        </el-form-item>
-        <el-form-item label="流程名称" prop="name">
-          <el-input v-model="saveForm.name" />
-        </el-form-item>
-        <el-form-item label="分类">
-          <el-input v-model="saveForm.category" />
-        </el-form-item>
-        <el-form-item label="描述">
-          <el-input v-model="saveForm.description" type="textarea" />
-        </el-form-item>
-      </el-form>
-
-      <div class="form-fields-section">
-        <div class="form-fields-header">
-          <span class="form-fields-title">流程表单字段</span>
-          <el-button type="primary" size="small" :icon="Delete" @click="addFormField">添加字段</el-button>
-        </div>
-        <el-table :data="formFields" size="small" border style="width: 100%">
-          <el-table-column label="字段名" min-width="120">
-            <template #default="{ $index }">
-              <el-input v-model="formFields[$index].name" placeholder="英文标识" size="small" />
-            </template>
-          </el-table-column>
-          <el-table-column label="显示名" min-width="120">
-            <template #default="{ $index }">
-              <el-input v-model="formFields[$index].label" placeholder="中文显示名" size="small" />
-            </template>
-          </el-table-column>
-          <el-table-column label="类型" width="120">
-            <template #default="{ $index }">
-              <el-select v-model="formFields[$index].type" placeholder="类型" size="small" style="width: 100%">
-                <el-option v-for="opt in fieldTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
-              </el-select>
-            </template>
-          </el-table-column>
-          <el-table-column label="必填" width="70" align="center">
-            <template #default="{ $index }">
-              <el-checkbox v-model="formFields[$index].required" />
-            </template>
-          </el-table-column>
-          <el-table-column label="多选" width="70" align="center">
-            <template #default="{ $index }">
-              <el-checkbox
-                v-model="formFields[$index].multiple"
-                :disabled="formFields[$index].type !== 'select'"
-              />
-            </template>
-          </el-table-column>
-          <el-table-column label="选项" width="90" align="center">
-            <template #default="{ $index }">
-              <el-button
-                link
-                type="primary"
-                size="small"
-                :disabled="formFields[$index].type !== 'select'"
-                @click="openOptionDialog($index)"
-              >
-                配置选项
-              </el-button>
-            </template>
-          </el-table-column>
-          <el-table-column label="操作" width="70" align="center">
-            <template #default="{ $index }">
-              <el-button link type="danger" size="small" @click="removeFormField($index)">删除</el-button>
-            </template>
-          </el-table-column>
-        </el-table>
-        <el-empty v-if="formFields.length === 0" description="暂无字段,点击上方按钮添加" :image-size="60" style="padding: 10px 0;" />
-      </div>
-
-      <template #footer>
-        <el-button @click="saveDialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="submitSave">确认保存</el-button>
-      </template>
-    </el-dialog>
-
-    <!-- 下拉选项配置弹窗 -->
-    <el-dialog v-model="optionDialogVisible" title="配置下拉选项" width="500px">
-      <div v-if="optionEditingField" class="option-edit-tip">
-        字段:{{ optionEditingField.label || optionEditingField.name }}
-        <el-checkbox v-model="optionEditingField.multiple" style="margin-left: 16px;">允许多选</el-checkbox>
-      </div>
-      <el-table :data="optionList" size="small" border>
-        <el-table-column label="显示文本" min-width="140">
-          <template #default="{ $index }">
-            <el-input v-model="optionList[$index].label" placeholder="显示文本" size="small" />
-          </template>
-        </el-table-column>
-        <el-table-column label="选项值" min-width="140">
-          <template #default="{ $index }">
-            <el-input v-model="optionList[$index].value" placeholder="选项值" size="small" />
-          </template>
-        </el-table-column>
-        <el-table-column label="操作" width="70" align="center">
-          <template #default="{ $index }">
-            <el-button link type="danger" size="small" @click="removeOption($index)">删除</el-button>
-          </template>
-        </el-table-column>
-      </el-table>
-      <el-button type="primary" size="small" style="margin-top: 12px;" @click="addOption">添加选项</el-button>
-      <template #footer>
-        <el-button @click="optionDialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="saveOptions">保存</el-button>
-      </template>
-    </el-dialog>
+    <DesignerCanvas
+      :set-container="setContainer"
+      @drop="handleDrop"
+      @save="handleSave"
+      @clear="handleClear"
+      @back="goBack"
+    />
+
+    <DesignerPropertyPanel
+      :selected-node="selectedNode"
+      :node-props="nodeProps"
+      :outgoing-edges="outgoingEdges"
+      :role-list="roleList"
+      :get-edge-target-name="getEdgeTargetName"
+      :update-node-name="updateNodeName"
+      :on-default-change="onDefaultChange"
+      :on-branch-blur="onBranchBlur"
+    />
+
+    <DesignerSaveDialog
+      :visible="saveDialogVisible"
+      @update:visible="saveDialogVisible = $event"
+      :option-dialog-visible="optionDialogVisible"
+      @update:optionDialogVisible="optionDialogVisible = $event"
+      :save-form="saveForm"
+      :save-form-rules="saveFormRules"
+      :form-fields="formFields"
+      :field-type-options="fieldTypeOptions"
+      :option-editing-field="optionEditingField"
+      :option-list="optionList"
+      @submit="submitSave"
+      @add-field="addFormField"
+      @remove-field="removeFormField"
+      @open-option-dialog="openOptionDialog"
+      @add-option="addOption"
+      @remove-option="removeOption"
+      @save-options="saveOptions"
+    />
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, onUnmounted, nextTick, reactive, computed } from 'vue'
-import { useRoute, useRouter } from 'vue-router'
-import LogicFlow, { RectNode, RectNodeModel, CircleNode, CircleNodeModel, PolygonNode, PolygonNodeModel } from '@logicflow/core'
-import { Menu, Snapshot } from '@logicflow/extension'
-import '@logicflow/core/dist/style/index.css'
-import '@logicflow/extension/lib/style/index.css'
-import { VideoPlay, User, Message, Operation, CircleCheck } from '@element-plus/icons-vue'
-import { ElMessage } from 'element-plus'
-import type { FormInstance, FormRules } from 'element-plus'
-import { listRole } from '@/api/system/role'
-import { addDefinition, updateDefinition, getDefinition } from '@/api/flow/definition'
-import type { Role } from '@/types/system'
-import type { FlowDefinition, FormField, FormFieldOption } from '@/types/flow'
-import { Delete } from '@element-plus/icons-vue'
-
-const route = useRoute()
-const router = useRouter()
+import { ref } from 'vue'
+import type { ComponentPublicInstance } from 'vue'
+import { useFlowDesigner } from './useFlowDesigner'
+import DesignerCanvas from './DesignerCanvas.vue'
+import DesignerPropertyPanel from './DesignerPropertyPanel.vue'
+import DesignerSaveDialog from './DesignerSaveDialog.vue'
 
 const lfContainer = ref<HTMLDivElement>()
-let lf: LogicFlow | null = null
-
-const mode = computed(() => (route.query.mode as string) || '')
-const flowId = computed(() => (route.query.id as string) || '')
-const flowCode = computed(() => (route.query.code as string) || '')
-const flowName = computed(() => (route.query.name as string) || '')
-const flowCategory = computed(() => (route.query.category as string) || '')
-const flowDescription = computed(() => (route.query.description as string) || '')
-const flowInfoVisible = computed(() => mode.value === 'create' || mode.value === 'edit')
-
-const selectedNode = ref<any>(null)
-const roleList = ref<Role[]>([])
-const nodeProps = reactive<Record<string, any>>({
-  assigneeType: 'ROLE',
-  assigneeValue: '',
-  approveMode: 'or',
-  timeoutHours: undefined as number | undefined,
-  timeoutAction: 'remind',
-  condition: '',
-  ccUsers: ''
-})
-
-const nodeName = computed({
-  get: () => selectedNode.value?.text?.value || '',
-  set: (val: string) => {
-    if (selectedNode.value) {
-      selectedNode.value.text = { ...selectedNode.value.text, value: val }
-    }
-  }
-})
-
-const nodeTypes = [
-  { type: 'start-node', label: '开始', icon: VideoPlay, color: '#67C23A' },
-  { type: 'approval-node', label: '审批', icon: User, color: '#409EFF' },
-  { type: 'cc-node', label: '抄送', icon: Message, color: '#E6A23C' },
-  { type: 'condition-node', label: '条件', icon: Operation, color: '#909399' },
-  { type: 'end-node', label: '结束', icon: CircleCheck, color: '#F56C6C' }
-]
-
-function getDefaultProps(type: string) {
-  if (type === 'approval-node') return { assigneeType: 'ROLE', assigneeValue: '', approveMode: 'or', timeoutHours: undefined, timeoutAction: 'remind' }
-  if (type === 'condition-node') return { condition: '' }
-  if (type === 'cc-node') return { ccUsers: '' }
-  return {}
-}
-
-function saveCurrentNodeProps() {
-  if (selectedNode.value && lf) {
-    const type = selectedNode.value.type
-    const defaultProps = getDefaultProps(type)
-    // 只保存当前节点类型相关的属性,避免不同类型节点属性互相污染
-    const props: Record<string, any> = JSON.parse(JSON.stringify(defaultProps))
-    if (type === 'approval-node') {
-      props.assigneeType = 'ROLE'
-      props.assigneeValue = nodeProps.assigneeValue ?? ''
-      props.approveMode = nodeProps.approveMode ?? 'or'
-      props.timeoutHours = nodeProps.timeoutHours
-      props.timeoutAction = nodeProps.timeoutAction ?? 'remind'
-    } else if (type === 'condition-node') {
-      props.condition = nodeProps.condition ?? ''
-    } else if (type === 'cc-node') {
-      props.ccUsers = nodeProps.ccUsers ?? ''
-    }
-    lf.setProperties(selectedNode.value.id, props)
-  }
-}
-
-function loadNodeProps(data: any) {
-  const props = data.properties || getDefaultProps(data.type)
-  const cloned = JSON.parse(JSON.stringify(props))
-  // 先重置为默认值,避免上一个节点的属性残留
-  Object.assign(nodeProps, getDefaultProps(data.type))
-  // 兼容旧格式:approver + approveType -> 新格式
-  if (data.type === 'approval-node') {
-    let assigneeType = 'ROLE'
-    let assigneeValue = cloned.assigneeValue ?? ''
-    let approveMode = cloned.approveMode ?? cloned.approveType ?? 'or'
-    if (!cloned.assigneeValue && cloned.approver) {
-      assigneeValue = cloned.approver
-    }
-    // 旧数据中的 USER/SELF/LEADER 不再支持,统一清空让用户重新选择员工角色
-    if (cloned.assigneeType && cloned.assigneeType !== 'ROLE') {
-      assigneeValue = ''
-    }
-    nodeProps.assigneeType = assigneeType
-    nodeProps.assigneeValue = assigneeValue
-    nodeProps.approveMode = approveMode
-    nodeProps.timeoutHours = cloned.timeoutHours ?? undefined
-    nodeProps.timeoutAction = cloned.timeoutAction ?? 'remind'
-  } else if (data.type === 'condition-node') {
-    nodeProps.condition = cloned.condition ?? ''
-  } else if (data.type === 'cc-node') {
-    nodeProps.ccUsers = cloned.ccUsers ?? ''
-  }
-}
-
-function registerNodes() {
-  if (!lf) return
-
-  class StartNodeModel extends CircleNodeModel {
-    setAttributes() {
-      this.r = 30
-      this.text.value = '开始'
-    }
-    getNodeStyle() {
-      const style = super.getNodeStyle()
-      style.fill = '#67C23A'
-      style.stroke = '#67C23A'
-      return style
-    }
-    getTextStyle() {
-      const style = super.getTextStyle()
-      style.color = '#fff'
-      return style
-    }
-  }
-  lf.register({ type: 'start-node', view: CircleNode, model: StartNodeModel })
-
-  class ApprovalNodeModel extends RectNodeModel {
-    setAttributes() {
-      this.width = 160
-      this.height = 60
-      this.radius = 8
-      this.text.value = '审批节点'
-    }
-    getNodeStyle() {
-      const style = super.getNodeStyle()
-      style.fill = '#fff'
-      style.stroke = '#409EFF'
-      style.strokeWidth = 1
-      return style
-    }
-  }
-  lf.register({ type: 'approval-node', view: RectNode, model: ApprovalNodeModel })
-
-  class CcNodeModel extends RectNodeModel {
-    setAttributes() {
-      this.width = 160
-      this.height = 60
-      this.radius = 8
-      this.text.value = '抄送节点'
-    }
-    getNodeStyle() {
-      const style = super.getNodeStyle()
-      style.fill = '#fff'
-      style.stroke = '#E6A23C'
-      style.strokeWidth = 1
-      return style
-    }
-  }
-  lf.register({ type: 'cc-node', view: RectNode, model: CcNodeModel })
-
-  class ConditionNodeModel extends PolygonNodeModel {
-    setAttributes() {
-      this.points = [[60, 0], [120, 40], [60, 80], [0, 40]]
-      this.text.value = '条件'
-    }
-    getNodeStyle() {
-      const style = super.getNodeStyle()
-      style.fill = '#fff'
-      style.stroke = '#909399'
-      return style
-    }
-  }
-  lf.register({ type: 'condition-node', view: PolygonNode, model: ConditionNodeModel })
-
-  class EndNodeModel extends CircleNodeModel {
-    setAttributes() {
-      this.r = 30
-      this.text.value = '结束'
-    }
-    getNodeStyle() {
-      const style = super.getNodeStyle()
-      style.fill = '#F56C6C'
-      style.stroke = '#F56C6C'
-      return style
-    }
-    getTextStyle() {
-      const style = super.getTextStyle()
-      style.color = '#fff'
-      return style
-    }
-  }
-  lf.register({ type: 'end-node', view: CircleNode, model: EndNodeModel })
-}
-
-function initLogicFlow() {
-  if (!lfContainer.value) return
-
-  lf = new LogicFlow({
-    container: lfContainer.value,
-    grid: true,
-    plugins: [Menu, Snapshot],
-    pluginsOptions: {
-      menu: {
-        nodeMenu: [{
-          text: '删除',
-          callback(node: any) { lf?.deleteNode(node.id) }
-        }]
-      }
-    } as any
-  })
-
-  registerNodes()
-
-  lf.on('node:click', ({ data }: any) => {
-    saveCurrentNodeProps()
-    selectedNode.value = data
-    loadNodeProps(data)
-  })
-
-  lf.on('blank:click', () => {
-    saveCurrentNodeProps()
-    selectedNode.value = null
-  })
-
-  // 编辑模式:加载已有流程图
-  if (mode.value === 'edit' && flowId.value) {
-    getDefinition(Number(flowId.value)).then((def: FlowDefinition) => {
-      parseFormSchema(def.formSchema)
-      if (def.flowJson) {
-        try {
-          const graphData = convertToFrontendFormat(JSON.parse(def.flowJson))
-          lf?.render(graphData)
-        } catch {
-          lf?.render({ nodes: defaultNodes(), edges: [] })
-        }
-      } else {
-        lf?.render({ nodes: defaultNodes(), edges: [] })
-      }
-    }).catch(() => {
-      lf?.render({ nodes: defaultNodes(), edges: [] })
-    })
-  } else {
-    lf.render({ nodes: defaultNodes(), edges: [] })
-  }
-}
-
-function defaultNodes() {
-  return [
-    { id: 'start-1', type: 'start-node', x: 200, y: 100, text: '开始' },
-    { id: 'end-1', type: 'end-node', x: 200, y: 500, text: '结束' }
-  ]
-}
-
-// 节点类型映射:LogicFlow <-> 后端
-const typeToBackend: Record<string, string> = {
-  'start-node': 'start',
-  'approval-node': 'approval',
-  'cc-node': 'cc',
-  'condition-node': 'condition',
-  'end-node': 'end'
-}
-const typeToFrontend: Record<string, string> = {
-  'start': 'start-node',
-  'approval': 'approval-node',
-  'cc': 'cc-node',
-  'condition': 'condition-node',
-  'end': 'end-node'
-}
-
-// 将 LogicFlow 的 graphData 转换为后端格式
-function convertToBackendFormat(graphData: any) {
-  const data = JSON.parse(JSON.stringify(graphData))
-  if (data.nodes) {
-    data.nodes = data.nodes.map((node: any) => {
-      const n = { ...node }
-      if (n.type && typeToBackend[n.type]) {
-        n.type = typeToBackend[n.type]
-      }
-      // text -> name
-      if (n.text !== undefined) {
-        n.name = typeof n.text === 'object' ? n.text.value : n.text
-        delete n.text
-      }
-      return n
-    })
-  }
-  // edges 保持原样,properties 中的 condition 会在后端解析
-  return data
-}
-
-// 将后端的 graphData 转换为 LogicFlow 格式
-function convertToFrontendFormat(graphData: any) {
-  const data = JSON.parse(JSON.stringify(graphData))
-  if (data.nodes) {
-    data.nodes = data.nodes.map((node: any) => {
-      const n = { ...node }
-      if (n.type && typeToFrontend[n.type]) {
-        n.type = typeToFrontend[n.type]
-      }
-      // name -> text
-      if (n.name !== undefined) {
-        n.text = n.name
-        delete n.name
-      }
-      return n
-    })
-  }
-  return data
-}
-
-function handleDragStart(e: DragEvent, node: any) {
-  const serializable = { type: node.type, label: node.label }
-  e.dataTransfer?.setData('application/json', JSON.stringify(serializable))
-}
-
-function handleDrop(e: DragEvent) {
-  e.preventDefault()
-  const dataStr = e.dataTransfer?.getData('application/json')
-  if (!dataStr || !lf) return
-  const nodeData = JSON.parse(dataStr)
-  const rect = lfContainer.value!.getBoundingClientRect()
-  lf.addNode({
-    type: nodeData.type,
-    x: e.clientX - rect.left,
-    y: e.clientY - rect.top,
-    text: nodeData.label
-  })
-}
-
-function updateNodeName() {
-  if (!lf || !selectedNode.value) return
-  lf.setNodeText(selectedNode.value.id, nodeName.value)
-}
-
-// 保存流程
-const saveDialogVisible = ref(false)
-const saveFormRef = ref<FormInstance>()
-const saveForm = reactive({
-  code: '',
-  name: '',
-  category: '',
-  description: ''
-})
-const saveFormRules: FormRules = {
-  code: [{ required: true, message: '请输入流程编码', trigger: 'blur' }],
-  name: [{ required: true, message: '请输入流程名称', trigger: 'blur' }]
-}
-
-const formFields = ref<FormField[]>([])
-const fieldTypeOptions = [
-  { label: '文本', value: 'text' },
-  { label: '多行文本', value: 'textarea' },
-  { label: '数字', value: 'number' },
-  { label: '日期', value: 'date' },
-  { label: '下拉选择', value: 'select' }
-]
-
-function addFormField() {
-  formFields.value.push({ name: '', label: '', type: 'text', required: false })
-}
 
-function removeFormField(index: number) {
-  formFields.value.splice(index, 1)
-}
-
-function getFormSchema(): string {
-  const validFields = formFields.value.filter(f => f.name.trim() && f.label.trim())
-  return validFields.length > 0 ? JSON.stringify(validFields) : ''
-}
-
-function parseFormSchema(schema?: string) {
-  if (!schema) return
-  try {
-    const parsed = JSON.parse(schema)
-    if (Array.isArray(parsed)) {
-      formFields.value = parsed
-    }
-  } catch {
-    formFields.value = []
-  }
-}
-
-// 下拉选项编辑
-const optionDialogVisible = ref(false)
-const optionEditingIndex = ref<number | null>(null)
-const optionEditingField = ref<FormField | null>(null)
-const optionList = ref<FormFieldOption[]>([])
-
-function openOptionDialog(index: number) {
-  optionEditingIndex.value = index
-  optionEditingField.value = formFields.value[index]
-  const rawOptions = optionEditingField.value.options || []
-  optionList.value = rawOptions.map((opt: any) => {
-    if (typeof opt === 'string') {
-      return { label: opt, value: opt }
-    }
-    return { label: String(opt.label ?? opt.value), value: opt.value }
-  })
-  optionDialogVisible.value = true
-}
-
-function addOption() {
-  optionList.value.push({ label: '', value: '' })
-}
-
-function removeOption(index: number) {
-  optionList.value.splice(index, 1)
-}
-
-function saveOptions() {
-  if (optionEditingIndex.value === null || !optionEditingField.value) return
-  const validOptions = optionList.value.filter(opt => String(opt.label).trim() !== '' && String(opt.value).trim() !== '')
-  formFields.value[optionEditingIndex.value].options = validOptions
-  formFields.value[optionEditingIndex.value].multiple = optionEditingField.value.multiple
-  optionDialogVisible.value = false
-}
-
-function validateFlow(graphData: any): string | null {
-  const nodes = graphData.nodes || []
-  const edges = graphData.edges || []
-  if (nodes.length === 0) return '流程图不能为空'
-  const startNodes = nodes.filter((n: any) => n.type === 'start-node' || n.type === 'start')
-  const endNodes = nodes.filter((n: any) => n.type === 'end-node' || n.type === 'end')
-  if (startNodes.length === 0) return '流程图缺少开始节点'
-  if (endNodes.length === 0) return '流程图缺少结束节点'
-  if (startNodes.length > 1) return '流程图只能有一个开始节点'
-  if (endNodes.length > 1) return '流程图只能有一个结束节点'
-  // 检查审批节点是否选择了员工管理中的成员(角色账号)
-  for (const node of nodes) {
-    if (node.type === 'approval-node' || node.type === 'approval') {
-      const props = node.properties || {}
-      const assigneeValue = props.assigneeValue || props.approver
-      if (!assigneeValue) {
-        return `审批节点 "${node.text || node.name}" 未选择员工管理中的审批成员`
-      }
-    }
-  }
-  // 检查孤立节点
-  const connectedNodeIds = new Set<string>()
-  for (const edge of edges) {
-    connectedNodeIds.add(edge.sourceNodeId)
-    connectedNodeIds.add(edge.targetNodeId)
-  }
-  for (const node of nodes) {
-    if (!connectedNodeIds.has(node.id) && node.type !== 'start-node' && node.type !== 'start' && node.type !== 'end-node' && node.type !== 'end') {
-      return `节点 "${node.text || node.name}" 是孤立节点,请删除或连接`
-    }
-  }
-  // 检查条件分支是否有默认分支
-  const sourceMap: Record<string, any[]> = {}
-  for (const edge of edges) {
-    if (!sourceMap[edge.sourceNodeId]) sourceMap[edge.sourceNodeId] = []
-    sourceMap[edge.sourceNodeId].push(edge)
-  }
-  for (const node of nodes) {
-    if (node.type === 'condition-node' || node.type === 'condition') {
-      const outEdges = sourceMap[node.id] || []
-      if (outEdges.length > 1) {
-        const hasDefault = outEdges.some((e: any) => !e.properties?.condition && !e.condition)
-        if (!hasDefault) {
-          return `条件节点 "${node.text || node.name}" 需要至少一条无条件的默认分支`
-        }
-      }
-    }
-  }
-  return null
-}
-
-async function handleSave() {
-  if (!lf) return
-  saveCurrentNodeProps()
-  const rawGraphData = lf.getGraphData()
-  const error = validateFlow(rawGraphData)
-  if (error) {
-    ElMessage.warning(error)
-    return
-  }
-
-  // 统一弹出保存弹窗,方便配置流程表单字段
-  if (mode.value === 'create') {
-    saveForm.code = flowCode.value
-    saveForm.name = flowName.value
-    saveForm.category = flowCategory.value
-    saveForm.description = flowDescription.value
-    formFields.value = []
-  } else if (mode.value === 'edit' && flowId.value) {
-    saveForm.code = flowCode.value
-    saveForm.name = flowName.value
-    saveForm.category = flowCategory.value
-    saveForm.description = flowDescription.value
-    // formFields 在编辑模式加载流程时已通过 parseFormSchema 填充
-  } else {
-    saveForm.code = ''
-    saveForm.name = ''
-    saveForm.category = ''
-    saveForm.description = ''
-    formFields.value = []
-  }
-  saveDialogVisible.value = true
-}
-
-async function submitSave() {
-  const valid = await saveFormRef.value?.validate().catch(() => false)
-  if (!valid) return
-  if (!lf) return
-  saveCurrentNodeProps()
-  const rawGraphData = lf.getGraphData()
-  const error = validateFlow(rawGraphData)
-  if (error) {
-    ElMessage.warning(error)
-    return
-  }
-  const graphData = convertToBackendFormat(rawGraphData)
-
-  if (mode.value === 'create') {
-    const data: Partial<FlowDefinition> = {
-      code: saveForm.code,
-      name: saveForm.name,
-      category: saveForm.category,
-      description: saveForm.description,
-      formSchema: getFormSchema(),
-      flowJson: JSON.stringify(graphData),
-      status: 0
-    }
-    await addDefinition(data)
-    ElMessage.success('流程新增成功')
-    saveDialogVisible.value = false
-    router.push('/flow/definition')
-  } else if (mode.value === 'edit' && flowId.value) {
-    const data: Partial<FlowDefinition> = {
-      id: Number(flowId.value),
-      code: saveForm.code,
-      name: saveForm.name,
-      category: saveForm.category,
-      description: saveForm.description,
-      formSchema: getFormSchema(),
-      flowJson: JSON.stringify(graphData)
-    }
-    const newId = await updateDefinition(data)
-    if (newId !== Number(flowId.value)) {
-      ElMessage.success('流程已创建为新版本,请重新发布')
-    } else {
-      ElMessage.success('流程修改成功')
-    }
-    saveDialogVisible.value = false
-    router.push('/flow/definition')
-  } else {
-    const data: Partial<FlowDefinition> = {
-      code: saveForm.code,
-      name: saveForm.name,
-      category: saveForm.category,
-      description: saveForm.description,
-      formSchema: getFormSchema(),
-      flowJson: JSON.stringify(graphData),
-      status: 0
-    }
-    await addDefinition(data)
-    ElMessage.success('流程保存成功')
-    saveDialogVisible.value = false
-    router.push('/flow/definition')
-  }
-}
-
-function handleClear() {
-  if (!lf) return
-  lf.clearData()
-  selectedNode.value = null
-}
-
-function goBack() {
-  router.push('/flow/definition')
-}
-
-async function loadRoles() {
-  try {
-    const res = await listRole({ pageNum: 1, pageSize: 100 })
-    roleList.value = res.list
-  } catch { /* ignore */ }
-}
-
-onMounted(() => {
-  // 防止快速进入/离开设计器时重复创建 LogicFlow 实例(v1.x 无 destroy 方法)
-  if (lf) {
-    if (typeof lf.destroy === 'function') {
-      lf.destroy()
-    }
-    lf = null
-  }
-  nextTick(initLogicFlow)
-  loadRoles()
-})
-
-onUnmounted(() => {
-  if (lf) {
-    if (typeof lf.destroy === 'function') {
-      lf.destroy()
-    }
-    lf = null
-  }
-  selectedNode.value = null
-})
+function setContainer(el: Element | ComponentPublicInstance | null) {
+  lfContainer.value = el as HTMLDivElement | undefined
+}
+
+const {
+  mode,
+  flowInfoVisible,
+  flowCode,
+  flowName,
+  flowCategory,
+  selectedNode,
+  nodeProps,
+  outgoingEdges,
+  roleList,
+  getEdgeTargetName,
+  updateNodeName,
+  onDefaultChange,
+  onBranchBlur,
+  saveDialogVisible,
+  optionDialogVisible,
+  saveForm,
+  saveFormRules,
+  formFields,
+  fieldTypeOptions,
+  optionEditingField,
+  optionList,
+  handleDrop,
+  handleSave,
+  handleClear,
+  goBack,
+  submitSave,
+  addFormField,
+  removeFormField,
+  openOptionDialog,
+  addOption,
+  removeOption,
+  saveOptions
+} = useFlowDesigner(lfContainer)
 </script>
 
 <style scoped>
@@ -869,86 +124,4 @@ onUnmounted(() => {
   font-size: 13px;
   color: #303133;
 }
-.node-panel {
-  width: 200px;
-  border-right: 1px solid #dcdfe6;
-  padding: 16px;
-  background: #fafafa;
-}
-.panel-title {
-  font-size: 16px;
-  font-weight: bold;
-  margin-bottom: 16px;
-  color: #303133;
-}
-.node-item {
-  display: flex;
-  align-items: center;
-  padding: 12px;
-  margin-bottom: 8px;
-  border-radius: 6px;
-  cursor: move;
-  background: #fff;
-  border: 1px solid #ebeef5;
-  transition: all 0.2s;
-}
-.node-item:hover {
-  box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
-  border-color: #c6e2ff;
-}
-.node-icon {
-  width: 32px;
-  height: 32px;
-  border-radius: 50%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: #fff;
-  margin-right: 12px;
-}
-.node-name {
-  font-size: 14px;
-  color: #606266;
-}
-.canvas-area {
-  flex: 1;
-  position: relative;
-  background: #f5f7fa;
-}
-.lf-container {
-  width: 100%;
-  height: 100%;
-}
-.toolbar {
-  position: absolute;
-  top: 16px;
-  right: 16px;
-  z-index: 10;
-}
-.property-panel {
-  width: 300px;
-  border-left: 1px solid #dcdfe6;
-  padding: 16px;
-  background: #fafafa;
-  overflow-y: auto;
-}
-.property-panel.empty {
-  display: flex;
-  flex-direction: column;
-}
-.form-fields-section {
-  margin-top: 16px;
-  border-top: 1px solid #ebeef5;
-  padding-top: 16px;
-}
-.form-fields-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 12px;
-}
-.form-fields-title {
-  font-weight: bold;
-  color: #303133;
-}
 </style>

+ 841 - 0
src/views/flow/designer/useFlowDesigner.ts

@@ -0,0 +1,841 @@
+import { ref, onMounted, onUnmounted, nextTick, reactive, computed, type Ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import LogicFlow, {
+  RectNode,
+  RectNodeModel,
+  CircleNode,
+  CircleNodeModel,
+  PolygonNode,
+  PolygonNodeModel,
+} from '@logicflow/core'
+import { Menu, Snapshot } from '@logicflow/extension'
+import '@logicflow/core/dist/style/index.css'
+import '@logicflow/extension/lib/style/index.css'
+import { ElMessage } from 'element-plus'
+import type { FormRules } from 'element-plus'
+import { listRole } from '@/api/system/role'
+import { addDefinition, updateDefinition, getDefinition } from '@/api/flow/definition'
+import type { Role } from '@/types/system'
+import type { FlowDefinition, FormField, FormFieldOption } from '@/types/flow'
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+export function useFlowDesigner(lfContainer: Ref<HTMLDivElement | undefined>) {
+  const route = useRoute()
+  const router = useRouter()
+
+  let lf: LogicFlow | null = null
+
+  const mode = computed(() => (route.query.mode as string) || '')
+  const flowId = computed(() => (route.query.id as string) || '')
+  const flowCode = computed(() => (route.query.code as string) || '')
+  const flowName = computed(() => (route.query.name as string) || '')
+  const flowCategory = computed(() => (route.query.category as string) || '')
+  const flowDescription = computed(() => (route.query.description as string) || '')
+  const flowInfoVisible = computed(() => mode.value === 'create' || mode.value === 'edit')
+
+  const selectedNode = ref<any>(null)
+  const roleList = ref<Role[]>([])
+  const nodeProps = reactive<Record<string, any>>({
+    assigneeType: 'ROLE',
+    assigneeValue: '',
+    approveMode: 'or',
+    timeoutHours: undefined as number | undefined,
+    timeoutAction: 'remind',
+    ccUsers: '',
+  })
+
+  // 条件节点出连线配置
+  const outgoingEdges = ref<any[]>([])
+
+  function loadOutgoingEdges(nodeId?: string) {
+    if (!lf) {
+      outgoingEdges.value = []
+      return
+    }
+    const id = nodeId || selectedNode.value?.id
+    if (!id) {
+      outgoingEdges.value = []
+      return
+    }
+    const graphData = lf.getGraphData()
+    const edges = (graphData.edges || []).filter((e: any) => e.sourceNodeId === id)
+    outgoingEdges.value = edges.map((e: any) => ({
+      ...e,
+      properties: {
+        condition: '',
+        isDefault: false,
+        branchName: '',
+        ...(e.properties || {}),
+      },
+    }))
+  }
+
+  function getEdgeTargetName(edge: any): string {
+    if (!lf || !edge?.targetNodeId) return '—'
+    try {
+      const node = lf.getNodeData(edge.targetNodeId)
+      return node?.text?.value || node?.text || '未命名节点'
+    } catch {
+      return '—'
+    }
+  }
+
+  function getEdgeDisplayText(edge: any): string {
+    const props = edge.properties || {}
+    if (props.branchName) return props.branchName
+    if (props.isDefault) return '默认'
+    if (props.condition) return props.condition
+    return ''
+  }
+
+  function syncEdgeToLf(edge: any) {
+    if (!lf || !edge?.id) return
+    const props = edge.properties || {}
+    const displayText = getEdgeDisplayText(edge)
+    lf.setEdgeData(edge.id, {
+      properties: { ...props },
+      text: displayText ? { value: displayText } : undefined,
+    })
+  }
+
+  function onDefaultChange(edge: any) {
+    if (edge.properties.isDefault) {
+      edge.properties.condition = ''
+      // 同一条件节点下只能有一个默认分支
+      const nodeId = edge.sourceNodeId
+      outgoingEdges.value.forEach((e: any) => {
+        if (e.id !== edge.id && e.sourceNodeId === nodeId) {
+          e.properties.isDefault = false
+          syncEdgeToLf(e)
+        }
+      })
+    }
+    syncEdgeToLf(edge)
+  }
+
+  function onBranchBlur(edge: any) {
+    syncEdgeToLf(edge)
+  }
+
+  function getDefaultProps(type: string) {
+    if (type === 'approval-node')
+      return {
+        assigneeType: 'ROLE',
+        assigneeValue: '',
+        approveMode: 'or',
+        timeoutHours: undefined,
+        timeoutAction: 'remind',
+        approveTimeField: '',
+      }
+    if (type === 'cc-node') return { ccUsers: '' }
+    return {}
+  }
+
+  function saveCurrentNodeProps() {
+    if (selectedNode.value && lf) {
+      const type = selectedNode.value.type
+      const defaultProps = getDefaultProps(type)
+      // 只保存当前节点类型相关的属性,避免不同类型节点属性互相污染
+      const props: Record<string, any> = JSON.parse(JSON.stringify(defaultProps))
+      if (type === 'approval-node') {
+        props.assigneeType = 'ROLE'
+        props.assigneeValue = nodeProps.assigneeValue ?? ''
+        props.approveMode = nodeProps.approveMode ?? 'or'
+        props.timeoutHours = nodeProps.timeoutHours
+        props.timeoutAction = nodeProps.timeoutAction ?? 'remind'
+        props.approveTimeField = nodeProps.approveTimeField ?? ''
+      } else if (type === 'cc-node') {
+        props.ccUsers = nodeProps.ccUsers ?? ''
+      }
+      lf.setProperties(selectedNode.value.id, props)
+    }
+  }
+
+  function loadNodeProps(data: any) {
+    const props = data.properties || getDefaultProps(data.type)
+    const cloned = JSON.parse(JSON.stringify(props))
+    // 先重置为默认值,避免上一个节点的属性残留
+    Object.assign(nodeProps, getDefaultProps(data.type))
+    // 兼容旧格式:approver + approveType -> 新格式
+    if (data.type === 'approval-node') {
+      const assigneeType = 'ROLE'
+      let assigneeValue = cloned.assigneeValue ?? ''
+      const approveMode = cloned.approveMode ?? cloned.approveType ?? 'or'
+      if (!cloned.assigneeValue && cloned.approver) {
+        assigneeValue = cloned.approver
+      }
+      // 旧数据中的 USER/SELF/LEADER 不再支持,统一清空让用户重新选择员工角色
+      if (cloned.assigneeType && cloned.assigneeType !== 'ROLE') {
+        assigneeValue = ''
+      }
+      nodeProps.assigneeType = assigneeType
+      nodeProps.assigneeValue = assigneeValue
+      nodeProps.approveMode = approveMode
+      nodeProps.timeoutHours = cloned.timeoutHours ?? undefined
+      nodeProps.timeoutAction = cloned.timeoutAction ?? 'remind'
+      nodeProps.approveTimeField = cloned.approveTimeField ?? ''
+    } else if (data.type === 'cc-node') {
+      nodeProps.ccUsers = cloned.ccUsers ?? ''
+    }
+  }
+
+  function registerNodes() {
+    if (!lf) return
+
+    class StartNodeModel extends CircleNodeModel {
+      setAttributes() {
+        this.r = 30
+        if (!this.text.value) this.text.value = '开始'
+      }
+      getNodeStyle() {
+        const style = super.getNodeStyle()
+        style.fill = '#67C23A'
+        style.stroke = '#67C23A'
+        return style
+      }
+      getTextStyle() {
+        const style = super.getTextStyle()
+        style.color = '#fff'
+        return style
+      }
+    }
+    lf.register({ type: 'start-node', view: CircleNode, model: StartNodeModel })
+
+    class ApprovalNodeModel extends RectNodeModel {
+      setAttributes() {
+        this.width = 160
+        this.height = 60
+        this.radius = 8
+        if (!this.text.value) this.text.value = '审批节点'
+      }
+      getNodeStyle() {
+        const style = super.getNodeStyle()
+        style.fill = '#fff'
+        style.stroke = '#409EFF'
+        style.strokeWidth = 1
+        return style
+      }
+    }
+    lf.register({ type: 'approval-node', view: RectNode, model: ApprovalNodeModel })
+
+    class CcNodeModel extends RectNodeModel {
+      setAttributes() {
+        this.width = 160
+        this.height = 60
+        this.radius = 8
+        if (!this.text.value) this.text.value = '抄送节点'
+      }
+      getNodeStyle() {
+        const style = super.getNodeStyle()
+        style.fill = '#fff'
+        style.stroke = '#E6A23C'
+        style.strokeWidth = 1
+        return style
+      }
+    }
+    lf.register({ type: 'cc-node', view: RectNode, model: CcNodeModel })
+
+    class ConditionNodeModel extends PolygonNodeModel {
+      setAttributes() {
+        this.points = [
+          [60, 0],
+          [120, 40],
+          [60, 80],
+          [0, 40],
+        ]
+        if (!this.text.value) this.text.value = '条件'
+      }
+      getNodeStyle() {
+        const style = super.getNodeStyle()
+        style.fill = '#fff'
+        style.stroke = '#909399'
+        return style
+      }
+    }
+    lf.register({ type: 'condition-node', view: PolygonNode, model: ConditionNodeModel })
+
+    class EndNodeModel extends CircleNodeModel {
+      setAttributes() {
+        this.r = 30
+        if (!this.text.value) this.text.value = '结束'
+      }
+      getNodeStyle() {
+        const style = super.getNodeStyle()
+        style.fill = '#F56C6C'
+        style.stroke = '#F56C6C'
+        return style
+      }
+      getTextStyle() {
+        const style = super.getTextStyle()
+        style.color = '#fff'
+        return style
+      }
+    }
+    lf.register({ type: 'end-node', view: CircleNode, model: EndNodeModel })
+  }
+
+  function defaultNodes() {
+    return [
+      { id: 'start-1', type: 'start-node', x: 200, y: 100, text: '开始' },
+      { id: 'end-1', type: 'end-node', x: 200, y: 500, text: '结束' },
+    ]
+  }
+
+  function initLogicFlow() {
+    if (!lfContainer.value) return
+
+    lf = new LogicFlow({
+      container: lfContainer.value,
+      grid: true,
+      plugins: [Menu, Snapshot],
+      pluginsOptions: {
+        menu: {
+          nodeMenu: [
+            {
+              text: '删除',
+              callback(node: any) {
+                lf?.deleteNode(node.id)
+              },
+            },
+          ],
+        },
+      } as any,
+    })
+
+    registerNodes()
+
+    lf.on('node:click', ({ data }: any) => {
+      saveCurrentNodeProps()
+      selectedNode.value = data
+      loadNodeProps(data)
+      loadOutgoingEdges(data.id)
+    })
+
+    lf.on('blank:click', () => {
+      saveCurrentNodeProps()
+      selectedNode.value = null
+      outgoingEdges.value = []
+    })
+
+    // 当条件节点的出连线发生变化时,刷新右侧分支配置面板
+    lf.on('edge:add', ({ data }: any) => {
+      if (selectedNode.value?.id && data?.sourceNodeId === selectedNode.value.id) {
+        loadOutgoingEdges(selectedNode.value.id)
+      }
+    })
+    lf.on('edge:delete', ({ data }: any) => {
+      if (selectedNode.value?.id && data?.sourceNodeId === selectedNode.value.id) {
+        loadOutgoingEdges(selectedNode.value.id)
+      }
+    })
+
+    // 编辑模式:加载已有流程图
+    if (mode.value === 'edit' && flowId.value) {
+      getDefinition(Number(flowId.value))
+        .then((def: FlowDefinition) => {
+          parseFormSchema(def.formSchema)
+          if (def.flowJson) {
+            try {
+              const parsed = JSON.parse(def.flowJson)
+              const graphData = convertToFrontendFormat(parsed)
+              lf?.render(graphData)
+            } catch {
+              lf?.render({ nodes: defaultNodes(), edges: [] })
+            }
+          } else {
+            lf?.render({ nodes: defaultNodes(), edges: [] })
+          }
+        })
+        .catch(() => {
+          lf?.render({ nodes: defaultNodes(), edges: [] })
+        })
+    } else {
+      lf.render({ nodes: defaultNodes(), edges: [] })
+    }
+  }
+
+  // 节点类型映射:LogicFlow <-> 后端
+  const typeToBackend: Record<string, string> = {
+    'start-node': 'start',
+    'approval-node': 'approval',
+    'cc-node': 'cc',
+    'condition-node': 'condition',
+    'end-node': 'end',
+  }
+  const typeToFrontend: Record<string, string> = {
+    start: 'start-node',
+    approval: 'approval-node',
+    cc: 'cc-node',
+    condition: 'condition-node',
+    end: 'end-node',
+  }
+
+  // 将 LogicFlow 的 graphData 转换为后端格式
+  function convertToBackendFormat(graphData: any) {
+    const data = JSON.parse(JSON.stringify(graphData))
+    if (data.nodes) {
+      data.nodes = data.nodes.map((node: any) => {
+        const n = { ...node }
+        if (n.type && typeToBackend[n.type]) {
+          n.type = typeToBackend[n.type]
+        }
+        // text -> name
+        if (n.text !== undefined) {
+          n.name = typeof n.text === 'object' ? n.text.value : n.text
+          delete n.text
+        }
+        return n
+      })
+    }
+    // 将 LogicFlow edge.properties 转换为后端的 edge.condition 结构
+    if (data.edges) {
+      data.edges = data.edges.map((edge: any) => {
+        const e = { ...edge }
+        const props = e.properties || {}
+        e.condition = {
+          condition: props.condition || '',
+          isDefault: props.isDefault ?? !props.condition,
+          branchName: props.branchName || '',
+        }
+        // 清理 LogicFlow 运行时不需要的属性,避免后端序列化冗余
+        if (e.text !== undefined && (!e.text || !e.text.value)) {
+          delete e.text
+        }
+        delete e.properties
+        return e
+      })
+    }
+    return data
+  }
+
+  // 将后端的 graphData 转换为 LogicFlow 格式
+  function convertToFrontendFormat(graphData: any) {
+    const data = JSON.parse(JSON.stringify(graphData))
+
+    // 兼容旧格式:条件表达式写在节点 properties.condition 上,需要迁移到连线
+    if (data.nodes && data.edges) {
+      data.nodes.forEach((node: any) => {
+        if ((node.type === 'condition' || node.type === 'condition-node') && node.properties?.condition) {
+          const nodeCondition = String(node.properties.condition)
+          const outEdges = data.edges.filter((e: any) => e.sourceNodeId === node.id)
+          if (outEdges.length > 0) {
+            // 第一条连线作为条件分支,其余作为默认分支
+            outEdges.forEach((e: any, idx: number) => {
+              if (!e.condition) {
+                e.condition = {}
+              }
+              if (idx === 0) {
+                e.condition.condition = nodeCondition
+                e.condition.isDefault = false
+              } else {
+                e.condition.condition = ''
+                e.condition.isDefault = true
+              }
+            })
+          }
+          delete node.properties.condition
+        }
+      })
+    }
+
+    if (data.edges) {
+      data.edges = data.edges.map((edge: any) => {
+        const e = { ...edge }
+        // 将后端的 edge.condition 转换为 LogicFlow 的 edge.properties
+        const conditionMap = e.condition || {}
+        e.properties = {
+          condition: conditionMap.condition || '',
+          isDefault: conditionMap.isDefault ?? !conditionMap.condition,
+          branchName: conditionMap.branchName || '',
+        }
+        // 设置/清理连线文本
+        const displayText =
+          e.properties.branchName ||
+          (!e.properties.isDefault && e.properties.condition)
+        if (displayText) {
+          e.text = { value: displayText }
+        } else {
+          delete e.text
+        }
+        delete e.condition
+        return e
+      })
+    }
+
+    if (data.nodes) {
+      data.nodes = data.nodes.map((node: any) => {
+        const n = { ...node }
+        if (n.type && typeToFrontend[n.type]) {
+          n.type = typeToFrontend[n.type]
+        }
+        // name -> text
+        if (n.name !== undefined) {
+          n.text = n.name
+          delete n.name
+        }
+        return n
+      })
+    }
+    return data
+  }
+
+  function handleDrop(e: DragEvent) {
+    e.preventDefault()
+    const dataStr = e.dataTransfer?.getData('application/json')
+    if (!dataStr || !lf || !lfContainer.value) return
+    const nodeData = JSON.parse(dataStr)
+    const rect = lfContainer.value.getBoundingClientRect()
+    lf.addNode({
+      type: nodeData.type,
+      x: e.clientX - rect.left,
+      y: e.clientY - rect.top,
+      text: nodeData.label,
+    })
+  }
+
+  function updateNodeName() {
+    if (!lf || !selectedNode.value) return
+    lf.setNodeText(selectedNode.value.id, selectedNode.value.text?.value || '')
+  }
+
+  // 保存流程
+  const saveDialogVisible = ref(false)
+  const saveForm = reactive({
+    code: '',
+    name: '',
+    category: '',
+    description: '',
+  })
+  const saveFormRules: FormRules = {
+    code: [{ required: true, message: '请输入流程编码', trigger: 'blur' }],
+    name: [{ required: true, message: '请输入流程名称', trigger: 'blur' }],
+  }
+
+  const formFields = ref<FormField[]>([])
+  const fieldTypeOptions = [
+    { label: '文本', value: 'text' },
+    { label: '多行文本', value: 'textarea' },
+    { label: '数字', value: 'number' },
+    { label: '日期', value: 'date' },
+    { label: '下拉选择', value: 'select' },
+  ]
+
+  function addFormField() {
+    formFields.value.push({ name: '', label: '', type: 'text', required: false })
+  }
+
+  function removeFormField(index: number) {
+    formFields.value.splice(index, 1)
+  }
+
+  function getFormSchema(): string {
+    const validFields = formFields.value.filter((f) => f.name.trim() && f.label.trim())
+    return validFields.length > 0 ? JSON.stringify(validFields) : ''
+  }
+
+  function parseFormSchema(schema?: string) {
+    if (!schema) return
+    try {
+      const parsed = JSON.parse(schema)
+      if (Array.isArray(parsed)) {
+        formFields.value = parsed
+      }
+    } catch {
+      formFields.value = []
+    }
+  }
+
+  // 下拉选项编辑
+  const optionDialogVisible = ref(false)
+  const optionEditingIndex = ref<number | null>(null)
+  const optionEditingField = ref<FormField | null>(null)
+  const optionList = ref<FormFieldOption[]>([])
+
+  function openOptionDialog(index: number) {
+    optionEditingIndex.value = index
+    optionEditingField.value = formFields.value[index]
+    const rawOptions = optionEditingField.value.options || []
+    optionList.value = rawOptions.map((opt: any) => {
+      if (typeof opt === 'string') {
+        return { label: opt, value: opt }
+      }
+      return { label: String(opt.label ?? opt.value), value: opt.value }
+    })
+    optionDialogVisible.value = true
+  }
+
+  function addOption() {
+    optionList.value.push({ label: '', value: '' })
+  }
+
+  function removeOption(index: number) {
+    optionList.value.splice(index, 1)
+  }
+
+  function saveOptions() {
+    if (optionEditingIndex.value === null || !optionEditingField.value) return
+    const validOptions = optionList.value.filter(
+      (opt) => String(opt.label).trim() !== '' && String(opt.value).trim() !== ''
+    )
+    formFields.value[optionEditingIndex.value].options = validOptions
+    formFields.value[optionEditingIndex.value].multiple = optionEditingField.value.multiple
+    optionDialogVisible.value = false
+  }
+
+  function validateFlow(graphData: any): string | null {
+    const nodes = graphData.nodes || []
+    const edges = graphData.edges || []
+    if (nodes.length === 0) return '流程图不能为空'
+    const startNodes = nodes.filter((n: any) => n.type === 'start-node' || n.type === 'start')
+    const endNodes = nodes.filter((n: any) => n.type === 'end-node' || n.type === 'end')
+    if (startNodes.length === 0) return '流程图缺少开始节点'
+    if (endNodes.length === 0) return '流程图缺少结束节点'
+    if (startNodes.length > 1) return '流程图只能有一个开始节点'
+    // 检查审批节点是否选择了审批角色
+    for (const node of nodes) {
+      if (node.type === 'approval-node' || node.type === 'approval') {
+        const props = node.properties || {}
+        const assigneeValue = props.assigneeValue || props.approver
+        if (!assigneeValue) {
+          return `审批节点 "${node.text || node.name}" 未选择审批角色`
+        }
+      }
+    }
+    // 检查孤立节点
+    const connectedNodeIds = new Set<string>()
+    for (const edge of edges) {
+      connectedNodeIds.add(edge.sourceNodeId)
+      connectedNodeIds.add(edge.targetNodeId)
+    }
+    for (const node of nodes) {
+      if (
+        !connectedNodeIds.has(node.id) &&
+        node.type !== 'start-node' &&
+        node.type !== 'start' &&
+        node.type !== 'end-node' &&
+        node.type !== 'end'
+      ) {
+        return `节点 "${node.text || node.name}" 是孤立节点,请删除或连接`
+      }
+    }
+    // 检查条件分支:有且只能有一个默认分支,非默认分支必须填写条件
+    const sourceMap: Record<string, any[]> = {}
+    for (const edge of edges) {
+      if (!sourceMap[edge.sourceNodeId]) sourceMap[edge.sourceNodeId] = []
+      sourceMap[edge.sourceNodeId].push(edge)
+    }
+    for (const node of nodes) {
+      if (node.type === 'condition-node' || node.type === 'condition') {
+        const outEdges = sourceMap[node.id] || []
+        if (outEdges.length > 1) {
+          const defaultEdges = outEdges.filter((e: any) => {
+            const props = e.properties || {}
+            return props.isDefault || (!props.condition && !e.condition)
+          })
+          if (defaultEdges.length === 0) {
+            return `条件节点 "${node.text || node.name}" 需要至少一条默认分支`
+          }
+          if (defaultEdges.length > 1) {
+            return `条件节点 "${node.text || node.name}" 只能有一条默认分支`
+          }
+          const invalid = outEdges.some((e: any) => {
+            const props = e.properties || {}
+            return !props.isDefault && !props.condition && !e.condition
+          })
+          if (invalid) {
+            return `条件节点 "${node.text || node.name}" 的非默认分支必须填写条件表达式`
+          }
+        }
+      }
+    }
+    return null
+  }
+
+  async function handleSave() {
+    if (!lf) return
+    saveCurrentNodeProps()
+    const rawGraphData = lf.getGraphData()
+    const error = validateFlow(rawGraphData)
+    if (error) {
+      ElMessage.warning(error)
+      return
+    }
+
+    // 统一弹出保存弹窗,方便配置流程表单字段
+    if (mode.value === 'create') {
+      saveForm.code = flowCode.value
+      saveForm.name = flowName.value
+      saveForm.category = flowCategory.value
+      saveForm.description = flowDescription.value
+      formFields.value = []
+    } else if (mode.value === 'edit' && flowId.value) {
+      saveForm.code = flowCode.value
+      saveForm.name = flowName.value
+      saveForm.category = flowCategory.value
+      saveForm.description = flowDescription.value
+      // formFields 在编辑模式加载流程时已通过 parseFormSchema 填充
+    } else {
+      saveForm.code = ''
+      saveForm.name = ''
+      saveForm.category = ''
+      saveForm.description = ''
+      formFields.value = []
+    }
+    saveDialogVisible.value = true
+  }
+
+  async function submitSave() {
+    if (!lf) return
+    saveCurrentNodeProps()
+    const rawGraphData = lf.getGraphData()
+    const error = validateFlow(rawGraphData)
+    if (error) {
+      ElMessage.warning(error)
+      return
+    }
+    const graphData = convertToBackendFormat(rawGraphData)
+
+    if (mode.value === 'create') {
+      const data: Partial<FlowDefinition> = {
+        code: saveForm.code,
+        name: saveForm.name,
+        category: saveForm.category,
+        description: saveForm.description,
+        formSchema: getFormSchema(),
+        flowJson: JSON.stringify(graphData),
+        status: 0,
+      }
+      await addDefinition(data)
+      ElMessage.success('流程新增成功')
+      saveDialogVisible.value = false
+      router.push('/flow/definition')
+    } else if (mode.value === 'edit' && flowId.value) {
+      const data: Partial<FlowDefinition> = {
+        id: Number(flowId.value),
+        code: saveForm.code,
+        name: saveForm.name,
+        category: saveForm.category,
+        description: saveForm.description,
+        formSchema: getFormSchema(),
+        flowJson: JSON.stringify(graphData),
+      }
+      const newId = await updateDefinition(data)
+      if (newId !== Number(flowId.value)) {
+        ElMessage.success('流程已创建为新版本,请重新发布')
+      } else {
+        ElMessage.success('流程修改成功')
+      }
+      saveDialogVisible.value = false
+      router.push('/flow/definition')
+    } else {
+      const data: Partial<FlowDefinition> = {
+        code: saveForm.code,
+        name: saveForm.name,
+        category: saveForm.category,
+        description: saveForm.description,
+        formSchema: getFormSchema(),
+        flowJson: JSON.stringify(graphData),
+        status: 0,
+      }
+      await addDefinition(data)
+      ElMessage.success('流程保存成功')
+      saveDialogVisible.value = false
+      router.push('/flow/definition')
+    }
+  }
+
+  function handleClear() {
+    if (!lf) return
+    lf.clearData()
+    selectedNode.value = null
+  }
+
+  function goBack() {
+    router.push('/flow/definition')
+  }
+
+  async function loadRoles() {
+    try {
+      const res = await listRole({ pageNum: 1, pageSize: 100 })
+      roleList.value = res.list
+    } catch {
+      /* ignore */
+    }
+  }
+
+  onMounted(() => {
+    // 防止快速进入/离开设计器时重复创建 LogicFlow 实例(v1.x 无 destroy 方法)
+    if (lf) {
+      if (typeof lf.destroy === 'function') {
+        lf.destroy()
+      }
+      lf = null
+    }
+    nextTick(initLogicFlow)
+    loadRoles()
+  })
+
+  onUnmounted(() => {
+    if (lf) {
+      if (typeof lf.destroy === 'function') {
+        lf.destroy()
+      }
+      lf = null
+    }
+    selectedNode.value = null
+  })
+
+  return {
+    // flow info
+    mode,
+    flowId,
+    flowCode,
+    flowName,
+    flowCategory,
+    flowDescription,
+    flowInfoVisible,
+    // logic flow / selection
+    selectedNode,
+    outgoingEdges,
+    roleList,
+    nodeProps,
+    // property panel helpers
+    loadOutgoingEdges,
+    getEdgeTargetName,
+    onDefaultChange,
+    onBranchBlur,
+    saveCurrentNodeProps,
+    loadNodeProps,
+    updateNodeName,
+    // save dialog
+    saveDialogVisible,
+    saveForm,
+    saveFormRules,
+    formFields,
+    fieldTypeOptions,
+    addFormField,
+    removeFormField,
+    getFormSchema,
+    parseFormSchema,
+    // option dialog
+    optionDialogVisible,
+    optionEditingIndex,
+    optionEditingField,
+    optionList,
+    openOptionDialog,
+    addOption,
+    removeOption,
+    saveOptions,
+    // toolbar actions
+    handleSave,
+    submitSave,
+    handleClear,
+    goBack,
+    // drag/drop
+    handleDrop,
+    // conversion
+    convertToBackendFormat,
+    convertToFrontendFormat,
+    validateFlow,
+  }
+}

+ 185 - 118
src/views/flow/execute/InstanceDetail.vue

@@ -9,7 +9,7 @@
     @closed="handleClose"
   >
     <div v-loading="loading" class="detail-content">
-      <FilePreview v-model="previewVisible" :url="previewUrl" />
+      <FilePreview v-model="fileUpload.previewVisible" :url="fileUpload.previewUrl" />
       <!-- 流程基本信息 -->
       <div class="info-section">
         <el-descriptions :column="3" size="small" border>
@@ -30,19 +30,35 @@
 
       <!-- 表单数据 -->
       <div v-if="formDataDisplay" class="form-data-section">
-        <h4>表单数据</h4>
+        <div class="section-title">
+          <h4>表单数据</h4>
+          <el-button v-if="canEditForm && !formEditing" type="primary" size="small" link @click="startEditForm">补充/编辑材料</el-button>
+        </div>
+        <template v-if="formEditing">
+          <FlowFormFields
+            ref="formFieldsRef"
+            :definition="progress?.definition"
+            v-model="editFormData"
+          />
+          <div class="form-edit-actions">
+            <el-button size="small" @click="cancelEditForm">取消</el-button>
+            <el-button type="primary" size="small" :loading="savingForm" @click="saveFormData">保存</el-button>
+          </div>
+        </template>
         <FormDataDisplay
+          v-else
           :form-schema="progress?.definition?.formSchema"
           :form-data="progress?.instance?.formData"
         />
       </div>
 
+      <!-- TODO: 接入OSS后重新启用附件功能 -->
       <!-- 附件列表 -->
-      <div class="attachment-section">
+      <div v-if="false" class="attachment-section">
         <h4>附件列表</h4>
         <div v-if="attachments.length > 0" class="attachment-list">
           <div v-for="att in attachments" :key="att.id" class="attachment-item">
-            <el-link type="primary" @click="openPreview(att.fileUrl)">
+            <el-link type="primary" @click="fileUpload.openPreview(att.fileUrl)">
               <el-icon><Document /></el-icon>
               <span class="attachment-name" :title="att.fileName">{{ att.fileName }}</span>
             </el-link>
@@ -82,6 +98,7 @@
                   <span class="node-name">{{ node.nodeName }}</span>
                   <el-tag v-if="node.isMyTurn" type="danger" size="small" effect="dark">待我处理</el-tag>
                 </div>
+                <!-- 任务分配信息 -->
                 <div v-if="node.tasks?.length" class="node-tasks">
                   <div
                     v-for="task in node.tasks"
@@ -95,6 +112,21 @@
                     <span v-if="task.comment" class="task-comment">{{ task.comment }}</span>
                   </div>
                 </div>
+                <!-- 该节点的审批记录 -->
+                <div v-if="getNodeRecords(node.nodeId).length" class="node-records">
+                  <div
+                    v-for="record in getNodeRecords(node.nodeId)"
+                    :key="record.id"
+                    class="record-item"
+                  >
+                    <span class="record-operator">{{ record.operatorName || '用户' + record.operatorId }}</span>
+                    <el-tag :type="recordActionType(record.actionResult)" size="small">
+                      {{ recordActionText(record.actionResult) }}
+                    </el-tag>
+                    <span v-if="record.comment" class="record-comment">{{ record.comment }}</span>
+                    <span class="record-time">{{ record.createTime }}</span>
+                  </div>
+                </div>
               </div>
             </el-timeline-item>
           </el-timeline>
@@ -110,34 +142,34 @@
                   <el-radio label="pass">通过</el-radio>
                   <el-radio label="reject">拒绝</el-radio>
                   <el-radio label="rollback">回退</el-radio>
-                  <el-radio label="transfer">转办</el-radio>
                 </el-radio-group>
               </el-form-item>
-              <el-form-item v-if="form.action === 'rollback'" label="回退到">
-                <el-select v-model="form.targetNodeId" filterable placeholder="请选择回退节点" style="width: 100%">
-                  <el-option
-                    v-for="node in rollbackNodeOptions"
-                    :key="node.nodeId"
-                    :label="node.nodeName"
-                    :value="node.nodeId"
-                  />
+              <el-form-item v-if="form.action === 'pass' && nextNodes.length > 0" label="选择分支">
+                <el-select v-model="form.targetNodeId" placeholder="请选择下一节点" style="width: 100%">
+                  <el-option v-for="n in nextNodes" :key="n.nodeId" :label="n.nodeName" :value="n.nodeId" />
                 </el-select>
               </el-form-item>
-              <el-form-item v-if="form.action === 'transfer'" label="转给人">
-                <el-select v-model="form.transferTo" filterable placeholder="请选择转办人" style="width: 100%">
-                  <el-option v-for="u in userList" :key="u.id" :label="u.realName || u.username" :value="u.id" />
-                </el-select>
+              <el-form-item v-if="form.action === 'rollback'" label="回退说明">
+                <el-alert type="warning" :closable="false" show-icon>
+                  <template #title>
+                    提交后将自动回退到上一个审批节点
+                    <div v-if="lastRollbackRecord" class="rollback-reason">
+                      上次回退原因:{{ lastRollbackRecord.comment }}
+                    </div>
+                  </template>
+                </el-alert>
               </el-form-item>
-              <el-form-item label="附件">
+              <!-- TODO: 接入OSS后重新启用附件功能 -->
+              <el-form-item v-if="false" label="附件">
                 <el-upload
-                  v-model:file-list="attachmentList"
+                  v-model:file-list="fileUpload.attachmentList"
                   action="#"
-                  :http-request="handleUpload"
+                  :http-request="fileUpload.handleUpload"
                   :before-upload="beforeFileUpload"
                   :before-remove="() => true"
                   :limit="5"
                   multiple
-                  :on-preview="handleFilePreview"
+                  :on-preview="fileUpload.handleFilePreview"
                 >
                   <el-button type="primary" size="small">上传附件</el-button>
                 </el-upload>
@@ -155,38 +187,6 @@
             <el-empty description="当前没有需要您处理的审批任务" />
           </template>
 
-          <el-divider />
-
-          <!-- 审批历史 -->
-          <h4>审批历史</h4>
-          <div v-if="progress?.records?.length" class="record-list">
-            <div
-              v-for="record in progress.records"
-              :key="record.id"
-              class="record-item"
-            >
-              <div class="record-header">
-                <span class="record-operator">{{ record.operatorName || '用户' + record.operatorId }}</span>
-                <el-tag :type="recordActionType(record.actionResult)" size="small">
-                  {{ recordActionText(record.actionResult) }}
-                </el-tag>
-                <span class="record-time">{{ record.createTime }}</span>
-              </div>
-              <div v-if="record.comment" class="record-comment">{{ record.comment }}</div>
-              <div v-if="record.attachmentUrls" class="record-attachments">
-                <el-link
-                  v-for="(url, idx) in parseAttachments(record.attachmentUrls)"
-                  :key="idx"
-                  type="primary"
-                  @click="openPreview(url)"
-                  size="small"
-                >
-                  附件{{ idx + 1 }}: {{ getFileName(url) }}
-                </el-link>
-              </div>
-            </div>
-          </div>
-          <el-empty v-else description="暂无审批记录" />
         </div>
       </div>
     </div>
@@ -212,15 +212,16 @@
 <script setup lang="ts">
 import { ref, reactive, computed, watch } from 'vue'
 import { ElMessage } from 'element-plus'
-import { getProgress, getInstanceAttachments } from '@/api/flow/instance'
-import { approveTask, rejectTask, returnTask, transferTask, addSignTask } from '@/api/flow/task'
-import { listUser } from '@/api/system/user'
-import { uploadFile } from '@/api/file'
-import { beforeFileUpload, getFileUrl, getFileName, getUploadUrl, parseAttachments, collectAttachmentUrls } from '@/utils/file'
+import { getProgress, getInstanceAttachments, updateInstanceFormData } from '@/api/flow/instance'
+import { approveTask, rejectTask, returnTask, addSignTask, getTransferableUsers, listNextNodes } from '@/api/flow/task'
+import type { NextNode } from '@/types/flow'
+import { beforeFileUpload, getFileUrl, getFileName, parseAttachments, collectAttachmentUrls } from '@/utils/file'
+import { useFileUpload } from '@/composables/useFileUpload'
 import { instanceStatusText, instanceStatusTagType, taskStatusText, taskStatusType, recordActionText, recordActionType } from '@/utils/flow'
 import FilePreview from '@/components/FilePreview/index.vue'
 import FormDataDisplay from '@/components/FormDataDisplay/index.vue'
-import type { ProcessProgress, FlowTask, NodeProgress, ApprovalAction, Attachment } from '@/types/flow'
+import FlowFormFields from '@/components/FlowFormFields/index.vue'
+import type { ProcessProgress, FlowTask, ApprovalAction, Attachment } from '@/types/flow'
 import type { User } from '@/types/system'
 import { CircleCheck, Clock, Document } from '@element-plus/icons-vue'
 
@@ -240,41 +241,34 @@ const visible = computed({
 })
 
 const loading = ref(false)
-const previewVisible = ref(false)
-const previewUrl = ref('')
 const progress = ref<ProcessProgress | null>(null)
 
-type ApprovalActionType = 'pass' | 'reject' | 'rollback' | 'transfer'
+type ApprovalActionType = 'pass' | 'reject' | 'rollback'
 
 const form = reactive<{
   action: ApprovalActionType
   comment: string
-  transferTo: number | undefined
-  targetNodeId: string | undefined
+  targetNodeId?: string
 }>({
   action: 'pass',
   comment: '',
-  transferTo: undefined,
   targetNodeId: undefined
 })
 
-const attachmentList = ref<any[]>([])
+const fileUpload = reactive(useFileUpload())
 const submitting = ref(false)
 const addSignDialogVisible = ref(false)
 const addSignUserId = ref<number | undefined>(undefined)
-
-const rollbackNodeOptions = computed<NodeProgress[]>(() => {
-  if (!progress.value) return []
-  return progress.value.nodes.filter(n => n.status === 'completed')
-})
-
 const userList = ref<User[]>([])
 
-async function loadUsers() {
+async function loadTransferableUsers() {
+  const task = myPendingTask.value
+  if (!task) {
+    userList.value = []
+    return
+  }
   try {
-    // 静默加载用户列表,避免无权限用户在查看审批详情时被弹窗打扰
-    const res = await listUser({ pageNum: 1, pageSize: 9999 }, { silent: true })
-    userList.value = res.list
+    userList.value = await getTransferableUsers(task.id)
   } catch {
     userList.value = []
   }
@@ -306,30 +300,18 @@ async function loadAttachments() {
   }
 }
 
-function handleFilePreview(file: any) {
-  const url = getUploadUrl(file)
-  if (url) openPreview(url)
-}
-
-function openPreview(url: string) {
-  previewUrl.value = url
-  previewVisible.value = true
-}
-
 function isImage(url: string): boolean {
   return /\.(png|jpe?g|gif|webp|bmp)$/i.test(url)
 }
 
-async function handleUpload(options: any) {
-  try {
-    const res = await uploadFile(options.file)
-    options.onSuccess(res)
-  } catch (e) {
-    options.onError(e)
-  }
+// 查找当前登录用户需要处理的待办任务
+// 分支选择
+const nextNodes = ref<NextNode[]>([])
+
+async function loadNextNodes(taskId: number) {
+  try { nextNodes.value = await listNextNodes(taskId) } catch { nextNodes.value = [] }
 }
 
-// 查找当前登录用户需要处理的待办任务
 const myPendingTask = computed<FlowTask | null>(() => {
   if (!progress.value) return null
   for (const node of progress.value.nodes) {
@@ -353,6 +335,11 @@ function timelineIcon(status: string) {
   return undefined
 }
 
+function getNodeRecords(nodeId: string) {
+  if (!progress.value?.records) return []
+  return progress.value.records.filter(r => r.nodeId === nodeId)
+}
+
 function timelineColor(status: string) {
   if (status === 'completed') return '#67C23A'
   if (status === 'current') return '#E6A23C'
@@ -376,12 +363,8 @@ async function loadProgress() {
 async function submitApprove() {
   const task = myPendingTask.value
   if (!task) return
-  if (form.action === 'transfer' && !form.transferTo) {
-    ElMessage.warning('请选择转办人')
-    return
-  }
-  if (form.action === 'rollback' && !form.targetNodeId) {
-    ElMessage.warning('请选择回退节点')
+  if (form.action === 'pass' && nextNodes.value.length > 0 && !form.targetNodeId) {
+    ElMessage.warning('存在多个下游分支,请选择一个目标节点')
     return
   }
   submitting.value = true
@@ -390,15 +373,12 @@ async function submitApprove() {
       action: form.action,
       comment: form.comment
     }
-    if (form.action === 'transfer' && form.transferTo) {
-      data.transferTo = Number(form.transferTo)
+    if (fileUpload.attachmentList.length > 0) {
+      data.attachmentUrls = collectAttachmentUrls(fileUpload.attachmentList)
     }
-    if (form.action === 'rollback' && form.targetNodeId) {
+    if (form.action === 'pass' && form.targetNodeId) {
       data.targetNodeId = form.targetNodeId
     }
-    if (attachmentList.value.length > 0) {
-      data.attachmentUrls = collectAttachmentUrls(attachmentList.value)
-    }
     switch (form.action) {
       case 'pass':
         await approveTask(task.id, data)
@@ -409,16 +389,11 @@ async function submitApprove() {
       case 'rollback':
         await returnTask(task.id, data)
         break
-      case 'transfer':
-        await transferTask(task.id, data)
-        break
     }
     ElMessage.success('审批提交成功')
     form.action = 'pass'
     form.comment = ''
-    form.transferTo = undefined
-    form.targetNodeId = undefined
-    attachmentList.value = []
+    fileUpload.resetFileUpload()
     // 刷新进度展示最新状态,不关闭抽屉
     await loadProgress()
     emit('approved')
@@ -429,10 +404,65 @@ async function submitApprove() {
 
 function handleClose() {
   progress.value = null
-  attachmentList.value = []
+  fileUpload.resetFileUpload()
   attachments.value = []
+  cancelEditForm()
 }
 
+// 表单补充/编辑
+const formEditing = ref(false)
+const savingForm = ref(false)
+const editFormData = ref<Record<string, unknown>>({})
+const formFieldsRef = ref<{ validate: () => boolean } | null>(null)
+
+const canEditForm = computed(() => !!myPendingTask.value)
+
+function parseInstanceFormData(): Record<string, unknown> {
+  const fd = progress.value?.instance?.formData
+  if (!fd) return {}
+  if (fd !== null && typeof fd === 'object') return { ...fd as Record<string, unknown> }
+  try {
+    return JSON.parse(fd as string)
+  } catch {
+    return {}
+  }
+}
+
+function startEditForm() {
+  editFormData.value = parseInstanceFormData()
+  formEditing.value = true
+}
+
+function cancelEditForm() {
+  formEditing.value = false
+  editFormData.value = {}
+}
+
+async function saveFormData() {
+  const valid = formFieldsRef.value?.validate?.()
+  if (valid === false) return
+  savingForm.value = true
+  try {
+    await updateInstanceFormData(props.instanceId, editFormData.value)
+    ElMessage.success('材料已更新')
+    cancelEditForm()
+    await loadProgress()
+    emit('approved')
+  } finally {
+    savingForm.value = false
+  }
+}
+
+// 当前节点最近一次被回退的记录
+const lastRollbackRecord = computed(() => {
+  if (!progress.value?.records) return null
+  const currentNodeId = progress.value.instance?.currentNode
+  const list = progress.value.records.filter(r =>
+    r.nodeId === currentNodeId && (r.actionType === 'RETURN' || r.actionResult === 'RETURN')
+  )
+  return list.length > 0 ? list[list.length - 1] : null
+})
+
 function handleAddSign() {
   const task = myPendingTask.value
   if (!task) return
@@ -464,10 +494,22 @@ watch(() => props.instanceId, (id) => {
   }
 })
 
-watch(() => props.modelValue, (val) => {
+// 切换到"通过"时加载下游节点列表
+watch(() => form.action, (action) => {
+  nextNodes.value = []
+  form.targetNodeId = undefined
+  if (action === 'pass' && myPendingTask.value) {
+    loadNextNodes(myPendingTask.value.id)
+  }
+})
+
+watch(() => props.modelValue, async (val) => {
   if (val && props.instanceId) {
-    loadProgress()
-    loadUsers()
+    await loadProgress()
+    if (form.action === 'pass' && myPendingTask.value) {
+      loadNextNodes(myPendingTask.value.id)
+    }
+    await loadTransferableUsers()
     loadAttachments()
   }
 })
@@ -572,9 +614,13 @@ watch(() => props.modelValue, (val) => {
   max-height: 300px;
   overflow-y: auto;
 }
+.node-records {
+  margin-top: 8px;
+  padding-left: 12px;
+  border-left: 2px solid #e4e7ed;
+}
 .record-item {
-  padding: 10px 0;
-  border-bottom: 1px solid #ebeef5;
+  padding: 6px 0;
 }
 .record-item:last-child {
   border-bottom: none;
@@ -606,4 +652,25 @@ watch(() => props.modelValue, (val) => {
   flex-wrap: wrap;
   gap: 8px;
 }
+.section-title {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+}
+.section-title h4 {
+  margin: 0;
+}
+.form-edit-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 8px;
+  margin-top: 12px;
+}
+.rollback-reason {
+  margin-top: 4px;
+  font-size: 13px;
+  color: #e6a23c;
+  line-height: 1.5;
+}
 </style>

+ 157 - 236
src/views/flow/execute/index.vue

@@ -8,16 +8,11 @@
       </template>
 
       <el-tabs v-model="activeTab">
-        <el-tab-pane label="发起流程" name="start">
+        <el-tab-pane label="发起流程" name="start" lazy>
           <div v-if="loadingDefinitions" v-loading="true" style="height: 200px;" />
           <el-empty v-else-if="definitionList.length === 0" description="暂无可用的流程" />
           <div v-else class="definition-list">
-            <el-card
-              v-for="def in definitionList"
-              :key="def.id"
-              class="definition-card"
-              shadow="hover"
-            >
+            <el-card v-for="def in definitionList" :key="def.id" class="definition-card" shadow="hover">
               <div class="def-header">
                 <el-tag type="success" size="small">已发布</el-tag>
                 <span class="def-name">{{ def.name }}</span>
@@ -27,87 +22,29 @@
                 <p v-if="def.category"><strong>分类:</strong>{{ def.category }}</p>
                 <p v-if="def.description"><strong>描述:</strong>{{ def.description }}</p>
               </div>
-              <el-button type="primary" size="small" @click="handleStart(def)">发起流程</el-button>
+              <div class="def-actions">
+                <el-button type="primary" size="small" @click="handleStart(def)">发起流程</el-button>
+                <el-button type="info" size="small" :icon="View" @click="openPreview(def)">查看详情</el-button>
+              </div>
             </el-card>
           </div>
         </el-tab-pane>
-
-        <el-tab-pane label="我发起的流程" name="mine">
-          <el-table v-loading="loadingInstances" :data="instanceList" border>
-            <el-table-column type="index" label="序号" width="60" />
-            <el-table-column prop="definitionName" label="流程名称" />
-            <el-table-column prop="title" label="标题" />
-            <el-table-column prop="status" label="状态" width="100">
-              <template #default="{ row }">
-                <el-tag :type="instanceStatusTagType(row.status)">
-                  {{ instanceStatusText(row.status) }}
-                </el-tag>
-              </template>
-            </el-table-column>
-            <el-table-column prop="currentNodeName" label="当前节点">
-              <template #default="{ row }">
-                {{ row.currentNodeName || row.currentNode || '-' }}
-              </template>
-            </el-table-column>
-            <el-table-column prop="startTime" label="发起时间" />
-            <el-table-column label="操作" width="120">
-              <template #default="{ row }">
-                <el-button link type="primary" @click="handleView(row)">查看详情</el-button>
-              </template>
-            </el-table-column>
-          </el-table>
-          <el-pagination
-            v-model:current-page="queryParams.pageNum"
-            v-model:page-size="queryParams.pageSize"
-            :total="total"
-            :page-sizes="[10, 20, 50]"
-            layout="total, sizes, prev, pager, next, jumper"
-            class="pagination"
-            @current-change="loadInstances"
-            @size-change="loadInstances"
-          />
-        </el-tab-pane>
       </el-tabs>
     </el-card>
 
-    <!-- 发起流程弹窗 -->
     <el-dialog v-model="startDialogVisible" title="发起流程" width="640px">
       <el-form ref="startFormRef" :model="startForm" :rules="startFormRules" label-width="80px">
-        <el-form-item v-show="false">
-          <el-input v-model="startForm.processDefinitionId" />
-        </el-form-item>
-        <el-form-item label="流程名称">
-          <el-input v-model="startForm.definitionName" disabled />
-        </el-form-item>
-        <el-form-item label="标题" prop="title">
-          <el-input v-model="startForm.title" placeholder="请输入流程标题" />
-        </el-form-item>
+        <el-form-item v-show="false"><el-input v-model="startForm.processDefinitionId" /></el-form-item>
+        <el-form-item label="流程名称"><el-input v-model="startForm.definitionName" disabled /></el-form-item>
+        <el-form-item label="标题" prop="title"><el-input v-model="startForm.title" placeholder="请输入流程标题" /></el-form-item>
         <el-form-item label="表单数据">
-          <FlowFormFields v-if="hasFormFields" ref="flowFormFieldsRef" v-model="dynamicFormData" :definition="selectedDefinition" />
+          <FlowFormFields v-if="hasFormFields" v-model="dynamicFormData" :definition="selectedDefinition" />
           <el-input v-else v-model="startForm.formData" type="textarea" :rows="4" placeholder="请输入表单数据,格式:每行一个字段,如 amount=1000" />
         </el-form-item>
-        <el-form-item label="附件">
-          <el-upload
-            v-model:file-list="attachmentList"
-            action="#"
-            :http-request="handleUpload"
-            :before-upload="beforeFileUpload"
-            :before-remove="() => true"
-            :limit="5"
-            multiple
-          >
+        <!-- TODO: 接入OSS后重新启用附件功能 -->
+        <el-form-item v-if="false" label="附件">
+          <el-upload v-model:file-list="fileUpload.attachmentList" action="#" :http-request="fileUpload.handleUpload" :before-upload="beforeFileUpload" :before-remove="() => true" :limit="5" multiple>
             <el-button type="primary" size="small">上传附件</el-button>
-            <template #tip>
-              <div class="el-upload__tip">支持 Excel、图片等常见格式</div>
-            </template>
-            <template #file="{ file }">
-              <div class="upload-file-item">
-                <el-icon><Document /></el-icon>
-                <el-link type="primary" @click="openPreview(getUploadUrl(file))">
-                  {{ file.name }}
-                </el-link>
-              </div>
-            </template>
           </el-upload>
         </el-form-item>
       </el-form>
@@ -117,15 +54,61 @@
       </template>
     </el-dialog>
 
-    <!-- 流程详情弹窗 -->
-    <InstanceDetail
-      v-model="detailVisible"
-      :instance-id="currentInstanceId"
-      @approved="loadInstances"
-    />
+    <!-- 流程详情预览弹窗 -->
+    <el-dialog v-model="previewVisible" title="流程详情" width="640px">
+      <div v-if="previewDefinition" class="preview-content">
+        <div class="preview-section">
+          <h4>基本信息</h4>
+          <p><strong>流程名称:</strong>{{ previewDefinition.name }}</p>
+          <p><strong>流程编码:</strong>{{ previewDefinition.code }}</p>
+          <p v-if="previewDefinition.category"><strong>分类:</strong>{{ previewDefinition.category }}</p>
+          <p v-if="previewDefinition.description"><strong>描述:</strong>{{ previewDefinition.description }}</p>
+        </div>
+
+        <div v-if="previewFormFields.length > 0" class="preview-section">
+          <h4>表单字段</h4>
+          <el-table :data="previewFormFields" size="small" border>
+            <el-table-column prop="label" label="显示名" min-width="120" />
+            <el-table-column prop="name" label="字段名" min-width="120" />
+            <el-table-column label="类型" width="90">
+              <template #default="{ row }">
+                {{ fieldTypeText(row.type) }}
+              </template>
+            </el-table-column>
+            <el-table-column label="必填" width="70" align="center">
+              <template #default="{ row }">
+                <el-checkbox v-model="row.required" disabled />
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+
+        <div v-if="previewApprovalNodes.length > 0" class="preview-section">
+          <h4>审批节点</h4>
+          <el-timeline>
+            <el-timeline-item
+              v-for="(node, index) in previewApprovalNodes"
+              :key="node.id"
+              :type="node.type === 'cc-node' ? 'info' : 'primary'"
+            >
+              <div class="preview-node">
+                <span class="preview-node-index">{{ index + 1 }}</span>
+                <span class="preview-node-name">{{ node.name }}</span>
+                <el-tag size="small" :type="node.type === 'cc-node' ? 'info' : 'warning'">
+                  {{ node.type === 'cc-node' ? '抄送' : '审批' }}
+                </el-tag>
+              </div>
+            </el-timeline-item>
+          </el-timeline>
+        </div>
+      </div>
+      <template #footer>
+        <el-button @click="previewVisible = false">关闭</el-button>
+        <el-button type="primary" @click="handleStartFromPreview">发起流程</el-button>
+      </template>
+    </el-dialog>
 
-    <!-- 附件预览 -->
-    <FilePreview v-model="previewVisible" :url="previewUrl" />
+    <InstanceDetail v-model="detailVisible" :instance-id="currentInstanceId" />
   </div>
 </template>
 
@@ -133,201 +116,139 @@
 import { ref, reactive, computed, onMounted } from 'vue'
 import { ElMessage } from 'element-plus'
 import type { FormInstance, FormRules } from 'element-plus'
-import { Document } from '@element-plus/icons-vue'
+import { View } from '@element-plus/icons-vue'
 import { listEnabled } from '@/api/flow/definition'
-import { listMyInstance, startInstance } from '@/api/flow/instance'
-import { uploadFile } from '@/api/file'
-import { beforeFileUpload, collectAttachmentUrls, getUploadUrl } from '@/utils/file'
-import { instanceStatusText, instanceStatusTagType } from '@/utils/flow'
-import type { FlowDefinition, FlowInstance } from '@/types/flow'
+import { startInstance } from '@/api/flow/instance'
+import { beforeFileUpload, collectAttachmentUrls } from '@/utils/file'
+import { useFileUpload } from '@/composables/useFileUpload'
+import type { FlowDefinition, FormField } from '@/types/flow'
 import InstanceDetail from './InstanceDetail.vue'
 import FlowFormFields from '@/components/FlowFormFields/index.vue'
-import FilePreview from '@/components/FilePreview/index.vue'
 
 const activeTab = ref('start')
 const loadingDefinitions = ref(false)
-const loadingInstances = ref(false)
 const submitting = ref(false)
 const definitionList = ref<FlowDefinition[]>([])
-const instanceList = ref<FlowInstance[]>([])
-const total = ref(0)
-const queryParams = reactive({ pageNum: 1, pageSize: 10 })
-
 const startDialogVisible = ref(false)
 const startFormRef = ref<FormInstance>()
-const startForm = ref({
-  processDefinitionId: 0 as number,
-  definitionName: '',
-  title: '',
-  formData: ''
-})
+const startForm = ref({ processDefinitionId: 0 as number, definitionName: '', title: '', formData: '' })
 const dynamicFormData = ref<Record<string, unknown>>({})
 const selectedDefinition = ref<FlowDefinition | null>(null)
-const flowFormFieldsRef = ref<any>(null)
-
+const fileUpload = reactive(useFileUpload())
+const startFormRules: FormRules = { title: [{ required: true, message: '请输入标题', trigger: 'blur' }] }
 const hasFormFields = computed(() => {
-  const schema = selectedDefinition.value?.formSchema
-  if (!schema) return false
+  const s = selectedDefinition.value?.formSchema
+  if (!s) return false
+  try { const a = JSON.parse(s); return Array.isArray(a) && a.length > 0 } catch { return false }
+})
+const detailVisible = ref(false)
+const currentInstanceId = ref(0)
+const previewVisible = ref(false)
+const previewDefinition = ref<FlowDefinition | null>(null)
+
+const previewFormFields = computed<FormField[]>(() => {
+  const schema = previewDefinition.value?.formSchema
+  if (!schema) return []
   try {
     const parsed = JSON.parse(schema)
-    return Array.isArray(parsed) && parsed.length > 0
+    return Array.isArray(parsed) ? parsed : []
   } catch {
-    return false
+    return []
   }
 })
-const attachmentList = ref<any[]>([])
-const startFormRules: FormRules = {
-  title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
+
+interface PreviewNode {
+  id: string
+  type: string
+  name: string
 }
 
-const detailVisible = ref(false)
-const currentInstanceId = ref(0)
-const previewVisible = ref(false)
-const previewUrl = ref('')
+const previewApprovalNodes = computed<PreviewNode[]>(() => {
+  const flowJson = previewDefinition.value?.flowJson
+  if (!flowJson) return []
+  try {
+    const model = JSON.parse(flowJson)
+    const nodes = Array.isArray(model?.nodes) ? model.nodes : []
+    return nodes.filter((n: any) => n?.type === 'approval-node' || n?.type === 'cc-node')
+      .map((n: any) => ({ id: n.id, type: n.type, name: n.name || '未命名节点' }))
+  } catch {
+    return []
+  }
+})
+
+function fieldTypeText(type?: string): string {
+  const map: Record<string, string> = {
+    text: '文本',
+    number: '数字',
+    date: '日期',
+    select: '下拉',
+    textarea: '多行文本'
+  }
+  return map[type || ''] || type || '-'
+}
 
-function openPreview(url: string) {
-  if (!url) return
-  previewUrl.value = url
+function openPreview(def: FlowDefinition) {
+  previewDefinition.value = def
   previewVisible.value = true
 }
 
-function handleFilePreview(file: any) {
-  const url = getUploadUrl(file)
-  if (url) openPreview(url)
+function handleStartFromPreview() {
+  if (!previewDefinition.value) return
+  previewVisible.value = false
+  handleStart(previewDefinition.value)
 }
 
 async function loadDefinitions() {
   loadingDefinitions.value = true
-  try {
-    const res = await listEnabled()
-    definitionList.value = res
-  } finally {
-    loadingDefinitions.value = false
-  }
-}
-
-async function loadInstances() {
-  loadingInstances.value = true
-  try {
-    const res = await listMyInstance(queryParams)
-    instanceList.value = res.list
-    total.value = res.total
-  } finally {
-    loadingInstances.value = false
-  }
+  try { definitionList.value = await listEnabled() } finally { loadingDefinitions.value = false }
 }
 
 function handleStart(def: FlowDefinition) {
-  startForm.value.processDefinitionId = def.id
-  startForm.value.definitionName = def.name
-  startForm.value.title = ''
-  startForm.value.formData = ''
-  dynamicFormData.value = {}
-  selectedDefinition.value = def
-  attachmentList.value = []
+  startForm.value.processDefinitionId = def.id; startForm.value.definitionName = def.name
+  startForm.value.title = ''; startForm.value.formData = ''; dynamicFormData.value = {}
+  selectedDefinition.value = def; fileUpload.resetFileUpload()
   startDialogVisible.value = true
 }
 
-async function handleUpload(options: any) {
-  try {
-    const res = await uploadFile(options.file)
-    options.onSuccess(res)
-  } catch (e) {
-    options.onError(e)
-  }
-}
-
 async function submitStart() {
-  const valid = await startFormRef.value?.validate().catch(() => false)
-  if (!valid) return
-  // 校验动态表单字段;无动态字段时视为校验通过
-  const dynamicValid = flowFormFieldsRef.value?.validate() ?? true
-  if (dynamicValid === false) return
+  const v = await startFormRef.value?.validate().catch(() => false)
+  if (!v) return
   submitting.value = true
   try {
-    let formData: Record<string, unknown> | undefined
-    if (selectedDefinition.value?.formSchema) {
-      formData = { ...dynamicFormData.value }
-    } else if (startForm.value.formData.trim()) {
-      // 兼容旧版无表单字段配置的流程
-      formData = {}
-      startForm.value.formData.split('\n').forEach(line => {
-        const idx = line.indexOf('=')
-        if (idx > 0) {
-          const key = line.substring(0, idx).trim()
-          const value = line.substring(idx + 1).trim()
-          if (key) {
-            const num = Number(value)
-            formData![key] = !isNaN(num) && value !== '' ? num : value
-          }
+    let fd: Record<string, unknown> | undefined
+    if (selectedDefinition.value?.formSchema) { fd = { ...dynamicFormData.value } }
+    else if (startForm.value.formData.trim()) {
+      fd = {}
+      startForm.value.formData.split('\n').forEach(l => {
+        const i = l.indexOf('='); if (i > 0) {
+          const k = l.substring(0, i).trim(), val = l.substring(i + 1).trim()
+          if (k) { const n = Number(val); fd![k] = !isNaN(n) && val !== '' ? n : val }
         }
       })
     }
-    const attachmentUrls = attachmentList.value.length > 0
-      ? collectAttachmentUrls(attachmentList.value)
-      : undefined
-    await startInstance(startForm.value.processDefinitionId, startForm.value.title || undefined, formData, attachmentUrls)
-    ElMessage.success('流程发起成功')
-    startDialogVisible.value = false
-    activeTab.value = 'mine'
-    loadInstances()
-  } finally {
-    submitting.value = false
-  }
+    const au = fileUpload.attachmentList.length > 0 ? collectAttachmentUrls(fileUpload.attachmentList) : undefined
+    await startInstance(startForm.value.processDefinitionId, startForm.value.title || undefined, fd, au)
+    ElMessage.success('流程发起成功'); startDialogVisible.value = false
+  } finally { submitting.value = false }
 }
 
-function handleView(row: FlowInstance) {
-  currentInstanceId.value = row.id
-  detailVisible.value = true
-}
-
-onMounted(() => {
-  loadDefinitions()
-  loadInstances()
-})
+onMounted(loadDefinitions)
 </script>
 
 <style scoped>
-.card-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-.definition-list {
-  display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
-  gap: 16px;
-}
-.definition-card {
-  cursor: default;
-}
-.def-header {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  margin-bottom: 12px;
-}
-.def-name {
-  font-size: 16px;
-  font-weight: bold;
-  color: #303133;
-}
-.def-info {
-  font-size: 13px;
-  color: #606266;
-  margin-bottom: 12px;
-}
-.def-info p {
-  margin: 4px 0;
-}
-.pagination {
-  margin-top: 20px;
-  justify-content: flex-end;
-}
-.upload-file-item {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-  padding: 4px 0;
-}
+.card-header { display: flex; justify-content: space-between; align-items: center; }
+.definition-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
+.definition-card { cursor: default; }
+.def-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
+.def-name { font-size: 16px; font-weight: bold; color: #303133; }
+.def-info { font-size: 13px; color: #606266; margin-bottom: 12px; }
+.def-info p { margin: 4px 0; }
+.def-actions { display: flex; gap: 8px; }
+.preview-content { max-height: 60vh; overflow-y: auto; padding-right: 8px; }
+.preview-section { margin-bottom: 20px; }
+.preview-section h4 { margin: 0 0 12px 0; font-size: 15px; color: #303133; border-left: 4px solid #409eff; padding-left: 8px; }
+.preview-section p { margin: 6px 0; color: #606266; font-size: 14px; }
+.preview-node { display: flex; align-items: center; gap: 8px; }
+.preview-node-index { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background-color: #f0f2f5; color: #606266; font-size: 12px; }
+.preview-node-name { font-size: 14px; color: #303133; }
 </style>

+ 136 - 0
src/views/flow/instance/all.vue

@@ -0,0 +1,136 @@
+<template>
+  <div class="app-container">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>流程明细(全部)</span>
+        </div>
+      </template>
+
+      <el-form :inline="true" :model="queryParams" class="search-form">
+        <el-form-item label="流程名称">
+          <el-select v-model="queryParams.processName" placeholder="全部" clearable style="width: 180px;">
+            <el-option v-for="def in definitionList" :key="def.id" :label="def.name" :value="def.name" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="归属用户">
+          <el-input v-model="queryParams.applicantName" placeholder="用户名或姓名" clearable style="width: 160px;" />
+        </el-form-item>
+        <el-form-item label="当前节点">
+          <el-select v-model="queryParams.currentNodeName" placeholder="全部" clearable style="width: 180px;">
+            <el-option v-for="node in currentNodeOptions" :key="node" :label="node" :value="node" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleQuery">查询</el-button>
+          <el-button @click="resetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-table v-loading="loading" :data="tableData" border>
+        <el-table-column type="index" label="序号" width="60" />
+        <el-table-column prop="definitionName" label="流程名称" />
+        <el-table-column label="业务编号">
+          <template #default="{ row }">
+            {{ row.bizValue || row.instanceNo || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="applicantName" label="发起人" width="120">
+          <template #default="{ row }">
+            {{ row.applicantName || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="status" label="状态" width="100">
+          <template #default="{ row }">
+            <el-tag :type="instanceStatusTagType(row.status)">{{ instanceStatusText(row.status) }}</el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="currentNodeName" label="当前节点">
+          <template #default="{ row }">
+            {{ row.currentNodeName || row.currentNode || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="startTime" label="发起时间" />
+        <el-table-column prop="endTime" label="结束时间" />
+        <el-table-column label="操作" width="120">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="handleDetail(row)">查看详情</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        v-model:current-page="queryParams.pageNum"
+        v-model:page-size="queryParams.pageSize"
+        :total="total"
+        :page-sizes="[10, 20, 50]"
+        layout="total, sizes, prev, pager, next, jumper"
+        class="pagination"
+        @current-change="loadData"
+        @size-change="loadData"
+      />
+
+      <InstanceDetail v-model="detailVisible" :instance-id="selectedInstanceId" />
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { listInstance } from '@/api/flow/instance'
+import { listEnabled } from '@/api/flow/definition'
+import InstanceDetail from '@/views/flow/execute/InstanceDetail.vue'
+import { instanceStatusText, instanceStatusTagType } from '@/utils/flow'
+import type { FlowInstance, FlowDefinition } from '@/types/flow'
+
+const loading = ref(false)
+const tableData = ref<FlowInstance[]>([])
+const total = ref(0)
+const detailVisible = ref(false)
+const selectedInstanceId = ref<number>(0)
+const definitionList = ref<FlowDefinition[]>([])
+const currentNodeOptions = ref<string[]>([])
+
+const queryParams = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  processName: undefined as string | undefined,
+  applicantName: undefined as string | undefined,
+  currentNodeName: undefined as string | undefined
+})
+
+function handleDetail(row: FlowInstance) {
+  selectedInstanceId.value = row.id
+  detailVisible.value = true
+}
+
+function handleQuery() { queryParams.pageNum = 1; loadData() }
+function resetQuery() {
+  queryParams.processName = undefined
+  queryParams.applicantName = undefined
+  queryParams.currentNodeName = undefined
+  queryParams.pageNum = 1
+  loadData()
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await listInstance(queryParams)
+    tableData.value = res.list
+    total.value = res.total
+  } finally { loading.value = false }
+}
+
+async function loadDefinitions() {
+  try { definitionList.value = await listEnabled() } catch { /* ignore */ }
+}
+
+onMounted(() => { loadDefinitions(); loadData() })
+</script>
+
+<style scoped>
+.card-header { display: flex; justify-content: space-between; align-items: center; }
+.pagination { margin-top: 20px; justify-content: flex-end; }
+.search-form { margin-bottom: 20px; }
+</style>

+ 221 - 32
src/views/flow/instance/mine.vue

@@ -9,28 +9,51 @@
 
       <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 @change="onProcessNameChange" style="width: 180px;">
+            <el-option v-for="def in definitionList" :key="def.id" :label="def.name" :value="def.name" />
+          </el-select>
         </el-form-item>
-        <el-form-item label="状态">
-          <el-select v-model="queryParams.status" placeholder="全部" clearable>
-            <el-option
-              v-for="opt in statusOptions"
-              :key="opt.value"
-              :label="opt.label"
-              :value="opt.value"
-            />
+        <el-form-item label="当前节点">
+          <el-select v-model="queryParams.currentNodeName" placeholder="全部" clearable :disabled="!queryParams.processName" style="width: 180px;">
+            <el-option v-for="node in currentNodeOptions" :key="node" :label="node" :value="node" />
           </el-select>
         </el-form-item>
         <el-form-item>
           <el-button type="primary" @click="handleQuery">查询</el-button>
           <el-button @click="resetQuery">重置</el-button>
+          <el-button type="success" @click="importDialogVisible = true">导入流程</el-button>
         </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 prop="businessKey" label="业务编号" />
+        <el-table-column label="业务编号">
+          <template #default="{ row }">
+            {{ row.bizValue || row.instanceNo || '-' }}
+          </template>
+        </el-table-column>
         <el-table-column prop="status" label="状态" width="100">
           <template #default="{ row }">
             <el-tag :type="instanceStatusTagType(row.status)">
@@ -67,40 +90,91 @@
         @size-change="loadData"
       />
     </el-card>
+
+    <!-- 导入流程弹窗 -->
+    <el-dialog v-model="importDialogVisible" title="导入流程" width="550px">
+      <el-form label-width="100px">
+        <el-form-item label="选择流程">
+          <el-select v-model="importDefinitionId" placeholder="请选择要导入的流程" style="width: 100%">
+            <el-option v-for="def in definitionList" :key="def.id" :label="def.name" :value="def.id" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <div class="import-buttons" style="margin-left: 100px; margin-bottom: 16px;">
+        <el-button type="primary" :disabled="!importDefinitionId" @click="downloadTemplate">下载Excel模板</el-button>
+        <el-upload
+          :show-file-list="false"
+          :before-upload="handleImportFlow"
+          accept=".xlsx,.xls"
+          action="#"
+          style="display: inline-block; margin-left: 12px;"
+        >
+          <el-button type="success" :disabled="!importDefinitionId" :loading="importing">导入流程</el-button>
+        </el-upload>
+      </div>
+      <el-alert v-if="importDefinitionId" type="info" :closable="false">
+        <template #title>操作说明:先下载模板,填写数据后点击「导入流程」上传。</template>
+      </el-alert>
+      <template #footer>
+        <el-button @click="importDialogVisible = false">关闭</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <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 type { FlowInstance } from '@/types/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[]>([])
 const total = ref(0)
 const detailVisible = ref(false)
 const selectedInstanceId = ref<number>(0)
-const statusOptions = [
-  { label: '待接收', value: 0 },
-  { label: '运行中', value: 1 },
-  { label: '已通过', value: 2 },
-  { label: '已拒绝', value: 3 },
-  { label: '已回退', value: 4 },
-  { label: '已完成', value: 5 },
-  { label: '已撤回', value: 6 },
-  { label: '已终止', value: 7 }
-]
+const definitionList = ref<FlowDefinition[]>([])
+const currentNodeOptions = ref<string[]>([])
+const tableRef = ref<TableInstance>()
+const selectedIds = ref<number[]>([])
 
 const queryParams = reactive({
   pageNum: 1,
   pageSize: 10,
   processName: undefined as string | undefined,
-  status: undefined as number | undefined
+  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)
+  if (def?.flowJson) {
+    try {
+      const model = JSON.parse(def.flowJson)
+      currentNodeOptions.value = (model.nodes || [])
+        .filter((n: any) => n.type !== 'start' && n.type !== 'end' && n.type !== 'condition' && n.type !== 'cc')
+        .map((n: any) => n.name)
+    } catch { currentNodeOptions.value = [] }
+  } else {
+    currentNodeOptions.value = []
+  }
+}
+
 function handleDetail(row: FlowInstance) {
   selectedInstanceId.value = row.id
   detailVisible.value = true
@@ -128,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()
@@ -135,7 +257,8 @@ function handleQuery() {
 
 function resetQuery() {
   queryParams.processName = undefined
-  queryParams.status = undefined
+  queryParams.currentNodeName = undefined
+  currentNodeOptions.value = []
   queryParams.pageNum = 1
   loadData()
 }
@@ -146,25 +269,91 @@ 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
   }
 }
 
-onMounted(loadData)
+async function loadDefinitions() {
+  try {
+    definitionList.value = await listEnabled()
+  } catch { /* ignore */ }
+}
+
+// 导入流程
+const importDialogVisible = ref(false)
+const importDefinitionId = ref<number | null>(null)
+const importing = ref(false)
+
+async function downloadTemplate() {
+  if (!importDefinitionId.value) return
+  try {
+    const def = definitionList.value.find(d => d.id === importDefinitionId.value)
+    await downloadFlowTemplate(importDefinitionId.value, (def?.name || '流程') + '模板')
+  } catch {
+    ElMessage.error({ message: '下载模板失败', duration: 3000 })
+  }
+}
+
+async function handleImportFlow(file: any) {
+  if (!importDefinitionId.value) return false
+  importing.value = true
+  try {
+    const def = await getDefinition(importDefinitionId.value)
+    const mappings = buildMappings(def)
+    const res = await importFlow(file, importDefinitionId.value, mappings)
+    ElMessage.success({ message: `导入成功:新增 ${res.successCount} 条,更新 ${res.updateCount} 条`, duration: 3000 })
+    importDialogVisible.value = false
+    loadData()
+  } catch (e: any) {
+    ElMessage.error({ message: e?.message || '导入失败', duration: 3000 })
+  } finally {
+    importing.value = false
+  }
+  return false
+}
+
+function buildMappings(def: any): Record<string, string> {
+  const map: Record<string, string> = {}
+  if (def.formSchema) {
+    try {
+      for (const f of JSON.parse(def.formSchema)) {
+        if (f.label && f.name) map[f.label] = f.name
+      }
+    } catch { /* ignore */ }
+  }
+  return map
+}
+
+onMounted(() => {
+  loadDefinitions()
+  loadData()
+})
 </script>
 
 <style scoped>
-.card-header {
+.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;
 }
-.pagination {
-  margin-top: 20px;
-  justify-content: flex-end;
+.batch-info {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  color: #606266;
 }
-.search-form {
-  margin-bottom: 20px;
+.batch-actions {
+  display: flex;
+  gap: 10px;
 }
 </style>

+ 16 - 10
src/views/flow/task/handled.vue

@@ -62,23 +62,29 @@ const queryParams = reactive({
 })
 
 function actionText(action?: string) {
+  const key = (action || '').toUpperCase()
   const map: Record<string, string> = {
-    pass: '通过',
-    reject: '拒绝',
-    rollback: '回退',
-    transfer: '转办'
+    PASS: '通过',
+    REJECT: '拒绝',
+    RETURN: '回退',
+    ROLLBACK: '回退',
+    TRANSFER: '转办',
+    ADD_SIGN: '加签'
   }
-  return map[action || ''] || action || '-'
+  return map[key] || action || '-'
 }
 
 function actionTagType(action?: string) {
+  const key = (action || '').toUpperCase()
   const map: Record<string, any> = {
-    pass: 'success',
-    reject: 'danger',
-    rollback: 'warning',
-    transfer: 'info'
+    PASS: 'success',
+    REJECT: 'danger',
+    RETURN: 'warning',
+    ROLLBACK: 'warning',
+    TRANSFER: 'info',
+    ADD_SIGN: 'info'
   }
-  return map[action || ''] || 'info'
+  return map[key] || 'info'
 }
 
 async function loadData() {

File diff suppressed because it is too large
+ 444 - 407
src/views/flow/task/todo.vue


+ 3 - 19
src/views/login/index.vue

@@ -2,10 +2,6 @@
   <div class="login-container">
     <el-card class="login-box">
       <h2 class="login-title">通用型流程审批系统</h2>
-      <el-radio-group v-model="loginType" class="login-type-switch">
-        <el-radio-button label="SYSTEM">系统用户登录</el-radio-button>
-        <el-radio-button label="ROLE">角色用户登录</el-radio-button>
-      </el-radio-group>
       <el-form
         ref="loginFormRef"
         :model="loginForm"
@@ -15,7 +11,7 @@
         <el-form-item prop="username">
           <el-input
             v-model="loginForm.username"
-            :placeholder="loginType === 'ROLE' ? '角色账号 / 手机号' : '用户名 / 手机号'"
+            placeholder="用户名 / 手机号"
             :prefix-icon="User"
             size="large"
           />
@@ -50,7 +46,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted, watch } from 'vue'
+import { ref, reactive, onMounted } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { User, Lock } from '@element-plus/icons-vue'
 import { ElMessage } from 'element-plus'
@@ -63,7 +59,6 @@ const userStore = useUserStore()
 
 const loginFormRef = ref<FormInstance>()
 const loading = ref(false)
-const loginType = ref<'SYSTEM' | 'ROLE'>('SYSTEM')
 
 const loginForm = reactive({
   username: '',
@@ -77,27 +72,20 @@ const loginRules: FormRules = {
   password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
 }
 
-watch(loginType, (newType) => {
-  loginForm.username = ''
-  loginForm.password = ''
-})
-
 async function handleLogin() {
   const valid = await loginFormRef.value?.validate().catch(() => false)
   if (!valid) return
 
   loading.value = true
   try {
-    await userStore.loginAction({ ...loginForm, loginType: loginType.value })
+    await userStore.loginAction({ ...loginForm })
     await userStore.fetchUserInfo()
     if (remember.value) {
       localStorage.setItem('login_username', loginForm.username)
       localStorage.setItem('login_remember', 'true')
-      localStorage.setItem('login_type', loginType.value)
     } else {
       localStorage.removeItem('login_username')
       localStorage.removeItem('login_remember')
-      localStorage.removeItem('login_type')
     }
     ElMessage.success('登录成功')
     const redirect = route.query.redirect as string
@@ -112,13 +100,9 @@ async function handleLogin() {
 onMounted(() => {
   const savedUsername = localStorage.getItem('login_username')
   const savedRemember = localStorage.getItem('login_remember')
-  const savedType = localStorage.getItem('login_type')
   if (savedRemember === 'true' && savedUsername) {
     loginForm.username = savedUsername
     remember.value = true
-    if (savedType === 'ROLE' || savedType === 'SYSTEM') {
-      loginType.value = savedType as 'SYSTEM' | 'ROLE'
-    }
   }
 })
 </script>

+ 1 - 1
src/views/system/notification-config/index.vue

@@ -49,7 +49,7 @@
           <p><strong>1. 配置来源</strong></p>
           <p>本系统将企业微信配置保存在 <code>sys_notification_config</code> 表中,对接后端可调用 <code>GET /system/notification-config/wecom</code> 读取。</p>
           <p><strong>2. 接收人映射</strong></p>
-          <p>员工的企业微信账号保存在 <code>sys_user.wecom_user_id</code> 字段,可在「员工管理」中为每个员工绑定。</p>
+          <p>企业微信账号可在「用户管理」或「审批角色」中为每个账号绑定。</p>
           <p><strong>3. 事件触发点</strong></p>
           <p>本系统会在以下事件发生时通过日志输出待通知内容,对接方可订阅事件或轮询待发送记录:</p>
           <ul>

+ 20 - 124
src/views/system/role/index.vue

@@ -32,22 +32,22 @@
         </el-card>
       </el-col>
 
-      <!-- 右侧员工列表 -->
+      <!-- 右侧审批角色列表 -->
       <el-col :span="19">
         <el-card>
           <template #header>
             <div class="card-header">
-              <span>员工列表</span>
-              <el-button type="primary" @click="handleAdd">新增员工</el-button>
+              <span>审批角色列表</span>
+              <el-button type="primary" @click="handleAdd">新增角色</el-button>
             </div>
           </template>
 
           <el-form :inline="true" :model="queryParams" class="search-form">
-            <el-form-item label="员工编码">
-              <el-input v-model="queryParams.roleCode" placeholder="请输入员工编码" clearable />
+            <el-form-item label="角色编码">
+              <el-input v-model="queryParams.roleCode" placeholder="请输入角色编码" clearable />
             </el-form-item>
-            <el-form-item label="员工姓名">
-              <el-input v-model="queryParams.roleName" placeholder="请输入员工姓名" clearable />
+            <el-form-item label="角色名称">
+              <el-input v-model="queryParams.roleName" placeholder="请输入角色名称" clearable />
             </el-form-item>
             <el-form-item>
               <el-button type="primary" @click="handleQuery">查询</el-button>
@@ -57,23 +57,10 @@
 
           <el-table v-loading="loading" :data="tableData" border>
             <el-table-column type="index" label="序号" width="60" />
-            <el-table-column prop="roleCode" label="员工编码" />
-            <el-table-column prop="roleName" label="员工姓名" />
-            <el-table-column prop="username" label="登录账号" />
-            <el-table-column prop="phone" label="手机号">
-              <template #default="{ row }">
-                <span v-if="row.phone">{{ row.phone }}</span>
-                <span v-else class="text-gray">未填写</span>
-              </template>
-            </el-table-column>
+            <el-table-column prop="roleCode" label="角色编码" />
+            <el-table-column prop="roleName" label="角色名称" />
             <el-table-column prop="deptName" label="所属部门" />
             <el-table-column prop="roleScope" label="描述" />
-            <el-table-column prop="wecomUserId" label="企微账号" width="120">
-              <template #default="{ row }">
-                <el-tag v-if="row.wecomUserId" type="success" size="small">{{ row.wecomUserId }}</el-tag>
-                <span v-else class="text-gray">未绑定</span>
-              </template>
-            </el-table-column>
             <el-table-column prop="status" label="状态" width="100">
               <template #default="{ row }">
                 <el-tag :type="row.status === 1 ? 'success' : 'danger'">
@@ -85,7 +72,6 @@
             <el-table-column label="操作" width="220">
               <template #default="{ row }">
                 <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
-                <el-button link type="primary" @click="handleWeComConfig(row)">企微配置</el-button>
                 <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
               </template>
             </el-table-column>
@@ -105,24 +91,15 @@
       </el-col>
     </el-row>
 
-    <!-- 员工弹窗 -->
+    <!-- 角色弹窗 -->
     <el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
       <el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
-        <el-form-item label="员工编码" prop="roleCode">
+        <el-form-item label="角色编码" prop="roleCode">
           <el-input v-model="form.roleCode" :disabled="isEdit" />
         </el-form-item>
-        <el-form-item label="员工姓名" prop="roleName">
+        <el-form-item label="角色名称" prop="roleName">
           <el-input v-model="form.roleName" />
         </el-form-item>
-        <el-form-item label="登录账号" prop="username">
-          <el-input v-model="form.username" placeholder="员工登录账号" />
-        </el-form-item>
-        <el-form-item label="手机号" prop="phone">
-          <el-input v-model="form.phone" placeholder="请输入手机号" />
-        </el-form-item>
-        <el-form-item label="密码" prop="password">
-          <el-input v-model="form.password" type="password" show-password :placeholder="isEdit ? '不填表示不修改密码' : '请输入密码'" />
-        </el-form-item>
         <el-form-item label="所属部门" prop="deptId">
           <el-tree-select
             v-model="form.deptId"
@@ -150,39 +127,6 @@
       </template>
     </el-dialog>
 
-    <!-- 企微配置抽屉 -->
-    <el-drawer v-model="wecomDrawerVisible" title="企微提醒配置" direction="rtl" size="400px">
-      <el-form :model="wecomForm" label-width="100px">
-        <el-alert
-          title="提示:实际企微消息推送由外部对接系统完成,本系统仅保存配置和接收人映射。"
-          type="info"
-          :closable="false"
-          style="margin-bottom: 20px;"
-        />
-        <el-form-item label="员工姓名">
-          <el-input v-model="wecomForm.roleName" disabled />
-        </el-form-item>
-        <el-form-item label="企微账号">
-          <el-input v-model="wecomForm.wecomUserId" placeholder="请输入企业微信用户ID" />
-        </el-form-item>
-        <el-form-item label="开启提醒">
-          <el-switch
-            v-model="wecomForm.wecomRemindEnabled"
-            :active-value="1"
-            :inactive-value="0"
-            active-text="开启"
-            inactive-text="关闭"
-          />
-        </el-form-item>
-      </el-form>
-      <template #footer>
-        <div style="flex: auto">
-          <el-button @click="wecomDrawerVisible = false">取消</el-button>
-          <el-button type="primary" :loading="wecomSubmitting" @click="submitWeComConfig">保存</el-button>
-        </div>
-      </template>
-    </el-drawer>
-
     <!-- 部门弹窗 -->
     <el-dialog v-model="deptDialogVisible" :title="deptDialogTitle" width="500px">
       <el-form ref="deptFormRef" :model="deptForm" :rules="deptFormRules" label-width="100px">
@@ -206,12 +150,6 @@
         <el-form-item label="排序">
           <el-input-number v-model="deptForm.sort" :min="0" />
         </el-form-item>
-        <el-form-item label="状态">
-          <el-radio-group v-model="deptForm.status">
-            <el-radio :label="1">正常</el-radio>
-            <el-radio :label="0">禁用</el-radio>
-          </el-radio-group>
-        </el-form-item>
       </el-form>
       <template #footer>
         <el-button @click="deptDialogVisible = false">取消</el-button>
@@ -225,7 +163,7 @@
 import { ref, shallowRef, reactive, onMounted, computed } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import type { FormInstance, FormRules } from 'element-plus'
-import { listRole, addRole, updateRole, deleteRole, bindRoleWeCom } from '@/api/system/role'
+import { listRole, addRole, updateRole, deleteRole } from '@/api/system/role'
 import { listDept, addDept, updateDept, deleteDept } from '@/api/system/dept'
 import type { Role, Dept } from '@/types/system'
 
@@ -255,7 +193,7 @@ const deptTree = computed(() => {
 
 const deptTreeForSelect = shallowRef<Dept[]>([])
 
-// 员工弹窗
+// 角色弹窗
 const dialogVisible = ref(false)
 const dialogTitle = ref('')
 const isEdit = ref(false)
@@ -264,17 +202,14 @@ const form = reactive<Partial<Role>>({
   id: undefined,
   roleCode: '',
   roleName: '',
-  username: '',
-  phone: '',
-  password: '',
   roleScope: '',
   deptId: undefined,
   status: 1
 })
 
 const formRules: FormRules = {
-  roleCode: [{ required: true, message: '请输入员工编码', trigger: 'blur' }],
-  roleName: [{ required: true, message: '请输入员工姓名', trigger: 'blur' }],
+  roleCode: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
+  roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
   phone: [{
     pattern: /^1[3-9]\d{9}$/,
     message: '请输入正确的手机号',
@@ -282,16 +217,6 @@ const formRules: FormRules = {
   }]
 }
 
-// 部门弹窗
-const wecomDrawerVisible = ref(false)
-const wecomSubmitting = ref(false)
-const wecomForm = reactive({
-  roleId: 0,
-  roleName: '',
-  wecomUserId: '',
-  wecomRemindEnabled: 1
-})
-
 const deptDialogVisible = ref(false)
 const deptDialogTitle = ref('')
 const isEditDept = ref(false)
@@ -363,14 +288,11 @@ function handleDeptClick(data: Dept) {
 
 function handleAdd() {
   isEdit.value = false
-  dialogTitle.value = '新增员工'
+  dialogTitle.value = '新增角色'
   Object.assign(form, {
     id: undefined,
     roleCode: '',
     roleName: '',
-    username: '',
-    phone: '',
-    password: '',
     roleScope: '',
     deptId: selectedDeptId.value,
     status: 1
@@ -380,17 +302,14 @@ function handleAdd() {
 
 function handleEdit(row: Role) {
   isEdit.value = true
-  dialogTitle.value = '编辑员工'
-  Object.assign(form, {
-    ...row,
-    phone: row.phone || ''
-  })
+  dialogTitle.value = '编辑角色'
+  Object.assign(form, { ...row })
   dialogVisible.value = true
 }
 
 async function handleDelete(row: Role) {
   try {
-    await ElMessageBox.confirm(`确认删除员工 "${row.roleName}" 吗?`, '提示', { type: 'warning' })
+    await ElMessageBox.confirm(`确认删除角色 "${row.roleName}" 吗?`, '提示', { type: 'warning' })
     await deleteRole(row.id)
     ElMessage.success('删除成功')
     loadData()
@@ -399,29 +318,6 @@ async function handleDelete(row: Role) {
   }
 }
 
-function handleWeComConfig(row: Role) {
-  wecomForm.roleId = row.id
-  wecomForm.roleName = row.roleName
-  wecomForm.wecomUserId = row.wecomUserId || ''
-  wecomForm.wecomRemindEnabled = row.wecomRemindEnabled ?? 1
-  wecomDrawerVisible.value = true
-}
-
-async function submitWeComConfig() {
-  wecomSubmitting.value = true
-  try {
-    await bindRoleWeCom(wecomForm.roleId, {
-      wecomUserId: wecomForm.wecomUserId,
-      wecomRemindEnabled: wecomForm.wecomRemindEnabled
-    })
-    ElMessage.success('保存成功')
-    wecomDrawerVisible.value = false
-    loadData()
-  } finally {
-    wecomSubmitting.value = false
-  }
-}
-
 async function submitForm() {
   const valid = await formRef.value?.validate().catch(() => false)
   if (!valid) return

+ 191 - 23
src/views/system/user/index.vue

@@ -3,8 +3,8 @@
     <el-card>
       <template #header>
         <div class="card-header">
-          <span>管理员列表</span>
-          <el-button type="primary" @click="handleAdd">新增管理员</el-button>
+          <span>用户列表</span>
+          <el-button type="primary" @click="handleAdd">新增用户</el-button>
         </div>
       </template>
 
@@ -13,7 +13,7 @@
           <el-input v-model="queryParams.username" placeholder="请输入账号" clearable />
         </el-form-item>
         <el-form-item label="状态">
-          <el-select v-model="queryParams.status" placeholder="全部" clearable>
+          <el-select v-model="queryParams.status" placeholder="全部" clearable style="width: auto; min-width: 120px;">
             <el-option label="正常" :value="0" />
             <el-option label="禁用" :value="1" />
           </el-select>
@@ -28,11 +28,21 @@
         <el-table-column type="index" label="序号" width="60" />
         <el-table-column prop="username" label="账号" />
         <el-table-column prop="realName" label="姓名" />
-        <el-table-column prop="employeeType" label="管理员类型">
+        <el-table-column prop="employeeType" label="用户类型">
           <template #default="{ row }">
             {{ adminEmployeeTypeLabel(row.employeeType) }}
           </template>
         </el-table-column>
+        <el-table-column label="关联角色" min-width="150">
+          <template #default="{ row }">
+            <template v-if="row.roleIds && row.roleIds.length > 0">
+              <el-tag v-for="rid in row.roleIds" :key="rid" size="small" style="margin-right: 4px; margin-bottom: 2px;">
+                {{ getRoleName(rid) }}
+              </el-tag>
+            </template>
+            <span v-else class="text-gray">未分配</span>
+          </template>
+        </el-table-column>
         <el-table-column prop="deptName" label="部门" />
         <el-table-column prop="email" label="邮箱" />
         <el-table-column prop="phone" label="手机号" />
@@ -44,9 +54,10 @@
           </template>
         </el-table-column>
         <el-table-column prop="createTime" label="创建时间" />
-        <el-table-column label="操作" width="180">
+        <el-table-column label="操作" width="260">
           <template #default="{ row }">
             <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
+            <el-button link type="success" @click="handleAssignRole(row)">分配角色</el-button>
             <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
@@ -64,6 +75,20 @@
       />
     </el-card>
 
+    <!-- 分配角色弹窗 -->
+    <el-dialog v-model="roleDialogVisible" title="分配角色" width="500px">
+      <el-checkbox-group v-model="selectedRoleIds">
+        <el-checkbox v-for="role in allRoles" :key="role.id" :label="role.id">
+          {{ role.roleName }}({{ role.roleCode }})
+        </el-checkbox>
+      </el-checkbox-group>
+      <el-empty v-if="allRoles.length === 0" description="暂无可分配的角色" :image-size="60" />
+      <template #footer>
+        <el-button @click="roleDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submitAssignRole">确认</el-button>
+      </template>
+    </el-dialog>
+
     <!-- 管理员弹窗 -->
     <el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
       <el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
@@ -93,12 +118,12 @@
             style="width: 100%"
           />
         </el-form-item>
-        <el-form-item label="管理员类型" prop="employeeType">
-          <el-select v-model="form.employeeType" placeholder="请选择管理员类型" style="width: 100%">
-            <el-option label="普通管理员" value="common_user" />
-            <el-option label="部门管理员" value="dept_manager" />
-            <el-option label="流程管理员" value="flow_manager" />
-            <el-option label="系统管理员" value="super_admin" />
+        <el-form-item label="用户类型" prop="employeeType">
+          <el-select v-model="form.employeeType" placeholder="请选择用户类型" style="width: 100%">
+            <el-option label="普通用户" value="common_user" />
+            <el-option label="部门运维" value="dept_manager" />
+            <el-option label="流程运维" value="flow_manager" />
+            <el-option label="管理员" value="super_admin" />
           </el-select>
         </el-form-item>
         <el-form-item label="状态">
@@ -107,24 +132,60 @@
             <el-radio :label="1">禁用</el-radio>
           </el-radio-group>
         </el-form-item>
+        <el-form-item label="企微账号">
+          <span v-if="form.wecomUserId" style="margin-right: 8px;">{{ wecomUserName || form.wecomUserId }}</span>
+          <el-button type="primary" size="small" @click="openWecomDialog">绑定企微账号</el-button>
+          <el-button v-if="form.wecomUserId" type="danger" size="small" @click="form.wecomUserId = ''; wecomUserName = ''">解绑</el-button>
+        </el-form-item>
       </el-form>
       <template #footer>
         <el-button @click="dialogVisible = false">取消</el-button>
         <el-button type="primary" @click="submitForm">确认</el-button>
       </template>
+
+      <!-- 企微绑定弹窗 -->
+      <el-dialog v-model="wecomDialogVisible" title="绑定企微账号" width="600px">
+        <el-row :gutter="16">
+          <el-col :span="8">
+            <div class="wecom-title">部门</div>
+            <el-tree
+              :props="{ label: 'name', isLeaf: 'leaf' }"
+              node-key="id"
+              :load="loadDeptChildren"
+              lazy
+              highlight-current
+              @node-click="onDeptClick"
+            />
+          </el-col>
+          <el-col :span="16">
+            <div class="wecom-title">成员</div>
+            <el-input v-model="memberSearch" placeholder="搜索成员" clearable size="small" style="margin-bottom: 8px;" :disabled="memberList.length === 0" />
+            <el-table v-loading="loadingMembers" :data="filteredMemberList" border height="370" highlight-current-row @row-click="onMemberClick">
+              <el-table-column prop="name" label="姓名" />
+            </el-table>
+            <el-empty v-if="!selectedDeptId" description="请选择部门" :image-size="60" />
+          </el-col>
+        </el-row>
+        <template #footer>
+          <el-button @click="wecomDialogVisible = false">取消</el-button>
+          <el-button type="primary" @click="confirmWecomBind">确认绑定</el-button>
+        </template>
+      </el-dialog>
     </el-dialog>
 
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, shallowRef, reactive, onMounted } from 'vue'
+import { ref, shallowRef, reactive, onMounted, computed } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import type { FormInstance, FormRules } from 'element-plus'
-import { listUser, addUser, updateUser, deleteUser } from '@/api/system/user'
+import { listUser, addUser, updateUser, deleteUser, assignUserRoles } from '@/api/system/user'
+import { listRole } from '@/api/system/role'
+import { listSubDepartments, listDepartmentMembers, type WecomMember } from '@/api/wecom'
 import { listDept } from '@/api/system/dept'
 import { adminEmployeeTypeLabel } from '@/utils/format'
-import type { User, Dept } from '@/types/system'
+import type { User, Role, Dept } from '@/types/system'
 
 const loading = ref(false)
 const tableData = ref<User[]>([])
@@ -154,7 +215,8 @@ const form = reactive<Partial<User>>({
   phone: '',
   deptId: undefined,
   employeeType: 'common_user',
-  status: 0
+  status: 0,
+  wecomUserId: ''
 })
 
 const PASSWORD_PLACEHOLDER = '********'
@@ -171,7 +233,7 @@ const formRules: FormRules = {
     },
     trigger: 'blur'
   }],
-  employeeType: [{ required: true, message: '请选择管理员类型', trigger: 'change' }]
+  employeeType: [{ required: true, message: '请选择用户类型', trigger: 'change' }]
 }
 
 async function loadDepts() {
@@ -218,7 +280,7 @@ function resetQuery() {
 
 function handleAdd() {
   isEdit.value = false
-  dialogTitle.value = '新增管理员'
+  dialogTitle.value = '新增用户'
   Object.assign(form, {
     id: undefined,
     username: '',
@@ -228,14 +290,16 @@ function handleAdd() {
     phone: '',
     deptId: undefined,
     employeeType: 'common_user',
-    status: 0
+    status: 0,
+    wecomUserId: ''
   })
+  wecomUserName.value = ''
   dialogVisible.value = true
 }
 
 function handleEdit(row: User) {
   isEdit.value = true
-  dialogTitle.value = '编辑管理员'
+  dialogTitle.value = '编辑用户'
   Object.assign(form, {
     id: row.id,
     username: row.username,
@@ -245,14 +309,16 @@ function handleEdit(row: User) {
     phone: row.phone,
     deptId: row.deptId,
     employeeType: row.employeeType || 'common_user',
-    status: row.status
+    status: row.status,
+    wecomUserId: row.wecomUserId || ''
   })
+  wecomUserName.value = (row as any).wecomUserName || ''
   dialogVisible.value = true
 }
 
 async function handleDelete(row: User) {
   try {
-    await ElMessageBox.confirm(`确认删除管理员 "${row.username}" 吗?`, '提示', { type: 'warning' })
+    await ElMessageBox.confirm(`确认删除用户 "${row.username}" 吗?`, '提示', { type: 'warning' })
     await deleteUser(row.id)
     ElMessage.success('删除成功')
     loadData()
@@ -273,7 +339,8 @@ async function submitForm() {
       phone: form.phone,
       deptId: form.deptId,
       employeeType: form.employeeType,
-      status: form.status
+      status: form.status,
+      wecomUserId: form.wecomUserId
     }
     if (form.password && form.password.trim() !== '' && form.password !== PASSWORD_PLACEHOLDER) {
       editData.password = form.password
@@ -288,8 +355,106 @@ async function submitForm() {
   loadData()
 }
 
+// 分配角色
+const roleDialogVisible = ref(false)
+const allRoles = ref<Role[]>([])
+const selectedRoleIds = ref<number[]>([])
+const assigningUserId = ref<number | null>(null)
+
+async function loadAllRoles() {
+  try {
+    const res = await listRole({ pageNum: 1, pageSize: 200 })
+    allRoles.value = res.list
+  } catch { /* ignore */ }
+}
+
+function getRoleName(roleId: number): string {
+  const role = allRoles.value.find(r => r.id === roleId)
+  return role ? role.roleName : `角色#${roleId}`
+}
+
+function handleAssignRole(row: User) {
+  assigningUserId.value = row.id
+  selectedRoleIds.value = row.roleIds ? [...row.roleIds] : []
+  roleDialogVisible.value = true
+}
+
+async function submitAssignRole() {
+  if (assigningUserId.value == null) return
+  try {
+    await assignUserRoles(assigningUserId.value, selectedRoleIds.value)
+    ElMessage.success('角色分配成功')
+    roleDialogVisible.value = false
+    loadData()
+  } catch (e: any) {
+    ElMessage.error(e?.message || '分配失败')
+  }
+}
+
+// 企微绑定
+const wecomDialogVisible = ref(false)
+const selectedDeptId = ref<number | null>(null)
+const memberList = ref<WecomMember[]>([])
+const selectedMember = ref<WecomMember | null>(null)
+const loadingMembers = ref(false)
+const wecomUserName = ref('')
+const memberSearch = ref('')
+const filteredMemberList = computed(() => {
+  if (!memberSearch.value) return memberList.value
+  const kw = memberSearch.value.toLowerCase()
+  return memberList.value.filter(m => m.name?.toLowerCase().includes(kw))
+})
+
+function openWecomDialog() {
+  wecomDialogVisible.value = true
+  selectedDeptId.value = null
+  memberList.value = []
+  selectedMember.value = null
+}
+
+async function loadDeptChildren(node: any, resolve: any) {
+  const parentId = node.data?.id || 0
+  try {
+    const depts = await listSubDepartments(parentId)
+    if (depts.length === 0) {
+      node.leaf = true
+    }
+    resolve(depts.map((d: any) => ({ id: d.id, name: d.name, leaf: false })))
+  } catch {
+    node.leaf = true
+    resolve([])
+  }
+}
+
+async function onDeptClick(data: any) {
+  selectedDeptId.value = data.id
+  selectedMember.value = null
+  loadingMembers.value = true
+  try {
+    memberList.value = await listDepartmentMembers(data.id)
+  } catch {
+    memberList.value = []
+  } finally {
+    loadingMembers.value = false
+  }
+}
+
+function onMemberClick(row: WecomMember) {
+  selectedMember.value = row
+}
+
+function confirmWecomBind() {
+  if (!selectedMember.value) {
+    ElMessage.warning('请选择一个成员')
+    return
+  }
+  form.wecomUserId = selectedMember.value.userid
+  wecomUserName.value = selectedMember.value.name
+  wecomDialogVisible.value = false
+}
+
 onMounted(() => {
-  Promise.all([loadDepts(), loadData()])
+  Promise.all([loadDepts(), loadData(), loadAllRoles()])
 })
 </script>
 
@@ -309,4 +474,7 @@ onMounted(() => {
 .text-gray {
   color: #909399;
 }
+.wecom-title {
+  font-weight: bold; margin-bottom: 8px; color: #303133;
+}
 </style>

+ 3 - 3
src/views/workbench/components/TaskCard.vue

@@ -24,7 +24,7 @@
     <div v-if="showActions" class="task-card-actions">
       <el-button circle type="success" size="small" :icon="Check" @click.stop="$emit('approve', task)" />
       <el-button circle type="danger" size="small" :icon="Close" @click.stop="$emit('reject', task)" />
-      <el-button circle type="warning" size="small" :icon="Switch" @click.stop="$emit('transfer', task)" />
+      <el-button circle type="warning" size="small" :icon="Back" @click.stop="$emit('rollback', task)" />
       <el-button circle type="info" size="small" :icon="Plus" @click.stop="$emit('add-sign', task)" />
       <el-button circle type="primary" size="small" :icon="View" @click.stop="$emit('detail', task)" />
     </div>
@@ -36,7 +36,7 @@
 
 <script setup lang="ts">
 import { computed } from 'vue'
-import { Check, Close, Switch, Plus, View } from '@element-plus/icons-vue'
+import { Check, Close, Back, Plus, View } from '@element-plus/icons-vue'
 import type { FlowTask } from '@/types/flow'
 
 const props = defineProps<{
@@ -47,7 +47,7 @@ const props = defineProps<{
 defineEmits<{
   (e: 'approve', task: FlowTask): void
   (e: 'reject', task: FlowTask): void
-  (e: 'transfer', task: FlowTask): void
+  (e: 'rollback', task: FlowTask): void
   (e: 'add-sign', task: FlowTask): void
   (e: 'detail', task: FlowTask): void
 }>()

+ 24 - 220
src/views/workbench/index.vue

@@ -63,7 +63,7 @@
               <div class="task-card-actions">
                 <el-button circle type="success" size="small" :icon="Check" @click.stop="openQuickApprove(row, 'pass')" />
                 <el-button circle type="danger" size="small" :icon="Close" @click.stop="openQuickApprove(row, 'reject')" />
-                <el-button circle type="warning" size="small" :icon="Switch" @click.stop="openTransfer(row)" />
+                <el-button circle type="warning" size="small" :icon="Back" @click.stop="openRollback(row)" />
                 <el-button circle type="info" size="small" :icon="Plus" @click.stop="handleAddSign(row)" />
                 <el-button circle type="primary" size="small" :icon="View" @click.stop="handleDetail(row)" />
               </div>
@@ -80,7 +80,7 @@
               :task="row"
               @approve="openQuickApprove($event, 'pass')"
               @reject="openQuickApprove($event, 'reject')"
-              @transfer="openTransfer"
+              @rollback="openRollback"
               @add-sign="handleAddSign"
               @detail="handleDetail"
             />
@@ -96,7 +96,7 @@
               :task="row"
               @approve="openQuickApprove($event, 'pass')"
               @reject="openQuickApprove($event, 'reject')"
-              @transfer="openTransfer"
+              @rollback="openRollback"
               @add-sign="handleAddSign"
               @detail="handleDetail"
             />
@@ -168,83 +168,14 @@
       />
     </el-card>
 
-    <!-- 批量/快速审批抽屉 -->
-    <el-drawer
-      v-model="approveDrawerVisible"
-      :title="drawerTitle"
-      direction="rtl"
-      size="420px"
-      :destroy-on-close="true"
-      @closed="resetApproveForm"
-    >
-      <div class="drawer-content">
-        <el-alert
-          v-if="currentTask"
-          :title="`流程:${currentTask.definitionName} · 节点:${currentTask.nodeName}`"
-          type="info"
-          :closable="false"
-          show-icon
-          class="batch-alert"
-        />
-        <el-form :model="form" label-width="80px">
-          <el-form-item v-if="form.action === 'transfer'" label="转办人">
-            <el-select v-model="form.transferTo" filterable placeholder="请选择转办人" style="width: 100%">
-              <el-option v-for="u in userList" :key="u.id" :label="u.realName || u.username" :value="u.id" />
-            </el-select>
-          </el-form-item>
-          <el-form-item label="附件">
-            <div v-if="previousAttachments.length > 0" class="previous-attachments">
-              <div class="previous-title">历史附件</div>
-              <div v-for="att in previousAttachments" :key="att.id" class="previous-item">
-                <el-link type="primary" size="small" @click="openPreview(att.fileUrl)">
-                  {{ att.fileName }}
-                </el-link>
-                <span class="previous-meta">{{ att.nodeName || '发起' }} · {{ att.uploaderName || '未知' }}</span>
-              </div>
-            </div>
-            <el-upload
-              v-model:file-list="attachmentList"
-              action="#"
-              :http-request="handleUpload"
-              :before-upload="beforeFileUpload"
-              :before-remove="() => true"
-              :on-preview="handleFilePreview"
-              multiple
-            >
-              <el-button type="primary" size="small">上传附件</el-button>
-            </el-upload>
-          </el-form-item>
-          <el-form-item label="审批意见">
-            <el-input v-model="form.comment" type="textarea" :rows="4" placeholder="请输入审批意见" />
-          </el-form-item>
-        </el-form>
-        <div class="drawer-footer">
-          <el-button @click="approveDrawerVisible = false">取消</el-button>
-          <el-button type="primary" :loading="submitting" @click="submitApprove">确认{{ drawerActionText }}</el-button>
-        </div>
-      </div>
-    </el-drawer>
+    <!-- 审批抽屉 -->
+    <ApprovalDrawer ref="approvalDrawerRef" @success="onApproved" />
 
     <!-- 加签对话框 -->
-    <el-dialog v-model="addSignVisible" title="任务加签" width="400px">
-      <el-form label-width="80px">
-        <el-form-item label="加签人">
-          <el-select v-model="addSignUserId" filterable placeholder="请选择加签人" style="width: 100%">
-            <el-option v-for="u in userList" :key="u.id" :label="u.realName || u.username" :value="u.id" />
-          </el-select>
-        </el-form-item>
-      </el-form>
-      <template #footer>
-        <el-button @click="addSignVisible = false">取消</el-button>
-        <el-button type="primary" :loading="addSignLoading" @click="submitAddSign">确认</el-button>
-      </template>
-    </el-dialog>
+    <AddSignDialog ref="addSignDialogRef" @success="onAddSigned" />
 
     <!-- 流程详情抽屉 -->
     <InstanceDetail v-model="detailVisible" :instance-id="currentInstanceId" @approved="loadData" />
-
-    <!-- 附件预览 -->
-    <FilePreview v-model="previewVisible" :url="previewUrl" />
   </div>
 </template>
 
@@ -252,20 +183,16 @@
 import { ref, reactive, onMounted, computed } from 'vue'
 import { useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
-import { Check, Close, Switch, Plus, View } from '@element-plus/icons-vue'
-import { listTodo, approveTask, rejectTask, transferTask, addSignTask } from '@/api/flow/task'
-import { listHandled, listCc } from '@/api/flow/task'
-import { listMyInstance, getInstanceAttachments } from '@/api/flow/instance'
-import { listUser } from '@/api/system/user'
-import { uploadFile } from '@/api/file'
+import { Check, Close, Back, Plus, View } from '@element-plus/icons-vue'
+import { listTodo, listHandled, listCc } from '@/api/flow/task'
+import { listMyInstance } from '@/api/flow/instance'
 import { todoCount } from '@/api/flow/task'
-import { beforeFileUpload, getUploadUrl } from '@/utils/file'
-import type { FlowTask, ApprovalAction, FlowInstance, Attachment } from '@/types/flow'
-import type { User } from '@/types/system'
+import type { FlowTask, FlowInstance } from '@/types/flow'
 import { instanceStatusText, instanceStatusTagType } from '@/utils/flow'
 import InstanceDetail from '@/views/flow/execute/InstanceDetail.vue'
 import TaskCard from './components/TaskCard.vue'
-import FilePreview from '@/components/FilePreview/index.vue'
+import ApprovalDrawer from '@/components/ApprovalDrawer/index.vue'
+import AddSignDialog from '@/components/AddSignDialog/index.vue'
 
 const router = useRouter()
 const activeTab = ref<'todo' | 'overdue' | 'warning' | 'handled' | 'cc' | 'mine'>('todo')
@@ -275,7 +202,8 @@ const handledList = ref<FlowTask[]>([])
 const ccList = ref<FlowTask[]>([])
 const mineList = ref<FlowInstance[]>([])
 const total = ref(0)
-const userList = ref<User[]>([])
+const approvalDrawerRef = ref<InstanceType<typeof ApprovalDrawer> | null>(null)
+const addSignDialogRef = ref<InstanceType<typeof AddSignDialog> | null>(null)
 
 const queryParams = reactive({
   pageNum: 1,
@@ -346,111 +274,17 @@ function goToExecute() {
   router.push('/approval/execute')
 }
 
-// 抽屉审批
-const approveDrawerVisible = ref(false)
-const currentTask = ref<FlowTask | null>(null)
-const submitting = ref(false)
-const form = reactive<ApprovalAction & { transferTo?: number }>({
-  action: 'pass',
-  comment: '',
-  transferTo: undefined
-})
-const attachmentList = ref<any[]>([])
-const previousAttachments = ref<Attachment[]>([])
-const previewVisible = ref(false)
-const previewUrl = ref('')
-
-const drawerActionText = computed(() => {
-  const map: Record<string, string> = { pass: '通过', reject: '拒绝', transfer: '转办' }
-  return map[form.action] || '审批'
-})
-
-const drawerTitle = computed(() => `${drawerActionText.value}审批`)
-
-function resetApproveForm() {
-  form.action = 'pass'
-  form.comment = ''
-  form.transferTo = undefined
-  attachmentList.value = []
-  previousAttachments.value = []
-  currentTask.value = null
+function openQuickApprove(row: FlowTask, action: 'pass' | 'reject') {
+  approvalDrawerRef.value?.open(row, action)
 }
 
-function buildActionData(): ApprovalAction {
-  const data: ApprovalAction = { action: form.action, comment: form.comment }
-  if (form.action === 'transfer' && form.transferTo) {
-    data.transferTo = Number(form.transferTo)
-  }
-  if (attachmentList.value.length > 0) {
-    data.attachmentUrls = JSON.stringify(attachmentList.value.map((f: any) => f.response || f.url).filter(Boolean))
-  }
-  return data
+function openRollback(row: FlowTask) {
+  approvalDrawerRef.value?.open(row, 'rollback')
 }
 
-async function openQuickApprove(row: FlowTask, action: 'pass' | 'reject') {
-  resetApproveForm()
-  currentTask.value = row
-  form.action = action
-  await loadPreviousAttachments(row.instanceId)
-  approveDrawerVisible.value = true
-}
-
-async function openTransfer(row: FlowTask) {
-  resetApproveForm()
-  currentTask.value = row
-  form.action = 'transfer'
-  await loadPreviousAttachments(row.instanceId)
-  approveDrawerVisible.value = true
-}
-
-async function submitApprove() {
-  if (!currentTask.value) return
-  submitting.value = true
-  try {
-    const data = buildActionData()
-    const taskId = currentTask.value.id
-    if (form.action === 'pass') await approveTask(taskId, data)
-    else if (form.action === 'reject') await rejectTask(taskId, data)
-    else if (form.action === 'transfer') await transferTask(taskId, data)
-    ElMessage.success('审批成功')
-    approveDrawerVisible.value = false
-    loadData()
-    loadStats()
-  } finally {
-    submitting.value = false
-  }
-}
-
-async function handleUpload(options: any) {
-  try {
-    const res = await uploadFile(options.file)
-    options.onSuccess(res)
-  } catch (e) {
-    options.onError(e)
-  }
-}
-
-async function loadPreviousAttachments(instanceId?: number) {
-  if (!instanceId) {
-    previousAttachments.value = []
-    return
-  }
-  try {
-    previousAttachments.value = await getInstanceAttachments(instanceId)
-  } catch {
-    previousAttachments.value = []
-  }
-}
-
-function openPreview(url: string) {
-  if (!url) return
-  previewUrl.value = url
-  previewVisible.value = true
-}
-
-function handleFilePreview(file: any) {
-  const url = getUploadUrl(file)
-  if (url) openPreview(url)
+function onApproved() {
+  loadData()
+  loadStats()
 }
 
 // 详情
@@ -471,47 +305,17 @@ function handleInstanceDetail(id: number) {
   detailVisible.value = true
 }
 
-// 加签
-const addSignVisible = ref(false)
-const addSignTaskId = ref<number>(0)
-const addSignUserId = ref<number | undefined>(undefined)
-const addSignLoading = ref(false)
-
 function handleAddSign(row: FlowTask) {
-  addSignTaskId.value = row.id
-  addSignUserId.value = undefined
-  addSignVisible.value = true
+  addSignDialogRef.value?.open(row.id)
 }
 
-async function submitAddSign() {
-  if (!addSignUserId.value) {
-    ElMessage.warning('请选择加签人')
-    return
-  }
-  addSignLoading.value = true
-  try {
-    await addSignTask(addSignTaskId.value, addSignUserId.value)
-    ElMessage.success('加签成功')
-    addSignVisible.value = false
-    addSignUserId.value = undefined
-    loadData()
-  } finally {
-    addSignLoading.value = false
-  }
-}
-
-async function loadUsers() {
-  try {
-    // 静默加载用户列表,避免普通用户进入工作台时被提示“权限不足”
-    const res = await listUser({ pageNum: 1, pageSize: 9999 }, { silent: true })
-    userList.value = res.list
-  } catch { userList.value = [] }
+function onAddSigned() {
+  loadData()
 }
 
 onMounted(() => {
   loadData()
   loadStats()
-  loadUsers()
 })
 </script>
 

+ 63 - 0
tests/unit/permission.spec.ts

@@ -0,0 +1,63 @@
+import { describe, it, expect } from 'vitest'
+import { hasPermission, isRouteAllowed, getAllowedMenuRoutes } from '@/utils/permission'
+import type { RouteRecordNormalized } from 'vue-router'
+
+function makeRoute(
+  path: string,
+  meta?: Record<string, unknown>,
+  children?: RouteRecordNormalized[]
+): RouteRecordNormalized {
+  return {
+    path,
+    meta: meta ?? {},
+    children: children ?? [],
+    name: path,
+    components: {},
+    instances: {},
+    aliasOf: undefined,
+    leaveGuards: {} as any,
+    updateGuards: {} as any,
+    enterCallbacks: {},
+    mods: new Set(),
+    props: {},
+    redirect: undefined,
+    beforeEnter: undefined
+  } as unknown as RouteRecordNormalized
+}
+
+describe('permission utils', () => {
+  it('hasPermission returns true when no permission required', () => {
+    expect(hasPermission(undefined, 'common_user')).toBe(true)
+    expect(hasPermission([], 'common_user')).toBe(true)
+  })
+
+  it('hasPermission checks employee type', () => {
+    expect(hasPermission(['flow_manager'], 'flow_manager')).toBe(true)
+    expect(hasPermission(['flow_manager'], 'common_user')).toBe(false)
+  })
+
+  it('isRouteAllowed respects meta.permissions', () => {
+    const allowed = makeRoute('/flow', { permissions: ['flow_manager'] })
+    expect(isRouteAllowed(allowed, 'flow_manager')).toBe(true)
+    expect(isRouteAllowed(allowed, 'common_user')).toBe(false)
+
+    const publicRoute = makeRoute('/dashboard', {})
+    expect(isRouteAllowed(publicRoute, 'common_user')).toBe(true)
+  })
+
+  it('getAllowedMenuRoutes filters by permissions', () => {
+    const routes = [
+      makeRoute('/', { title: '首页', icon: 'HomeFilled' }),
+      makeRoute(
+        '/system',
+        { title: '系统管理', permissions: ['dept_manager'] },
+        [makeRoute('/system/user', { title: '用户管理', permissions: ['dept_manager'] })]
+      )
+    ]
+    const common = getAllowedMenuRoutes(routes, 'common_user')
+    expect(common.some((r) => r.path === '/system')).toBe(false)
+
+    const manager = getAllowedMenuRoutes(routes, 'dept_manager')
+    expect(manager.some((r) => r.path === '/system')).toBe(true)
+  })
+})

+ 18 - 0
tests/unit/request.spec.ts

@@ -0,0 +1,18 @@
+import { describe, it, expect } from 'vitest'
+import { http, createRequestController } from '@/api/request'
+
+describe('request layer', () => {
+  it('http exposes typed methods', () => {
+    expect(typeof http.get).toBe('function')
+    expect(typeof http.post).toBe('function')
+    expect(typeof http.put).toBe('function')
+    expect(typeof http.delete).toBe('function')
+  })
+
+  it('createRequestController returns an AbortController', () => {
+    const controller = createRequestController()
+    expect(controller).toBeInstanceOf(AbortController)
+    expect(typeof controller.abort).toBe('function')
+    expect(typeof controller.signal).toBe('object')
+  })
+})

+ 43 - 0
tests/unit/useApproval.spec.ts

@@ -0,0 +1,43 @@
+import { describe, it, expect } from 'vitest'
+import { nextTick } from 'vue'
+import { useApproval } from '@/composables/useApproval'
+
+describe('useApproval', () => {
+  it('initial state is reset', () => {
+    const approval = useApproval()
+    expect(approval.form.action).toBe('pass')
+    expect(approval.form.comment).toBe('')
+    expect(approval.submitting.value).toBe(false)
+    expect(approval.batchMode.value).toBe(false)
+    expect(approval.nextNodes.value).toEqual([])
+  })
+
+  it('buildActionData includes targetNodeId and attachmentUrls', async () => {
+    const approval = useApproval()
+    approval.form.action = 'pass'
+    approval.form.comment = '同意'
+    approval.form.targetNodeId = 'node_2'
+    approval.attachmentList.value = [
+      { response: '/uploads/a.pdf' },
+      { url: '/uploads/b.pdf' }
+    ]
+    await nextTick()
+    const data = approval.buildActionData()
+    expect(data.action).toBe('pass')
+    expect(data.comment).toBe('同意')
+    expect(data.targetNodeId).toBe('node_2')
+    expect(data.attachmentUrls).toContain('/uploads/a.pdf')
+    expect(data.attachmentUrls).toContain('/uploads/b.pdf')
+  })
+
+  it('reset clears form and attachments', () => {
+    const approval = useApproval()
+    approval.form.action = 'reject'
+    approval.form.comment = '拒绝'
+    approval.attachmentList.value = [{ response: '/uploads/a.pdf' }]
+    approval.reset()
+    expect(approval.form.action).toBe('pass')
+    expect(approval.form.comment).toBe('')
+    expect(approval.attachmentList.value).toHaveLength(0)
+  })
+})

+ 21 - 0
vitest.config.ts

@@ -0,0 +1,21 @@
+import { defineConfig } from 'vitest/config'
+import vue from '@vitejs/plugin-vue'
+import { resolve } from 'path'
+
+export default defineConfig({
+  plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': resolve(__dirname, 'src')
+    }
+  },
+  test: {
+    environment: 'jsdom',
+    globals: true,
+    coverage: {
+      provider: 'v8',
+      reporter: ['text', 'html'],
+      exclude: ['node_modules/', 'dist/', 'tests/', '**/*.d.ts', '**/*.config.*']
+    }
+  }
+})

Some files were not shown because too many files changed in this diff