Prechádzať zdrojové kódy

feat: 附件持久化、历史附件查看、预览与待办权限提示优化

ye-zhaojia 4 dní pred
rodič
commit
2d43833ed4
51 zmenil súbory, kde vykonal 5662 pridanie a 627 odobranie
  1. 2 1
      Dockerfile
  2. 1 1
      README.md
  3. 15 0
      gateway/nginx.conf
  4. 549 1
      package-lock.json
  5. 6 1
      package.json
  6. 62 0
      src/api/analysis.ts
  7. 4 0
      src/api/auth.ts
  8. 38 0
      src/api/file.ts
  9. 3 2
      src/api/flow/definition.ts
  10. 18 4
      src/api/flow/instance.ts
  11. 8 0
      src/api/flow/task.ts
  12. 69 6
      src/api/request.ts
  13. 16 0
      src/api/system/notification-config.ts
  14. 4 0
      src/api/system/role.ts
  15. 7 2
      src/api/system/user.ts
  16. 189 0
      src/components/FilePreview/index.vue
  17. 244 0
      src/components/FlowFormFields/index.vue
  18. 123 0
      src/components/FormDataDisplay/index.vue
  19. 2 15
      src/components/Layout/index.vue
  20. 13 3
      src/components/Navbar/index.vue
  21. 14 15
      src/components/Sidebar/index.vue
  22. 46 3
      src/main.ts
  23. 82 38
      src/router/index.ts
  24. 0 4
      src/stores/user-store.ts
  25. 23 1
      src/style.css
  26. 75 0
      src/types/analysis.ts
  27. 48 2
      src/types/flow.ts
  28. 5 0
      src/types/system.ts
  29. 7 1
      src/utils/auth.ts
  30. 132 0
      src/utils/file.ts
  31. 35 0
      src/utils/flow.ts
  32. 21 0
      src/utils/format.ts
  33. 728 0
      src/views/analysis/index.vue
  34. 352 22
      src/views/dashboard/index.vue
  35. 22 0
      src/views/error/404.vue
  36. 194 10
      src/views/flow/definition/index.vue
  37. 312 114
      src/views/flow/designer/index.vue
  38. 183 140
      src/views/flow/execute/InstanceDetail.vue
  39. 99 45
      src/views/flow/execute/index.vue
  40. 47 34
      src/views/flow/instance/mine.vue
  41. 139 0
      src/views/flow/task/cc.vue
  42. 4 1
      src/views/flow/task/handled.vue
  43. 572 122
      src/views/flow/task/todo.vue
  44. 2 3
      src/views/login/index.vue
  45. 93 0
      src/views/profile/index.vue
  46. 132 0
      src/views/system/notification-config/index.vue
  47. 106 14
      src/views/system/role/index.vue
  48. 12 20
      src/views/system/user/index.vue
  49. 128 0
      src/views/workbench/components/TaskCard.vue
  50. 656 0
      src/views/workbench/index.vue
  51. 20 2
      vite.config.ts

+ 2 - 1
Dockerfile

@@ -1,8 +1,9 @@
 FROM node:18-alpine AS build
 WORKDIR /app
 RUN npm config set registry https://registry.npmmirror.com
+COPY package*.json ./
+RUN npm ci --legacy-peer-deps
 COPY . .
-RUN npm install --legacy-peer-deps
 RUN npx vite build
 
 FROM nginx:1.27-alpine

+ 1 - 1
README.md

@@ -4,7 +4,7 @@
 
 ## 技术栈
 
-- Vue 3.4 + Vite 5
+- Vue 3.4 + Vite 4.5
 - TypeScript 5
 - Element Plus 2.7
 - Pinia

+ 15 - 0
gateway/nginx.conf

@@ -2,6 +2,9 @@ server {
     listen       80;
     server_name  localhost;
 
+    client_max_body_size 50M;
+
+    # 后端 API 代理
     location /api/ {
         proxy_pass http://qqflow-backend-test:8080/;
         proxy_set_header Host $host;
@@ -10,11 +13,23 @@ server {
         proxy_set_header X-Forwarded-Proto $scheme;
     }
 
+    # 文件上传/下载代理
+    location /uploads/ {
+        proxy_pass http://qqflow-backend-test:8080/uploads/;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+    }
+
+    # 前端静态资源 + SPA fallback
     location / {
         proxy_pass http://frontend:80;
         proxy_set_header Host $host;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
         proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_intercept_errors on;
+        error_page 404 = /index.html;
     }
 }

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 549 - 1
package-lock.json


+ 6 - 1
package.json

@@ -13,11 +13,16 @@
     "@logicflow/core": "^1.2.27",
     "@logicflow/extension": "^1.2.27",
     "axios": "^1.7.2",
+    "dompurify": "^3.4.10",
+    "echarts": "^6.1.0",
     "element-plus": "^2.7.6",
     "js-cookie": "^3.0.5",
+    "mammoth": "^1.12.0",
     "pinia": "^2.1.7",
     "vue": "^3.4.31",
-    "vue-router": "^4.4.0"
+    "vue-echarts": "^8.0.1",
+    "vue-router": "^4.4.0",
+    "xlsx": "^0.18.5"
   },
   "devDependencies": {
     "@types/js-cookie": "^3.0.6",

+ 62 - 0
src/api/analysis.ts

@@ -0,0 +1,62 @@
+import request from '@/api/request'
+import type { PageQuery, PageResult } from '@/types/api'
+import type { AxiosRequestConfig } from 'axios'
+import type {
+  ProcessEfficiency,
+  NodeStayStat,
+  StuckInstance,
+  AnalysisOverview,
+  StatusDistribution,
+  Trend,
+  EfficiencyQuery,
+  OverviewQuery,
+  StuckInstanceQuery
+} from '@/types/analysis'
+
+export function getOverview(
+  params?: OverviewQuery,
+  config?: AxiosRequestConfig
+): Promise<AnalysisOverview> {
+  return request.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 })
+}
+
+export function getTrend(
+  processDefinitionId?: number,
+  config?: AxiosRequestConfig
+): Promise<Trend[]> {
+  return request.get('/analysis/trend', {
+    params: processDefinitionId !== undefined ? { processDefinitionId } : undefined,
+    ...config
+  })
+}
+
+export function getCompletedEfficiency(
+  params: EfficiencyQuery,
+  config?: AxiosRequestConfig
+): Promise<ProcessEfficiency[]> {
+  return request.get('/analysis/completed-efficiency', { params, ...config })
+}
+
+export function getInProgressByNode(
+  processDefinitionId?: number,
+  config?: AxiosRequestConfig
+): Promise<NodeStayStat[]> {
+  return request.get('/analysis/in-progress-by-node', {
+    params: processDefinitionId !== undefined ? { processDefinitionId } : undefined,
+    ...config
+  })
+}
+
+export function getStuckInstances(
+  params: StuckInstanceQuery & PageQuery,
+  config?: AxiosRequestConfig
+): Promise<PageResult<StuckInstance>> {
+  return request.get('/analysis/stuck-instances', { params, ...config })
+}

+ 4 - 0
src/api/auth.ts

@@ -13,3 +13,7 @@ export function logout(): Promise<void> {
 export function getUserInfo(): Promise<User> {
   return request.get('/auth/info')
 }
+
+export function changePassword(oldPassword: string, newPassword: string): Promise<void> {
+  return request.post('/auth/change-password', { oldPassword, newPassword })
+}

+ 38 - 0
src/api/file.ts

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

+ 3 - 2
src/api/flow/definition.ts

@@ -1,9 +1,10 @@
 import request 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>): Promise<PageResult<FlowDefinition>> {
-  return request.get('/flow/definition/page', { params })
+export function listDefinition(params?: PageQuery & Partial<FlowDefinition>, config?: AxiosRequestConfig): Promise<PageResult<FlowDefinition>> {
+  return request.get('/flow/definition/page', { params, ...config })
 }
 
 export function getDefinition(id: number): Promise<FlowDefinition> {

+ 18 - 4
src/api/flow/instance.ts

@@ -1,21 +1,27 @@
 import request from '@/api/request'
-import type { FlowInstance, ProcessProgress } from '@/types/flow'
+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 })
 }
 
 export function startInstance(processDefinitionId: number, title?: string, formData?: Record<string, unknown>, attachmentUrls?: string): Promise<void> {
-  return request.post('/flow/instance/start', { processDefinitionId, title, formData, attachmentUrls })
+  return request.post('/flow/instance/start', {
+    processDefinitionId,
+    title,
+    formData: formData ? JSON.stringify(formData) : undefined,
+    attachmentUrls
+  })
 }
 
 export function getInstance(id: number): Promise<FlowInstance> {
   return request.get(`/flow/instance/${id}`)
 }
 
-export function getProgress(id: number): Promise<ProcessProgress> {
-  return request.get(`/flow/instance/${id}/progress`)
+export function getProgress(id: number, config?: AxiosRequestConfig): Promise<ProcessProgress> {
+  return request.get(`/flow/instance/${id}/progress`, config)
 }
 
 export function participatedList(params?: PageQuery): Promise<PageResult<FlowInstance>> {
@@ -25,3 +31,11 @@ export function participatedList(params?: PageQuery): Promise<PageResult<FlowIns
 export function revokeInstance(id: number): Promise<void> {
   return request.post(`/flow/instance/${id}/revoke`)
 }
+
+export function deleteInstance(id: number): Promise<void> {
+  return request.delete(`/flow/instance/${id}`)
+}
+
+export function getInstanceAttachments(id: number): Promise<Attachment[]> {
+  return request.get(`/flow/instance/${id}/attachments`)
+}

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

@@ -22,6 +22,14 @@ export function rejectTask(id: number, data: ApprovalAction): Promise<void> {
   return request.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 })
+}
+
+export function batchRejectTask(taskIds: number[], data: ApprovalAction): Promise<void> {
+  return request.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)
 }

+ 69 - 6
src/api/request.ts

@@ -1,19 +1,42 @@
 import axios, { type AxiosInstance, type AxiosError, type InternalAxiosRequestConfig } from 'axios'
 import { ElMessage } from 'element-plus'
-import { getToken } from '@/utils/auth'
+import { getToken, removeToken } from '@/utils/auth'
 import type { ApiResponse } from '@/types'
 
+// 扩展 axios 请求配置,支持静默模式(页面初始化查询失败时不弹全局错误提示)
+declare module 'axios' {
+  interface AxiosRequestConfig {
+    silent?: boolean
+  }
+}
+
+const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
+
 const request: AxiosInstance = axios.create({
-  baseURL: '/api',
+  baseURL: API_BASE_URL,
   timeout: 10000
 })
 
+function getRequestTimeout(config: InternalAxiosRequestConfig): number {
+  if (config.url?.includes('/file/upload')) {
+    return 120000
+  }
+  if (config.method === 'post' && config.data && typeof config.data === 'object') {
+    const payload = JSON.stringify(config.data)
+    if (payload.length > 1024 * 50) {
+      return 30000
+    }
+  }
+  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) => {
@@ -21,23 +44,63 @@ request.interceptors.request.use(
   }
 )
 
+let hasShownAuthError = false
+
 request.interceptors.response.use(
   (response) => {
+    // 二进制流直接返回,不做统一 code 校验
+    if (response.config.responseType === 'blob' || response.config.responseType === 'arraybuffer') {
+      return response.data as any
+    }
     const res = response.data as ApiResponse<unknown>
     if (res.code !== 200) {
-      ElMessage.error(res.msg || '请求失败')
+      // 业务码非 200 时,如果请求标记为 silent 则不弹全局提示,由调用方处理
+      if (!response.config.silent) {
+        ElMessage.error(res.msg || '请求失败')
+      }
       if (res.code === 401) {
-        window.location.href = '/login'
+        handleUnauthorized()
       }
       return Promise.reject(new Error(res.msg || '请求失败'))
     }
     return res.data as any
   },
   (error: AxiosError) => {
-    const msg = (error.response?.data as any)?.msg || error.message || '网络错误'
-    ElMessage.error(msg)
+    const status = error.response?.status
+    const data = error.response?.data as any
+    const silent = error.config?.silent ?? false
+    let msg = data?.msg || error.message || '网络错误'
+
+    if (status === 401) {
+      msg = '登录已过期,请重新登录'
+      handleUnauthorized()
+    } else if (status === 403) {
+      msg = '无权访问该资源'
+    } else if (typeof status === 'number' && status >= 500) {
+      msg = '服务器繁忙,请稍后重试'
+    } else if (error.code === 'ECONNABORTED') {
+      msg = '请求超时,请稍后重试'
+    }
+
+    // silent 模式下不弹全局 Message,把错误信息附加到 error 上供调用方决定
+    if (!silent && (!hasShownAuthError || status !== 401)) {
+      ElMessage.error(msg)
+    }
+    if (silent && error.response) {
+      ;(error as any).displayMsg = msg
+    }
     return Promise.reject(error)
   }
 )
 
+function handleUnauthorized() {
+  if (hasShownAuthError) return
+  hasShownAuthError = true
+  removeToken()
+  setTimeout(() => {
+    window.location.href = '/login'
+    hasShownAuthError = false
+  }, 500)
+}
+
 export default request

+ 16 - 0
src/api/system/notification-config.ts

@@ -0,0 +1,16 @@
+import request from '@/api/request'
+
+export interface WeComConfig {
+  corpId?: string
+  agentId?: string
+  secret?: string
+  enabled?: number
+}
+
+export function getWeComConfig(): Promise<WeComConfig> {
+  return request.get('/system/notification-config/wecom')
+}
+
+export function saveWeComConfig(data: WeComConfig): Promise<void> {
+  return request.put('/system/notification-config/wecom', data)
+}

+ 4 - 0
src/api/system/role.ts

