Переглянути джерело

feat: 前端专项优化(request泛型化、共享组件、路由权限统一、大文件拆分、工程化工具链)

ye-zhaojia 1 тиждень тому
батько
коміт
592ef07b20
56 змінених файлів з 4399 додано та 2587 видалено
  1. 30 0
      .eslintrc.cjs
  2. 1 0
      .gitignore
  3. 3 0
      .prettierignore
  4. 9 0
      .prettierrc
  5. 875 55
      package-lock.json
  6. 17 1
      package.json
  7. 13 19
      src/api/analysis.ts
  8. 6 6
      src/api/auth.ts
  9. 2 3
      src/api/file.ts
  10. 14 11
      src/api/flow/definition.ts
  11. 19 9
      src/api/flow/import.ts
  12. 24 17
      src/api/flow/instance.ts
  13. 19 17
      src/api/flow/task.ts
  14. 134 37
      src/api/request.ts
  15. 6 6
      src/api/system/dept.ts
  16. 3 3
      src/api/system/notification-config.ts
  17. 7 7
      src/api/system/role.ts
  18. 12 9
      src/api/system/user.ts
  19. 4 4
      src/api/wecom.ts
  20. 45 0
      src/components/AddSignDialog/index.vue
  21. 143 0
      src/components/ApprovalDrawer/index.vue
  22. 2 3
      src/components/FlowFormFields/index.vue
  23. 5 22
      src/components/Sidebar/index.vue
  24. 51 0
      src/composables/useAddSign.ts
  25. 182 0
      src/composables/useApproval.ts
  26. 62 0
      src/composables/useFileUpload.ts
  27. 2 2
      src/main.ts
  28. 64 81
      src/router/index.ts
  29. 8 2
      src/stores/user-store.ts
  30. 1 1
      src/utils/auth.ts
  31. 43 8
      src/utils/file.ts
  32. 16 4
      src/utils/flow.ts
  33. 1 1
      src/utils/format.ts
  34. 38 0
      src/utils/permission.ts
  35. 28 0
      src/views/analysis/EfficiencyChart.vue
  36. 133 0
      src/views/analysis/KpiCards.vue
  37. 19 0
      src/views/analysis/NodeCountChart.vue
  38. 128 0
      src/views/analysis/StuckInstanceTable.vue
  39. 18 0
      src/views/analysis/TimeoutChart.vue
  40. 42 0
      src/views/analysis/TrendChart.vue
  41. 58 664
      src/views/analysis/index.vue
  42. 512 0
      src/views/analysis/useAnalysis.ts
  43. 11 33
      src/views/dashboard/index.vue
  44. 122 0
      src/views/flow/designer/DesignerCanvas.vue
  45. 164 0
      src/views/flow/designer/DesignerPropertyPanel.vue
  46. 176 0
      src/views/flow/designer/DesignerSaveDialog.vue
  47. 82 1018
      src/views/flow/designer/index.vue
  48. 839 0
      src/views/flow/designer/useFlowDesigner.ts
  49. 13 34
      src/views/flow/execute/InstanceDetail.vue
  50. 5 9
      src/views/flow/execute/index.vue
  51. 22 276
      src/views/flow/task/todo.vue
  52. 21 225
      src/views/workbench/index.vue
  53. 63 0
      tests/unit/permission.spec.ts
  54. 18 0
      tests/unit/request.spec.ts
  55. 43 0
      tests/unit/useApproval.spec.ts
  56. 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"
+}

Різницю між файлами не показано, бо вона завелика
+ 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"
   }
 }

+ 13 - 19
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,9 +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 request.get('/analysis/node-count', { params: { processDefinitionId } })
+  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 - 3
src/api/file.ts

@@ -1,8 +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)
+  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')
 }

+ 19 - 9
src/api/flow/import.ts

@@ -1,10 +1,11 @@
-import request from '@/api/request'
+import { http } from '@/api/request'
 
 /** 下载流程 Excel 导入模板 */
 export async function downloadTemplate(definitionId: number): Promise<void> {
-  const blob = (await request.get(`/flow/import/template/${definitionId}`, {
-    responseType: 'blob', timeout: 120000
-  })) as unknown as Blob
+  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
@@ -16,14 +17,23 @@ export async function downloadTemplate(definitionId: number): Promise<void> {
 }
 
 /** 批量导入流程 */
-export function importFlow(file: File, definitionId: number, mappings: Record<string, string>): Promise<{
-  total: number; successCount: number; updateCount: number; instanceIds: number[]
+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 request.post('/flow/import/execute', formData, {
-    headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120000
-  })
+  return http.post<{ total: number; successCount: number; updateCount: number; instanceIds: number[] }>(
+    '/flow/import/execute',
+    formData,
+    { headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120000 }
+  )
 }

+ 24 - 17
src/api/flow/instance.ts

@@ -1,59 +1,66 @@
-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 listInstance(params?: PageQuery & { applicantName?: string; processName?: string; currentNodeName?: string }): Promise<PageResult<FlowInstance>> {
-  return request.get('/flow/instance/page', { params })
+export function listInstance(
+  params?: PageQuery & { applicantName?: string; processName?: 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 request.post('/flow/instance/start', {
+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 request.post('/flow/instance/batch-revoke', ids)
+  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 request.post('/flow/instance/batch-delete', ids)
+  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 request.post(`/flow/instance/${id}/form-data`, {
-    formData: JSON.stringify(formData)
+  return http.post(`/flow/instance/${id}/form-data`, {
+    formData: JSON.stringify(formData),
   })
 }

+ 19 - 17
src/api/flow/task.ts

@@ -1,65 +1,67 @@
-import request from '@/api/request'
+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 & { processName?: string; formFilters?: string }): 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 request.get(`/flow/task/${taskId}/next-nodes`)
+  return http.get(`/flow/task/${taskId}/next-nodes`)
 }
 
 /** 获取可转办/加签的用户列表(按当前节点审批角色过滤) */
 export function getTransferableUsers(taskId: number): Promise<User[]> {
-  return request.get(`/flow/task/${taskId}/transferable-users`)
+  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)
 }

+ 7 - 7
src/api/system/role.ts

@@ -1,28 +1,28 @@
-import request from '@/api/request'
+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 listUsersByRole(roleId: number): Promise<User[]> {
-  return request.get(`/system/role/${roleId}/users`)
+  return http.get(`/system/role/${roleId}/users`)
 }

+ 12 - 9
src/api/system/user.ts

@@ -1,32 +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 request.put(`/system/user/${id}/roles`, { roleIds })
+  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)
 }

+ 4 - 4
src/api/wecom.ts

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

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

@@ -0,0 +1,143 @@
+<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>
+        <el-form-item 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>

+ 2 - 3
src/components/FlowFormFields/index.vue

@@ -93,7 +93,7 @@ import { computed, watch } from 'vue'
 import { ElMessage } from 'element-plus'
 import type { FormField, FormFieldOption, FlowDefinition } from '@/types/flow'
 import { downloadTemplate as downloadFlowTemplate } from '@/api/flow/import'
-import request from '@/api/request'
+import { http } from '@/api/request'
 
 const props = defineProps<{
   definition: FlowDefinition | null | undefined
@@ -188,10 +188,9 @@ async function handleExcelUpload(options: any) {
     formData2.append('file', options.file)
     formData2.append('definitionId', String(props.definition.id))
     formData2.append('mappings', JSON.stringify(mappings))
-    const res = await request.post('/flow/import/parse', formData2, {
+    const data = await http.post<Record<string, unknown>>('/flow/import/parse', formData2, {
       headers: { 'Content-Type': 'multipart/form-data' }
     })
-    const data = res as unknown as Record<string, unknown>
     formData.value = { ...formData.value, ...data }
     ElMessage.success('Excel 数据已填充')
     options.onSuccess?.()

+ 5 - 22
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,30 +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(() => {
-  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 }
+}

+ 182 - 0
src/composables/useApproval.ts

@@ -0,0 +1,182 @@
+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
+    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)

+ 64 - 81
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,76 @@ const router = createRouter({
           path: 'dashboard',
           name: 'Dashboard',
           component: () => import('@/views/dashboard/index.vue'),
-          meta: { title: '首页', icon: 'HomeFilled' }
-        }
-      ]
+          meta: { title: '首页', icon: 'HomeFilled' },
+        },
+      ],
     },
     {
       path: '/workbench',
       component: () => import('@/components/Layout/index.vue'),
-      meta: { title: '工作台', icon: 'Monitor' },
+      meta: { title: '工作台', icon: 'Monitor', permissions: ['flow_manager', 'super_admin'] },
       children: [
         {
           path: '',
           name: 'Workbench',
           component: () => import('@/views/workbench/index.vue'),
-          meta: { title: '智能工作台', icon: 'Monitor' }
-        }
-      ]
+          meta: { title: '智能工作台', icon: 'Monitor' },
+        },
+      ],
     },
     {
       path: '/analysis',
       component: () => import('@/components/Layout/index.vue'),
       redirect: '/analysis/flow',
-      meta: { title: '数据看板', icon: 'TrendCharts' },
+      meta: { title: '数据看板', icon: 'TrendCharts', permissions: ['flow_manager', 'super_admin'] },
       children: [
         {
           path: 'flow',
           name: 'FlowAnalysis',
           component: () => import('@/views/analysis/index.vue'),
-          meta: { title: '流程分析看板', icon: 'TrendCharts' }
-        }
-      ]
+          meta: { title: '流程分析看板', icon: 'TrendCharts' },
+        },
+      ],
     },
     {
       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,103 +121,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 }
-        }
-      ]
-    }
-  ]
+          meta: { title: '流程明细', icon: 'Document', hidden: true },
+        },
+      ],
+    },
+  ],
 })
 
 const publicPaths = new Set(['/login'])
 
-// 按员工类型限制的路径
-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
+
+  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
+        })
       }
-    }
-    // 按 employeeType 限制
-    const employeeType = userStore.userInfo?.employeeType || 'common_user'
-    const allowedPaths = permissionPaths[employeeType] || permissionPaths['common_user']
-    if (!isPathAllowed(allowedPaths, to.path)) {
-      next('/dashboard')
+      await fetchingUserInfo
+    } catch {
+      next(`/login?redirect=${to.path}`)
       return
+    } finally {
+      fetchingUserInfo = null
     }
-    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 - 2
src/stores/user-store.ts

@@ -1,6 +1,6 @@
 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'
 
@@ -23,6 +23,11 @@ export const useUserStore = defineStore('user', () => {
     return res
   }
 
+  function setToken(newToken: string) {
+    token.value = newToken
+    setTokenCookie(newToken)
+  }
+
   async function logoutAction() {
     try {
       await logout()
@@ -42,6 +47,7 @@ export const useUserStore = defineStore('user', () => {
     isLoggedIn,
     loginAction,
     fetchUserInfo,
-    logoutAction
+    logoutAction,
+    setToken,
   }
 })

+ 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'
 }

+ 1 - 1
src/utils/format.ts

@@ -2,7 +2,7 @@ const employeeTypeMap: Record<string, string> = {
   common_user: '普通用户',
   dept_manager: '部门运维',
   flow_manager: '流程运维',
-  super_admin: '管理员'
+  super_admin: '管理员',
 }
 
 export function employeeTypeLabel(type?: string): string {

+ 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">
+    <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>

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

@@ -0,0 +1,128 @@
+<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.processName" placeholder="流程名称" clearable style="width: 160px;" @change="onProcessChange">
+            <el-option v-for="def in definitionList" :key="def.id" :label="def.name" :value="def.name" />
+          </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
+    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>

+ 58 - 664
src/views/analysis/index.vue

@@ -35,161 +35,38 @@
       </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="loadingNodeCount">
-          <template #header>
-            <span>各节点统计</span>
-          </template>
-          <v-chart v-if="nodeCountData.length > 0" :option="nodeCountOption" autoresize style="height: 60px;" :style="{ height: Math.max(nodeCountData.length * 36, 200) + 'px' }" />
-          <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="loadingInstanceList">
-      <template #header>
-        <div class="detail-header">
-          <span>流程明细</span>
-          <div class="detail-actions">
-            <el-select v-model="instanceQuery.processName" placeholder="流程名称" clearable style="width: 160px;" @change="onInstanceProcessChange">
-              <el-option v-for="def in definitionList" :key="def.id" :label="def.name" :value="def.name" />
-            </el-select>
-            <el-input v-model="instanceQuery.applicantName" placeholder="归属用户" clearable style="width: 140px; margin-left: 8px;" @change="loadInstanceList" />
-            <el-select v-model="instanceQuery.currentNodeName" placeholder="当前节点" clearable style="width: 140px; margin-left: 8px;" @change="loadInstanceList">
-              <el-option v-for="node in currentNodeOptions" :key="node" :label="node" :value="node" />
-            </el-select>
-            <el-button type="primary" @click="loadInstanceList" 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="openInstanceDetail(row.id)">详情</el-button>
-          </template>
-        </el-table-column>
-      </el-table>
-
-      <el-pagination
-        v-model:current-page="instanceQuery.pageNum"
-        v-model:page-size="instanceQuery.pageSize"
-        :total="instanceTotal"
-        :page-sizes="[10, 20, 50]"
-        layout="total, sizes, prev, pager, next, jumper"
-        class="pagination"
-        @size-change="loadInstanceList"
-        @current-change="loadInstanceList"
-      />
-    </el-card>
+    <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" />
 
@@ -197,440 +74,50 @@
 </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 } from 'echarts/charts'
-import { GridComponent, TooltipComponent, LegendComponent, TitleComponent, DataZoomComponent } from 'echarts/components'
-import VChart from 'vue-echarts'
-import {
-  getCompletedEfficiency,
-  getInProgressByNode,
-  getStuckInstances,
-  getOverview,
-  getStatusDistribution,
-  getNodeCount
-} from '@/api/analysis'
-import { listEnabled } from '@/api/flow/definition'
-import { listInstance } from '@/api/flow/instance'
-import { instanceStatusText, instanceStatusTagType } from '@/utils/flow'
+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'
-import type { FlowInstance } from '@/types/flow'
-import type {
-  ProcessEfficiency,
-  NodeStayStat,
-  StuckInstance,
-  AnalysisOverview,
-  StatusDistribution
-} from '@/types/analysis'
-import type { FlowDefinition } from '@/types/flow'
-
-use([
-  CanvasRenderer,
-  BarChart,
-  PieChart,
-  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 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 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
-  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 + '小时' : ''}`
+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 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]
-        }
-      }
-    ]
-  }
-}
-
-// 流程明细
-const instanceQuery = reactive({ pageNum: 1, pageSize: 10, 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)
-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,
-      processName: instanceQuery.processName || undefined,
-      applicantName: instanceQuery.applicantName || undefined,
-      currentNodeName: instanceQuery.currentNodeName || undefined
-    })
-    instanceList.value = res.list
-    instanceTotal.value = res.total
-  } finally { loadingInstanceList.value = false }
-}
-
-// 各节点统计图
-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 }
-  }]
-}))
-
-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()
-})
 </script>
 
 <style scoped>
@@ -640,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>

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

@@ -0,0 +1,512 @@
+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, 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()
+    } 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
+    stuckQuery.nodeId = node.nodeId
+    nextTick(() => loadStuck(1))
+    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,
+        processName: 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,
+  }
+}

+ 11 - 33
src/views/dashboard/index.vue

@@ -92,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>
@@ -108,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>
@@ -123,7 +123,7 @@
     </el-dialog>
 
     <!-- 附件预览 -->
-    <FilePreview v-model="previewVisible" :url="previewUrl" />
+    <FilePreview v-model="fileUpload.previewVisible" :url="fileUpload.previewUrl" />
   </div>
 </template>
 
@@ -141,8 +141,8 @@ import { listMyInstance, startInstance } from '@/api/flow/instance'
 import { listDefinition, listEnabled } from '@/api/flow/definition'
 import { listUser } from '@/api/system/user'
 import { getOverview } from '@/api/analysis'
-import { uploadFile } from '@/api/file'
-import { beforeFileUpload, collectAttachmentUrls, getUploadUrl } from '@/utils/file'
+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'
@@ -218,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' }]
@@ -291,19 +278,10 @@ 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
@@ -329,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('流程发起成功')

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

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

@@ -0,0 +1,164 @@
+<template>
+  <div v-if="selectedNode" class="property-panel">
+    <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>
+          <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>
+      </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 - 1018
src/views/flow/designer/index.vue


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

@@ -0,0 +1,839 @@
+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',
+      }
+    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 === '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'
+    } 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) ||
+          (e.properties.isDefault ? '默认' : '')
+        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,
+  }
+}

+ 13 - 34
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>
@@ -57,7 +57,7 @@
         <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>
@@ -139,14 +139,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>
                 </el-upload>
@@ -187,7 +187,7 @@
                   v-for="(url, idx) in parseAttachments(record.attachmentUrls)"
                   :key="idx"
                   type="primary"
-                  @click="openPreview(url)"
+                  @click="fileUpload.openPreview(url)"
                   size="small"
                 >
                   附件{{ idx + 1 }}: {{ getFileName(url) }}
@@ -223,8 +223,8 @@ import { ref, reactive, computed, watch } from 'vue'
 import { ElMessage } from 'element-plus'
 import { getProgress, getInstanceAttachments, updateInstanceFormData } from '@/api/flow/instance'
 import { approveTask, rejectTask, returnTask, addSignTask, getTransferableUsers } from '@/api/flow/task'
-import { uploadFile } from '@/api/file'
-import { beforeFileUpload, getFileUrl, getFileName, getUploadUrl, parseAttachments, collectAttachmentUrls } from '@/utils/file'
+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'
@@ -249,8 +249,6 @@ 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'
@@ -263,7 +261,7 @@ const form = reactive<{
   comment: ''
 })
 
-const attachmentList = ref<any[]>([])
+const fileUpload = reactive(useFileUpload())
 const submitting = ref(false)
 const addSignDialogVisible = ref(false)
 const addSignUserId = ref<number | undefined>(undefined)
@@ -308,29 +306,10 @@ 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 myPendingTask = computed<FlowTask | null>(() => {
   if (!progress.value) return null
@@ -384,8 +363,8 @@ async function submitApprove() {
       action: form.action,
       comment: form.comment
     }
-    if (attachmentList.value.length > 0) {
-      data.attachmentUrls = collectAttachmentUrls(attachmentList.value)
+    if (fileUpload.attachmentList.length > 0) {
+      data.attachmentUrls = collectAttachmentUrls(fileUpload.attachmentList)
     }
     switch (form.action) {
       case 'pass':
@@ -401,7 +380,7 @@ async function submitApprove() {
     ElMessage.success('审批提交成功')
     form.action = 'pass'
     form.comment = ''
-    attachmentList.value = []
+    fileUpload.resetFileUpload()
     // 刷新进度展示最新状态,不关闭抽屉
     await loadProgress()
     emit('approved')
@@ -412,7 +391,7 @@ async function submitApprove() {
 
 function handleClose() {
   progress.value = null
-  attachmentList.value = []
+  fileUpload.resetFileUpload()
   attachments.value = []
   cancelEditForm()
 }

+ 5 - 9
src/views/flow/execute/index.vue

@@ -42,7 +42,7 @@
           <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>
+          <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>
           </el-upload>
         </el-form-item>
@@ -118,8 +118,8 @@ import type { FormInstance, FormRules } from 'element-plus'
 import { View } from '@element-plus/icons-vue'
 import { listEnabled } from '@/api/flow/definition'
 import { startInstance } from '@/api/flow/instance'
-import { uploadFile } from '@/api/file'
 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'
@@ -133,7 +133,7 @@ const startFormRef = ref<FormInstance>()
 const startForm = ref({ processDefinitionId: 0 as number, definitionName: '', title: '', formData: '' })
 const dynamicFormData = ref<Record<string, unknown>>({})
 const selectedDefinition = ref<FlowDefinition | null>(null)
-const attachmentList = ref<any[]>([])
+const fileUpload = reactive(useFileUpload())
 const startFormRules: FormRules = { title: [{ required: true, message: '请输入标题', trigger: 'blur' }] }
 const hasFormFields = computed(() => {
   const s = selectedDefinition.value?.formSchema
@@ -205,14 +205,10 @@ async function loadDefinitions() {
 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 = []
+  selectedDefinition.value = def; fileUpload.resetFileUpload()
   startDialogVisible.value = true
 }
 
-async function handleUpload(options: any) {
-  try { const r = await uploadFile(options.file); options.onSuccess(r) } catch (e) { options.onError(e) }
-}
-
 async function submitStart() {
   const v = await startFormRef.value?.validate().catch(() => false)
   if (!v) return
@@ -229,7 +225,7 @@ async function submitStart() {
         }
       })
     }
-    const au = attachmentList.value.length > 0 ? collectAttachmentUrls(attachmentList.value) : undefined
+    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 }

+ 22 - 276
src/views/flow/task/todo.vue

@@ -175,90 +175,11 @@
       />
     </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="batchMode && currentTasks.length > 0"
-          :title="`将对 ${currentTasks.length} 个待办任务执行「${drawerActionText}」操作`"
-          type="info"
-          :closable="false"
-          show-icon
-          class="batch-alert"
-        />
-        <el-alert
-          v-else-if="!batchMode && 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="!batchMode && 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.branchName || n.nodeName"
-                :value="n.nodeId"
-              />
-            </el-select>
-          </el-form-item>
-          <el-form-item label="附件">
-            <div v-if="!batchMode && 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
@@ -267,9 +188,6 @@
       @approved="loadData"
     />
 
-    <!-- 附件预览 -->
-    <FilePreview v-model="previewVisible" :url="previewUrl" />
-
     <!-- 表单字段筛选抽屉 -->
     <el-drawer
       v-model="filterDrawerVisible"
@@ -336,19 +254,14 @@
 import { ref, reactive, onMounted, computed } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Check, Close, Back, Plus, View, ArrowDown, Filter } from '@element-plus/icons-vue'
-import { listTodo, approveTask, rejectTask, returnTask, addSignTask, batchApproveTask, batchRejectTask, listNextNodes, getTransferableUsers } from '@/api/flow/task'
+import { listTodo } from '@/api/flow/task'
 import { listEnabled } from '@/api/flow/definition'
-import { uploadFile } from '@/api/file'
-import { getInstanceAttachments } from '@/api/flow/instance'
-import { beforeFileUpload, getUploadUrl, getFileName } from '@/utils/file'
-import type { FlowTask, ApprovalAction, Attachment, NextNode, FormField, FormFieldOption, FlowDefinition } from '@/types/flow'
-import { useUserStore } from '@/stores/user-store'
-import type { User } from '@/types/system'
+import type { FlowTask, FormField, FormFieldOption, FlowDefinition } from '@/types/flow'
 import type { TableInstance } from 'element-plus'
 import InstanceDetail from '@/views/flow/execute/InstanceDetail.vue'
-import FilePreview from '@/components/FilePreview/index.vue'
+import ApprovalDrawer from '@/components/ApprovalDrawer/index.vue'
+import AddSignDialog from '@/components/AddSignDialog/index.vue'
 
-const userStore = useUserStore()
 const loading = ref(false)
 const tableData = ref<FlowTask[]>([])
 const total = ref(0)
@@ -538,8 +451,8 @@ function getFieldDisplayValue(row: FlowTask, field: FormField): string {
   return formatValue(value)
 }
 
-const userList = ref<User[]>([])
-const nextNodes = ref<NextNode[]>([])
+const approvalDrawerRef = ref<InstanceType<typeof ApprovalDrawer> | null>(null)
+const addSignDialogRef = ref<InstanceType<typeof AddSignDialog> | null>(null)
 
 // 选择相关
 const selectedIds = ref<number[]>([])
@@ -553,67 +466,8 @@ function clearSelection() {
   tableRef.value?.clearSelection()
 }
 
-// 抽屉审批相关
-const approveDrawerVisible = ref(false)
-const batchMode = ref(false)
-const currentTask = ref<FlowTask | null>(null)
-const currentTasks = ref<FlowTask[]>([])
-const submitting = ref(false)
-const form = reactive<ApprovalAction>({
-  action: 'pass',
-  comment: ''
-})
-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: '拒绝', rollback: '回退' }
-  return map[form.action] || '审批'
-})
-
-const drawerTitle = computed(() => {
-  if (batchMode.value) return `批量${drawerActionText.value}`
-  return `${drawerActionText.value}审批`
-})
-
-function resetApproveForm() {
-  form.action = 'pass'
-  form.comment = ''
-  attachmentList.value = []
-  previousAttachments.value = []
-  currentTask.value = null
-  currentTasks.value = []
-  batchMode.value = false
-}
-
-function buildActionData(): ApprovalAction {
-  const data: ApprovalAction = {
-    action: form.action,
-    comment: form.comment
-  }
-  if (form.action === 'pass' && form.targetNodeId) {
-    data.targetNodeId = form.targetNodeId
-  }
-  if (attachmentList.value.length > 0) {
-    data.attachmentUrls = JSON.stringify(attachmentList.value.map((f: any) => f.response || f.url).filter(Boolean))
-  }
-  return data
-}
-
-async function openQuickApprove(row: FlowTask, action: 'pass' | 'reject') {
-  resetApproveForm()
-  currentTask.value = row
-  form.action = action
-  batchMode.value = false
-  if (action === 'pass') {
-    try { nextNodes.value = await listNextNodes(row.id) } catch { nextNodes.value = [] }
-  } else {
-    nextNodes.value = []
-  }
-  await loadPreviousAttachments(row.instanceId)
-  approveDrawerVisible.value = true
+function openQuickApprove(row: FlowTask, action: 'pass' | 'reject') {
+  approvalDrawerRef.value?.open(row, action)
 }
 
 function openBatchDrawer(action: 'pass' | 'reject') {
@@ -621,98 +475,17 @@ function openBatchDrawer(action: 'pass' | 'reject') {
     ElMessage.warning('请先选择待办任务')
     return
   }
-  resetApproveForm()
-  currentTasks.value = tableData.value.filter(r => selectedIds.value.includes(r.id))
-  form.action = action
-  batchMode.value = true
-  approveDrawerVisible.value = true
-}
-
-async function openRollback(row: FlowTask) {
-  resetApproveForm()
-  currentTask.value = row
-  form.action = 'rollback'
-  batchMode.value = false
-  nextNodes.value = []
-  await loadPreviousAttachments(row.instanceId)
-  approveDrawerVisible.value = true
-}
-
-async function submitApprove() {
-  if (batchMode.value) {
-    if (selectedIds.value.length === 0) return
-    submitting.value = true
-    try {
-      const data = buildActionData()
-      if (form.action === 'pass') {
-        await batchApproveTask(selectedIds.value, data)
-      } else if (form.action === 'reject') {
-        await batchRejectTask(selectedIds.value, data)
-      }
-      ElMessage.success('批量审批成功')
-      approveDrawerVisible.value = false
-      selectedIds.value = []
-      loadData()
-    } finally {
-      submitting.value = false
-    }
-    return
-  }
-
-  if (!currentTask.value) return
-  submitting.value = true
-  try {
-    const data = buildActionData()
-    const taskId = currentTask.value.id
-    switch (form.action) {
-      case 'pass':
-        await approveTask(taskId, data)
-        break
-      case 'reject':
-        await rejectTask(taskId, data)
-        break
-      case 'rollback':
-        await returnTask(taskId, data)
-        break
-    }
-    ElMessage.success('审批成功')
-    approveDrawerVisible.value = false
-    loadData()
-  } finally {
-    submitting.value = false
-  }
+  const selectedTasks = tableData.value.filter(r => selectedIds.value.includes(r.id))
+  approvalDrawerRef.value?.open(selectedTasks, action)
 }
 
-async function handleUpload(options: any) {
-  try {
-    const res = await uploadFile(options.file)
-    options.onSuccess(res)
-  } catch (e) {
-    options.onError(e)
-  }
+function openRollback(row: FlowTask) {
+  approvalDrawerRef.value?.open(row, 'rollback')
 }
 
-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() {
+  selectedIds.value = []
+  loadData()
 }
 
 // 详情抽屉
@@ -728,39 +501,12 @@ function handleDetail(row: FlowTask) {
   detailVisible.value = true
 }
 
-// 加签
-const addSignVisible = ref(false)
-const addSignTaskId = ref<number>(0)
-const addSignUserId = ref<number | undefined>(undefined)
-const addSignLoading = ref(false)
-
-async function handleAddSign(row: FlowTask) {
-  addSignTaskId.value = row.id
-  addSignUserId.value = undefined
-  // 加签选人:按当前节点审批角色过滤可选用户,已自动排除当前登录人
-  try {
-    userList.value = await getTransferableUsers(row.id)
-  } catch {
-    userList.value = []
-  }
-  addSignVisible.value = true
+function handleAddSign(row: FlowTask) {
+  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
-  }
+function onAddSigned() {
+  loadData()
 }
 
 function handleMoreAction(cmd: string, row: FlowTask) {

+ 21 - 225
src/views/workbench/index.vue

@@ -168,88 +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 === '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.branchName || n.nodeName"
-                :value="n.nodeId"
-              />
-            </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>
 
@@ -258,21 +184,16 @@ import { ref, reactive, onMounted, computed } from 'vue'
 import { useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
 import { Check, Close, Back, Plus, View } from '@element-plus/icons-vue'
-import { listTodo, approveTask, rejectTask, returnTask, addSignTask, listNextNodes, getTransferableUsers } from '@/api/flow/task'
-import { listHandled, listCc } from '@/api/flow/task'
-import { listMyInstance, getInstanceAttachments } from '@/api/flow/instance'
-import { uploadFile } from '@/api/file'
+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, NextNode } 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 { useUserStore } from '@/stores/user-store'
+import ApprovalDrawer from '@/components/ApprovalDrawer/index.vue'
+import AddSignDialog from '@/components/AddSignDialog/index.vue'
 
-const userStore = useUserStore()
 const router = useRouter()
 const activeTab = ref<'todo' | 'overdue' | 'warning' | 'handled' | 'cc' | 'mine'>('todo')
 const loading = ref(false)
@@ -281,8 +202,8 @@ const handledList = ref<FlowTask[]>([])
 const ccList = ref<FlowTask[]>([])
 const mineList = ref<FlowInstance[]>([])
 const total = ref(0)
-const userList = ref<User[]>([])
-const nextNodes = ref<NextNode[]>([])
+const approvalDrawerRef = ref<InstanceType<typeof ApprovalDrawer> | null>(null)
+const addSignDialogRef = ref<InstanceType<typeof AddSignDialog> | null>(null)
 
 const queryParams = reactive({
   pageNum: 1,
@@ -353,115 +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>({
-  action: 'pass',
-  comment: ''
-})
-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: '拒绝', rollback: '回退' }
-  return map[form.action] || '审批'
-})
-
-const drawerTitle = computed(() => `${drawerActionText.value}审批`)
-
-function resetApproveForm() {
-  form.action = 'pass'
-  form.comment = ''
-  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 === 'pass' && form.targetNodeId) {
-    data.targetNodeId = form.targetNodeId
-  }
-  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
-  if (action === 'pass') {
-    try { nextNodes.value = await listNextNodes(row.id) } catch { nextNodes.value = [] }
-  } else {
-    nextNodes.value = []
-  }
-  await loadPreviousAttachments(row.instanceId)
-  approveDrawerVisible.value = true
-}
-
-async function openRollback(row: FlowTask) {
-  resetApproveForm()
-  currentTask.value = row
-  form.action = 'rollback'
-  nextNodes.value = []
-  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 === 'rollback') await returnTask(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()
 }
 
 // 详情
@@ -482,39 +305,12 @@ 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)
-
-async function handleAddSign(row: FlowTask) {
-  addSignTaskId.value = row.id
-  addSignUserId.value = undefined
-  // 加签选人:按当前节点审批角色过滤可选用户,已自动排除当前登录人
-  try {
-    userList.value = await getTransferableUsers(row.id)
-  } catch {
-    userList.value = []
-  }
-  addSignVisible.value = true
+function handleAddSign(row: FlowTask) {
+  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
-  }
+function onAddSigned() {
+  loadData()
 }
 
 onMounted(() => {

+ 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.*']
+    }
+  }
+})

Деякі файли не було показано, через те що забагато файлів було змінено