@@ -21,3 +21,7 @@ export function updateRole(data: Partial<Role>): Promise<void> {
 export function deleteRole(id: number): Promise<void> {
   return request.delete(`/system/role/${id}`)
 }
+
+export function bindRoleWeCom(id: number, data: { wecomUserId?: string; wecomRemindEnabled?: number }): Promise<void> {
+  return request.put(`/system/role/${id}/wecom`, data)
+}

+ 7 - 2
src/api/system/user.ts

@@ -1,9 +1,10 @@
 import request 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 }): Promise<PageResult<User>> {
-  return request.get('/system/user/list', { params })
+export function listUser(params?: PageQuery & Partial<User> & { deptId?: number }, config?: AxiosRequestConfig): Promise<PageResult<User>> {
+  return request.get('/system/user/list', { params, ...config })
 }
 
 export function getUser(id: number): Promise<User> {
@@ -21,3 +22,7 @@ export function updateUser(data: Partial<User>): Promise<void> {
 export function deleteUser(id: number): Promise<void> {
   return request.delete(`/system/user/${id}`)
 }
+
+export function bindWeCom(id: number, data: { wecomUserId?: string; wecomRemindEnabled?: number }): Promise<void> {
+  return request.put(`/system/user/${id}/wecom`, data)
+}

+ 189 - 0
src/components/FilePreview/index.vue

@@ -0,0 +1,189 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    title="附件预览"
+    width="70%"
+    top="5vh"
+    :close-on-click-modal="true"
+    destroy-on-close
+  >
+    <div v-loading="loading" class="preview-container">
+      <!-- 图片 -->
+      <div v-if="previewType === 'image'" class="image-preview">
+        <el-image :src="fileUrl" fit="contain" style="width: 100%; max-height: 70vh;" />
+      </div>
+
+      <!-- PDF -->
+      <div v-else-if="previewType === 'pdf'" class="pdf-preview">
+        <iframe :src="fileUrl" frameborder="0" width="100%" style="height: 70vh;" />
+      </div>
+
+      <!-- Excel -->
+      <div v-else-if="previewType === 'excel'" class="excel-preview">
+        <el-tabs v-model="activeSheet" type="border-card">
+          <el-tab-pane v-for="(sheet, name) in sheets" :key="name" :label="name" :name="name">
+            <el-table :data="sheet" size="small" border max-height="60vh">
+              <el-table-column
+                v-for="col in Object.keys(sheet[0] || {})"
+                :key="col"
+                :prop="col"
+                :label="col"
+                min-width="120"
+              />
+            </el-table>
+          </el-tab-pane>
+        </el-tabs>
+      </div>
+
+      <!-- Word -->
+      <div v-else-if="previewType === 'word'" class="word-preview" v-html="purifiedWordHtml" />
+
+      <!-- 不支持的类型 -->
+      <div v-else class="unsupported-preview">
+        <el-empty description="该文件类型暂不支持在线预览,请下载后查看">
+          <el-button type="primary" @click="downloadFile">下载文件</el-button>
+        </el-empty>
+      </div>
+    </div>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { getFileUrl, getFileName } from '@/utils/file'
+import DOMPurify from 'dompurify'
+import * as XLSX from 'xlsx'
+import * as mammoth from 'mammoth'
+
+const props = defineProps<{
+  url: string
+  modelValue: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', value: boolean): void
+}>()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (val) => emit('update:modelValue', val)
+})
+
+const fileUrl = computed(() => getFileUrl(props.url))
+const fileName = computed(() => getFileName(props.url))
+const loading = ref(false)
+const sheets = ref<Record<string, any[]>>({})
+const activeSheet = ref('')
+const wordHtml = ref('')
+const purifiedWordHtml = computed(() => DOMPurify.sanitize(wordHtml.value))
+
+const previewType = computed(() => {
+  const ext = fileName.value.split('.').pop()?.toLowerCase()
+  if (!ext) return 'unsupported'
+  if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext)) return 'image'
+  if (ext === 'pdf') return 'pdf'
+  if (['xls', 'xlsx', 'csv'].includes(ext)) return 'excel'
+  if (['doc', 'docx'].includes(ext)) return 'word'
+  return 'unsupported'
+})
+
+watch(() => props.modelValue, async (val) => {
+  if (!val) {
+    sheets.value = {}
+    wordHtml.value = ''
+    return
+  }
+  if (!fileUrl.value) {
+    ElMessage.error('文件地址为空,无法预览')
+    emit('update:modelValue', false)
+    return
+  }
+  if (previewType.value === 'excel') {
+    await loadExcel()
+  } else if (previewType.value === 'word') {
+    await loadWord()
+  }
+})
+
+async function loadExcel() {
+  loading.value = true
+  try {
+    const res = await fetch(fileUrl.value)
+    const blob = await res.blob()
+    const data = await blob.arrayBuffer()
+    const workbook = XLSX.read(data, { type: 'array' })
+    const result: Record<string, any[]> = {}
+    workbook.SheetNames.forEach(name => {
+      const worksheet = workbook.Sheets[name]
+      result[name] = XLSX.utils.sheet_to_json(worksheet, { header: 1 })
+    })
+    // 把第一行作为表头
+    const formatted: Record<string, any[]> = {}
+    Object.entries(result).forEach(([name, rows]) => {
+      if (rows.length === 0) {
+        formatted[name] = []
+        return
+      }
+      const headers = rows[0] as string[]
+      formatted[name] = rows.slice(1).map((row: any) => {
+        const obj: Record<string, any> = {}
+        headers.forEach((h, i) => {
+          obj[h] = row[i] ?? ''
+        })
+        return obj
+      })
+    })
+    sheets.value = formatted
+    activeSheet.value = workbook.SheetNames[0] || ''
+  } catch (e) {
+    ElMessage.error('Excel 预览失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+async function loadWord() {
+  loading.value = true
+  try {
+    const res = await fetch(fileUrl.value)
+    const blob = await res.blob()
+    const arrayBuffer = await blob.arrayBuffer()
+    const result = await mammoth.convertToHtml({ arrayBuffer })
+    wordHtml.value = result.value
+  } catch (e) {
+    ElMessage.error('Word 预览失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+function downloadFile() {
+  const link = document.createElement('a')
+  link.href = fileUrl.value
+  link.download = fileName.value
+  document.body.appendChild(link)
+  link.click()
+  document.body.removeChild(link)
+}
+</script>
+
+<style scoped>
+.preview-container {
+  min-height: 200px;
+}
+.image-preview,
+.pdf-preview {
+  text-align: center;
+}
+.word-preview {
+  max-height: 70vh;
+  overflow: auto;
+  padding: 16px;
+  background: #fff;
+  border: 1px solid #ebeef5;
+}
+.unsupported-preview {
+  padding: 40px 0;
+}
+</style>

+ 244 - 0
src/components/FlowFormFields/index.vue

@@ -0,0 +1,244 @@
+<template>
+  <div class="flow-form-fields">
+    <div v-if="fields.length === 0" class="empty-tip">
+      <el-text type="info">该流程未配置表单字段,可直接发起。</el-text>
+    </div>
+    <div v-else>
+      <div class="form-actions">
+        <el-button type="primary" size="small" plain @click="handleDownloadTemplate">下载 Excel 模板</el-button>
+        <el-upload
+          action="#"
+          :show-file-list="false"
+          :http-request="handleExcelUpload"
+          :before-upload="beforeExcelUpload"
+          accept=".xlsx,.xls"
+          style="display: inline-block; margin-left: 8px;"
+        >
+          <el-button type="success" size="small" plain>上传 Excel 自动填充</el-button>
+        </el-upload>
+      </div>
+
+      <div class="field-list">
+        <div
+          v-for="field in fields"
+          :key="field.name"
+          class="field-row"
+          :class="{ 'field-row-full': field.type === 'textarea' }"
+        >
+          <label class="field-label">
+            <span>{{ field.label }}</span>
+            <span v-if="field.required" class="required-star">*</span>
+          </label>
+          <div class="field-control">
+            <el-input
+              v-if="!field.type || field.type === 'text'"
+              v-model="formData[field.name]"
+              :placeholder="`请输入${field.label}`"
+              clearable
+              size="large"
+            />
+            <el-input
+              v-else-if="field.type === 'textarea'"
+              v-model="formData[field.name]"
+              type="textarea"
+              :rows="4"
+              :placeholder="`请输入${field.label}`"
+              clearable
+              size="large"
+            />
+            <el-input-number
+              v-else-if="field.type === 'number'"
+              v-model="formData[field.name]"
+              style="width: 100%;"
+              :placeholder="`请输入${field.label}`"
+              controls-position="right"
+              size="large"
+            />
+            <el-date-picker
+              v-else-if="field.type === 'date'"
+              v-model="formData[field.name]"
+              type="date"
+              style="width: 100%;"
+              :placeholder="`请选择${field.label}`"
+              value-format="YYYY-MM-DD"
+              size="large"
+            />
+            <el-select
+              v-else-if="field.type === 'select'"
+              v-model="formData[field.name]"
+              style="width: 100%;"
+              :multiple="field.multiple"
+              :placeholder="`请选择${field.label}`"
+              clearable
+              collapse-tags
+              collapse-tags-tooltip
+              size="large"
+            >
+              <el-option
+                v-for="opt in normalizeOptions(field.options)"
+                :key="String(opt.value)"
+                :label="opt.label"
+                :value="opt.value"
+              />
+            </el-select>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import type { FormField, FormFieldOption, FlowDefinition } from '@/types/flow'
+import { parseExcel, downloadTemplate } from '@/api/file'
+
+const props = defineProps<{
+  definition: FlowDefinition | null | undefined
+}>()
+
+const formData = defineModel<Record<string, unknown>>('modelValue', { default: () => ({}) })
+
+const fields = computed<FormField[]>(() => {
+  const schema = props.definition?.formSchema
+  if (!schema) return []
+  try {
+    const parsed = JSON.parse(schema)
+    return Array.isArray(parsed) ? parsed : []
+  } catch {
+    return []
+  }
+})
+
+// 切换流程定义时重置数据;同时为多选下拉设置数组默认值
+watch(() => props.definition?.id, () => {
+  formData.value = {}
+  for (const field of fields.value) {
+    if (field.type === 'select' && field.multiple) {
+      formData.value[field.name] = []
+    }
+  }
+}, { immediate: true })
+
+function normalizeOptions(options?: FormFieldOption[] | string[]): FormFieldOption[] {
+  if (!options || !Array.isArray(options)) return []
+  return options.map((opt: any) => {
+    if (typeof opt === 'string') {
+      return { label: opt, value: opt }
+    }
+    return { label: String(opt.label ?? opt.value), value: opt.value }
+  })
+}
+
+function validate(): boolean {
+  for (const field of fields.value) {
+    if (!field.required) continue
+    const value = formData.value[field.name]
+    if (field.type === 'select' && field.multiple) {
+      if (!Array.isArray(value) || value.length === 0) {
+        ElMessage.warning(`请选择 ${field.label}`)
+        return false
+      }
+    } else {
+      if (value === undefined || value === null || String(value).trim() === '') {
+        ElMessage.warning(`请填写 ${field.label}`)
+        return false
+      }
+    }
+  }
+  return true
+}
+
+async function handleDownloadTemplate() {
+  if (!props.definition?.id) return
+  try {
+    await downloadTemplate(props.definition.id)
+  } catch (e: any) {
+    ElMessage.error(e?.message || '模板下载失败')
+  }
+}
+
+function beforeExcelUpload(file: File): boolean {
+  const ext = file.name.split('.').pop()?.toLowerCase()
+  if (!ext || !['xlsx', 'xls'].includes(ext)) {
+    ElMessage.warning('请上传 Excel 文件')
+    return false
+  }
+  if (file.size > 10 * 1024 * 1024) {
+    ElMessage.warning('文件大小不能超过 10MB')
+    return false
+  }
+  return true
+}
+
+async function handleExcelUpload(options: any) {
+  if (!props.definition?.id) return
+  try {
+    const mappings: Record<string, string> = {}
+    for (const field of fields.value) {
+      mappings[field.label] = field.name
+      mappings[`${field.label}(${field.name})`] = field.name
+    }
+    const data = await parseExcel(options.file, props.definition.id, mappings)
+    formData.value = { ...formData.value, ...data }
+    ElMessage.success('Excel 数据已填充')
+    options.onSuccess?.()
+  } catch (e: any) {
+    ElMessage.error(e?.message || 'Excel 解析失败')
+    options.onError?.(e)
+  }
+}
+
+defineExpose({ validate, normalizeOptions })
+</script>
+
+<style scoped>
+.flow-form-fields {
+  padding: 4px 0;
+}
+.empty-tip {
+  padding: 20px 0;
+  text-align: center;
+  font-size: 14px;
+}
+.form-actions {
+  margin-bottom: 16px;
+}
+.field-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 18px;
+}
+.field-row {
+  width: calc(50% - 9px);
+  min-width: 240px;
+  flex: 1 1 calc(50% - 9px);
+}
+.field-row-full {
+  width: 100%;
+  flex: 1 1 100%;
+}
+.field-label {
+  display: block;
+  margin-bottom: 8px;
+  font-size: 14px;
+  font-weight: 500;
+  color: #606266;
+  line-height: 1.4;
+}
+.required-star {
+  color: #f56c6c;
+  margin-left: 4px;
+}
+.field-control {
+  width: 100%;
+}
+.field-control :deep(.el-input__inner),
+.field-control :deep(.el-textarea__inner),
+.field-control :deep(.el-input-number),
+.field-control :deep(.el-date-editor),
+.field-control :deep(.el-select) {
+  width: 100%;
+}
+</style>

+ 123 - 0
src/components/FormDataDisplay/index.vue

@@ -0,0 +1,123 @@
+<template>
+  <div v-if="items.length > 0" class="form-data-display">
+    <el-descriptions :column="2" size="large" border>
+      <el-descriptions-item
+        v-for="item in items"
+        :key="item.name"
+        :label="item.label"
+      >
+        <template v-if="item.type === 'select'">
+          {{ formatSelectValue(item.value, item.options, item.multiple) }}
+        </template>
+        <template v-else-if="item.type === 'textarea'">
+          <pre class="text-value">{{ formatValue(item.value) }}</pre>
+        </template>
+        <template v-else>
+          <span class="text-value">{{ formatValue(item.value) }}</span>
+        </template>
+      </el-descriptions-item>
+    </el-descriptions>
+  </div>
+  <el-empty v-else description="暂无表单数据" :image-size="60" />
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import type { FormField, FormFieldOption } from '@/types/flow'
+
+const props = defineProps<{
+  formSchema?: string
+  formData?: string | Record<string, unknown>
+}>()
+
+const fields = computed<FormField[]>(() => {
+  if (!props.formSchema) return []
+  try {
+    const parsed = JSON.parse(props.formSchema)
+    return Array.isArray(parsed) ? parsed : []
+  } catch {
+    return []
+  }
+})
+
+const dataMap = computed<Record<string, unknown>>(() => {
+  if (!props.formData) return {}
+  if (typeof props.formData === 'object') return props.formData as Record<string, unknown>
+  try {
+    return JSON.parse(props.formData)
+  } catch {
+    return { 原始数据: props.formData }
+  }
+})
+
+const items = computed(() => {
+  if (fields.value.length > 0) {
+    return fields.value.map(field => ({
+      name: field.name,
+      label: field.label || field.name,
+      type: field.type,
+      multiple: field.multiple,
+      options: normalizeOptions(field.options),
+      value: dataMap.value[field.name]
+    }))
+  }
+  // 没有字段配置时,直接按 key/value 展示
+  return Object.entries(dataMap.value).map(([name, value]) => ({
+    name,
+    label: name,
+    type: undefined as string | undefined,
+    multiple: false,
+    options: [] as FormFieldOption[],
+    value
+  }))
+})
+
+function normalizeOptions(options?: FormFieldOption[] | string[]): FormFieldOption[] {
+  if (!options || !Array.isArray(options)) return []
+  return options.map((opt: any) => {
+    if (typeof opt === 'string') {
+      return { label: opt, value: opt }
+    }
+    return { label: String(opt.label ?? opt.value), value: opt.value }
+  })
+}
+
+function formatValue(value: unknown): string {
+  if (value === undefined || value === null) return '-'
+  if (typeof value === 'boolean') return value ? '是' : '否'
+  return String(value)
+}
+
+function formatSelectValue(value: unknown, options: FormFieldOption[], multiple?: boolean): string {
+  const list = multiple && Array.isArray(value) ? value : [value]
+  const labels = list.map(v => {
+    const found = options.find(opt => String(opt.value) === String(v))
+    return found ? found.label : formatValue(v)
+  })
+  return labels.length > 0 ? labels.join(',') : '-'
+}
+</script>
+
+<style scoped>
+.form-data-display {
+  font-size: 15px;
+}
+.text-value {
+  margin: 0;
+  font-size: 15px;
+  line-height: 1.7;
+  color: #303133;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+:deep(.el-descriptions__label) {
+  font-weight: 500;
+  color: #606266;
+  min-width: 100px;
+  padding: 12px 16px !important;
+}
+:deep(.el-descriptions__content) {
+  color: #303133;
+  padding: 12px 16px !important;
+}
+</style>

+ 2 - 15
src/components/Layout/index.vue

@@ -4,21 +4,15 @@
     <el-container direction="vertical">
       <Navbar />
       <el-main class="main-content">
-        <router-view v-slot="{ Component, route }">
-          <keep-alive>
-            <component :is="Component" v-if="Component" :key="route.fullPath" />
-          </keep-alive>
-        </router-view>
+        <router-view />
       </el-main>
     </el-container>
   </el-container>
 </template>
 
 <script setup lang="ts">
-import { useRoute } from 'vue-router'
 import Sidebar from '@/components/Sidebar/index.vue'
 import Navbar from '@/components/Navbar/index.vue'
-const route = useRoute()
 </script>
 
 <style scoped>
@@ -30,12 +24,5 @@ const route = useRoute()
   padding: 20px;
   overflow-y: auto;
 }
-.fade-enter-active,
-.fade-leave-active {
-  transition: opacity 0.3s ease;
-}
-.fade-enter-from,
-.fade-leave-to {
-  opacity: 0;
-}
+
 </style>

+ 13 - 3
src/components/Navbar/index.vue

@@ -37,6 +37,7 @@ const userStore = useUserStore()
 
 const todoCountValue = ref(0)
 let timer: ReturnType<typeof setInterval> | null = null
+let todoInitialized = false
 
 async function loadTodoCount() {
   try {
@@ -67,12 +68,21 @@ function handleCommand(command: string) {
 }
 
 onMounted(() => {
-  loadTodoCount()
-  timer = setInterval(loadTodoCount, 30000)
+  if (!todoInitialized) {
+    loadTodoCount()
+    todoInitialized = true
+  }
+  if (!timer) {
+    timer = setInterval(loadTodoCount, 30000)
+  }
 })
 
 onUnmounted(() => {
-  if (timer) clearInterval(timer)
+  if (timer) {
+    clearInterval(timer)
+    timer = null
+  }
+  todoInitialized = false
 })
 </script>
 

+ 14 - 15
src/components/Sidebar/index.vue

@@ -68,30 +68,29 @@ function resolvePath(parentPath: string, childPath: string) {
 const menuPermissions: Record<string, string[]> = {
   common_user: ['/', '/approval'],
   dept_manager: ['/', '/system', '/approval'],
-  flow_manager: ['/', '/flow', '/approval'],
-  super_admin: ['/', '/system', '/flow', '/approval']
+  flow_manager: ['/', '/flow', '/approval', '/analysis'],
+  super_admin: ['/', '/system', '/flow', '/approval', '/analysis']
 }
 
-const menuRoutes = computed(() => {
-  return 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 = 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 filteredMenuRoutes = computed(() => {
   // 角色用户只显示首页和审批中心
   if (userStore.isRoleUser) {
-    return menuRoutes.value.filter(r => ['/', '/approval'].includes(r.path))
+    return menuRoutes.filter(r => ['/', '/approval'].includes(r.path))
   }
   const empType = userStore.userInfo?.employeeType || 'common_user'
   const allowedPaths = menuPermissions[empType] || menuPermissions.common_user
-  return menuRoutes.value.filter(r => allowedPaths.includes(r.path))
+  return menuRoutes.filter(r => allowedPaths.includes(r.path))
 })
 </script>
 

+ 46 - 3
src/main.ts

@@ -2,15 +2,58 @@ import { createApp } from 'vue'
 import { createPinia } from 'pinia'
 import ElementPlus from 'element-plus'
 import 'element-plus/dist/index.css'
-import * as ElementPlusIconsVue from '@element-plus/icons-vue'
+import {
+  ArrowDown,
+  Bell,
+  CircleCheck,
+  Clock,
+  Connection,
+  DataAnalysis,
+  Document,
+  EditPen,
+  HomeFilled,
+  List,
+  Message,
+  Monitor,
+  Pointer,
+  Promotion,
+  Setting,
+  Stamp,
+  TrendCharts,
+  User,
+  UserFilled,
+  Warning
+} from '@element-plus/icons-vue'
 import App from './App.vue'
 import router from './router'
 import './style.css'
 
 const app = createApp(App)
 
-// 注册所有图标
-for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+// 按需注册图标
+const icons = {
+  ArrowDown,
+  Bell,
+  CircleCheck,
+  Clock,
+  Connection,
+  DataAnalysis,
+  Document,
+  EditPen,
+  HomeFilled,
+  List,
+  Message,
+  Monitor,
+  Pointer,
+  Promotion,
+  Setting,
+  Stamp,
+  TrendCharts,
+  User,
+  UserFilled,
+  Warning
+}
+for (const [key, component] of Object.entries(icons)) {
   app.component(key, component)
 }
 

+ 82 - 38
src/router/index.ts

@@ -45,6 +45,33 @@ const router = createRouter({
       ]
     },
     {
+      path: '/workbench',
+      component: () => import('@/components/Layout/index.vue'),
+      meta: { title: '工作台', icon: 'Monitor' },
+      children: [
+        {
+          path: '',
+          name: 'Workbench',
+          component: () => import('@/views/workbench/index.vue'),
+          meta: { title: '智能工作台', icon: 'Monitor' }
+        }
+      ]
+    },
+    {
+      path: '/analysis',
+      component: () => import('@/components/Layout/index.vue'),
+      redirect: '/analysis/flow',
+      meta: { title: '数据看板', icon: 'TrendCharts' },
+      children: [
+        {
+          path: 'flow',
+          name: 'FlowAnalysis',
+          component: () => import('@/views/analysis/index.vue'),
+          meta: { title: '流程分析看板', icon: 'TrendCharts' }
+        }
+      ]
+    },
+    {
       path: '/system',
       component: () => import('@/components/Layout/index.vue'),
       redirect: '/system/user',
@@ -62,7 +89,6 @@ const router = createRouter({
           component: () => import('@/views/system/role/index.vue'),
           meta: { title: '员工管理', icon: 'User' }
         },
-
       ]
     },
     {
@@ -126,58 +152,76 @@ const router = createRouter({
   ]
 })
 
-const roleAllowedPaths = ['/', '/dashboard', '/login', '/profile', '/approval', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute']
+const publicPaths = new Set(['/login'])
+
+const roleAllowedPaths = new Set(['/', '/dashboard', '/login', '/profile', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc'])
 
 // 按员工类型限制的路径
-const permissionPaths: Record<string, string[]> = {
-  common_user: ['/', '/dashboard', '/login', '/profile', '/approval', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc'],
-  dept_manager: ['/', '/dashboard', '/login', '/profile', '/system', '/system/user', '/system/role', '/system/dept', '/approval', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc'],
-  flow_manager: ['/', '/dashboard', '/login', '/profile', '/flow', '/flow/designer', '/flow/definition', '/approval', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc'],
-  super_admin: ['/', '/dashboard', '/login', '/profile', '/system', '/system/user', '/system/role', '/system/dept', '/flow', '/flow/designer', '/flow/definition', '/approval', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc']
+const permissionPaths: Record<string, Set<string>> = {
+  common_user: new Set(['/', '/dashboard', '/login', '/profile', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc']),
+  dept_manager: new Set(['/', '/dashboard', '/login', '/profile', '/system/user', '/system/role', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc']),
+  flow_manager: new Set(['/', '/dashboard', '/login', '/profile', '/flow/designer', '/flow/definition', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc', '/analysis']),
+  super_admin: new Set(['/', '/dashboard', '/login', '/profile', '/system/user', '/system/role', '/flow/designer', '/flow/definition', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute', '/approval/cc', '/analysis'])
 }
 
+function isPathAllowed(allowedSet: Set<string>, path: string): boolean {
+  if (allowedSet.has(path)) return true
+  // 允许子路径,但父路径必须以 / 结尾或精确匹配
+  for (const allowed of allowedSet) {
+    if (allowed !== '/' && path.startsWith(allowed + '/')) {
+      return true
+    }
+  }
+  return false
+}
+
+// 防止快速切换 tab 时并发多次拉取用户信息
+let fetchingUserInfo: Promise<unknown> | null = null
+
 router.beforeEach(async (to, from, next) => {
   const token = getToken()
+  // 404、登录页等公开页面直接放行
+  if (publicPaths.has(to.path) || to.name === 'NotFound') {
+    next()
+    return
+  }
   if (token) {
-    if (to.path === '/login') {
-      next('/')
-    } else {
-      const userStore = useUserStore()
-      if (!userStore.userInfo) {
-        try {
-          await userStore.fetchUserInfo()
-        } catch {
-          await userStore.logoutAction()
-          next(`/login?redirect=${to.path}`)
-          return
-        }
-      }
-      // 角色用户路由限制
-      if (userStore.isRoleUser) {
-        const allowed = roleAllowedPaths.some(p => to.path === p || to.path.startsWith(p + '/'))
-        if (!allowed) {
-          next('/approval/todo')
-          return
+    const userStore = useUserStore()
+    if (!userStore.userInfo) {
+      try {
+        if (!fetchingUserInfo) {
+          fetchingUserInfo = userStore.fetchUserInfo().catch(async (err) => {
+            await userStore.logoutAction()
+            throw err
+          })
         }
-        next()
+        await fetchingUserInfo
+      } catch {
+        next(`/login?redirect=${to.path}`)
         return
+      } finally {
+        fetchingUserInfo = null
       }
-      // 普通用户按 employeeType 限制
-      const employeeType = userStore.userInfo?.employeeType || 'common_user'
-      const allowedPaths = permissionPaths[employeeType] || permissionPaths['common_user']
-      const isAllowed = allowedPaths.some(p => to.path === p || to.path.startsWith(p + '/'))
-      if (!isAllowed) {
-        next('/dashboard')
+    }
+    // 角色用户路由限制
+    if (userStore.isRoleUser) {
+      if (!isPathAllowed(roleAllowedPaths, to.path)) {
+        next('/approval/todo')
         return
       }
       next()
+      return
     }
-  } else {
-    if (to.path === '/login') {
-      next()
-    } else {
-      next(`/login?redirect=${to.path}`)
+    // 普通用户按 employeeType 限制
+    const employeeType = userStore.userInfo?.employeeType || 'common_user'
+    const allowedPaths = permissionPaths[employeeType] || permissionPaths['common_user']
+    if (!isPathAllowed(allowedPaths, to.path)) {
+      next('/dashboard')
+      return
     }
+    next()
+  } else {
+    next(`/login?redirect=${to.path}`)
   }
 })
 

+ 0 - 4
src/stores/user-store.ts

@@ -7,7 +7,6 @@ import type { LoginData, User } from '@/types/system'
 export const useUserStore = defineStore('user', () => {
   const token = ref<string | undefined>(getToken())
   const userInfo = ref<User | null>(null)
-  const roles = ref<string[]>([])
   const userType = ref<string>('SYSTEM')
 
   const isLoggedIn = computed(() => !!token.value)
@@ -24,7 +23,6 @@ export const useUserStore = defineStore('user', () => {
     const res = await getUserInfo()
     userInfo.value = res
     userType.value = res.userType || 'SYSTEM'
-    roles.value = res.roleIds?.map(String) || []
     return res
   }
 
@@ -36,7 +34,6 @@ export const useUserStore = defineStore('user', () => {
     }
     token.value = undefined
     userInfo.value = null
-    roles.value = []
     userType.value = 'SYSTEM'
     removeToken()
     localStorage.removeItem('login_username')
@@ -47,7 +44,6 @@ export const useUserStore = defineStore('user', () => {
   return {
     token,
     userInfo,
-    roles,
     userType,
     isLoggedIn,
     isRoleUser,

+ 23 - 1
src/style.css

@@ -1,6 +1,24 @@
-* {
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed,
+figure, figcaption, footer, header, hgroup,
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
   margin: 0;
   padding: 0;
+  border: 0;
+  font-size: 100%;
+  vertical-align: baseline;
+}
+
+*, *::before, *::after {
   box-sizing: border-box;
 }
 
@@ -9,6 +27,10 @@ html, body, #app {
   font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
 }
 
+ol, ul {
+  list-style: none;
+}
+
 .app-container {
   padding: 20px;
 }

+ 75 - 0
src/types/analysis.ts

@@ -0,0 +1,75 @@
+export interface ProcessEfficiency {
+  processDefinitionId: number
+  processName: string
+  instanceCount: number
+  avgDurationMinutes: number
+  maxDurationMinutes: number
+  minDurationMinutes: number
+  nodeStats?: NodeStayStat[]
+}
+
+export interface NodeStayStat {
+  nodeId: string
+  nodeName: string
+  processDefinitionId?: number
+  processName?: string
+  taskCount: number
+  avgStayMinutes: number
+  maxStayMinutes: number
+}
+
+export interface StuckInstance {
+  instanceId: number
+  instanceNo: string
+  title: string
+  processDefinitionId?: number
+  processName?: string
+  applicantId?: number
+  applicantName?: string
+  nodeId: string
+  nodeName: string
+  taskCreateTime: string
+  stayMinutes: number
+}
+
+export interface AnalysisOverview {
+  totalInstances: number
+  completedCount: number
+  runningCount: number
+  rejectedCount: number
+  revokedCount: number
+  timeoutCount: number
+  timeoutRate: number
+  avgDurationMinutes: number
+}
+
+export interface StatusDistribution {
+  status: number
+  statusName?: string
+  count: number
+}
+
+export interface Trend {
+  date: string
+  startedCount: number
+  completedCount: number
+  rejectedCount: number
+}
+
+export interface EfficiencyQuery {
+  startTime?: string
+  endTime?: string
+  processDefinitionId?: number
+}
+
+export interface OverviewQuery {
+  startTime?: string
+  endTime?: string
+  processDefinitionId?: number
+}
+
+export interface StuckInstanceQuery {
+  nodeId?: string
+  processDefinitionId?: number
+  minStayMinutes?: number
+}

+ 48 - 2
src/types/flow.ts

@@ -1,3 +1,18 @@
+export interface FormFieldOption {
+  label: string
+  value: string | number
+}
+
+export interface FormField {
+  name: string
+  label: string
+  type?: 'text' | 'number' | 'date' | 'select' | 'textarea'
+  required?: boolean
+  multiple?: boolean
+  // 兼容旧数据:早期 options 为字符串数组,新数据为 { label, value } 数组
+  options?: FormFieldOption[] | string[]
+}
+
 export interface FlowDefinition {
   id: number
   code: string
@@ -7,6 +22,7 @@ export interface FlowDefinition {
   version: number
   status: number // 0-草稿 1-已发布 2-停用
   formSchema?: string
+  formFields?: FormField[]
   flowJson?: string
   createTime?: string
   updateTime?: string
@@ -16,12 +32,16 @@ export interface FlowInstance {
   id: number
   definitionId: number
   definitionName?: string
+  title?: string
   instanceNo?: string
   businessKey?: string
-  status: number // 0-运行中 1-已完成 2-已终止
+  status: number // 0-待接收, 1-运行中, 2-已通过, 3-已拒绝, 4-已回退, 5-已完成, 6-已撤回, 7-已终止
   startTime?: string
   endTime?: string
   currentNode?: string
+  currentNodeName?: string
+  attachmentUrls?: string
+  formData?: string
   createBy?: string
 }
 
@@ -29,14 +49,23 @@ export interface FlowTask {
   id: number
   instanceId: number
   definitionName?: string
+  instanceTitle?: string
+  instanceNo?: string
   nodeName: string
+  nodeType?: string
   assigneeId?: number
+  assigneeType?: string
   assigneeName?: string
-  status: number // 0-待处理 1-已处理 2-转办
+  status: number // 0-待处理, 1-已处理, 2-已转办, 3-已跳过, 4-已回退
+  taskStatus?: number
   action?: string
   comment?: string
+  attachmentUrls?: string
   createTime?: string
   endTime?: string
+  timeoutTime?: string
+  urgency?: number // 0-正常,1-即将超时,2-已超时
+  remainingMinutes?: number
 }
 
 export interface FlowNodeConfig {
@@ -59,6 +88,7 @@ export interface ApprovalAction {
   action: 'pass' | 'reject' | 'rollback' | 'transfer'
   comment?: string
   transferTo?: number
+  targetNodeId?: string
   attachmentUrls?: string
 }
 
@@ -93,3 +123,19 @@ export interface ApprovalRecord {
   attachmentUrls?: string
   createTime?: string
 }
+
+export interface Attachment {
+  id: number
+  instanceId: number
+  taskId?: number
+  recordId?: number
+  nodeId?: string
+  nodeName?: string
+  fileName: string
+  fileUrl: string
+  fileSize?: number
+  uploaderId?: number
+  uploaderName?: string
+  uploaderType?: string
+  createTime?: string
+}

+ 5 - 0
src/types/system.ts

@@ -12,6 +12,8 @@ export interface User {
   userType?: string // SYSTEM / ROLE
   roleIds?: number[]
   password?: string
+  wecomUserId?: string
+  wecomRemindEnabled?: number
   createTime?: string
 }
 
@@ -22,10 +24,13 @@ export interface Role {
   username?: string
   password?: string
   roleScope?: string
+  phone?: string
   parentId?: number
   deptId?: number
   deptName?: string
   status: number // 0-禁用 1-正常
+  wecomUserId?: string
+  wecomRemindEnabled?: number
   createTime?: string
 }
 

+ 7 - 1
src/utils/auth.ts

@@ -2,12 +2,18 @@ import Cookies from 'js-cookie'
 
 const TokenKey = 'Admin-Token'
 
+const isSecure = window.location.protocol === 'https:'
+
 export function getToken(): string | undefined {
   return Cookies.get(TokenKey)
 }
 
 export function setToken(token: string): void {
-  Cookies.set(TokenKey, token)
+  Cookies.set(TokenKey, token, {
+    secure: isSecure,
+    sameSite: 'Strict',
+    expires: 1
+  })
 }
 
 export function removeToken(): void {

+ 132 - 0
src/utils/file.ts

@@ -0,0 +1,132 @@
+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'
+])
+
+const BLOCKED_EXTENSIONS = new Set([
+  'jsp', 'jspx', 'php', 'asp', 'aspx', 'sh', 'bat', 'cmd',
+  'exe', 'dll', 'jar', 'war', 'ear', 'html', 'htm', 'js'
+])
+
+const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
+
+export function extractExtension(filename: string): string | null {
+  const lastDot = filename.lastIndexOf('.')
+  if (lastDot < 0 || lastDot === filename.length - 1) return null
+  return filename.substring(lastDot + 1).toLowerCase()
+}
+
+export function isAllowedFile(filename: string): boolean {
+  if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
+    return false
+  }
+  const ext = extractExtension(filename)
+  if (!ext) return false
+  if (BLOCKED_EXTENSIONS.has(ext)) return false
+  return ALLOWED_EXTENSIONS.has(ext)
+}
+
+export function beforeFileUpload(rawFile: UploadRawFile): boolean {
+  if (rawFile.size > MAX_FILE_SIZE) {
+    ElMessage.error(`文件大小超过限制,最大允许 ${MAX_FILE_SIZE / 1024 / 1024}MB`)
+    return false
+  }
+  if (!isAllowedFile(rawFile.name)) {
+    ElMessage.error('不允许上传该类型文件')
+    return false
+  }
+  return true
+}
+
+export function isSafeUrl(url: string): boolean {
+  if (!url) return false
+  try {
+    // 允许的协议白名单
+    const allowedProtocols = new Set(['http:', 'https:', 'blob:'])
+    const trimmed = url.trim()
+    if (!trimmed) return false
+    // 相对路径(无协议)允许
+    if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
+      return true
+    }
+    const parsed = new URL(trimmed)
+    return allowedProtocols.has(parsed.protocol.toLowerCase())
+  } catch {
+    return false
+  }
+}
+
+export function getFileUrl(url?: string): string {
+  if (!url) return ''
+  const trimmed = url.trim()
+  if (!isSafeUrl(trimmed)) return ''
+  if (/^https?:/i.test(trimmed)) return trimmed
+  if (trimmed.startsWith('//')) return window.location.protocol + trimmed
+  return trimmed.startsWith('/') ? trimmed : '/' + trimmed
+}
+
+function extractUploadResponse(response: unknown): string {
+  if (!response) return ''
+  if (typeof response === 'string') return response
+  if (typeof response === 'object' && response !== null) {
+    const data = (response as any).data
+    if (typeof data === 'string') return data
+    const url = (response as any).url
+    if (typeof url === 'string') return url
+  }
+  return ''
+}
+
+export function getFileName(url: string): string {
+  if (!url) return ''
+  try {
+    const parsed = new URL(url, window.location.origin)
+    const pathname = parsed.pathname
+    const parts = pathname.split('/')
+    return parts[parts.length - 1] || url
+  } catch {
+    const parts = url.split('/')
+    return parts[parts.length - 1] || url
+  }
+}
+
+export function parseAttachments(urlsStr?: string): string[] {
+  if (!urlsStr) return []
+  try {
+    const parsed = JSON.parse(urlsStr)
+    if (Array.isArray(parsed)) {
+      return parsed.filter((u): u is string => typeof u === 'string' && isSafeUrl(u))
+    }
+  } catch {
+    if (typeof urlsStr === 'string' && urlsStr.includes(',')) {
+      return urlsStr.split(',').map(s => s.trim()).filter(isSafeUrl)
+    }
+  }
+  return isSafeUrl(urlsStr) ? [urlsStr] : []
+}
+
+export function getUploadUrl(file: any): string {
+  if (!file) return ''
+  const fromResponse = extractUploadResponse(file.response)
+  if (fromResponse) return fromResponse
+  if (typeof file.url === 'string' && !file.url.startsWith('blob:')) return file.url
+  return ''
+}
+
+export function collectAttachmentUrls(fileList: any[]): string {
+  const urls = fileList
+    .map((f: any) => {
+      const fromResponse = extractUploadResponse(f?.response)
+      if (fromResponse) return fromResponse
+      if (f?.url && typeof f.url === 'string' && !f.url.startsWith('blob:')) return f.url
+      return null
+    })
+    .filter((url): url is string => !!url && isSafeUrl(url))
+  return JSON.stringify(urls)
+}

+ 35 - 0
src/utils/flow.ts

@@ -0,0 +1,35 @@
+export function instanceStatusText(status?: number): string {
+  if (status === undefined) return '未知'
+  const map: Record<number, string> = {
+    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'
+  }
+  return map[status ?? 0] || 'info'
+}
+
+export function taskStatusText(status?: number): string {
+  if (status === undefined) return '未知'
+  return ['待处理', '已处理', '已转办', '已跳过', '已回退'][status] || '未知'
+}
+
+export function taskStatusType(status?: number): string {
+  return ['warning', 'success', 'info', 'info', 'danger'][status ?? 0] || 'info'
+}
+
+export function recordActionText(result?: string): string {
+  const map: Record<string, string> = { PASS: '通过', REJECT: '拒绝', RETURN: '回退', TRANSFER: '转办' }
+  return map[result || ''] || result || '-'
+}
+
+export function recordActionType(result?: string): string {
+  const map: Record<string, string> = { PASS: 'success', REJECT: 'danger', RETURN: 'warning', TRANSFER: 'info' }
+  return map[result || ''] || 'info'
+}

+ 21 - 0
src/utils/format.ts

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

+ 728 - 0
src/views/analysis/index.vue

@@ -0,0 +1,728 @@
+<template>
+  <div class="analysis-page">
+    <el-card shadow="never" class="filter-card">
+      <el-form :model="query" inline>
+        <el-form-item label="时间范围">
+          <el-date-picker
+            v-model="dateRange"
+            type="datetimerange"
+            range-separator="至"
+            start-placeholder="开始时间"
+            end-placeholder="结束时间"
+            value-format="YYYY-MM-DD HH:mm:ss"
+            @change="onDateRangeChange"
+          />
+        </el-form-item>
+        <el-form-item label="流程">
+          <el-select
+            v-model="query.processDefinitionId"
+            clearable
+            placeholder="全部流程"
+            style="width: 220px"
+            @change="loadAll"
+          >
+            <el-option
+              v-for="def in definitionOptions"
+              :key="def.id"
+              :label="def.name"
+              :value="def.id"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="loadAll">刷新</el-button>
+        </el-form-item>
+      </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>
+
+    <!-- 状态分布 + 趋势 -->
+    <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>
+      </el-col>
+      <el-col :span="12">
+        <el-card shadow="hover" v-loading="loadingTrend">
+          <template #header>
+            <span>近30天趋势</span>
+          </template>
+          <v-chart v-if="trendChartOption" :option="trendChartOption" autoresize style="height: 320px" />
+          <el-empty v-else description="暂无数据" />
+        </el-card>
+      </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>
+      </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>
+      </el-col>
+    </el-row>
+
+    <el-card shadow="hover" class="detail-card" v-loading="loadingStuck">
+      <template #header>
+        <div class="detail-header">
+          <span>节点下钻明细(卡住流程)</span>
+          <div class="detail-actions">
+            <el-input-number
+              v-model="stuckQuery.minStayMinutes"
+              :min="0"
+              placeholder="最小停留分钟"
+              controls-position="right"
+              style="width: 160px"
+              @change="loadStuck(1)"
+            />
+            <el-button type="primary" @click="loadStuck(1)">查询</el-button>
+          </div>
+        </div>
+      </template>
+
+      <el-table :data="stuckList" stripe>
+        <el-table-column prop="instanceNo" label="实例编号" min-width="160" show-overflow-tooltip />
+        <el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
+        <el-table-column prop="processName" label="流程" min-width="140" show-overflow-tooltip />
+        <el-table-column prop="nodeName" label="当前节点" min-width="140" show-overflow-tooltip />
+        <el-table-column prop="applicantName" label="申请人" min-width="100" />
+        <el-table-column prop="stayMinutes" label="已停留" min-width="120">
+          <template #default="{ row }">
+            <el-tag :type="row.stayMinutes > 1440 ? 'danger' : row.stayMinutes > 240 ? 'warning' : 'info'">
+              {{ formatMinutes(row.stayMinutes) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="taskCreateTime" label="任务到达时间" min-width="160" />
+        <el-table-column label="操作" width="100" fixed="right">
+          <template #default="{ row }">
+            <el-link type="primary" @click="goInstance(row.instanceId)">查看</el-link>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        v-model:current-page="stuckQuery.pageNum"
+        v-model:page-size="stuckQuery.pageSize"
+        :total="stuckTotal"
+        layout="total, sizes, prev, pager, next"
+        :page-sizes="[10, 20, 50]"
+        @size-change="loadStuck(1)"
+        @current-change="loadStuck"
+      />
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, computed, onMounted, nextTick } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import { use } from 'echarts/core'
+import { CanvasRenderer } from 'echarts/renderers'
+import { BarChart, PieChart, LineChart } from 'echarts/charts'
+import { GridComponent, TooltipComponent, LegendComponent, TitleComponent, DataZoomComponent } from 'echarts/components'
+import VChart from 'vue-echarts'
+import {
+  getCompletedEfficiency,
+  getInProgressByNode,
+  getStuckInstances,
+  getOverview,
+  getStatusDistribution,
+  getTrend
+} from '@/api/analysis'
+import { listEnabled } from '@/api/flow/definition'
+import type {
+  ProcessEfficiency,
+  NodeStayStat,
+  StuckInstance,
+  AnalysisOverview,
+  StatusDistribution,
+  Trend
+} from '@/types/analysis'
+import type { FlowDefinition } from '@/types/flow'
+
+use([
+  CanvasRenderer,
+  BarChart,
+  PieChart,
+  LineChart,
+  GridComponent,
+  TooltipComponent,
+  LegendComponent,
+  TitleComponent,
+  DataZoomComponent
+])
+
+const router = useRouter()
+
+const colorPalette = ['#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc']
+
+const now = new Date()
+const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
+const defaultStart = formatDateTime(thirtyDaysAgo)
+const defaultEnd = formatDateTime(now)
+
+const dateRange = ref<[string, string]>([defaultStart, defaultEnd])
+const query = reactive({
+  startTime: defaultStart as string | undefined,
+  endTime: defaultEnd as string | undefined,
+  processDefinitionId: undefined as number | undefined
+})
+
+const definitionOptions = ref<FlowDefinition[]>([])
+
+const loadingOverview = ref(false)
+const overview = ref<AnalysisOverview>({
+  totalInstances: 0,
+  completedCount: 0,
+  runningCount: 0,
+  rejectedCount: 0,
+  revokedCount: 0,
+  timeoutCount: 0,
+  timeoutRate: 0,
+  avgDurationMinutes: 0
+})
+
+const loadingStatus = ref(false)
+const statusList = ref<StatusDistribution[]>([])
+const statusChartOption = computed(() => buildStatusOption(statusList.value))
+
+const loadingTrend = ref(false)
+const trendList = ref<Trend[]>([])
+const trendChartOption = computed(() => buildTrendOption(trendList.value))
+
+const loadingEfficiency = ref(false)
+const efficiencyList = ref<ProcessEfficiency[]>([])
+const efficiencyChartOption = computed(() => buildEfficiencyOption(efficiencyList.value))
+
+const loadingNode = ref(false)
+const nodeList = ref<NodeStayStat[]>([])
+const nodeChartOption = computed(() => buildNodeOption(nodeList.value))
+
+const stuckQuery = reactive({
+  nodeId: undefined as string | undefined,
+  processDefinitionId: undefined as number | undefined,
+  minStayMinutes: 60,
+  pageNum: 1,
+  pageSize: 10
+})
+const loadingStuck = ref(false)
+const stuckList = ref<StuckInstance[]>([])
+const stuckTotal = ref(0)
+
+// 防止快速切换Tab或连续刷新时并发触发大量请求
+const loadingAll = ref(false)
+
+function formatDateTime(d: Date): string {
+  const pad = (n: number) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+}
+
+function onDateRangeChange(val: [string, string] | null) {
+  if (val && val.length === 2) {
+    query.startTime = val[0]
+    query.endTime = val[1]
+  } else {
+    query.startTime = undefined
+    query.endTime = undefined
+  }
+  loadAll()
+}
+
+async function loadDefinitions() {
+  try {
+    definitionOptions.value = await listEnabled()
+  } catch {
+    definitionOptions.value = []
+  }
+}
+
+async function loadOverview() {
+  loadingOverview.value = true
+  try {
+    overview.value = await getOverview({
+      startTime: query.startTime,
+      endTime: query.endTime,
+      processDefinitionId: query.processDefinitionId
+    })
+  } catch {
+    // keep default zeros
+  } finally {
+    loadingOverview.value = false
+  }
+}
+
+async function loadStatusDistribution() {
+  loadingStatus.value = true
+  try {
+    statusList.value = await getStatusDistribution({
+      startTime: query.startTime,
+      endTime: query.endTime,
+      processDefinitionId: query.processDefinitionId
+    })
+  } finally {
+    loadingStatus.value = false
+  }
+}
+
+async function loadTrend() {
+  loadingTrend.value = true
+  try {
+    trendList.value = await getTrend(query.processDefinitionId)
+  } finally {
+    loadingTrend.value = false
+  }
+}
+
+async function loadEfficiency() {
+  loadingEfficiency.value = true
+  try {
+    efficiencyList.value = await getCompletedEfficiency({
+      startTime: query.startTime,
+      endTime: query.endTime,
+      processDefinitionId: query.processDefinitionId
+    })
+  } finally {
+    loadingEfficiency.value = false
+  }
+}
+
+async function loadNodes() {
+  loadingNode.value = true
+  try {
+    nodeList.value = await getInProgressByNode(query.processDefinitionId)
+  } finally {
+    loadingNode.value = false
+  }
+}
+
+async function loadStuck(pageNum = stuckQuery.pageNum) {
+  loadingStuck.value = true
+  stuckQuery.pageNum = pageNum
+  stuckQuery.processDefinitionId = query.processDefinitionId
+  try {
+    const res = await getStuckInstances({
+      nodeId: stuckQuery.nodeId,
+      processDefinitionId: stuckQuery.processDefinitionId,
+      minStayMinutes: stuckQuery.minStayMinutes,
+      pageNum: stuckQuery.pageNum,
+      pageSize: stuckQuery.pageSize
+    })
+    stuckList.value = res.list || []
+    stuckTotal.value = res.total || 0
+  } finally {
+    loadingStuck.value = false
+  }
+}
+
+async function loadAll() {
+  if (loadingAll.value) return
+  loadingAll.value = true
+  try {
+    await Promise.all([
+      loadOverview(),
+      loadStatusDistribution(),
+      loadTrend(),
+      loadEfficiency(),
+      loadNodes()
+    ])
+    await loadStuck(1)
+  } finally {
+    loadingAll.value = false
+  }
+}
+
+function onNodeChartClick(params: any) {
+  const idx = params?.dataIndex
+  if (idx == null) return
+  const node = nodeList.value[idx]
+  if (!node) return
+  stuckQuery.nodeId = node.nodeId
+  nextTick(() => loadStuck(1))
+  ElMessage.info(`已切换至节点「${node.nodeName}」明细`)
+}
+
+function goInstance(id: number) {
+  router.push(`/approval/execute?id=${id}`)
+}
+
+function formatMinutes(minutes: number): string {
+  if (minutes < 60) return `${minutes}分钟`
+  const hours = Math.floor(minutes / 60)
+  const mins = minutes % 60
+  if (hours < 24) return `${hours}小时${mins ? mins + '分钟' : ''}`
+  const days = Math.floor(hours / 24)
+  const remHours = hours % 24
+  return `${days}天${remHours ? remHours + '小时' : ''}`
+}
+
+function buildStatusOption(list: StatusDistribution[]) {
+  if (!list.length) return null
+  return {
+    color: colorPalette,
+    tooltip: {
+      trigger: 'item',
+      formatter: (params: any) => {
+        return `${params.name}<br/>数量:${params.value} 个<br/>占比:${params.percent}%`
+      }
+    },
+    legend: {
+      bottom: 0,
+      type: 'scroll'
+    },
+    series: [
+      {
+        name: '流程状态',
+        type: 'pie',
+        radius: ['40%', '70%'],
+        center: ['50%', '45%'],
+        avoidLabelOverlap: true,
+        itemStyle: {
+          borderRadius: 8,
+          borderColor: '#fff',
+          borderWidth: 2
+        },
+        label: {
+          formatter: '{b}: {c} ({d}%)'
+        },
+        data: list.map((i) => ({ name: i.statusName || `状态${i.status}`, value: i.count }))
+      }
+    ]
+  }
+}
+
+function buildTrendOption(list: Trend[]) {
+  if (!list.length) return null
+  const dates = list.map((i) => i.date)
+  return {
+    color: colorPalette,
+    tooltip: {
+      trigger: 'axis',
+      formatter: (params: any[]) => {
+        let html = params[0]?.axisValue + '<br/>'
+        params.forEach((p) => {
+          html += `${p.marker} ${p.seriesName}:${p.value} 个<br/>`
+        })
+        return html
+      }
+    },
+    legend: {
+      data: ['发起数', '完成数', '拒绝数'],
+      bottom: 0
+    },
+    grid: { left: '3%', right: '4%', bottom: '12%', top: '10%', containLabel: true },
+    xAxis: {
+      type: 'category',
+      boundaryGap: false,
+      data: dates,
+      axisLabel: { rotate: 30 }
+    },
+    yAxis: { type: 'value', name: '个' },
+    series: [
+      {
+        name: '发起数',
+        type: 'line',
+        smooth: true,
+        data: list.map((i) => i.startedCount),
+        areaStyle: {
+          color: {
+            type: 'linear',
+            x: 0,
+            y: 0,
+            x2: 0,
+            y2: 1,
+            colorStops: [
+              { offset: 0, color: 'rgba(84,112,198,0.4)' },
+              { offset: 1, color: 'rgba(84,112,198,0.05)' }
+            ]
+          }
+        }
+      },
+      {
+        name: '完成数',
+        type: 'line',
+        smooth: true,
+        data: list.map((i) => i.completedCount)
+      },
+      {
+        name: '拒绝数',
+        type: 'line',
+        smooth: true,
+        data: list.map((i) => i.rejectedCount)
+      }
+    ]
+  }
+}
+
+function buildEfficiencyOption(list: ProcessEfficiency[]) {
+  if (!list.length) return null
+  return {
+    color: colorPalette,
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'shadow' },
+      formatter: (params: any[]) => {
+        const p = params[0]
+        const item = list[p.dataIndex]
+        return `${p.name}<br/>实例数:${item.instanceCount} 个<br/>平均耗时:${formatMinutes(Math.round(item.avgDurationMinutes))}<br/>最大:${formatMinutes(item.maxDurationMinutes)}<br/>最小:${formatMinutes(item.minDurationMinutes)}`
+      }
+    },
+    grid: { left: '3%', right: '4%', bottom: list.length > 10 ? '18%' : '3%', containLabel: true },
+    dataZoom:
+      list.length > 10
+        ? [
+            {
+              type: 'slider',
+              xAxisIndex: 0,
+              start: 0,
+              end: Math.min(100, Math.round((10 / list.length) * 100)),
+              height: 20,
+              bottom: 0
+            }
+          ]
+        : [],
+    xAxis: { type: 'category', data: list.map((i) => i.processName), axisLabel: { interval: 0, rotate: 20 } },
+    yAxis: { type: 'value', name: '分钟' },
+    series: [
+      {
+        name: '平均耗时',
+        type: 'bar',
+        data: list.map((i) => i.avgDurationMinutes),
+        itemStyle: { color: colorPalette[0], borderRadius: [6, 6, 0, 0] }
+      }
+    ]
+  }
+}
+
+function buildNodeOption(list: NodeStayStat[]) {
+  if (!list.length) return null
+  const top = list.slice(0, 20)
+  return {
+    color: colorPalette,
+    tooltip: {
+      trigger: 'axis',
+      axisPointer: { type: 'shadow' },
+      formatter: (params: any[]) => {
+        const p = params[0]
+        const item = top[p.dataIndex]
+        return `${item.processName || ''} / ${item.nodeName}<br/>任务数:${item.taskCount} 个<br/>平均停留:${formatMinutes(Math.round(item.avgStayMinutes))}<br/>最大停留:${formatMinutes(item.maxStayMinutes)}`
+      }
+    },
+    grid: { left: '3%', right: '4%', bottom: top.length > 10 ? '18%' : '3%', containLabel: true },
+    dataZoom:
+      top.length > 10
+        ? [
+            {
+              type: 'slider',
+              xAxisIndex: 0,
+              start: 0,
+              end: Math.min(100, Math.round((10 / top.length) * 100)),
+              height: 20,
+              bottom: 0
+            }
+          ]
+        : [],
+    xAxis: { type: 'category', data: top.map((i) => i.nodeName), axisLabel: { interval: 0, rotate: 25 } },
+    yAxis: { type: 'value', name: '分钟' },
+    series: [
+      {
+        name: '平均停留',
+        type: 'bar',
+        data: top.map((i) => i.avgStayMinutes),
+        itemStyle: {
+          color: (p: any) => {
+            const v = top[p.dataIndex].avgStayMinutes
+            return v > 1440 ? '#F56C6C' : v > 240 ? '#E6A23C' : '#67C23A'
+          },
+          borderRadius: [6, 6, 0, 0]
+        }
+      }
+    ]
+  }
+}
+
+onMounted(() => {
+  loadDefinitions()
+  loadAll()
+})
+</script>
+
+<style scoped>
+.analysis-page {
+  padding: 16px;
+}
+.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>

+ 352 - 22
src/views/dashboard/index.vue

@@ -2,7 +2,7 @@
   <div class="dashboard">
     <el-row :gutter="20">
       <el-col :span="6">
-        <el-card>
+        <el-card class="stat-card" shadow="hover" @click="$router.push('/flow/definition')">
           <div class="stat-item">
             <div class="stat-icon" style="background: #409EFF;">
               <el-icon><Document /></el-icon>
@@ -15,7 +15,7 @@
         </el-card>
       </el-col>
       <el-col :span="6">
-        <el-card>
+        <el-card class="stat-card" shadow="hover" @click="$router.push('/approval/todo')">
           <div class="stat-item">
             <div class="stat-icon" style="background: #67C23A;">
               <el-icon><Stamp /></el-icon>
@@ -28,7 +28,7 @@
         </el-card>
       </el-col>
       <el-col :span="6">
-        <el-card>
+        <el-card class="stat-card" shadow="hover" @click="$router.push('/approval/mine')">
           <div class="stat-item">
             <div class="stat-icon" style="background: #E6A23C;">
               <el-icon><Connection /></el-icon>
@@ -41,7 +41,7 @@
         </el-card>
       </el-col>
       <el-col :span="6">
-        <el-card>
+        <el-card class="stat-card" shadow="hover" @click="$router.push('/system/user')">
           <div class="stat-item">
             <div class="stat-icon" style="background: #F56C6C;">
               <el-icon><UserFilled /></el-icon>
@@ -57,36 +57,141 @@
 
     <el-row :gutter="20" style="margin-top: 20px;">
       <el-col :span="12">
-        <el-card title="快捷入口">
+        <el-card class="quick-card">
           <template #header>
-            <span>快捷入口</span>
+            <div class="quick-header">
+              <span>快捷操作</span>
+            </div>
           </template>
-          <div class="quick-actions">
-            <el-button type="primary" @click="$router.push('/approval/todo')">处理待办</el-button>
-            <el-button type="success" @click="$router.push('/flow/designer')">设计流程</el-button>
-            <el-button type="warning" @click="$router.push('/flow/definition')">流程管理</el-button>
+          <div class="quick-grid">
+            <div class="quick-item" @click="$router.push('/approval/todo')">
+              <div class="quick-icon" style="background: #67C23A;">
+                <el-icon><Bell /></el-icon>
+                <el-badge v-if="stats.todoCount > 0" :value="stats.todoCount" class="todo-badge" />
+              </div>
+              <div class="quick-text">处理待办</div>
+            </div>
+            <div class="quick-item" @click="$router.push('/flow/designer')">
+              <div class="quick-icon" style="background: #409EFF;">
+                <el-icon><EditPen /></el-icon>
+              </div>
+              <div class="quick-text">设计流程</div>
+            </div>
+            <div class="quick-item" @click="$router.push('/flow/definition')">
+              <div class="quick-icon" style="background: #E6A23C;">
+                <el-icon><Document /></el-icon>
+              </div>
+              <div class="quick-text">流程管理</div>
+            </div>
+            <div class="quick-item" @click="$router.push('/approval/execute')">
+              <div class="quick-icon" style="background: #909399;">
+                <el-icon><Pointer /></el-icon>
+              </div>
+              <div class="quick-text">流程执行</div>
+            </div>
           </div>
         </el-card>
       </el-col>
       <el-col :span="12">
-        <el-card>
+        <el-card class="quick-card">
           <template #header>
-            <span>系统公告</span>
+            <div class="quick-header">
+              <span>常用流程</span>
+              <el-link type="primary" :underline="false" @click="$router.push('/approval/execute')">更多</el-link>
+            </div>
           </template>
-          <el-empty description="暂无公告" />
+          <div v-loading="loadingDefinitions" class="common-flow-list">
+            <el-empty v-if="!loadingDefinitions && commonFlows.length === 0" description="暂无可用流程" />
+            <div
+              v-for="def in commonFlows"
+              :key="def.id"
+              class="common-flow-item"
+              @click="handleStart(def)"
+            >
+              <div class="common-flow-icon">
+                <el-icon><Promotion /></el-icon>
+              </div>
+              <div class="common-flow-info">
+                <div class="common-flow-name">{{ def.name }}</div>
+                <div v-if="def.category" class="common-flow-category">{{ def.category }}</div>
+              </div>
+              <el-button type="primary" size="small" @click.stop="handleStart(def)">发起</el-button>
+            </div>
+          </div>
         </el-card>
       </el-col>
     </el-row>
+
+    <!-- 发起流程弹窗 -->
+    <el-dialog v-model="startDialogVisible" title="快速发起流程" width="640px">
+      <el-form ref="startFormRef" :model="startForm" :rules="startFormRules" label-width="80px">
+        <el-form-item v-show="false">
+          <el-input v-model="startForm.processDefinitionId" />
+        </el-form-item>
+        <el-form-item label="流程名称">
+          <el-input v-model="startForm.definitionName" disabled />
+        </el-form-item>
+        <el-form-item label="标题" prop="title">
+          <el-input v-model="startForm.title" placeholder="请输入流程标题" />
+        </el-form-item>
+        <el-form-item label="表单数据">
+          <FlowFormFields v-if="hasFormFields" ref="flowFormFieldsRef" v-model="dynamicFormData" :definition="selectedDefinition" />
+          <el-input v-else v-model="startForm.formData" type="textarea" :rows="4" placeholder="请输入表单数据,格式:每行一个字段,如 amount=1000" />
+        </el-form-item>
+        <el-form-item label="附件">
+          <el-upload
+            v-model:file-list="attachmentList"
+            action="#"
+            :http-request="handleUpload"
+            :before-upload="beforeFileUpload"
+            :before-remove="() => true"
+            :limit="5"
+            multiple
+            :on-preview="handleFilePreview"
+          >
+            <el-button type="primary" size="small">上传附件</el-button>
+            <template #tip>
+              <div class="el-upload__tip">支持 Excel、图片等常见格式</div>
+            </template>
+            <template #file="{ file }">
+              <div class="upload-file-item">
+                <el-icon><Document /></el-icon>
+                <el-link type="primary" @click="openPreview(getUploadUrl(file))">
+                  {{ file.name }}
+                </el-link>
+              </div>
+            </template>
+          </el-upload>
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="startDialogVisible = false">取消</el-button>
+        <el-button type="primary" :loading="starting" @click="submitStart">确认发起</el-button>
+      </template>
+    </el-dialog>
+
+    <!-- 附件预览 -->
+    <FilePreview v-model="previewVisible" :url="previewUrl" />
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted } from 'vue'
-import { Document, Stamp, Connection, UserFilled } from '@element-plus/icons-vue'
+import { ref, onMounted, reactive, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import type { FormInstance, FormRules } from 'element-plus'
+import { Document, Stamp, Connection, UserFilled, Bell, EditPen, Pointer, Promotion } from '@element-plus/icons-vue'
 import { listTodo } from '@/api/flow/task'
-import { listMyInstance } from '@/api/flow/instance'
-import { listDefinition } from '@/api/flow/definition'
+import { listMyInstance, startInstance } from '@/api/flow/instance'
+import { listDefinition, listEnabled } from '@/api/flow/definition'
 import { listUser } from '@/api/system/user'
+import { uploadFile } from '@/api/file'
+import { beforeFileUpload, collectAttachmentUrls, getUploadUrl } from '@/utils/file'
+import type { FlowDefinition } from '@/types/flow'
+import FlowFormFields from '@/components/FlowFormFields/index.vue'
+import FilePreview from '@/components/FilePreview/index.vue'
+
+const router = useRouter()
 
 const stats = ref({
   definitionCount: 0,
@@ -95,13 +200,59 @@ const stats = ref({
   userCount: 0
 })
 
-onMounted(async () => {
+const loadingDefinitions = ref(false)
+const commonFlows = ref<FlowDefinition[]>([])
+
+const startDialogVisible = ref(false)
+const starting = ref(false)
+const startFormRef = ref<FormInstance>()
+const startForm = reactive({
+  processDefinitionId: 0,
+  definitionName: '',
+  title: '',
+  formData: ''
+})
+const dynamicFormData = ref<Record<string, unknown>>({})
+const selectedDefinition = ref<FlowDefinition | null>(null)
+const flowFormFieldsRef = ref<any>(null)
+
+const hasFormFields = computed(() => {
+  const schema = selectedDefinition.value?.formSchema
+  if (!schema) return false
+  try {
+    const parsed = JSON.parse(schema)
+    return Array.isArray(parsed) && parsed.length > 0
+  } catch {
+    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 startFormRules: FormRules = {
+  title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
+}
+
+async function loadStats() {
   try {
     const [defRes, todoRes, instRes, userRes] = await Promise.all([
-      listDefinition({ pageNum: 1, pageSize: 1 }),
+      // 统计卡片查询失败时静默处理,避免无权限用户进入首页即弹“权限不足”
+      listDefinition({ pageNum: 1, pageSize: 1 }, { silent: true }),
       listTodo({ pageNum: 1, pageSize: 1 }),
       listMyInstance({ pageNum: 1, pageSize: 1 }),
-      listUser({ pageNum: 1, pageSize: 1 })
+      listUser({ pageNum: 1, pageSize: 1 }, { silent: true })
     ])
     stats.value = {
       definitionCount: defRes.total ?? 0,
@@ -112,10 +263,94 @@ onMounted(async () => {
   } catch {
     // ignore
   }
+}
+
+async function loadCommonFlows() {
+  loadingDefinitions.value = true
+  try {
+    const res = await listEnabled()
+    commonFlows.value = (res || []).slice(0, 6)
+  } catch {
+    commonFlows.value = []
+  } finally {
+    loadingDefinitions.value = false
+  }
+}
+
+function handleStart(def: FlowDefinition) {
+  startForm.processDefinitionId = def.id
+  startForm.definitionName = def.name
+  startForm.title = ''
+  startForm.formData = ''
+  dynamicFormData.value = {}
+  selectedDefinition.value = def
+  attachmentList.value = []
+  startDialogVisible.value = true
+}
+
+async function handleUpload(options: any) {
+  try {
+    const res = await uploadFile(options.file)
+    options.onSuccess(res)
+  } catch (e) {
+    options.onError(e)
+  }
+}
+
+async function submitStart() {
+  const valid = await startFormRef.value?.validate().catch(() => false)
+  if (!valid) return
+  starting.value = true
+  try {
+    // 校验动态表单字段;无动态字段时视为校验通过
+    const dynamicValid = flowFormFieldsRef.value?.validate() ?? true
+    if (dynamicValid === false) return
+
+    let formData: Record<string, unknown> | undefined
+    if (selectedDefinition.value?.formSchema) {
+      formData = { ...dynamicFormData.value }
+    } else if (startForm.formData.trim()) {
+      // 兼容旧版无表单字段配置的流程
+      formData = {}
+      startForm.formData.split('\n').forEach(line => {
+        const idx = line.indexOf('=')
+        if (idx > 0) {
+          const key = line.substring(0, idx).trim()
+          const value = line.substring(idx + 1).trim()
+          if (key) {
+            const num = Number(value)
+            formData![key] = !isNaN(num) && value !== '' ? num : value
+          }
+        }
+      })
+    }
+    const attachmentUrls = attachmentList.value.length > 0
+      ? collectAttachmentUrls(attachmentList.value)
+      : undefined
+    await startInstance(startForm.processDefinitionId, startForm.title || undefined, formData, attachmentUrls)
+    ElMessage.success('流程发起成功')
+    startDialogVisible.value = false
+    router.push('/approval/mine')
+  } finally {
+    starting.value = false
+  }
+}
+
+onMounted(() => {
+  loadStats()
+  loadCommonFlows()
 })
 </script>
 
 <style scoped>
+.stat-card {
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+.stat-card:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
 .stat-item {
   display: flex;
   align-items: center;
@@ -141,9 +376,104 @@ onMounted(async () => {
   color: #909399;
   margin-top: 4px;
 }
-.quick-actions {
+.quick-card {
+  min-height: 260px;
+}
+.quick-header {
   display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.quick-grid {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 16px;
+}
+.quick-item {
+  display: flex;
+  align-items: center;
   gap: 12px;
-  flex-wrap: wrap;
+  padding: 16px;
+  border-radius: 8px;
+  background: #f5f7fa;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+.quick-item:hover {
+  background: #e6f2ff;
+  transform: translateY(-2px);
+}
+.quick-icon {
+  position: relative;
+  width: 48px;
+  height: 48px;
+  border-radius: 8px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #fff;
+  font-size: 24px;
+}
+.todo-badge :deep(.el-badge__content) {
+  position: absolute;
+  top: -6px;
+  right: -6px;
+}
+.quick-text {
+  font-size: 15px;
+  font-weight: 500;
+  color: #303133;
+}
+.common-flow-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+.common-flow-item {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 12px;
+  border-radius: 8px;
+  background: #f5f7fa;
+  cursor: pointer;
+  transition: all 0.2s ease;
+}
+.common-flow-item:hover {
+  background: #e6f2ff;
+}
+.common-flow-icon {
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  background: #409eff;
+  color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 18px;
+}
+.common-flow-info {
+  flex: 1;
+  min-width: 0;
+}
+.common-flow-name {
+  font-size: 14px;
+  font-weight: 500;
+  color: #303133;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.common-flow-category {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 2px;
+}
+.upload-file-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 0;
 }
 </style>

+ 22 - 0
src/views/error/404.vue

@@ -0,0 +1,22 @@
+<template>
+  <div class="not-found">
+    <el-result
+      icon="error"
+      title="404"
+      sub-title="抱歉,您访问的页面不存在"
+    >
+      <template #extra>
+        <el-button type="primary" @click="$router.push('/')">返回首页</el-button>
+      </template>
+    </el-result>
+  </div>
+</template>
+
+<style scoped>
+.not-found {
+  height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+</style>

+ 194 - 10
src/views/flow/definition/index.vue

@@ -89,7 +89,7 @@
     </el-dialog>
 
     <!-- 编辑流程 - 基本信息弹窗 -->
-    <el-dialog v-model="editDialogVisible" title="编辑流程信息" width="500px">
+    <el-dialog v-model="editDialogVisible" title="编辑流程信息" width="700px">
       <el-form ref="editFormRef" :model="editForm" :rules="editFormRules" label-width="100px">
         <el-form-item label="流程编码">
           <el-input v-model="editForm.code" disabled />
@@ -104,23 +104,113 @@
           <el-input v-model="editForm.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" @click="addFormField">添加字段</el-button>
+        </div>
+        <el-table :data="editFormFields" size="small" border style="width: 100%">
+          <el-table-column label="字段名" min-width="120">
+            <template #default="{ $index }">
+              <el-input v-model="editFormFields[$index].name" placeholder="英文标识" size="small" />
+            </template>
+          </el-table-column>
+          <el-table-column label="显示名" min-width="120">
+            <template #default="{ $index }">
+              <el-input v-model="editFormFields[$index].label" placeholder="中文显示名" size="small" />
+            </template>
+          </el-table-column>
+          <el-table-column label="类型" width="120">
+            <template #default="{ $index }">
+              <el-select v-model="editFormFields[$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="editFormFields[$index].required" />
+            </template>
+          </el-table-column>
+          <el-table-column label="多选" width="70" align="center">
+            <template #default="{ $index }">
+              <el-checkbox
+                v-model="editFormFields[$index].multiple"
+                :disabled="editFormFields[$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="editFormFields[$index].type !== 'select'"
+                @click="openOptionDialog($index)"
+              >
+                配置选项
+              </el-button>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="70" align="center">
+            <template #default="{ $index }">
+              <el-button link type="danger" size="small" @click="removeFormField($index)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <el-empty v-if="editFormFields.length === 0" description="暂无字段,点击上方按钮添加" :image-size="60" style="padding: 10px 0;" />
+      </div>
+
       <template #footer>
         <el-button @click="editDialogVisible = false">取消</el-button>
         <el-button type="primary" @click="confirmEdit">保存</el-button>
       </template>
     </el-dialog>
+
+    <!-- 下拉选项配置弹窗 -->
+    <el-dialog v-model="optionDialogVisible" title="配置下拉选项" width="500px">
+      <div v-if="optionEditingField" class="option-edit-tip">
+        字段:{{ optionEditingField.label || optionEditingField.name }}
+        <el-checkbox v-model="optionEditingField.multiple" style="margin-left: 16px;">允许多选</el-checkbox>
+      </div>
+      <el-table :data="optionList" size="small" border>
+        <el-table-column label="显示文本" min-width="140">
+          <template #default="{ $index }">
+            <el-input v-model="optionList[$index].label" placeholder="显示文本" size="small" />
+          </template>
+        </el-table-column>
+        <el-table-column label="选项值" min-width="140">
+          <template #default="{ $index }">
+            <el-input v-model="optionList[$index].value" placeholder="选项值" size="small" />
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="70" align="center">
+          <template #default="{ $index }">
+            <el-button link type="danger" size="small" @click="removeOption($index)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-button type="primary" size="small" style="margin-top: 12px;" @click="addOption">添加选项</el-button>
+      <template #footer>
+        <el-button @click="optionDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="saveOptions">保存</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted, onActivated } from 'vue'
-import { useRouter } from 'vue-router'
+import { ref, reactive, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import type { FormInstance, FormRules } from 'element-plus'
-import { listDefinition, updateDefinition, deleteDefinition, publishDefinition, stopDefinition, enableDefinition } from '@/api/flow/definition'
-import type { FlowDefinition } from '@/types/flow'
+import { listDefinition, getDefinition, updateDefinition, deleteDefinition, publishDefinition, stopDefinition, enableDefinition } from '@/api/flow/definition'
+import type { FlowDefinition, FormField, FormFieldOption } from '@/types/flow'
 
 const router = useRouter()
+const route = useRoute()
 
 const loading = ref(false)
 const tableData = ref<FlowDefinition[]>([])
@@ -160,6 +250,75 @@ const editForm = reactive<Partial<FlowDefinition>>({
 const editFormRules: FormRules = {
   name: [{ required: true, message: '请输入流程名称', trigger: 'blur' }]
 }
+const editFormFields = ref<FormField[]>([])
+const fieldTypeOptions = [
+  { label: '文本', value: 'text' },
+  { label: '多行文本', value: 'textarea' },
+  { label: '数字', value: 'number' },
+  { label: '日期', value: 'date' },
+  { label: '下拉选择', value: 'select' }
+]
+
+function addFormField() {
+  editFormFields.value.push({ name: '', label: '', type: 'text', required: false })
+}
+
+function removeFormField(index: number) {
+  editFormFields.value.splice(index, 1)
+}
+
+function getEditFormSchema(): string {
+  const validFields = editFormFields.value.filter(f => f.name.trim() && f.label.trim())
+  return validFields.length > 0 ? JSON.stringify(validFields) : ''
+}
+
+function parseEditFormSchema(schema?: string) {
+  editFormFields.value = []
+  if (!schema) return
+  try {
+    const parsed = JSON.parse(schema)
+    if (Array.isArray(parsed)) {
+      editFormFields.value = parsed
+    }
+  } catch {
+    editFormFields.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 = editFormFields.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() !== '')
+  editFormFields.value[optionEditingIndex.value].options = validOptions
+  editFormFields.value[optionEditingIndex.value].multiple = optionEditingField.value.multiple
+  optionDialogVisible.value = false
+}
 
 function statusText(status: number) {
   return ['草稿', '已发布', '已停用'][status] || '未知'
@@ -170,13 +329,14 @@ function statusTagType(status: number) {
 }
 
 async function loadData() {
+  if (loading.value) return
   loading.value = true
   try {
     const res = await listDefinition(queryParams)
     tableData.value = res.list
     total.value = res.total
-  } catch (e) {
-    console.error('load definition failed', e)
+  } catch (e: any) {
+    ElMessage.error('加载流程列表失败' + (e?.displayMsg || e?.message ? ':' + (e.displayMsg || e.message) : ''))
     tableData.value = []
     total.value = 0
   } finally {
@@ -244,8 +404,14 @@ function handleDesign(row: FlowDefinition) {
 }
 
 // 编辑流程信息:打开编辑弹窗
-function handleEdit(row: FlowDefinition) {
+async function handleEdit(row: FlowDefinition) {
   Object.assign(editForm, row)
+  try {
+    const def = await getDefinition(row.id)
+    parseEditFormSchema(def.formSchema)
+  } catch {
+    parseEditFormSchema('')
+  }
   editDialogVisible.value = true
 }
 
@@ -253,7 +419,11 @@ function handleEdit(row: FlowDefinition) {
 async function confirmEdit() {
   const valid = await editFormRef.value?.validate().catch(() => false)
   if (!valid) return
-  await updateDefinition(editForm)
+  const data: Partial<FlowDefinition> = {
+    ...editForm,
+    formSchema: getEditFormSchema()
+  }
+  await updateDefinition(data)
   ElMessage.success('修改成功')
   editDialogVisible.value = false
   loadData()
@@ -304,7 +474,6 @@ async function handleDelete(row: FlowDefinition) {
 }
 
 onMounted(loadData)
-onActivated(loadData)
 </script>
 
 <style scoped>
@@ -320,4 +489,19 @@ onActivated(loadData)
   margin-top: 20px;
   justify-content: flex-end;
 }
+.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>

+ 312 - 114
src/views/flow/designer/index.vue

@@ -44,20 +44,12 @@
           <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.assigneeType" placeholder="请选择" style="width: 100%">
-              <el-option label="角色" value="ROLE" />
-              <el-option label="指定用户" value="USER" />
-              <el-option label="发起人" value="SELF" />
-              <el-option label="部门主管" value="LEADER" />
-            </el-select>
-          </el-form-item>
-          <el-form-item v-if="nodeProps.assigneeType === 'ROLE'" label="角色">
+          <el-form-item label="审批员工">
             <el-select
               v-model="nodeProps.assigneeValue"
               filterable
               clearable
-              placeholder="请选择角色"
+              placeholder="请选择员工管理中的成员"
               style="width: 100%"
             >
               <el-option
@@ -68,30 +60,32 @@
               />
             </el-select>
           </el-form-item>
-          <el-form-item v-if="nodeProps.assigneeType === 'USER'" label="指定用户">
-            <el-select
-              v-model="nodeProps.assigneeValue"
-              filterable
-              clearable
-              multiple
-              collapse-tags
-              placeholder="请选择用户"
-              style="width: 100%"
-            >
-              <el-option
-                v-for="user in userList"
-                :key="user.id"
-                :label="user.realName || user.username"
-                :value="String(user.id)"
-              />
-            </el-select>
-          </el-form-item>
           <el-form-item label="审批方式">
             <el-select v-model="nodeProps.approveMode" placeholder="请选择" style="width: 100%">
               <el-option label="或签(一人通过即可)" value="or" />
               <el-option label="会签(全部通过)" value="and" />
             </el-select>
           </el-form-item>
+          <el-form-item label="审批时限">
+            <el-input-number
+              v-model="nodeProps.timeoutHours"
+              :min="1"
+              :max="720"
+              :precision="0"
+              placeholder="不填表示无限制"
+              controls-position="right"
+              style="width: 100%"
+            />
+            <div class="form-tip">单位:小时,为空则不计算超时</div>
+          </el-form-item>
+          <el-form-item label="超时动作">
+            <el-select v-model="nodeProps.timeoutAction" placeholder="请选择" style="width: 100%">
+              <el-option label="提醒" value="remind" />
+              <el-option label="自动通过" value="pass" />
+              <el-option label="自动驳回" value="reject" />
+            </el-select>
+            <div class="form-tip">目前仅实现「提醒」,自动通过/驳回预留</div>
+          </el-form-item>
         </template>
         <template v-if="selectedNode.type === 'condition-node'">
           <el-form-item label="条件表达式">
@@ -124,7 +118,7 @@
     </div>
 
     <!-- 保存确认弹窗(无模式时) -->
-    <el-dialog v-model="saveDialogVisible" title="保存流程" width="500px">
+    <el-dialog v-model="saveDialogVisible" title="保存流程" width="600px">
       <el-form ref="saveFormRef" :model="saveForm" :rules="saveFormRules" label-width="100px">
         <el-form-item label="流程编码" prop="code">
           <el-input v-model="saveForm.code" />
@@ -139,11 +133,100 @@
           <el-input v-model="saveForm.description" type="textarea" />
         </el-form-item>
       </el-form>
+
+      <div class="form-fields-section">
+        <div class="form-fields-header">
+          <span class="form-fields-title">流程表单字段</span>
+          <el-button type="primary" size="small" :icon="Delete" @click="addFormField">添加字段</el-button>
+        </div>
+        <el-table :data="formFields" size="small" border style="width: 100%">
+          <el-table-column label="字段名" min-width="120">
+            <template #default="{ $index }">
+              <el-input v-model="formFields[$index].name" placeholder="英文标识" size="small" />
+            </template>
+          </el-table-column>
+          <el-table-column label="显示名" min-width="120">
+            <template #default="{ $index }">
+              <el-input v-model="formFields[$index].label" placeholder="中文显示名" size="small" />
+            </template>
+          </el-table-column>
+          <el-table-column label="类型" width="120">
+            <template #default="{ $index }">
+              <el-select v-model="formFields[$index].type" placeholder="类型" size="small" style="width: 100%">
+                <el-option v-for="opt in fieldTypeOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
+              </el-select>
+            </template>
+          </el-table-column>
+          <el-table-column label="必填" width="70" align="center">
+            <template #default="{ $index }">
+              <el-checkbox v-model="formFields[$index].required" />
+            </template>
+          </el-table-column>
+          <el-table-column label="多选" width="70" align="center">
+            <template #default="{ $index }">
+              <el-checkbox
+                v-model="formFields[$index].multiple"
+                :disabled="formFields[$index].type !== 'select'"
+              />
+            </template>
+          </el-table-column>
+          <el-table-column label="选项" width="90" align="center">
+            <template #default="{ $index }">
+              <el-button
+                link
+                type="primary"
+                size="small"
+                :disabled="formFields[$index].type !== 'select'"
+                @click="openOptionDialog($index)"
+              >
+                配置选项
+              </el-button>
+            </template>
+          </el-table-column>
+          <el-table-column label="操作" width="70" align="center">
+            <template #default="{ $index }">
+              <el-button link type="danger" size="small" @click="removeFormField($index)">删除</el-button>
+            </template>
+          </el-table-column>
+        </el-table>
+        <el-empty v-if="formFields.length === 0" description="暂无字段,点击上方按钮添加" :image-size="60" style="padding: 10px 0;" />
+      </div>
+
       <template #footer>
         <el-button @click="saveDialogVisible = false">取消</el-button>
         <el-button type="primary" @click="submitSave">确认保存</el-button>
       </template>
     </el-dialog>
+
+    <!-- 下拉选项配置弹窗 -->
+    <el-dialog v-model="optionDialogVisible" title="配置下拉选项" width="500px">
+      <div v-if="optionEditingField" class="option-edit-tip">
+        字段:{{ optionEditingField.label || optionEditingField.name }}
+        <el-checkbox v-model="optionEditingField.multiple" style="margin-left: 16px;">允许多选</el-checkbox>
+      </div>
+      <el-table :data="optionList" size="small" border>
+        <el-table-column label="显示文本" min-width="140">
+          <template #default="{ $index }">
+            <el-input v-model="optionList[$index].label" placeholder="显示文本" size="small" />
+          </template>
+        </el-table-column>
+        <el-table-column label="选项值" min-width="140">
+          <template #default="{ $index }">
+            <el-input v-model="optionList[$index].value" placeholder="选项值" size="small" />
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="70" align="center">
+          <template #default="{ $index }">
+            <el-button link type="danger" size="small" @click="removeOption($index)">删除</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+      <el-button type="primary" size="small" style="margin-top: 12px;" @click="addOption">添加选项</el-button>
+      <template #footer>
+        <el-button @click="optionDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="saveOptions">保存</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -158,10 +241,10 @@ import { VideoPlay, User, Message, Operation, CircleCheck } from '@element-plus/
 import { ElMessage } from 'element-plus'
 import type { FormInstance, FormRules } from 'element-plus'
 import { listRole } from '@/api/system/role'
-import { listUser } from '@/api/system/user'
 import { addDefinition, updateDefinition, getDefinition } from '@/api/flow/definition'
 import type { Role } from '@/types/system'
-import type { FlowDefinition } from '@/types/flow'
+import type { FlowDefinition, FormField, FormFieldOption } from '@/types/flow'
+import { Delete } from '@element-plus/icons-vue'
 
 const route = useRoute()
 const router = useRouter()
@@ -179,11 +262,12 @@ const flowInfoVisible = computed(() => mode.value === 'create' || mode.value ===
 
 const selectedNode = ref<any>(null)
 const roleList = ref<Role[]>([])
-const userList = ref<{id: number; username: string; realName?: string}[]>([])
 const nodeProps = reactive<Record<string, any>>({
   assigneeType: 'ROLE',
   assigneeValue: '',
   approveMode: 'or',
+  timeoutHours: undefined as number | undefined,
+  timeoutAction: 'remind',
   condition: '',
   ccUsers: ''
 })
@@ -206,7 +290,7 @@ const nodeTypes = [
 ]
 
 function getDefaultProps(type: string) {
-  if (type === 'approval-node') return { assigneeType: 'ROLE', assigneeValue: '', approveMode: 'or' }
+  if (type === 'approval-node') return { assigneeType: 'ROLE', assigneeValue: '', approveMode: 'or', timeoutHours: undefined, timeoutAction: 'remind' }
   if (type === 'condition-node') return { condition: '' }
   if (type === 'cc-node') return { ccUsers: '' }
   return {}
@@ -214,12 +298,21 @@ function getDefaultProps(type: string) {
 
 function saveCurrentNodeProps() {
   if (selectedNode.value && lf) {
-    const props = JSON.parse(JSON.stringify(nodeProps))
-    // USER 类型多选时,将数组转为逗号分隔字符串
-    if (props.assigneeType === 'USER' && Array.isArray(props.assigneeValue)) {
-      props.assigneeValue = props.assigneeValue.join(',')
+    const type = selectedNode.value.type
+    const defaultProps = getDefaultProps(type)
+    // 只保存当前节点类型相关的属性,避免不同类型节点属性互相污染
+    const props: Record<string, any> = JSON.parse(JSON.stringify(defaultProps))
+    if (type === 'approval-node') {
+      props.assigneeType = 'ROLE'
+      props.assigneeValue = nodeProps.assigneeValue ?? ''
+      props.approveMode = nodeProps.approveMode ?? 'or'
+      props.timeoutHours = nodeProps.timeoutHours
+      props.timeoutAction = nodeProps.timeoutAction ?? 'remind'
+    } else if (type === 'condition-node') {
+      props.condition = nodeProps.condition ?? ''
+    } else if (type === 'cc-node') {
+      props.ccUsers = nodeProps.ccUsers ?? ''
     }
-    // 只保存当前节点类型相关的属性
     lf.setProperties(selectedNode.value.id, props)
   }
 }
@@ -227,25 +320,30 @@ function saveCurrentNodeProps() {
 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 -> 新格式
-  let assigneeType = cloned.assigneeType ?? 'ROLE'
-  let assigneeValue = cloned.assigneeValue ?? ''
-  let approveMode = cloned.approveMode ?? cloned.approveType ?? 'or'
-  if (!cloned.assigneeType && cloned.approver) {
-    assigneeType = 'ROLE'
-    assigneeValue = cloned.approver
-  }
-  // USER 类型:将逗号分隔字符串转为数组
-  if (assigneeType === 'USER' && typeof assigneeValue === 'string' && assigneeValue) {
-    assigneeValue = assigneeValue.split(',').filter(Boolean)
+  if (data.type === 'approval-node') {
+    let assigneeType = 'ROLE'
+    let assigneeValue = cloned.assigneeValue ?? ''
+    let approveMode = cloned.approveMode ?? cloned.approveType ?? 'or'
+    if (!cloned.assigneeValue && cloned.approver) {
+      assigneeValue = cloned.approver
+    }
+    // 旧数据中的 USER/SELF/LEADER 不再支持,统一清空让用户重新选择员工角色
+    if (cloned.assigneeType && cloned.assigneeType !== 'ROLE') {
+      assigneeValue = ''
+    }
+    nodeProps.assigneeType = assigneeType
+    nodeProps.assigneeValue = assigneeValue
+    nodeProps.approveMode = approveMode
+    nodeProps.timeoutHours = cloned.timeoutHours ?? undefined
+    nodeProps.timeoutAction = cloned.timeoutAction ?? 'remind'
+  } else if (data.type === 'condition-node') {
+    nodeProps.condition = cloned.condition ?? ''
+  } else if (data.type === 'cc-node') {
+    nodeProps.ccUsers = cloned.ccUsers ?? ''
   }
-  Object.assign(nodeProps, {
-    assigneeType,
-    assigneeValue,
-    approveMode,
-    condition: cloned.condition ?? '',
-    ccUsers: cloned.ccUsers ?? ''
-  })
 }
 
 function registerNodes() {
@@ -371,6 +469,7 @@ function initLogicFlow() {
   // 编辑模式:加载已有流程图
   if (mode.value === 'edit' && flowId.value) {
     getDefinition(Number(flowId.value)).then((def: FlowDefinition) => {
+      parseFormSchema(def.formSchema)
       if (def.flowJson) {
         try {
           const graphData = convertToFrontendFormat(JSON.parse(def.flowJson))
@@ -429,13 +528,7 @@ function convertToBackendFormat(graphData: any) {
       return n
     })
   }
-  if (data.edges) {
-    data.edges = data.edges.map((edge: any) => {
-      const e = { ...edge }
-      if (e.sourceNodeId === undefined && e.sourceNodeId !== undefined) { /* noop */ }
-      return e
-    })
-  }
+  // edges 保持原样,properties 中的 condition 会在后端解析
   return data
 }
 
@@ -497,6 +590,75 @@ const saveFormRules: FormRules = {
   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 || []
@@ -507,17 +669,13 @@ function validateFlow(graphData: any): string | null {
   if (endNodes.length === 0) return '流程图缺少结束节点'
   if (startNodes.length > 1) return '流程图只能有一个开始节点'
   if (endNodes.length > 1) return '流程图只能有一个结束节点'
-  // 检查审批节点是否配置了审批人
+  // 检查审批节点是否选择了员工管理中的成员(角色账号)
   for (const node of nodes) {
     if (node.type === 'approval-node' || node.type === 'approval') {
       const props = node.properties || {}
-      const assigneeType = props.assigneeType || props.approver
-      if (!assigneeType) {
-        return `审批节点 "${node.text || node.name}" 未配置审批人`
-      }
       const assigneeValue = props.assigneeValue || props.approver
-      if (!assigneeValue && assigneeType !== 'SELF' && assigneeType !== 'LEADER') {
-        return `审批节点 "${node.text || node.name}" 未选择具体的审批人/角色`
+      if (!assigneeValue) {
+        return `审批节点 "${node.text || node.name}" 未选择员工管理中的审批成员`
       }
     }
   }
@@ -555,34 +713,71 @@ function validateFlow(graphData: any): string | null {
 async function handleSave() {
   if (!lf) return
   saveCurrentNodeProps()
-  const graphData = convertToBackendFormat(lf.getGraphData())
-  const error = validateFlow(lf.getGraphData())
+  const rawGraphData = lf.getGraphData()
+  const error = validateFlow(rawGraphData)
   if (error) {
     ElMessage.warning(error)
     return
   }
 
+  // 统一弹出保存弹窗,方便配置流程表单字段
+  if (mode.value === 'create') {
+    saveForm.code = flowCode.value
+    saveForm.name = flowName.value
+    saveForm.category = flowCategory.value
+    saveForm.description = flowDescription.value
+    formFields.value = []
+  } else if (mode.value === 'edit' && flowId.value) {
+    saveForm.code = flowCode.value
+    saveForm.name = flowName.value
+    saveForm.category = flowCategory.value
+    saveForm.description = flowDescription.value
+    // formFields 在编辑模式加载流程时已通过 parseFormSchema 填充
+  } else {
+    saveForm.code = ''
+    saveForm.name = ''
+    saveForm.category = ''
+    saveForm.description = ''
+    formFields.value = []
+  }
+  saveDialogVisible.value = true
+}
+
+async function submitSave() {
+  const valid = await saveFormRef.value?.validate().catch(() => false)
+  if (!valid) return
+  if (!lf) return
+  saveCurrentNodeProps()
+  const rawGraphData = lf.getGraphData()
+  const error = validateFlow(rawGraphData)
+  if (error) {
+    ElMessage.warning(error)
+    return
+  }
+  const graphData = convertToBackendFormat(rawGraphData)
+
   if (mode.value === 'create') {
-    // 新增模式:直接保存
     const data: Partial<FlowDefinition> = {
-      code: flowCode.value,
-      name: flowName.value,
-      category: flowCategory.value,
-      description: flowDescription.value,
+      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: flowCode.value,
-      name: flowName.value,
-      category: flowCategory.value,
-      description: flowDescription.value,
+      code: saveForm.code,
+      name: saveForm.name,
+      category: saveForm.category,
+      description: saveForm.description,
+      formSchema: getFormSchema(),
       flowJson: JSON.stringify(graphData)
     }
     const newId = await updateDefinition(data)
@@ -591,34 +786,23 @@ async function handleSave() {
     } else {
       ElMessage.success('流程修改成功')
     }
+    saveDialogVisible.value = false
     router.push('/flow/definition')
   } else {
-    // 无模式:弹出保存表单
-    saveForm.code = ''
-    saveForm.name = ''
-    saveForm.category = ''
-    saveForm.description = ''
-    saveDialogVisible.value = true
-  }
-}
-
-async function submitSave() {
-  const valid = await saveFormRef.value?.validate().catch(() => false)
-  if (!valid) return
-  if (!lf) return
-  const graphData = convertToBackendFormat(lf.getGraphData())
-  const data: Partial<FlowDefinition> = {
-    code: saveForm.code,
-    name: saveForm.name,
-    category: saveForm.category,
-    description: saveForm.description,
-    flowJson: JSON.stringify(graphData),
-    status: 0
+    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')
   }
-  await addDefinition(data)
-  ElMessage.success('流程保存成功')
-  saveDialogVisible.value = false
-  router.push('/flow/definition')
 }
 
 function handleClear() {
@@ -638,21 +822,20 @@ async function loadRoles() {
   } catch { /* ignore */ }
 }
 
-async function loadUsers() {
-  try {
-    const res = await listUser({ pageNum: 1, pageSize: 500 })
-    userList.value = res.list
-  } catch { /* ignore */ }
-}
-
 onMounted(() => {
+  // 防止快速进入/离开设计器时重复创建 LogicFlow 实例
+  if (lf) {
+    lf.destroy()
+    lf = null
+  }
   nextTick(initLogicFlow)
   loadRoles()
-  loadUsers()
 })
 
 onUnmounted(() => {
   lf?.destroy()
+  lf = null
+  selectedNode.value = null
 })
 </script>
 
@@ -747,4 +930,19 @@ onUnmounted(() => {
   display: flex;
   flex-direction: column;
 }
+.form-fields-section {
+  margin-top: 16px;
+  border-top: 1px solid #ebeef5;
+  padding-top: 16px;
+}
+.form-fields-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+}
+.form-fields-title {
+  font-weight: bold;
+  color: #303133;
+}
 </style>

+ 183 - 140
src/views/flow/execute/InstanceDetail.vue

@@ -1,20 +1,23 @@
 <template>
-  <el-dialog
+  <el-drawer
     v-model="visible"
     title="流程详情"
-    width="900px"
+    direction="rtl"
+    size="55%"
     :close-on-click-modal="false"
-    @close="handleClose"
+    :destroy-on-close="true"
+    @closed="handleClose"
   >
     <div v-loading="loading" class="detail-content">
+      <FilePreview v-model="previewVisible" :url="previewUrl" />
       <!-- 流程基本信息 -->
       <div class="info-section">
         <el-descriptions :column="3" size="small" border>
           <el-descriptions-item label="流程名称">{{ progress?.definition?.name }}</el-descriptions-item>
-          <el-descriptions-item label="实例编号">{{ (progress?.instance as any)?.instanceNo || progress?.instance?.id }}</el-descriptions-item>
+          <el-descriptions-item label="实例编号">{{ progress?.instance?.instanceNo || progress?.instance?.id }}</el-descriptions-item>
           <el-descriptions-item label="状态">
-            <el-tag :type="statusTagType(progress?.instance?.status)">
-              {{ statusText(progress?.instance?.status) }}
+            <el-tag :type="instanceStatusTagType(progress?.instance?.status)">
+              {{ instanceStatusText(progress?.instance?.status) }}
             </el-tag>
           </el-descriptions-item>
           <el-descriptions-item label="剩余节点">
@@ -28,31 +31,36 @@
       <!-- 表单数据 -->
       <div v-if="formDataDisplay" class="form-data-section">
         <h4>表单数据</h4>
-        <el-descriptions :column="2" size="small" border>
-          <el-descriptions-item v-for="(value, key) in formDataDisplay" :key="key" :label="String(key)">
-            {{ value }}
-          </el-descriptions-item>
-        </el-descriptions>
+        <FormDataDisplay
+          :form-schema="progress?.definition?.formSchema"
+          :form-data="progress?.instance?.formData"
+        />
       </div>
 
       <!-- 附件列表 -->
-      <div v-if="instanceAttachments.length > 0" class="attachment-section">
+      <div class="attachment-section">
         <h4>附件列表</h4>
-        <div class="attachment-list">
-          <div v-for="(url, idx) in instanceAttachments" :key="idx" class="attachment-item">
-            <el-link type="primary" :href="getFileUrl(url)" target="_blank">
+        <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-icon><Document /></el-icon>
-              {{ getFileName(url) }}
+              <span class="attachment-name" :title="att.fileName">{{ att.fileName }}</span>
             </el-link>
+            <span class="attachment-meta">
+              {{ att.nodeName || '发起' }}
+              <template v-if="att.uploaderName">· {{ att.uploaderName }}</template>
+              <template v-if="att.createTime">· {{ att.createTime }}</template>
+            </span>
             <el-image
-              v-if="isImage(url)"
-              :src="getFileUrl(url)"
+              v-if="isImage(att.fileUrl)"
+              :src="getFileUrl(att.fileUrl)"
               :preview-src-list="imagePreviewList"
               fit="cover"
               style="width: 60px; height: 60px; margin-left: 8px;"
             />
           </div>
         </div>
+        <el-empty v-else description="暂无附件" :image-size="60" style="padding: 10px 0;" />
       </div>
 
       <el-divider />
@@ -125,8 +133,11 @@
                   v-model:file-list="attachmentList"
                   action="#"
                   :http-request="handleUpload"
+                  :before-upload="beforeFileUpload"
                   :before-remove="() => true"
+                  :limit="5"
                   multiple
+                  :on-preview="handleFilePreview"
                 >
                   <el-button type="primary" size="small">上传附件</el-button>
                 </el-upload>
@@ -135,8 +146,8 @@
                 <el-input v-model="form.comment" type="textarea" placeholder="请输入审批意见" />
               </el-form-item>
               <el-form-item>
-                <el-button type="primary" @click="submitApprove">提交</el-button>
-                <el-button @click="handleAddSign">加签</el-button>
+                <el-button type="primary" :loading="submitting" @click="submitApprove">提交</el-button>
+                <el-button :loading="submitting" @click="handleAddSign">加签</el-button>
               </el-form-item>
             </el-form>
           </template>
@@ -167,8 +178,7 @@
                   v-for="(url, idx) in parseAttachments(record.attachmentUrls)"
                   :key="idx"
                   type="primary"
-                  :href="getFileUrl(url)"
-                  target="_blank"
+                  @click="openPreview(url)"
                   size="small"
                 >
                   附件{{ idx + 1 }}: {{ getFileName(url) }}
@@ -180,19 +190,39 @@
         </div>
       </div>
     </div>
+  </el-drawer>
+
+  <!-- 加签弹窗 -->
+  <el-dialog
+    v-model="addSignDialogVisible"
+    title="加签"
+    width="400px"
+    :close-on-click-modal="false"
+  >
+    <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>
+    <template #footer>
+      <el-button @click="addSignDialogVisible = false">取消</el-button>
+      <el-button type="primary" :loading="submitting" @click="confirmAddSign">确认</el-button>
+    </template>
   </el-dialog>
 </template>
 
 <script setup lang="ts">
 import { ref, reactive, computed, watch } from 'vue'
 import { ElMessage } from 'element-plus'
-import { getProgress } from '@/api/flow/instance'
+import { getProgress, getInstanceAttachments } from '@/api/flow/instance'
 import { approveTask, rejectTask, returnTask, transferTask, addSignTask } from '@/api/flow/task'
 import { listUser } from '@/api/system/user'
 import { uploadFile } from '@/api/file'
-import type { ProcessProgress, FlowTask, NodeProgress } from '@/types/flow'
+import { beforeFileUpload, getFileUrl, getFileName, getUploadUrl, parseAttachments, collectAttachmentUrls } from '@/utils/file'
+import { instanceStatusText, instanceStatusTagType, taskStatusText, taskStatusType, recordActionText, recordActionType } from '@/utils/flow'
+import FilePreview from '@/components/FilePreview/index.vue'
+import FormDataDisplay from '@/components/FormDataDisplay/index.vue'
+import type { ProcessProgress, FlowTask, NodeProgress, ApprovalAction, Attachment } from '@/types/flow'
 import type { User } from '@/types/system'
-import { Check, CircleCheck, Clock, Warning, Document } from '@element-plus/icons-vue'
+import { CircleCheck, Clock, Document } from '@element-plus/icons-vue'
 
 const props = defineProps<{
   modelValue: boolean
@@ -210,16 +240,28 @@ const visible = computed({
 })
 
 const loading = ref(false)
+const previewVisible = ref(false)
+const previewUrl = ref('')
 const progress = ref<ProcessProgress | null>(null)
 
-const form = reactive({
+type ApprovalActionType = 'pass' | 'reject' | 'rollback' | 'transfer'
+
+const form = reactive<{
+  action: ApprovalActionType
+  comment: string
+  transferTo: number | undefined
+  targetNodeId: string | undefined
+}>({
   action: 'pass',
   comment: '',
-  transferTo: undefined as number | undefined,
-  targetNodeId: undefined as string | undefined
+  transferTo: undefined,
+  targetNodeId: undefined
 })
 
 const attachmentList = ref<any[]>([])
+const submitting = ref(false)
+const addSignDialogVisible = ref(false)
+const addSignUserId = ref<number | undefined>(undefined)
 
 const rollbackNodeOptions = computed<NodeProgress[]>(() => {
   if (!progress.value) return []
@@ -230,62 +272,52 @@ const userList = ref<User[]>([])
 
 async function loadUsers() {
   try {
-    const res = await listUser({ pageNum: 1, pageSize: 9999 })
+    // 静默加载用户列表,避免无权限用户在查看审批详情时被弹窗打扰
+    const res = await listUser({ pageNum: 1, pageSize: 9999 }, { silent: true })
     userList.value = res.list
   } catch {
-    // ignore
+    userList.value = []
   }
 }
 
-// 解析表单数据
+// 是否有表单数据用于展示
 const formDataDisplay = computed(() => {
-  const formDataStr = (progress.value?.instance as any)?.formData
-  if (!formDataStr) return null
-  try {
-    return JSON.parse(formDataStr)
-  } catch {
-    return { 原始数据: formDataStr }
-  }
+  return !!progress.value?.instance?.formData
 })
 
-// 实例附件列表
+const attachments = ref<Attachment[]>([])
+
+// 实例附件列表(从附件表读取,包含所有节点上传的历史附件)
 const instanceAttachments = computed(() => {
-  const urlsStr = (progress.value?.instance as any)?.attachmentUrls
-  if (!urlsStr) return []
-  try {
-    return JSON.parse(urlsStr) as string[]
-  } catch {
-    return []
-  }
+  return attachments.value.map(a => a.fileUrl)
 })
 
 // 图片预览列表
 const imagePreviewList = computed(() => {
-  return instanceAttachments.value.filter(isImage).map(getFileUrl)
+  return attachments.value.filter(a => isImage(a.fileUrl)).map(a => getFileUrl(a.fileUrl))
 })
 
-function isImage(url: string): boolean {
-  return /\.(png|jpe?g|gif|webp|bmp)$/i.test(url)
+async function loadAttachments() {
+  if (!props.instanceId) return
+  try {
+    attachments.value = await getInstanceAttachments(props.instanceId)
+  } catch {
+    attachments.value = []
+  }
 }
 
-function getFileUrl(url: string): string {
-  if (url.startsWith('http')) return url
-  // 相对路径,拼接当前页面 origin(通过 /uploads 代理到后端)
-  return window.location.origin + url
+function handleFilePreview(file: any) {
+  const url = getUploadUrl(file)
+  if (url) openPreview(url)
 }
 
-function getFileName(url: string): string {
-  const parts = url.split('/')
-  return parts[parts.length - 1] || url
+function openPreview(url: string) {
+  previewUrl.value = url
+  previewVisible.value = true
 }
 
-function parseAttachments(urlsStr?: string): string[] {
-  if (!urlsStr) return []
-  try {
-    return JSON.parse(urlsStr) as string[]
-  } catch {
-    return urlsStr ? [urlsStr] : []
-  }
+function isImage(url: string): boolean {
+  return /\.(png|jpe?g|gif|webp|bmp)$/i.test(url)
 }
 
 async function handleUpload(options: any) {
@@ -309,23 +341,6 @@ const myPendingTask = computed<FlowTask | null>(() => {
   return null
 })
 
-function statusText(status?: number) {
-  if (status === undefined) return '未知'
-  const map: Record<number, string> = {
-    0: '待接收', 1: '运行中', 2: '已通过', 3: '已拒绝',
-    4: '已回退', 5: '已完成', 6: '已撤回', 7: '已终止'
-  }
-  return map[status] || '未知'
-}
-
-function statusTagType(status?: number) {
-  const map: Record<number, any> = {
-    0: 'info', 1: 'primary', 2: 'success', 3: 'danger',
-    4: 'warning', 5: 'success', 6: 'info', 7: 'danger'
-  }
-  return map[status ?? 0] || 'info'
-}
-
 function timelineType(status: string) {
   if (status === 'completed') return 'success'
   if (status === 'current') return 'warning'
@@ -344,30 +359,15 @@ function timelineColor(status: string) {
   return '#909399'
 }
 
-function taskStatusType(status: number) {
-  return ['warning', 'success', 'info', 'info', 'danger'][status] || 'info'
-}
-
-function taskStatusText(status: number) {
-  return ['待处理', '已处理', '已转办', '已跳过', '已回退'][status] || '未知'
-}
-
-function recordActionType(result: string) {
-  const map: Record<string, string> = { PASS: 'success', REJECT: 'danger', RETURN: 'warning', TRANSFER: 'info' }
-  return map[result] || 'info'
-}
-
-function recordActionText(result: string) {
-  const map: Record<string, string> = { PASS: '通过', REJECT: '拒绝', RETURN: '回退', TRANSFER: '转办' }
-  return map[result] || result
-}
-
 async function loadProgress() {
   if (!props.instanceId) return
   loading.value = true
   try {
-    const res = await getProgress(props.instanceId)
+    // 静默加载进度,无权限时不在详情页弹全局错误提示
+    const res = await getProgress(props.instanceId, { silent: true })
     progress.value = res
+  } catch {
+    progress.value = null
   } finally {
     loading.value = false
   }
@@ -376,57 +376,86 @@ async function loadProgress() {
 async function submitApprove() {
   const task = myPendingTask.value
   if (!task) return
-  const data: any = {
-    action: form.action,
-    comment: form.comment
+  if (form.action === 'transfer' && !form.transferTo) {
+    ElMessage.warning('请选择转办人')
+    return
   }
-  if (form.action === 'transfer' && form.transferTo) {
-    data.transferTo = Number(form.transferTo)
+  if (form.action === 'rollback' && !form.targetNodeId) {
+    ElMessage.warning('请选择回退节点')
+    return
   }
-  if (form.action === 'rollback' && 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))
-  }
-  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
-    case 'transfer':
-      await transferTask(task.id, data)
-      break
+  submitting.value = true
+  try {
+    const data: ApprovalAction = {
+      action: form.action,
+      comment: form.comment
+    }
+    if (form.action === 'transfer' && form.transferTo) {
+      data.transferTo = Number(form.transferTo)
+    }
+    if (form.action === 'rollback' && form.targetNodeId) {
+      data.targetNodeId = form.targetNodeId
+    }
+    if (attachmentList.value.length > 0) {
+      data.attachmentUrls = collectAttachmentUrls(attachmentList.value)
+    }
+    switch (form.action) {
+      case 'pass':
+        await approveTask(task.id, data)
+        break
+      case 'reject':
+        await rejectTask(task.id, data)
+        break
+      case 'rollback':
+        await returnTask(task.id, data)
+        break
+      case 'transfer':
+        await transferTask(task.id, data)
+        break
+    }
+    ElMessage.success('审批提交成功')
+    form.action = 'pass'
+    form.comment = ''
+    form.transferTo = undefined
+    form.targetNodeId = undefined
+    attachmentList.value = []
+    // 刷新进度展示最新状态,不关闭抽屉
+    await loadProgress()
+    emit('approved')
+  } finally {
+    submitting.value = false
   }
-  ElMessage.success('审批提交成功')
-  form.action = 'pass'
-  form.comment = ''
-  form.transferTo = undefined
-  form.targetNodeId = undefined
-  attachmentList.value = []
-  // 刷新进度展示最新状态,不关闭弹窗
-  await loadProgress()
-  emit('approved')
 }
 
 function handleClose() {
   progress.value = null
   attachmentList.value = []
+  attachments.value = []
 }
 
-async function handleAddSign() {
+function handleAddSign() {
   const task = myPendingTask.value
   if (!task) return
-  const assigneeId = window.prompt('请输入加签人用户ID')
-  if (!assigneeId) return
-  await addSignTask(task.id, Number(assigneeId))
-  ElMessage.success('加签成功')
-  loadProgress()
+  addSignUserId.value = undefined
+  addSignDialogVisible.value = true
+}
+
+async function confirmAddSign() {
+  const task = myPendingTask.value
+  if (!task || !addSignUserId.value) {
+    ElMessage.warning('请选择加签人')
+    return
+  }
+  submitting.value = true
+  try {
+    await addSignTask(task.id, addSignUserId.value)
+    ElMessage.success('加签成功')
+    addSignDialogVisible.value = false
+    addSignUserId.value = undefined
+    await loadProgress()
+  } finally {
+    submitting.value = false
+  }
 }
 
 watch(() => props.instanceId, (id) => {
@@ -439,14 +468,16 @@ watch(() => props.modelValue, (val) => {
   if (val && props.instanceId) {
     loadProgress()
     loadUsers()
+    loadAttachments()
   }
 })
 </script>
 
 <style scoped>
 .detail-content {
-  max-height: 600px;
+  height: calc(100vh - 120px);
   overflow-y: auto;
+  padding-right: 8px;
 }
 .info-section {
   margin-bottom: 10px;
@@ -469,10 +500,22 @@ watch(() => props.modelValue, (val) => {
 .attachment-item {
   display: flex;
   align-items: center;
-  padding: 6px 10px;
+  flex-wrap: wrap;
+  gap: 8px;
+  padding: 8px 12px;
   background: #f5f7fa;
   border-radius: 4px;
 }
+.attachment-name {
+  max-width: 220px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.attachment-meta {
+  font-size: 12px;
+  color: #909399;
+}
 .detail-body {
   display: flex;
   gap: 24px;

+ 99 - 45
src/views/flow/execute/index.vue

@@ -32,19 +32,23 @@
           </div>
         </el-tab-pane>
 
-        <el-tab-pane label="我的流程" name="mine">
+        <el-tab-pane label="我发起的流程" name="mine">
           <el-table v-loading="loadingInstances" :data="instanceList" border>
             <el-table-column type="index" label="序号" width="60" />
             <el-table-column prop="definitionName" label="流程名称" />
             <el-table-column prop="title" label="标题" />
             <el-table-column prop="status" label="状态" width="100">
               <template #default="{ row }">
-                <el-tag :type="statusTagType(row.status)">
-                  {{ statusText(row.status) }}
+                <el-tag :type="instanceStatusTagType(row.status)">
+                  {{ instanceStatusText(row.status) }}
                 </el-tag>
               </template>
             </el-table-column>
-            <el-table-column prop="currentNode" label="当前节点" />
+            <el-table-column prop="currentNodeName" label="当前节点">
+              <template #default="{ row }">
+                {{ row.currentNodeName || row.currentNode || '-' }}
+              </template>
+            </el-table-column>
             <el-table-column prop="startTime" label="发起时间" />
             <el-table-column label="操作" width="120">
               <template #default="{ row }">
@@ -59,14 +63,15 @@
             :page-sizes="[10, 20, 50]"
             layout="total, sizes, prev, pager, next, jumper"
             class="pagination"
-            @change="loadInstances"
+            @current-change="loadInstances"
+            @size-change="loadInstances"
           />
         </el-tab-pane>
       </el-tabs>
     </el-card>
 
     <!-- 发起流程弹窗 -->
-    <el-dialog v-model="startDialogVisible" title="发起流程" width="500px">
+    <el-dialog v-model="startDialogVisible" title="发起流程" width="640px">
       <el-form ref="startFormRef" :model="startForm" :rules="startFormRules" label-width="80px">
         <el-form-item v-show="false">
           <el-input v-model="startForm.processDefinitionId" />
@@ -78,26 +83,37 @@
           <el-input v-model="startForm.title" placeholder="请输入流程标题" />
         </el-form-item>
         <el-form-item label="表单数据">
-          <el-input v-model="startForm.formData" type="textarea" :rows="4" placeholder="请输入表单数据,格式:每行一个字段,如 amount=1000" />
+          <FlowFormFields v-if="hasFormFields" ref="flowFormFieldsRef" v-model="dynamicFormData" :definition="selectedDefinition" />
+          <el-input v-else v-model="startForm.formData" type="textarea" :rows="4" placeholder="请输入表单数据,格式:每行一个字段,如 amount=1000" />
         </el-form-item>
         <el-form-item label="附件">
           <el-upload
             v-model:file-list="attachmentList"
             action="#"
             :http-request="handleUpload"
+            :before-upload="beforeFileUpload"
             :before-remove="() => true"
+            :limit="5"
             multiple
           >
             <el-button type="primary" size="small">上传附件</el-button>
             <template #tip>
               <div class="el-upload__tip">支持 Excel、图片等常见格式</div>
             </template>
+            <template #file="{ file }">
+              <div class="upload-file-item">
+                <el-icon><Document /></el-icon>
+                <el-link type="primary" @click="openPreview(getUploadUrl(file))">
+                  {{ file.name }}
+                </el-link>
+              </div>
+            </template>
           </el-upload>
         </el-form-item>
       </el-form>
       <template #footer>
         <el-button @click="startDialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="submitStart">确认发起</el-button>
+        <el-button type="primary" :loading="submitting" @click="submitStart">确认发起</el-button>
       </template>
     </el-dialog>
 
@@ -107,22 +123,31 @@
       :instance-id="currentInstanceId"
       @approved="loadInstances"
     />
+
+    <!-- 附件预览 -->
+    <FilePreview v-model="previewVisible" :url="previewUrl" />
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue'
+import { ref, reactive, computed, onMounted } from 'vue'
 import { ElMessage } from 'element-plus'
 import type { FormInstance, FormRules } from 'element-plus'
+import { Document } from '@element-plus/icons-vue'
 import { listEnabled } from '@/api/flow/definition'
-import { participatedList, startInstance } from '@/api/flow/instance'
+import { listMyInstance, startInstance } from '@/api/flow/instance'
 import { uploadFile } from '@/api/file'
+import { beforeFileUpload, collectAttachmentUrls, getUploadUrl } from '@/utils/file'
+import { instanceStatusText, instanceStatusTagType } from '@/utils/flow'
 import type { FlowDefinition, FlowInstance } from '@/types/flow'
 import InstanceDetail from './InstanceDetail.vue'
+import FlowFormFields from '@/components/FlowFormFields/index.vue'
+import FilePreview from '@/components/FilePreview/index.vue'
 
 const activeTab = ref('start')
 const loadingDefinitions = ref(false)
 const loadingInstances = ref(false)
+const submitting = ref(false)
 const definitionList = ref<FlowDefinition[]>([])
 const instanceList = ref<FlowInstance[]>([])
 const total = ref(0)
@@ -136,6 +161,20 @@ const startForm = ref({
   title: '',
   formData: ''
 })
+const dynamicFormData = ref<Record<string, unknown>>({})
+const selectedDefinition = ref<FlowDefinition | null>(null)
+const flowFormFieldsRef = ref<any>(null)
+
+const hasFormFields = computed(() => {
+  const schema = selectedDefinition.value?.formSchema
+  if (!schema) return false
+  try {
+    const parsed = JSON.parse(schema)
+    return Array.isArray(parsed) && parsed.length > 0
+  } catch {
+    return false
+  }
+})
 const attachmentList = ref<any[]>([])
 const startFormRules: FormRules = {
   title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
@@ -143,21 +182,18 @@ const startFormRules: FormRules = {
 
 const detailVisible = ref(false)
 const currentInstanceId = ref(0)
+const previewVisible = ref(false)
+const previewUrl = ref('')
 
-function statusText(status: number) {
-  const map: Record<number, string> = {
-    0: '待接收', 1: '运行中', 2: '已通过', 3: '已拒绝',
-    4: '已回退', 5: '已完成', 6: '已撤回', 7: '已终止'
-  }
-  return map[status] || '未知'
+function openPreview(url: string) {
+  if (!url) return
+  previewUrl.value = url
+  previewVisible.value = true
 }
 
-function statusTagType(status: number) {
-  const map: Record<number, any> = {
-    0: 'info', 1: 'primary', 2: 'success', 3: 'danger',
-    4: 'warning', 5: 'success', 6: 'info', 7: 'danger'
-  }
-  return map[status] || 'info'
+function handleFilePreview(file: any) {
+  const url = getUploadUrl(file)
+  if (url) openPreview(url)
 }
 
 async function loadDefinitions() {
@@ -173,7 +209,7 @@ async function loadDefinitions() {
 async function loadInstances() {
   loadingInstances.value = true
   try {
-    const res = await participatedList(queryParams)
+    const res = await listMyInstance(queryParams)
     instanceList.value = res.list
     total.value = res.total
   } finally {
@@ -186,6 +222,8 @@ function handleStart(def: FlowDefinition) {
   startForm.value.definitionName = def.name
   startForm.value.title = ''
   startForm.value.formData = ''
+  dynamicFormData.value = {}
+  selectedDefinition.value = def
   attachmentList.value = []
   startDialogVisible.value = true
 }
@@ -202,30 +240,40 @@ async function handleUpload(options: any) {
 async function submitStart() {
   const valid = await startFormRef.value?.validate().catch(() => false)
   if (!valid) return
-  let formData: Record<string, unknown> | undefined
-  if (startForm.value.formData.trim()) {
-    formData = {}
-    startForm.value.formData.split('\n').forEach(line => {
-      const idx = line.indexOf('=')
-      if (idx > 0) {
-        const key = line.substring(0, idx).trim()
-        const value = line.substring(idx + 1).trim()
-        if (key) {
-          // 尝试数字转换
-          const num = Number(value)
-          formData![key] = !isNaN(num) && value !== '' ? num : value
+  // 校验动态表单字段;无动态字段时视为校验通过
+  const dynamicValid = flowFormFieldsRef.value?.validate() ?? true
+  if (dynamicValid === false) return
+  submitting.value = true
+  try {
+    let formData: Record<string, unknown> | undefined
+    if (selectedDefinition.value?.formSchema) {
+      formData = { ...dynamicFormData.value }
+    } else if (startForm.value.formData.trim()) {
+      // 兼容旧版无表单字段配置的流程
+      formData = {}
+      startForm.value.formData.split('\n').forEach(line => {
+        const idx = line.indexOf('=')
+        if (idx > 0) {
+          const key = line.substring(0, idx).trim()
+          const value = line.substring(idx + 1).trim()
+          if (key) {
+            const num = Number(value)
+            formData![key] = !isNaN(num) && value !== '' ? num : value
+          }
         }
-      }
-    })
+      })
+    }
+    const attachmentUrls = attachmentList.value.length > 0
+      ? collectAttachmentUrls(attachmentList.value)
+      : undefined
+    await startInstance(startForm.value.processDefinitionId, startForm.value.title || undefined, formData, attachmentUrls)
+    ElMessage.success('流程发起成功')
+    startDialogVisible.value = false
+    activeTab.value = 'mine'
+    loadInstances()
+  } finally {
+    submitting.value = false
   }
-  const attachmentUrls = attachmentList.value.length > 0
-    ? JSON.stringify(attachmentList.value.map((f: any) => f.response || f.url).filter(Boolean))
-    : undefined
-  await startInstance(startForm.value.processDefinitionId, startForm.value.title || undefined, formData, attachmentUrls)
-  ElMessage.success('流程发起成功')
-  startDialogVisible.value = false
-  activeTab.value = 'mine'
-  loadInstances()
 }
 
 function handleView(row: FlowInstance) {
@@ -276,4 +324,10 @@ onMounted(() => {
   margin-top: 20px;
   justify-content: flex-end;
 }
+.upload-file-item {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 0;
+}
 </style>

+ 47 - 34
src/views/flow/instance/mine.vue

@@ -11,6 +11,16 @@
         <el-form-item label="流程名称">
           <el-input v-model="queryParams.processName" placeholder="请输入流程名称" clearable />
         </el-form-item>
+        <el-form-item label="状态">
+          <el-select v-model="queryParams.status" placeholder="全部" clearable>
+            <el-option
+              v-for="opt in statusOptions"
+              :key="opt.value"
+              :label="opt.label"
+              :value="opt.value"
+            />
+          </el-select>
+        </el-form-item>
         <el-form-item>
           <el-button type="primary" @click="handleQuery">查询</el-button>
           <el-button @click="resetQuery">重置</el-button>
@@ -23,18 +33,23 @@
         <el-table-column prop="businessKey" label="业务编号" />
         <el-table-column prop="status" label="状态" width="100">
           <template #default="{ row }">
-            <el-tag :type="statusTagType(row.status)">
-              {{ statusText(row.status) }}
+            <el-tag :type="instanceStatusTagType(row.status)">
+              {{ instanceStatusText(row.status) }}
             </el-tag>
           </template>
         </el-table-column>
-        <el-table-column prop="currentNode" label="当前节点" />
+        <el-table-column prop="currentNodeName" label="当前节点">
+          <template #default="{ row }">
+            {{ row.currentNodeName || row.currentNode || '-' }}
+          </template>
+        </el-table-column>
         <el-table-column prop="startTime" label="发起时间" />
         <el-table-column prop="endTime" label="结束时间" />
         <el-table-column label="操作" width="200">
           <template #default="{ row }">
             <el-button link type="primary" @click="handleDetail(row)">查看详情</el-button>
             <el-button v-if="row.status === 0 || row.status === 1 || row.status === 4" link type="warning" @click="handleRevoke(row)">撤回</el-button>
+            <el-button v-if="row.status === 6" link type="danger" @click="handleDelete(row)">删除</el-button>
           </template>
         </el-table-column>
       </el-table>
@@ -48,7 +63,8 @@
         :page-sizes="[10, 20, 50]"
         layout="total, sizes, prev, pager, next, jumper"
         class="pagination"
-        @change="loadData"
+        @current-change="loadData"
+        @size-change="loadData"
       />
     </el-card>
   </div>
@@ -56,8 +72,9 @@
 
 <script setup lang="ts">
 import { ref, reactive, onMounted } from 'vue'
-import { listMyInstance, revokeInstance } from '@/api/flow/instance'
+import { listMyInstance, revokeInstance, deleteInstance } from '@/api/flow/instance'
 import InstanceDetail from '@/views/flow/execute/InstanceDetail.vue'
+import { instanceStatusText, instanceStatusTagType } from '@/utils/flow'
 import type { FlowInstance } from '@/types/flow'
 import { ElMessage, ElMessageBox } from 'element-plus'
 
@@ -66,10 +83,22 @@ const tableData = ref<FlowInstance[]>([])
 const total = ref(0)
 const detailVisible = ref(false)
 const selectedInstanceId = ref<number>(0)
+const statusOptions = [
+  { label: '待接收', value: 0 },
+  { label: '运行中', value: 1 },
+  { label: '已通过', value: 2 },
+  { label: '已拒绝', value: 3 },
+  { label: '已回退', value: 4 },
+  { label: '已完成', value: 5 },
+  { label: '已撤回', value: 6 },
+  { label: '已终止', value: 7 }
+]
+
 const queryParams = reactive({
   pageNum: 1,
   pageSize: 10,
-  processName: undefined as string | undefined
+  processName: undefined as string | undefined,
+  status: undefined as number | undefined
 })
 
 function handleDetail(row: FlowInstance) {
@@ -88,6 +117,17 @@ async function handleRevoke(row: FlowInstance) {
   }
 }
 
+async function handleDelete(row: FlowInstance) {
+  try {
+    await ElMessageBox.confirm(`确认删除流程 "${row.definitionName || row.instanceNo}" 吗?删除后不可恢复。`, '提示', { type: 'error' })
+    await deleteInstance(row.id)
+    ElMessage.success('删除成功')
+    loadData()
+  } catch {
+    // cancel
+  }
+}
+
 function handleQuery() {
   queryParams.pageNum = 1
   loadData()
@@ -95,38 +135,11 @@ function handleQuery() {
 
 function resetQuery() {
   queryParams.processName = undefined
+  queryParams.status = undefined
   queryParams.pageNum = 1
   loadData()
 }
 
-function statusText(status: number) {
-  const map: Record<number, string> = {
-    0: '待接收',
-    1: '运行中',
-    2: '已通过',
-    3: '已拒绝',
-    4: '已回退',
-    5: '已完成',
-    6: '已撤回',
-    7: '已终止'
-  }
-  return map[status] || '未知'
-}
-
-function statusTagType(status: number) {
-  const map: Record<number, any> = {
-    0: 'info',
-    1: 'primary',
-    2: 'success',
-    3: 'danger',
-    4: 'warning',
-    5: 'success',
-    6: 'info',
-    7: 'danger'
-  }
-  return map[status] || 'info'
-}
-
 async function loadData() {
   loading.value = true
   try {

+ 139 - 0
src/views/flow/task/cc.vue

@@ -0,0 +1,139 @@
+<template>
+  <div class="app-container">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>我的抄送</span>
+        </div>
+      </template>
+
+      <el-form :inline="true" :model="queryParams" class="search-form">
+        <el-form-item label="流程名称">
+          <el-input v-model="queryParams.processName" placeholder="请输入流程名称" clearable />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleQuery">查询</el-button>
+          <el-button @click="resetQuery">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-table v-loading="loading" :data="tableData" border>
+        <el-table-column type="index" label="序号" width="60" />
+        <el-table-column prop="definitionName" label="流程名称" />
+        <el-table-column prop="nodeName" label="抄送节点" />
+        <el-table-column prop="instanceTitle" label="流程标题" />
+        <el-table-column label="状态" width="100">
+          <template #default="{ row }">
+            <el-tag :type="row.taskStatus === 0 ? 'danger' : 'info'">
+              {{ row.taskStatus === 0 ? '未读' : '已读' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="createTime" label="抄送时间" />
+        <el-table-column label="操作" width="120">
+          <template #default="{ row }">
+            <el-button link type="primary" @click="handleView(row)">查看</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <el-pagination
+        v-model:current-page="queryParams.pageNum"
+        v-model:page-size="queryParams.pageSize"
+        :total="total"
+        :page-sizes="[10, 20, 50]"
+        layout="total, sizes, prev, pager, next, jumper"
+        class="pagination"
+        @current-change="loadData"
+        @size-change="loadData"
+      />
+    </el-card>
+
+    <InstanceDetail
+      v-model="detailVisible"
+      :instance-id="currentInstanceId"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { listCc, readCc } from '@/api/flow/task'
+import InstanceDetail from '@/views/flow/execute/InstanceDetail.vue'
+import type { FlowTask } from '@/types/flow'
+
+const loading = ref(false)
+const tableData = ref<FlowTask[]>([])
+const total = ref(0)
+const queryParams = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  processName: undefined as string | undefined
+})
+
+const detailVisible = ref(false)
+const currentInstanceId = ref<number>(0)
+const currentTaskId = ref<number>(0)
+const currentTaskStatus = ref<number>(0)
+
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await listCc(queryParams)
+    tableData.value = res.list
+    total.value = res.total
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleQuery() {
+  queryParams.pageNum = 1
+  loadData()
+}
+
+function resetQuery() {
+  queryParams.processName = undefined
+  queryParams.pageNum = 1
+  loadData()
+}
+
+function handleView(row: FlowTask) {
+  currentInstanceId.value = row.instanceId
+  currentTaskId.value = row.id
+  currentTaskStatus.value = row.taskStatus ?? 0
+  detailVisible.value = true
+}
+
+watch(detailVisible, async (val, oldVal) => {
+  // 弹窗关闭且之前是打开状态,且抄送是未读的,则标记已读
+  if (!val && oldVal && currentTaskId.value && currentTaskStatus.value === 0) {
+    try {
+      await readCc(currentTaskId.value)
+      ElMessage.success('已标记为已读')
+      currentTaskStatus.value = 1
+      loadData()
+    } catch {
+      // 标记已读失败时不修改本地状态
+    }
+  }
+})
+
+onMounted(loadData)
+</script>
+
+<style scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.pagination {
+  margin-top: 20px;
+  justify-content: flex-end;
+}
+.search-form {
+  margin-bottom: 20px;
+}
+</style>

+ 4 - 1
src/views/flow/task/handled.vue

@@ -39,7 +39,8 @@
         :page-sizes="[10, 20, 50]"
         layout="total, sizes, prev, pager, next, jumper"
         class="pagination"
-        @change="loadData"
+        @current-change="loadData"
+        @size-change="loadData"
       />
     </el-card>
   </div>
@@ -86,6 +87,8 @@ async function loadData() {
     const res = await listHandled(queryParams)
     tableData.value = res.list
     total.value = res.total
+  } catch {
+    ElMessage.error('加载已办任务失败')
   } finally {
     loading.value = false
   }

+ 572 - 122
src/views/flow/task/todo.vue

@@ -4,6 +4,7 @@
       <template #header>
         <div class="card-header">
           <span>我的待办</span>
+          <el-tag v-if="total > 0" type="danger" effect="dark" round>{{ total }}</el-tag>
         </div>
       </template>
 
@@ -17,18 +18,95 @@
         </el-form-item>
       </el-form>
 
-      <el-table v-loading="loading" :data="tableData" border>
-        <el-table-column type="index" label="序号" width="60" />
-        <el-table-column prop="definitionName" label="流程名称" />
-        <el-table-column prop="nodeName" label="当前节点" />
-        <el-table-column prop="createTime" label="到达时间" />
-        <el-table-column label="操作" width="200">
-          <template #default="{ row }">
-            <el-button link type="primary" @click="handleApprove(row)">审批</el-button>
-        <el-button link type="warning" @click="handleAddSign(row)">加签</el-button>
-          </template>
-        </el-table-column>
-      </el-table>
+      <el-radio-group v-model="activeUrgency" class="urgency-tabs" @change="handleUrgencyChange">
+        <el-radio-button label="all">全部</el-radio-button>
+        <el-radio-button label="overdue">已超时</el-radio-button>
+        <el-radio-button label="warning">即将超时</el-radio-button>
+        <el-radio-button label="normal">正常</el-radio-button>
+      </el-radio-group>
+
+      <!-- 批量操作工具条 -->
+      <div v-if="selectedIds.length > 0" class="batch-toolbar">
+        <div class="batch-info">
+          <el-checkbox :model-value="true" @change="clearSelection" />
+          <span>已选择 <strong>{{ selectedIds.length }}</strong> 项</span>
+        </div>
+        <div class="batch-actions">
+          <el-button type="success" :icon="Check" size="small" @click="openBatchDrawer('pass')">批量通过</el-button>
+          <el-button type="danger" :icon="Close" size="small" @click="openBatchDrawer('reject')">批量拒绝</el-button>
+          <el-button size="small" @click="clearSelection">取消选择</el-button>
+        </div>
+      </div>
+
+      <div v-loading="loading" class="task-card-list">
+        <el-empty v-if="!loading && tableData.length === 0" description="暂无待办任务" />
+        <el-card
+          v-for="row in filteredTableData"
+          :key="row.id"
+          class="task-card"
+          shadow="hover"
+          :class="{ 'is-selected': isSelected(row.id) }"
+        >
+          <div class="task-card-main" @click="toggleSelect(row)">
+            <div class="task-card-header">
+              <el-checkbox
+                :model-value="isSelected(row.id)"
+                @click.stop
+                @change="(val: any) => handleSelectChange(val, row)"
+              />
+              <div class="task-title">{{ row.definitionName || '未命名流程' }}</div>
+              <el-tag v-if="row.urgency === 2" type="danger" size="small" effect="dark">已超时</el-tag>
+              <el-tag v-else-if="row.urgency === 1" type="warning" size="small" effect="dark">即将超时</el-tag>
+              <el-tag v-else-if="row.timeoutTime" type="info" size="small">正常</el-tag>
+            </div>
+            <div class="task-card-body">
+              <div class="task-meta">
+                <span class="meta-label">当前节点</span>
+                <el-tag type="warning" size="small">{{ row.nodeName }}</el-tag>
+              </div>
+              <div class="task-meta">
+                <span class="meta-label">到达时间</span>
+                <span>{{ row.createTime }}</span>
+              </div>
+              <div v-if="row.timeoutTime" class="task-meta">
+                <span class="meta-label">剩余时间</span>
+                <span :class="{ 'text-danger': row.urgency === 2, 'text-warning': row.urgency === 1 }">
+                  {{ formatRemaining(row) }}
+                </span>
+              </div>
+              <div v-if="row.instanceTitle" class="task-meta">
+                <span class="meta-label">实例标题</span>
+                <el-tooltip :content="row.instanceTitle" placement="top">
+                  <span class="meta-value ellipsis">{{ row.instanceTitle }}</span>
+                </el-tooltip>
+              </div>
+              <div v-if="row.instanceNo" class="task-meta">
+                <span class="meta-label">实例编号</span>
+                <span class="meta-value">{{ row.instanceNo }}</span>
+              </div>
+            </div>
+          </div>
+
+          <!-- 悬浮快捷操作 -->
+          <div class="task-card-actions">
+            <el-tooltip content="通过" placement="top">
+              <el-button circle type="success" size="small" :icon="Check" @click.stop="openQuickApprove(row, 'pass')" />
+            </el-tooltip>
+            <el-tooltip content="拒绝" placement="top">
+              <el-button circle type="danger" size="small" :icon="Close" @click.stop="openQuickApprove(row, 'reject')" />
+            </el-tooltip>
+            <el-tooltip content="转办" placement="top">
+              <el-button circle type="warning" size="small" :icon="Switch" @click.stop="openTransfer(row)" />
+            </el-tooltip>
+            <el-tooltip content="加签" placement="top">
+              <el-button circle type="info" size="small" :icon="Plus" @click.stop="handleAddSign(row)" />
+            </el-tooltip>
+            <el-tooltip content="详情" placement="top">
+              <el-button circle type="primary" size="small" :icon="View" @click.stop="handleDetail(row)" />
+            </el-tooltip>
+          </div>
+        </el-card>
+      </div>
 
       <el-pagination
         v-model:current-page="queryParams.pageNum"
@@ -37,46 +115,75 @@
         :page-sizes="[10, 20, 50]"
         layout="total, sizes, prev, pager, next, jumper"
         class="pagination"
-        @change="loadData"
+        @current-change="loadData"
+        @size-change="loadData"
       />
     </el-card>
 
-    <!-- 审批对话框 -->
-    <el-dialog v-model="dialogVisible" title="审批处理" width="500px">
-      <el-form ref="formRef" :model="form" label-width="80px">
-        <el-form-item label="审批操作" prop="action">
-          <el-radio-group v-model="form.action">
-            <el-radio label="pass">通过</el-radio>
-            <el-radio label="reject">拒绝</el-radio>
-            <el-radio label="rollback">回退</el-radio>
-            <el-radio label="transfer">转办</el-radio>
-          </el-radio-group>
-        </el-form-item>
-        <el-form-item label="转办人" v-if="form.action === 'transfer'">
-          <el-select v-model="form.transferTo" filterable placeholder="请选择转办人" style="width: 100%">
-            <el-option v-for="u in userList" :key="u.id" :label="u.realName || u.username" :value="u.id" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="附件">
-          <el-upload
-            v-model:file-list="attachmentList"
-            action="#"
-            :http-request="handleUpload"
-            :before-remove="() => true"
-            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" placeholder="请输入审批意见" />
-        </el-form-item>
-      </el-form>
-      <template #footer>
-        <el-button @click="dialogVisible = false">取消</el-button>
-        <el-button type="primary" @click="submitApprove">确认</el-button>
-      </template>
-    </el-dialog>
+    <!-- 批量/快速审批抽屉 -->
+    <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 === 'transfer'" label="转办人">
+            <el-select v-model="form.transferTo" filterable placeholder="请选择转办人" style="width: 100%">
+              <el-option v-for="u in userList" :key="u.id" :label="u.realName || u.username" :value="u.id" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="附件">
+            <div v-if="!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>
 
     <!-- 加签对话框 -->
     <el-dialog v-model="addSignVisible" title="任务加签" width="400px">
@@ -89,21 +196,35 @@
       </el-form>
       <template #footer>
         <el-button @click="addSignVisible = false">取消</el-button>
-        <el-button type="primary" @click="submitAddSign">确认</el-button>
+        <el-button type="primary" :loading="addSignLoading" @click="submitAddSign">确认</el-button>
       </template>
     </el-dialog>
+
+    <!-- 流程详情抽屉 -->
+    <InstanceDetail
+      v-model="detailVisible"
+      :instance-id="currentInstanceId"
+      @approved="loadData"
+    />
+
+    <!-- 附件预览 -->
+    <FilePreview v-model="previewVisible" :url="previewUrl" />
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted } from 'vue'
-import { ElMessage } from 'element-plus'
-import type { FormInstance } from 'element-plus'
-import { listTodo, approveTask, rejectTask, returnTask, transferTask, addSignTask } from '@/api/flow/task'
+import { ref, reactive, onMounted, computed } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Check, Close, Switch, Plus, View } from '@element-plus/icons-vue'
+import { listTodo, approveTask, rejectTask, transferTask, addSignTask, batchApproveTask, batchRejectTask } from '@/api/flow/task'
 import { listUser } from '@/api/system/user'
 import { uploadFile } from '@/api/file'
-import type { FlowTask, ApprovalAction } from '@/types/flow'
+import { getInstanceAttachments } from '@/api/flow/instance'
+import { beforeFileUpload, getUploadUrl, getFileName } from '@/utils/file'
+import type { FlowTask, ApprovalAction, Attachment } from '@/types/flow'
 import type { User } from '@/types/system'
+import InstanceDetail from '@/views/flow/execute/InstanceDetail.vue'
+import FilePreview from '@/components/FilePreview/index.vue'
 
 const loading = ref(false)
 const tableData = ref<FlowTask[]>([])
@@ -114,60 +235,195 @@ const queryParams = reactive({
   processName: undefined as string | undefined
 })
 
+const activeUrgency = ref<'all' | 'overdue' | 'warning' | 'normal'>('all')
+
+const urgencyText = (urgency?: number) => {
+  const map: Record<number, string> = { 0: '正常', 1: '即将超时', 2: '已超时' }
+  return map[urgency ?? 0] || '正常'
+}
+
+const urgencyType = (urgency?: number) => {
+  const map: Record<number, string> = { 0: 'info', 1: 'warning', 2: 'danger' }
+  return map[urgency ?? 0] || 'info'
+}
+
+function formatRemaining(row: FlowTask) {
+  if (row.urgency === 2) {
+    const hours = Math.abs(Math.ceil((row.remainingMinutes ?? 0) / 60))
+    return `已超时 ${hours} 小时`
+  }
+  const hours = Math.ceil((row.remainingMinutes ?? 0) / 60)
+  return `剩余 ${hours} 小时`
+}
+
+function handleUrgencyChange() {
+  queryParams.pageNum = 1
+  loadData()
+}
+
+const filteredTableData = computed(() => {
+  if (activeUrgency.value === 'all') return tableData.value
+  const map: Record<string, number> = { overdue: 2, warning: 1, normal: 0 }
+  return tableData.value.filter(row => row.urgency === map[activeUrgency.value])
+})
+
 const userList = ref<User[]>([])
 
-const dialogVisible = ref(false)
+// 选择相关
+const selectedIds = ref<number[]>([])
+
+function isSelected(id: number) {
+  return selectedIds.value.includes(id)
+}
+
+function toggleSelect(row: FlowTask) {
+  const idx = selectedIds.value.indexOf(row.id)
+  if (idx > -1) {
+    selectedIds.value.splice(idx, 1)
+  } else {
+    selectedIds.value.push(row.id)
+  }
+}
+
+function handleSelectChange(val: boolean, row: FlowTask) {
+  if (val) {
+    if (!selectedIds.value.includes(row.id)) {
+      selectedIds.value.push(row.id)
+    }
+  } else {
+    const idx = selectedIds.value.indexOf(row.id)
+    if (idx > -1) selectedIds.value.splice(idx, 1)
+  }
+}
+
+function clearSelection() {
+  selectedIds.value = []
+}
+
+// 抽屉审批相关
+const approveDrawerVisible = ref(false)
+const batchMode = ref(false)
 const currentTask = ref<FlowTask | null>(null)
-const formRef = ref<FormInstance>()
-const form = reactive<ApprovalAction & { transferTo?: string | number }>({
+const currentTasks = ref<FlowTask[]>([])
+const submitting = ref(false)
+const form = reactive<ApprovalAction & { transferTo?: number }>({
   action: 'pass',
   comment: '',
   transferTo: undefined
 })
 const attachmentList = ref<any[]>([])
+const previousAttachments = ref<Attachment[]>([])
+const previewVisible = ref(false)
+const previewUrl = ref('')
 
-const addSignVisible = ref(false)
-const addSignTaskId = ref<number>(0)
-const addSignUserId = ref<number | undefined>(undefined)
+const drawerActionText = computed(() => {
+  const map: Record<string, string> = { pass: '通过', reject: '拒绝', transfer: '转办' }
+  return map[form.action] || '审批'
+})
 
-async function loadData() {
-  loading.value = true
-  try {
-    const res = await listTodo(queryParams)
-    tableData.value = res.list
-    total.value = res.total
-  } finally {
-    loading.value = false
-  }
+const drawerTitle = computed(() => {
+  if (batchMode.value) return `批量${drawerActionText.value}`
+  return `${drawerActionText.value}审批`
+})
+
+function resetApproveForm() {
+  form.action = 'pass'
+  form.comment = ''
+  form.transferTo = undefined
+  attachmentList.value = []
+  previousAttachments.value = []
+  currentTask.value = null
+  currentTasks.value = []
+  batchMode.value = false
 }
 
-async function loadUsers() {
-  try {
-    const res = await listUser({ pageNum: 1, pageSize: 9999 })
-    userList.value = res.list
-  } catch {
-    // ignore
+function buildActionData(): ApprovalAction {
+  const data: ApprovalAction = {
+    action: form.action,
+    comment: form.comment
+  }
+  if (form.action === 'transfer' && form.transferTo) {
+    data.transferTo = Number(form.transferTo)
   }
+  if (attachmentList.value.length > 0) {
+    data.attachmentUrls = JSON.stringify(attachmentList.value.map((f: any) => f.response || f.url).filter(Boolean))
+  }
+  return data
 }
 
-function handleQuery() {
-  queryParams.pageNum = 1
-  loadData()
+async function openQuickApprove(row: FlowTask, action: 'pass' | 'reject') {
+  resetApproveForm()
+  currentTask.value = row
+  form.action = action
+  batchMode.value = false
+  await loadPreviousAttachments(row.instanceId)
+  approveDrawerVisible.value = true
 }
 
-function resetQuery() {
-  queryParams.processName = undefined
-  queryParams.pageNum = 1
-  loadData()
+function openBatchDrawer(action: 'pass' | 'reject') {
+  if (selectedIds.value.length === 0) {
+    ElMessage.warning('请先选择待办任务')
+    return
+  }
+  resetApproveForm()
+  currentTasks.value = tableData.value.filter(r => selectedIds.value.includes(r.id))
+  form.action = action
+  batchMode.value = true
+  approveDrawerVisible.value = true
 }
 
-function handleApprove(row: FlowTask) {
+async function openTransfer(row: FlowTask) {
+  resetApproveForm()
   currentTask.value = row
-  form.action = 'pass'
-  form.comment = ''
-  form.transferTo = undefined
-  attachmentList.value = []
-  dialogVisible.value = true
+  form.action = 'transfer'
+  batchMode.value = false
+  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 'transfer':
+        await transferTask(taskId, data)
+        break
+    }
+    ElMessage.success('审批成功')
+    approveDrawerVisible.value = false
+    loadData()
+  } finally {
+    submitting.value = false
+  }
 }
 
 async function handleUpload(options: any) {
@@ -179,6 +435,48 @@ async function handleUpload(options: any) {
   }
 }
 
+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)
+}
+
+// 详情抽屉
+const detailVisible = ref(false)
+const currentInstanceId = ref(0)
+
+function handleDetail(row: FlowTask) {
+  if (!row.instanceId) {
+    ElMessage.warning('该任务没有关联流程实例')
+    return
+  }
+  currentInstanceId.value = row.instanceId
+  detailVisible.value = true
+}
+
+// 加签
+const addSignVisible = ref(false)
+const addSignTaskId = ref<number>(0)
+const addSignUserId = ref<number | undefined>(undefined)
+const addSignLoading = ref(false)
+
 function handleAddSign(row: FlowTask) {
   addSignTaskId.value = row.id
   addSignUserId.value = undefined
@@ -190,41 +488,51 @@ async function submitAddSign() {
     ElMessage.warning('请选择加签人')
     return
   }
-  await addSignTask(addSignTaskId.value, addSignUserId.value)
-  ElMessage.success('加签成功')
-  addSignVisible.value = false
-  loadData()
+  addSignLoading.value = true
+  try {
+    await addSignTask(addSignTaskId.value, addSignUserId.value)
+    ElMessage.success('加签成功')
+    addSignVisible.value = false
+    addSignUserId.value = undefined
+    loadData()
+  } finally {
+    addSignLoading.value = false
+  }
 }
 
-async function submitApprove() {
-  if (!currentTask.value) return
-  const data: ApprovalAction = {
-    action: form.action,
-    comment: form.comment
-  }
-  if (form.action === 'transfer' && form.transferTo) {
-    data.transferTo = Number(form.transferTo)
+async function loadData() {
+  loading.value = true
+  try {
+    const res = await listTodo(queryParams)
+    tableData.value = res.list
+    total.value = res.total
+    // 清除已选择中不在当前页的数据
+    const pageIds = new Set(res.list.map(r => r.id))
+    selectedIds.value = selectedIds.value.filter(id => pageIds.has(id))
+  } finally {
+    loading.value = false
   }
-  if (attachmentList.value.length > 0) {
-    data.attachmentUrls = JSON.stringify(attachmentList.value.map((f: any) => f.response || f.url).filter(Boolean))
+}
+
+async function loadUsers() {
+  try {
+    // 静默加载用户列表,避免普通用户进入待办页时被提示“权限不足”
+    const res = await listUser({ pageNum: 1, pageSize: 9999 }, { silent: true })
+    userList.value = res.list
+  } catch {
+    userList.value = []
   }
-  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
-    case 'transfer':
-      await transferTask(taskId, data)
-      break
-  }
-  ElMessage.success('审批成功')
-  dialogVisible.value = false
+}
+
+function handleQuery() {
+  queryParams.pageNum = 1
+  loadData()
+}
+
+function resetQuery() {
+  queryParams.processName = undefined
+  activeUrgency.value = 'all'
+  queryParams.pageNum = 1
   loadData()
 }
 
@@ -239,12 +547,154 @@ onMounted(() => {
   display: flex;
   justify-content: space-between;
   align-items: center;
+  gap: 8px;
+}
+.search-form {
+  margin-bottom: 20px;
+}
+.urgency-tabs {
+  margin-bottom: 16px;
+}
+.text-danger {
+  color: #f56c6c;
+}
+.text-warning {
+  color: #e6a23c;
+}
+.batch-toolbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  background: #f5f7fa;
+  padding: 12px 16px;
+  border-radius: 8px;
+  margin-bottom: 16px;
+}
+.batch-info {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  color: #606266;
+}
+.batch-actions {
+  display: flex;
+  gap: 10px;
+}
+.task-card-list {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+  gap: 16px;
+}
+.task-card {
+  position: relative;
+  transition: all 0.2s ease;
+  cursor: pointer;
+  overflow: hidden;
+}
+.task-card:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+.task-card.is-selected {
+  border-color: #409eff;
+  background: #f0f9ff;
+}
+.task-card-main {
+  padding-right: 48px;
+}
+.task-card-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 12px;
+}
+.task-title {
+  font-size: 16px;
+  font-weight: bold;
+  color: #303133;
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.task-card-body {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.task-meta {
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  color: #606266;
+}
+.meta-label {
+  color: #909399;
+  width: 70px;
+  flex-shrink: 0;
+}
+.meta-value {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.task-card-actions {
+  position: absolute;
+  top: 12px;
+  right: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  opacity: 0.3;
+  transition: opacity 0.2s ease;
+}
+.task-card:hover .task-card-actions,
+.task-card.is-selected .task-card-actions {
+  opacity: 1;
+}
+.task-card-actions .el-button {
+  margin: 0;
 }
 .pagination {
   margin-top: 20px;
   justify-content: flex-end;
 }
-.search-form {
-  margin-bottom: 20px;
+.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/views/login/index.vue

@@ -15,7 +15,7 @@
         <el-form-item prop="username">
           <el-input
             v-model="loginForm.username"
-            :placeholder="loginType === 'ROLE' ? '角色账号' : '用户名'"
+            :placeholder="loginType === 'ROLE' ? '角色账号 / 手机号' : '用户名 / 手机号'"
             :prefix-icon="User"
             size="large"
           />
@@ -31,7 +31,7 @@
           />
         </el-form-item>
         <el-form-item>
-          <el-checkbox v-model="remember">记住密码</el-checkbox>
+          <el-checkbox v-model="remember">记住用户名</el-checkbox>
         </el-form-item>
         <el-form-item>
           <el-button
@@ -96,7 +96,6 @@ async function handleLogin() {
       localStorage.setItem('login_type', loginType.value)
     } else {
       localStorage.removeItem('login_username')
-      localStorage.removeItem('login_password')
       localStorage.removeItem('login_remember')
       localStorage.removeItem('login_type')
     }

+ 93 - 0
src/views/profile/index.vue

@@ -0,0 +1,93 @@
+<template>
+  <div class="app-container">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>个人中心</span>
+        </div>
+      </template>
+
+      <el-descriptions :column="2" border>
+        <el-descriptions-item label="用户名">{{ userStore.userInfo?.username }}</el-descriptions-item>
+        <el-descriptions-item label="昵称">{{ userStore.userInfo?.realName || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="员工类型">{{ employeeTypeLabel(userStore.userInfo?.employeeType) }}</el-descriptions-item>
+        <el-descriptions-item label="用户类型">{{ userStore.userInfo?.userType === 'ROLE' ? '角色用户' : '系统用户' }}</el-descriptions-item>
+      </el-descriptions>
+
+      <el-divider />
+
+      <h4>修改密码</h4>
+      <el-form ref="pwdFormRef" :model="pwdForm" :rules="pwdRules" label-width="100px" style="max-width: 400px;">
+        <el-form-item label="原密码" prop="oldPassword">
+          <el-input v-model="pwdForm.oldPassword" type="password" show-password placeholder="请输入原密码" />
+        </el-form-item>
+        <el-form-item label="新密码" prop="newPassword">
+          <el-input v-model="pwdForm.newPassword" type="password" show-password placeholder="请输入新密码" />
+        </el-form-item>
+        <el-form-item label="确认密码" prop="confirmPassword">
+          <el-input v-model="pwdForm.confirmPassword" type="password" show-password placeholder="请再次输入新密码" />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="submitChangePassword">确认修改</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive } from 'vue'
+import { ElMessage } from 'element-plus'
+import type { FormInstance, FormRules } from 'element-plus'
+import { useUserStore } from '@/stores/user-store'
+import { changePassword } from '@/api/auth'
+import { employeeTypeLabel } from '@/utils/format'
+
+const userStore = useUserStore()
+
+const pwdFormRef = ref<FormInstance>()
+const pwdForm = reactive({
+  oldPassword: '',
+  newPassword: '',
+  confirmPassword: ''
+})
+
+const pwdRules: FormRules = {
+  oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
+  newPassword: [{ required: true, message: '请输入新密码', trigger: 'blur' }],
+  confirmPassword: [{
+    validator: (rule: any, value: any, callback: any) => {
+      if (!value) {
+        callback(new Error('请再次输入新密码'))
+      } else if (value !== pwdForm.newPassword) {
+        callback(new Error('两次输入的密码不一致'))
+      } else {
+        callback()
+      }
+    },
+    trigger: 'blur'
+  }]
+}
+
+async function submitChangePassword() {
+  const valid = await pwdFormRef.value?.validate().catch(() => false)
+  if (!valid) return
+  await changePassword(pwdForm.oldPassword, pwdForm.newPassword)
+  ElMessage.success('密码修改成功,请重新登录')
+  await userStore.logoutAction()
+  window.location.href = '/login'
+}
+</script>
+
+<style scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+h4 {
+  margin: 0 0 16px 0;
+  font-size: 15px;
+  color: #303133;
+}
+</style>

+ 132 - 0
src/views/system/notification-config/index.vue

@@ -0,0 +1,132 @@
+<template>
+  <div class="app-container">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>通知配置</span>
+        </div>
+      </template>
+
+      <el-alert
+        title="本页面仅做配置预留,实际的企业微信消息推送由外部对接系统完成。本系统读取配置后,在触发通知事件时输出待发送内容和接收人信息。"
+        type="info"
+        :closable="false"
+        style="margin-bottom: 20px;"
+      />
+
+      <el-form :model="form" label-width="120px" style="max-width: 600px;">
+        <el-divider content-position="left">企业微信配置</el-divider>
+        <el-form-item label="CorpID">
+          <el-input v-model="form.corpId" placeholder="企业微信 CorpID" />
+        </el-form-item>
+        <el-form-item label="AgentID">
+          <el-input v-model="form.agentId" placeholder="企业微信应用 AgentID" />
+        </el-form-item>
+        <el-form-item label="Secret">
+          <el-input v-model="form.secret" type="password" placeholder="企业微信应用 Secret" show-password />
+        </el-form-item>
+        <el-form-item label="是否启用">
+          <el-switch
+            v-model="form.enabled"
+            :active-value="1"
+            :inactive-value="0"
+            active-text="启用"
+            inactive-text="禁用"
+          />
+        </el-form-item>
+      </el-form>
+
+      <div style="margin-top: 20px; padding-left: 120px;">
+        <el-button type="primary" :loading="loading" @click="handleSave">保存配置</el-button>
+        <el-button @click="loadConfig">重置</el-button>
+      </div>
+
+      <el-card style="margin-top: 30px;" shadow="never">
+        <template #header>
+          <span>对接说明</span>
+        </template>
+        <div class="doc-block">
+          <p><strong>1. 配置来源</strong></p>
+          <p>本系统将企业微信配置保存在 <code>sys_notification_config</code> 表中,对接后端可调用 <code>GET /system/notification-config/wecom</code> 读取。</p>
+          <p><strong>2. 接收人映射</strong></p>
+          <p>员工的企业微信账号保存在 <code>sys_user.wecom_user_id</code> 字段,可在「员工管理」中为每个员工绑定。</p>
+          <p><strong>3. 事件触发点</strong></p>
+          <p>本系统会在以下事件发生时通过日志输出待通知内容,对接方可订阅事件或轮询待发送记录:</p>
+          <ul>
+            <li>待办分配(TaskAssignedEvent)</li>
+            <li>审批完成(TaskCompletedEvent)</li>
+            <li>流程结束(ProcessCompletedEvent)</li>
+          </ul>
+          <p><strong>4. 企微 API</strong></p>
+          <p>对接后端需自行获取 access_token,并调用企微消息推送 API:<code>POST https://qyapi.weixin.qq.com/cgi-bin/message/send</code>。</p>
+        </div>
+      </el-card>
+    </el-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import { getWeComConfig, saveWeComConfig, type WeComConfig } from '@/api/system/notification-config'
+
+const loading = ref(false)
+const form = reactive<WeComConfig>({
+  corpId: '',
+  agentId: '',
+  secret: '',
+  enabled: 0
+})
+
+async function loadConfig() {
+  try {
+    const res = await getWeComConfig()
+    Object.assign(form, {
+      corpId: res.corpId || '',
+      agentId: res.agentId || '',
+      secret: res.secret || '',
+      enabled: res.enabled ?? 0
+    })
+  } catch {
+    // ignore
+  }
+}
+
+async function handleSave() {
+  loading.value = true
+  try {
+    await saveWeComConfig(form)
+    ElMessage.success('保存成功')
+  } finally {
+    loading.value = false
+  }
+}
+
+onMounted(loadConfig)
+</script>
+
+<style scoped>
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.doc-block {
+  font-size: 14px;
+  line-height: 1.8;
+  color: #606266;
+}
+.doc-block p {
+  margin: 10px 0;
+}
+.doc-block ul {
+  margin: 5px 0;
+  padding-left: 20px;
+}
+.doc-block code {
+  background: #f4f4f5;
+  padding: 2px 6px;
+  border-radius: 4px;
+  font-family: monospace;
+}
+</style>

+ 106 - 14
src/views/system/role/index.vue

@@ -16,7 +16,7 @@
             :props="{ label: 'name', children: 'children' }"
             node-key="id"
             highlight-current
-            default-expand-all
+            :default-expanded-keys="[0]"
             @node-click="handleDeptClick"
           >
             <template #default="{ node, data }">
@@ -60,8 +60,20 @@
             <el-table-column prop="roleCode" label="员工编码" />
             <el-table-column prop="roleName" label="员工姓名" />
             <el-table-column prop="username" label="登录账号" />
+            <el-table-column prop="phone" label="手机号">
+              <template #default="{ row }">
+                <span v-if="row.phone">{{ row.phone }}</span>
+                <span v-else class="text-gray">未填写</span>
+              </template>
+            </el-table-column>
             <el-table-column prop="deptName" label="所属部门" />
             <el-table-column prop="roleScope" label="描述" />
+            <el-table-column prop="wecomUserId" label="企微账号" width="120">
+              <template #default="{ row }">
+                <el-tag v-if="row.wecomUserId" type="success" size="small">{{ row.wecomUserId }}</el-tag>
+                <span v-else class="text-gray">未绑定</span>
+              </template>
+            </el-table-column>
             <el-table-column prop="status" label="状态" width="100">
               <template #default="{ row }">
                 <el-tag :type="row.status === 1 ? 'success' : 'danger'">
@@ -70,9 +82,10 @@
               </template>
             </el-table-column>
             <el-table-column prop="createTime" label="创建时间" />
-            <el-table-column label="操作" width="180">
+            <el-table-column label="操作" width="220">
               <template #default="{ row }">
                 <el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
+                <el-button link type="primary" @click="handleWeComConfig(row)">企微配置</el-button>
                 <el-button link type="danger" @click="handleDelete(row)">删除</el-button>
               </template>
             </el-table-column>
@@ -102,7 +115,10 @@
           <el-input v-model="form.roleName" />
         </el-form-item>
         <el-form-item label="登录账号" prop="username">
-          <el-input v-model="form.username" :disabled="isEdit" placeholder="员工登录账号" />
+          <el-input v-model="form.username" placeholder="员工登录账号" />
+        </el-form-item>
+        <el-form-item label="手机号" prop="phone">
+          <el-input v-model="form.phone" placeholder="请输入手机号" />
         </el-form-item>
         <el-form-item label="密码" prop="password">
           <el-input v-model="form.password" type="password" show-password :placeholder="isEdit ? '不填表示不修改密码' : '请输入密码'" />
@@ -134,6 +150,39 @@
       </template>
     </el-dialog>
 
+    <!-- 企微配置抽屉 -->
+    <el-drawer v-model="wecomDrawerVisible" title="企微提醒配置" direction="rtl" size="400px">
+      <el-form :model="wecomForm" label-width="100px">
+        <el-alert
+          title="提示:实际企微消息推送由外部对接系统完成,本系统仅保存配置和接收人映射。"
+          type="info"
+          :closable="false"
+          style="margin-bottom: 20px;"
+        />
+        <el-form-item label="员工姓名">
+          <el-input v-model="wecomForm.roleName" disabled />
+        </el-form-item>
+        <el-form-item label="企微账号">
+          <el-input v-model="wecomForm.wecomUserId" placeholder="请输入企业微信用户ID" />
+        </el-form-item>
+        <el-form-item label="开启提醒">
+          <el-switch
+            v-model="wecomForm.wecomRemindEnabled"
+            :active-value="1"
+            :inactive-value="0"
+            active-text="开启"
+            inactive-text="关闭"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <div style="flex: auto">
+          <el-button @click="wecomDrawerVisible = false">取消</el-button>
+          <el-button type="primary" :loading="wecomSubmitting" @click="submitWeComConfig">保存</el-button>
+        </div>
+      </template>
+    </el-drawer>
+
     <!-- 部门弹窗 -->
     <el-dialog v-model="deptDialogVisible" :title="deptDialogTitle" width="500px">
       <el-form ref="deptFormRef" :model="deptForm" :rules="deptFormRules" label-width="100px">
@@ -173,10 +222,10 @@
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted, computed } from 'vue'
+import { ref, shallowRef, reactive, onMounted, computed } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import type { FormInstance, FormRules } from 'element-plus'
-import { listRole, addRole, updateRole, deleteRole } from '@/api/system/role'
+import { listRole, addRole, updateRole, deleteRole, bindRoleWeCom } from '@/api/system/role'
 import { listDept, addDept, updateDept, deleteDept } from '@/api/system/dept'
 import type { Role, Dept } from '@/types/system'
 
@@ -204,9 +253,7 @@ const deptTree = computed(() => {
   return list
 })
 
-const deptTreeForSelect = computed(() => {
-  return [{ id: 0, parentId: 0, name: '根部门', sort: 0, status: 1 }, ...deptList.value]
-})
+const deptTreeForSelect = shallowRef<Dept[]>([])
 
 // 员工弹窗
 const dialogVisible = ref(false)
@@ -218,6 +265,7 @@ const form = reactive<Partial<Role>>({
   roleCode: '',
   roleName: '',
   username: '',
+  phone: '',
   password: '',
   roleScope: '',
   deptId: undefined,
@@ -226,10 +274,24 @@ const form = reactive<Partial<Role>>({
 
 const formRules: FormRules = {
   roleCode: [{ required: true, message: '请输入员工编码', trigger: 'blur' }],
-  roleName: [{ required: true, message: '请输入员工姓名', trigger: 'blur' }]
+  roleName: [{ required: true, message: '请输入员工姓名', trigger: 'blur' }],
+  phone: [{
+    pattern: /^1[3-9]\d{9}$/,
+    message: '请输入正确的手机号',
+    trigger: 'blur'
+  }]
 }
 
 // 部门弹窗
+const wecomDrawerVisible = ref(false)
+const wecomSubmitting = ref(false)
+const wecomForm = reactive({
+  roleId: 0,
+  roleName: '',
+  wecomUserId: '',
+  wecomRemindEnabled: 1
+})
+
 const deptDialogVisible = ref(false)
 const deptDialogTitle = ref('')
 const isEditDept = ref(false)
@@ -251,8 +313,9 @@ async function loadDepts() {
   try {
     const res = await listDept()
     deptList.value = res
-  } catch {
-    // ignore
+    deptTreeForSelect.value = [{ id: 0, parentId: 0, name: '根部门', sort: 0, status: 1 }, ...res]
+  } catch (error: any) {
+    ElMessage.error(error?.message || '加载部门失败')
   }
 }
 
@@ -306,6 +369,7 @@ function handleAdd() {
     roleCode: '',
     roleName: '',
     username: '',
+    phone: '',
     password: '',
     roleScope: '',
     deptId: selectedDeptId.value,
@@ -317,7 +381,10 @@ function handleAdd() {
 function handleEdit(row: Role) {
   isEdit.value = true
   dialogTitle.value = '编辑员工'
-  Object.assign(form, row)
+  Object.assign(form, {
+    ...row,
+    phone: row.phone || ''
+  })
   dialogVisible.value = true
 }
 
@@ -332,6 +399,29 @@ async function handleDelete(row: Role) {
   }
 }
 
+function handleWeComConfig(row: Role) {
+  wecomForm.roleId = row.id
+  wecomForm.roleName = row.roleName
+  wecomForm.wecomUserId = row.wecomUserId || ''
+  wecomForm.wecomRemindEnabled = row.wecomRemindEnabled ?? 1
+  wecomDrawerVisible.value = true
+}
+
+async function submitWeComConfig() {
+  wecomSubmitting.value = true
+  try {
+    await bindRoleWeCom(wecomForm.roleId, {
+      wecomUserId: wecomForm.wecomUserId,
+      wecomRemindEnabled: wecomForm.wecomRemindEnabled
+    })
+    ElMessage.success('保存成功')
+    wecomDrawerVisible.value = false
+    loadData()
+  } finally {
+    wecomSubmitting.value = false
+  }
+}
+
 async function submitForm() {
   const valid = await formRef.value?.validate().catch(() => false)
   if (!valid) return
@@ -396,8 +486,7 @@ async function submitDeptForm() {
 }
 
 onMounted(() => {
-  loadDepts()
-  loadData()
+  Promise.all([loadDepts(), loadData()])
 })
 </script>
 
@@ -428,4 +517,7 @@ onMounted(() => {
 .custom-tree-node:hover .tree-actions {
   display: inline;
 }
+.text-gray {
+  color: #909399;
+}
 </style>

+ 12 - 20
src/views/system/user/index.vue

@@ -30,7 +30,7 @@
         <el-table-column prop="realName" label="姓名" />
         <el-table-column prop="employeeType" label="管理员类型">
           <template #default="{ row }">
-            {{ employeeTypeLabel(row.employeeType) }}
+            {{ adminEmployeeTypeLabel(row.employeeType) }}
           </template>
         </el-table-column>
         <el-table-column prop="deptName" label="部门" />
@@ -113,15 +113,17 @@
         <el-button type="primary" @click="submitForm">确认</el-button>
       </template>
     </el-dialog>
+
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, onMounted, computed } from 'vue'
+import { ref, shallowRef, reactive, onMounted } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import type { FormInstance, FormRules } from 'element-plus'
 import { listUser, addUser, updateUser, deleteUser } from '@/api/system/user'
 import { listDept } from '@/api/system/dept'
+import { adminEmployeeTypeLabel } from '@/utils/format'
 import type { User, Dept } from '@/types/system'
 
 const loading = ref(false)
@@ -136,9 +138,7 @@ const queryParams = reactive({
   status: undefined as number | undefined
 })
 
-const deptTreeForSelect = computed(() => {
-  return [{ id: 0, parentId: 0, name: '根部门', sort: 0, status: 0 }, ...deptList.value]
-})
+const deptTreeForSelect = shallowRef<Dept[]>([])
 
 // 管理员弹窗
 const dialogVisible = ref(false)
@@ -159,17 +159,6 @@ const form = reactive<Partial<User>>({
 
 const PASSWORD_PLACEHOLDER = '********'
 
-const employeeTypeMap: Record<string, string> = {
-  common_user: '普通管理员',
-  dept_manager: '部门管理员',
-  flow_manager: '流程管理员',
-  super_admin: '系统管理员'
-}
-
-function employeeTypeLabel(type?: string) {
-  return employeeTypeMap[type || ''] || type || '-'
-}
-
 const formRules: FormRules = {
   username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
   password: [{
@@ -189,8 +178,9 @@ async function loadDepts() {
   try {
     const res = await listDept()
     deptList.value = res
-  } catch {
-    // ignore
+    deptTreeForSelect.value = [{ id: 0, parentId: 0, name: '根部门', sort: 0, status: 0 }, ...res]
+  } catch (error: any) {
+    ElMessage.error(error?.message || '加载部门失败')
   }
 }
 
@@ -299,8 +289,7 @@ async function submitForm() {
 }
 
 onMounted(() => {
-  loadDepts()
-  loadData()
+  Promise.all([loadDepts(), loadData()])
 })
 </script>
 
@@ -317,4 +306,7 @@ onMounted(() => {
   margin-top: 20px;
   justify-content: flex-end;
 }
+.text-gray {
+  color: #909399;
+}
 </style>

+ 128 - 0
src/views/workbench/components/TaskCard.vue

@@ -0,0 +1,128 @@
+<template>
+  <el-card class="task-card" shadow="hover" :class="{ 'urgent': task.urgency === 1, 'overdue': task.urgency === 2 }">
+    <div class="task-card-main">
+      <div class="task-card-header">
+        <div class="task-title">{{ task.definitionName || '未命名流程' }}</div>
+        <el-tag v-if="task.urgency === 2" type="danger" size="small" effect="dark">已超时</el-tag>
+        <el-tag v-else-if="task.urgency === 1" type="warning" size="small" effect="dark">即将超时</el-tag>
+      </div>
+      <div class="task-card-body">
+        <div class="task-meta">
+          <span class="meta-label">当前节点</span>
+          <el-tag type="warning" size="small">{{ task.nodeName }}</el-tag>
+        </div>
+        <div class="task-meta">
+          <span class="meta-label">到达时间</span>
+          <span>{{ task.createTime }}</span>
+        </div>
+        <div v-if="task.timeoutTime" class="task-meta">
+          <span class="meta-label">剩余时间</span>
+          <span :class="{ 'text-danger': task.urgency === 2, 'text-warning': task.urgency === 1 }">{{ formatRemaining }}</span>
+        </div>
+      </div>
+    </div>
+    <div v-if="showActions" class="task-card-actions">
+      <el-button circle type="success" size="small" :icon="Check" @click.stop="$emit('approve', task)" />
+      <el-button circle type="danger" size="small" :icon="Close" @click.stop="$emit('reject', task)" />
+      <el-button circle type="warning" size="small" :icon="Switch" @click.stop="$emit('transfer', task)" />
+      <el-button circle type="info" size="small" :icon="Plus" @click.stop="$emit('add-sign', task)" />
+      <el-button circle type="primary" size="small" :icon="View" @click.stop="$emit('detail', task)" />
+    </div>
+    <div v-else class="task-card-actions">
+      <el-button circle type="primary" size="small" :icon="View" @click.stop="$emit('detail', task)" />
+    </div>
+  </el-card>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { Check, Close, Switch, Plus, View } from '@element-plus/icons-vue'
+import type { FlowTask } from '@/types/flow'
+
+const props = defineProps<{
+  task: FlowTask
+  showActions?: boolean
+}>()
+
+defineEmits<{
+  (e: 'approve', task: FlowTask): void
+  (e: 'reject', task: FlowTask): void
+  (e: 'transfer', task: FlowTask): void
+  (e: 'add-sign', task: FlowTask): void
+  (e: 'detail', task: FlowTask): void
+}>()
+
+const formatRemaining = computed(() => {
+  if (props.task.urgency === 2) {
+    const hours = Math.abs(Math.ceil((props.task.remainingMinutes ?? 0) / 60))
+    return `已超时 ${hours} 小时`
+  }
+  const hours = Math.ceil((props.task.remainingMinutes ?? 0) / 60)
+  return `剩余 ${hours} 小时`
+})
+</script>
+
+<style scoped>
+.task-card {
+  position: relative;
+  overflow: hidden;
+}
+.task-card.overdue {
+  border-left: 4px solid #f56c6c;
+}
+.task-card.urgent {
+  border-left: 4px solid #e6a23c;
+}
+.task-card-main {
+  padding-right: 48px;
+}
+.task-card-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 12px;
+}
+.task-title {
+  font-size: 16px;
+  font-weight: bold;
+  color: #303133;
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.task-card-body {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.task-meta {
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  color: #606266;
+}
+.meta-label {
+  color: #909399;
+  width: 70px;
+  flex-shrink: 0;
+}
+.task-card-actions {
+  position: absolute;
+  top: 12px;
+  right: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  opacity: 0.3;
+  transition: opacity 0.2s ease;
+}
+.task-card:hover .task-card-actions {
+  opacity: 1;
+}
+.task-card-actions .el-button {
+  margin: 0;
+}
+.text-danger { color: #f56c6c; }
+.text-warning { color: #e6a23c; }
+</style>

+ 656 - 0
src/views/workbench/index.vue

@@ -0,0 +1,656 @@
+<template>
+  <div class="app-container">
+    <!-- 顶部统计卡片 -->
+    <el-row :gutter="16" class="stat-row">
+      <el-col :xs="24" :sm="12" :md="6">
+        <el-card class="stat-card" shadow="hover">
+          <div class="stat-value">{{ statTotal }}</div>
+          <div class="stat-label">待办总数</div>
+        </el-card>
+      </el-col>
+      <el-col :xs="24" :sm="12" :md="6">
+        <el-card class="stat-card stat-overdue" shadow="hover">
+          <div class="stat-value">{{ statOverdue }}</div>
+          <div class="stat-label">已超时</div>
+        </el-card>
+      </el-col>
+      <el-col :xs="24" :sm="12" :md="6">
+        <el-card class="stat-card stat-warning" shadow="hover">
+          <div class="stat-value">{{ statWarning }}</div>
+          <div class="stat-label">即将超时</div>
+        </el-card>
+      </el-col>
+      <el-col :xs="24" :sm="12" :md="6">
+        <el-card class="stat-card stat-completed" shadow="hover">
+          <div class="stat-value">{{ statHandledToday }}</div>
+          <div class="stat-label">今日已处理</div>
+        </el-card>
+      </el-col>
+    </el-row>
+
+    <!-- 任务列表 -->
+    <el-card class="task-section">
+      <template #header>
+        <div class="section-header">
+          <span>我的工作台</span>
+          <el-button type="primary" size="small" @click="goToExecute">发起流程</el-button>
+        </div>
+      </template>
+
+      <el-tabs v-model="activeTab" @tab-change="handleTabChange">
+        <el-tab-pane label="待处理" name="todo">
+          <div v-loading="loading" class="task-card-list">
+            <el-empty v-if="!loading && filteredTodoList.length === 0" description="暂无待办任务" />
+            <el-card
+              v-for="row in filteredTodoList"
+              :key="row.id"
+              class="task-card"
+              shadow="hover"
+              :class="{ 'urgent': row.urgency === 1, 'overdue': row.urgency === 2 }"
+            >
+              <div class="task-card-main">
+                <div class="task-card-header">
+                  <div class="task-title">{{ row.definitionName || '未命名流程' }}</div>
+                  <el-tag v-if="row.urgency === 2" type="danger" size="small" effect="dark">已超时</el-tag>
+                  <el-tag v-else-if="row.urgency === 1" type="warning" size="small" effect="dark">即将超时</el-tag>
+                </div>
+                <div class="task-card-body">
+                  <div class="task-meta"><span class="meta-label">当前节点</span><el-tag type="warning" size="small">{{ row.nodeName }}</el-tag></div>
+                  <div class="task-meta"><span class="meta-label">到达时间</span><span>{{ row.createTime }}</span></div>
+                  <div v-if="row.timeoutTime" class="task-meta"><span class="meta-label">剩余时间</span><span :class="{ 'text-danger': row.urgency === 2, 'text-warning': row.urgency === 1 }">{{ formatRemaining(row) }}</span></div>
+                </div>
+              </div>
+              <div class="task-card-actions">
+                <el-button circle type="success" size="small" :icon="Check" @click.stop="openQuickApprove(row, 'pass')" />
+                <el-button circle type="danger" size="small" :icon="Close" @click.stop="openQuickApprove(row, 'reject')" />
+                <el-button circle type="warning" size="small" :icon="Switch" @click.stop="openTransfer(row)" />
+                <el-button circle type="info" size="small" :icon="Plus" @click.stop="handleAddSign(row)" />
+                <el-button circle type="primary" size="small" :icon="View" @click.stop="handleDetail(row)" />
+              </div>
+            </el-card>
+          </div>
+        </el-tab-pane>
+
+        <el-tab-pane label="已超时" name="overdue">
+          <div v-loading="loading" class="task-card-list">
+            <el-empty v-if="!loading && overdueList.length === 0" description="暂无已超时任务" />
+            <TaskCard
+              v-for="row in overdueList"
+              :key="row.id"
+              :task="row"
+              @approve="openQuickApprove($event, 'pass')"
+              @reject="openQuickApprove($event, 'reject')"
+              @transfer="openTransfer"
+              @add-sign="handleAddSign"
+              @detail="handleDetail"
+            />
+          </div>
+        </el-tab-pane>
+
+        <el-tab-pane label="即将超时" name="warning">
+          <div v-loading="loading" class="task-card-list">
+            <el-empty v-if="!loading && warningList.length === 0" description="暂无即将超时任务" />
+            <TaskCard
+              v-for="row in warningList"
+              :key="row.id"
+              :task="row"
+              @approve="openQuickApprove($event, 'pass')"
+              @reject="openQuickApprove($event, 'reject')"
+              @transfer="openTransfer"
+              @add-sign="handleAddSign"
+              @detail="handleDetail"
+            />
+          </div>
+        </el-tab-pane>
+
+        <el-tab-pane label="已办" name="handled">
+          <div v-loading="loading" class="task-card-list">
+            <el-empty v-if="!loading && handledList.length === 0" description="暂无已办任务" />
+            <TaskCard
+              v-for="row in handledList"
+              :key="row.id"
+              :task="row"
+              :show-actions="false"
+              @detail="handleDetail"
+            />
+          </div>
+        </el-tab-pane>
+
+        <el-tab-pane label="抄送" name="cc">
+          <div v-loading="loading" class="task-card-list">
+            <el-empty v-if="!loading && ccList.length === 0" description="暂无抄送任务" />
+            <TaskCard
+              v-for="row in ccList"
+              :key="row.id"
+              :task="row"
+              @detail="handleDetail"
+            />
+          </div>
+        </el-tab-pane>
+
+        <el-tab-pane label="我发起的" name="mine">
+          <div v-loading="loading" class="task-card-list">
+            <el-empty v-if="!loading && mineList.length === 0" description="暂无我发起的流程" />
+            <el-card
+              v-for="row in mineList"
+              :key="row.id"
+              class="task-card"
+              shadow="hover"
+            >
+              <div class="task-card-main">
+                <div class="task-card-header">
+                  <div class="task-title">{{ row.definitionName || '未命名流程' }}</div>
+                  <el-tag :type="instanceStatusTagType(row.status)" size="small">{{ instanceStatusText(row.status) }}</el-tag>
+                </div>
+                <div class="task-card-body">
+                  <div class="task-meta"><span class="meta-label">业务编号</span><span>{{ row.businessKey || row.instanceNo }}</span></div>
+                  <div class="task-meta"><span class="meta-label">当前节点</span><span>{{ row.currentNodeName || row.currentNode || '-' }}</span></div>
+                  <div class="task-meta"><span class="meta-label">发起时间</span><span>{{ row.startTime }}</span></div>
+                </div>
+              </div>
+              <div class="task-card-actions">
+                <el-button circle type="primary" size="small" :icon="View" @click.stop="handleInstanceDetail(row.id)" />
+              </div>
+            </el-card>
+          </div>
+        </el-tab-pane>
+      </el-tabs>
+
+      <el-pagination
+        v-model:current-page="queryParams.pageNum"
+        v-model:page-size="queryParams.pageSize"
+        :total="total"
+        :page-sizes="[10, 20, 50]"
+        layout="total, sizes, prev, pager, next, jumper"
+        class="pagination"
+        @current-change="loadData"
+        @size-change="loadData"
+      />
+    </el-card>
+
+    <!-- 批量/快速审批抽屉 -->
+    <el-drawer
+      v-model="approveDrawerVisible"
+      :title="drawerTitle"
+      direction="rtl"
+      size="420px"
+      :destroy-on-close="true"
+      @closed="resetApproveForm"
+    >
+      <div class="drawer-content">
+        <el-alert
+          v-if="currentTask"
+          :title="`流程:${currentTask.definitionName} · 节点:${currentTask.nodeName}`"
+          type="info"
+          :closable="false"
+          show-icon
+          class="batch-alert"
+        />
+        <el-form :model="form" label-width="80px">
+          <el-form-item v-if="form.action === 'transfer'" label="转办人">
+            <el-select v-model="form.transferTo" filterable placeholder="请选择转办人" style="width: 100%">
+              <el-option v-for="u in userList" :key="u.id" :label="u.realName || u.username" :value="u.id" />
+            </el-select>
+          </el-form-item>
+          <el-form-item label="附件">
+            <div v-if="previousAttachments.length > 0" class="previous-attachments">
+              <div class="previous-title">历史附件</div>
+              <div v-for="att in previousAttachments" :key="att.id" class="previous-item">
+                <el-link type="primary" size="small" @click="openPreview(att.fileUrl)">
+                  {{ att.fileName }}
+                </el-link>
+                <span class="previous-meta">{{ att.nodeName || '发起' }} · {{ att.uploaderName || '未知' }}</span>
+              </div>
+            </div>
+            <el-upload
+              v-model:file-list="attachmentList"
+              action="#"
+              :http-request="handleUpload"
+              :before-upload="beforeFileUpload"
+              :before-remove="() => true"
+              :on-preview="handleFilePreview"
+              multiple
+            >
+              <el-button type="primary" size="small">上传附件</el-button>
+            </el-upload>
+          </el-form-item>
+          <el-form-item label="审批意见">
+            <el-input v-model="form.comment" type="textarea" :rows="4" placeholder="请输入审批意见" />
+          </el-form-item>
+        </el-form>
+        <div class="drawer-footer">
+          <el-button @click="approveDrawerVisible = false">取消</el-button>
+          <el-button type="primary" :loading="submitting" @click="submitApprove">确认{{ drawerActionText }}</el-button>
+        </div>
+      </div>
+    </el-drawer>
+
+    <!-- 加签对话框 -->
+    <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>
+
+    <!-- 流程详情抽屉 -->
+    <InstanceDetail v-model="detailVisible" :instance-id="currentInstanceId" @approved="loadData" />
+
+    <!-- 附件预览 -->
+    <FilePreview v-model="previewVisible" :url="previewUrl" />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, onMounted, computed } from 'vue'
+import { useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import { Check, Close, Switch, Plus, View } from '@element-plus/icons-vue'
+import { listTodo, approveTask, rejectTask, transferTask, addSignTask } from '@/api/flow/task'
+import { listHandled, listCc } from '@/api/flow/task'
+import { listMyInstance, getInstanceAttachments } from '@/api/flow/instance'
+import { listUser } from '@/api/system/user'
+import { uploadFile } from '@/api/file'
+import { todoCount } from '@/api/flow/task'
+import { beforeFileUpload, getUploadUrl } from '@/utils/file'
+import type { FlowTask, ApprovalAction, FlowInstance, Attachment } from '@/types/flow'
+import type { User } from '@/types/system'
+import { 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'
+
+const router = useRouter()
+const activeTab = ref<'todo' | 'overdue' | 'warning' | 'handled' | 'cc' | 'mine'>('todo')
+const loading = ref(false)
+const todoList = ref<FlowTask[]>([])
+const handledList = ref<FlowTask[]>([])
+const ccList = ref<FlowTask[]>([])
+const mineList = ref<FlowInstance[]>([])
+const total = ref(0)
+const userList = ref<User[]>([])
+
+const queryParams = reactive({
+  pageNum: 1,
+  pageSize: 10,
+  processName: undefined as string | undefined
+})
+
+const statTotal = ref(0)
+const statOverdue = ref(0)
+const statWarning = ref(0)
+const statHandledToday = ref(0)
+
+const overdueList = computed(() => todoList.value.filter(r => r.urgency === 2))
+const warningList = computed(() => todoList.value.filter(r => r.urgency === 1))
+const filteredTodoList = computed(() => todoList.value.filter(r => r.urgency !== 2))
+
+function formatRemaining(row: FlowTask) {
+  if (row.urgency === 2) {
+    const hours = Math.abs(Math.ceil((row.remainingMinutes ?? 0) / 60))
+    return `已超时 ${hours} 小时`
+  }
+  const hours = Math.ceil((row.remainingMinutes ?? 0) / 60)
+  return `剩余 ${hours} 小时`
+}
+
+function handleTabChange() {
+  queryParams.pageNum = 1
+  loadData()
+}
+
+async function loadStats() {
+  try {
+    statTotal.value = await todoCount()
+  } catch { statTotal.value = 0 }
+}
+
+async function loadData() {
+  loading.value = true
+  try {
+    const tab = activeTab.value
+    if (tab === 'todo' || tab === 'overdue' || tab === 'warning') {
+      const res = await listTodo(queryParams)
+      todoList.value = res.list
+      total.value = res.total
+      statOverdue.value = res.list.filter(r => r.urgency === 2).length
+      statWarning.value = res.list.filter(r => r.urgency === 1).length
+    } else if (tab === 'handled') {
+      const res = await listHandled(queryParams)
+      handledList.value = res.list
+      total.value = res.total
+      const today = new Date().toISOString().slice(0, 10)
+      statHandledToday.value = res.list.filter(r => r.endTime && r.endTime.startsWith(today)).length
+    } else if (tab === 'cc') {
+      const res = await listCc(queryParams)
+      ccList.value = res.list
+      total.value = res.total
+    } else if (tab === 'mine') {
+      const res = await listMyInstance(queryParams)
+      mineList.value = res.list
+      total.value = res.total
+    }
+  } finally {
+    loading.value = false
+  }
+}
+
+function goToExecute() {
+  router.push('/approval/execute')
+}
+
+// 抽屉审批
+const approveDrawerVisible = ref(false)
+const currentTask = ref<FlowTask | null>(null)
+const submitting = ref(false)
+const form = reactive<ApprovalAction & { transferTo?: number }>({
+  action: 'pass',
+  comment: '',
+  transferTo: undefined
+})
+const attachmentList = ref<any[]>([])
+const previousAttachments = ref<Attachment[]>([])
+const previewVisible = ref(false)
+const previewUrl = ref('')
+
+const drawerActionText = computed(() => {
+  const map: Record<string, string> = { pass: '通过', reject: '拒绝', transfer: '转办' }
+  return map[form.action] || '审批'
+})
+
+const drawerTitle = computed(() => `${drawerActionText.value}审批`)
+
+function resetApproveForm() {
+  form.action = 'pass'
+  form.comment = ''
+  form.transferTo = undefined
+  attachmentList.value = []
+  previousAttachments.value = []
+  currentTask.value = null
+}
+
+function buildActionData(): ApprovalAction {
+  const data: ApprovalAction = { action: form.action, comment: form.comment }
+  if (form.action === 'transfer' && form.transferTo) {
+    data.transferTo = Number(form.transferTo)
+  }
+  if (attachmentList.value.length > 0) {
+    data.attachmentUrls = JSON.stringify(attachmentList.value.map((f: any) => f.response || f.url).filter(Boolean))
+  }
+  return data
+}
+
+async function openQuickApprove(row: FlowTask, action: 'pass' | 'reject') {
+  resetApproveForm()
+  currentTask.value = row
+  form.action = action
+  await loadPreviousAttachments(row.instanceId)
+  approveDrawerVisible.value = true
+}
+
+async function openTransfer(row: FlowTask) {
+  resetApproveForm()
+  currentTask.value = row
+  form.action = 'transfer'
+  await loadPreviousAttachments(row.instanceId)
+  approveDrawerVisible.value = true
+}
+
+async function submitApprove() {
+  if (!currentTask.value) return
+  submitting.value = true
+  try {
+    const data = buildActionData()
+    const taskId = currentTask.value.id
+    if (form.action === 'pass') await approveTask(taskId, data)
+    else if (form.action === 'reject') await rejectTask(taskId, data)
+    else if (form.action === 'transfer') await transferTask(taskId, data)
+    ElMessage.success('审批成功')
+    approveDrawerVisible.value = false
+    loadData()
+    loadStats()
+  } finally {
+    submitting.value = false
+  }
+}
+
+async function handleUpload(options: any) {
+  try {
+    const res = await uploadFile(options.file)
+    options.onSuccess(res)
+  } catch (e) {
+    options.onError(e)
+  }
+}
+
+async function loadPreviousAttachments(instanceId?: number) {
+  if (!instanceId) {
+    previousAttachments.value = []
+    return
+  }
+  try {
+    previousAttachments.value = await getInstanceAttachments(instanceId)
+  } catch {
+    previousAttachments.value = []
+  }
+}
+
+function openPreview(url: string) {
+  if (!url) return
+  previewUrl.value = url
+  previewVisible.value = true
+}
+
+function handleFilePreview(file: any) {
+  const url = getUploadUrl(file)
+  if (url) openPreview(url)
+}
+
+// 详情
+const detailVisible = ref(false)
+const currentInstanceId = ref(0)
+
+function handleDetail(row: FlowTask) {
+  if (!row.instanceId) {
+    ElMessage.warning('该任务没有关联流程实例')
+    return
+  }
+  currentInstanceId.value = row.instanceId
+  detailVisible.value = true
+}
+
+function handleInstanceDetail(id: number) {
+  currentInstanceId.value = id
+  detailVisible.value = true
+}
+
+// 加签
+const addSignVisible = ref(false)
+const addSignTaskId = ref<number>(0)
+const addSignUserId = ref<number | undefined>(undefined)
+const addSignLoading = ref(false)
+
+function handleAddSign(row: FlowTask) {
+  addSignTaskId.value = row.id
+  addSignUserId.value = undefined
+  addSignVisible.value = true
+}
+
+async function submitAddSign() {
+  if (!addSignUserId.value) {
+    ElMessage.warning('请选择加签人')
+    return
+  }
+  addSignLoading.value = true
+  try {
+    await addSignTask(addSignTaskId.value, addSignUserId.value)
+    ElMessage.success('加签成功')
+    addSignVisible.value = false
+    addSignUserId.value = undefined
+    loadData()
+  } finally {
+    addSignLoading.value = false
+  }
+}
+
+async function loadUsers() {
+  try {
+    // 静默加载用户列表,避免普通用户进入工作台时被提示“权限不足”
+    const res = await listUser({ pageNum: 1, pageSize: 9999 }, { silent: true })
+    userList.value = res.list
+  } catch { userList.value = [] }
+}
+
+onMounted(() => {
+  loadData()
+  loadStats()
+  loadUsers()
+})
+</script>
+
+<style scoped>
+.stat-row {
+  margin-bottom: 16px;
+}
+.stat-card {
+  text-align: center;
+  padding: 16px 0;
+}
+.stat-card .stat-value {
+  font-size: 28px;
+  font-weight: bold;
+  color: #409eff;
+}
+.stat-card .stat-label {
+  margin-top: 8px;
+  color: #606266;
+  font-size: 14px;
+}
+.stat-overdue .stat-value { color: #f56c6c; }
+.stat-warning .stat-value { color: #e6a23c; }
+.stat-completed .stat-value { color: #67c23a; }
+.task-section {
+  margin-top: 16px;
+}
+.section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+.task-card-list {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+  gap: 16px;
+  min-height: 200px;
+}
+.task-card {
+  position: relative;
+  overflow: hidden;
+}
+.task-card.overdue {
+  border-left: 4px solid #f56c6c;
+}
+.task-card.urgent {
+  border-left: 4px solid #e6a23c;
+}
+.task-card-main {
+  padding-right: 48px;
+}
+.task-card-header {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 12px;
+}
+.task-title {
+  font-size: 16px;
+  font-weight: bold;
+  color: #303133;
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.task-card-body {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+}
+.task-meta {
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  color: #606266;
+}
+.meta-label {
+  color: #909399;
+  width: 70px;
+  flex-shrink: 0;
+}
+.task-card-actions {
+  position: absolute;
+  top: 12px;
+  right: 12px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  opacity: 0.3;
+  transition: opacity 0.2s ease;
+}
+.task-card:hover .task-card-actions {
+  opacity: 1;
+}
+.task-card-actions .el-button {
+  margin: 0;
+}
+.text-danger { color: #f56c6c; }
+.text-warning { color: #e6a23c; }
+.pagination {
+  margin-top: 20px;
+  justify-content: flex-end;
+}
+.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>

+ 20 - 2
vite.config.ts

@@ -2,13 +2,27 @@ import { defineConfig } from 'vite'
 import vue from '@vitejs/plugin-vue'
 import { resolve } from 'path'
 
-export default defineConfig({
+export default defineConfig(({ mode }) => ({
+  base: '/',
   plugins: [vue()],
   resolve: {
     alias: {
       '@': resolve(__dirname, 'src')
     }
   },
+  build: {
+    sourcemap: mode !== 'production',
+    chunkSizeWarningLimit: 1000,
+    rollupOptions: {
+      output: {
+        manualChunks: {
+          'element-plus': ['element-plus'],
+          'vue-vendor': ['vue', 'vue-router', 'pinia'],
+          'logicflow': ['@logicflow/core', '@logicflow/extension']
+        }
+      }
+    }
+  },
   server: {
     port: 3000,
     proxy: {
@@ -20,7 +34,11 @@ export default defineConfig({
       '/uploads': {
         target: 'http://localhost:8080',
         changeOrigin: true
+      },
+      '/analysis': {
+        target: 'http://localhost:8080',
+        changeOrigin: true
       }
     }
   }
-})
+}))

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov