Quellcode durchsuchen

Merge branch '0.1.0'

ye-zhaojia vor 1 Woche
Ursprung
Commit
708225b1cd

+ 9 - 9
package-lock.json

@@ -20,7 +20,7 @@
       },
       "devDependencies": {
         "@types/js-cookie": "^3.0.6",
-        "@vitejs/plugin-vue": "^5.0.5",
+        "@vitejs/plugin-vue": "^4.5.0",
         "typescript": "^5.5.3",
         "vite": "^4.5.3",
         "vue-tsc": "^2.0.26"
@@ -1307,15 +1307,15 @@
       "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="
     },
     "node_modules/@vitejs/plugin-vue": {
-      "version": "5.2.4",
-      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
-      "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz",
+      "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==",
       "dev": true,
       "engines": {
-        "node": "^18.0.0 || >=20.0.0"
+        "node": "^14.18.0 || >=16.0.0"
       },
       "peerDependencies": {
-        "vite": "^5.0.0 || ^6.0.0",
+        "vite": "^4.0.0 || ^5.0.0",
         "vue": "^3.2.25"
       }
     },
@@ -5811,9 +5811,9 @@
       "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="
     },
     "@vitejs/plugin-vue": {
-      "version": "5.2.4",
-      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
-      "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz",
+      "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==",
       "dev": true,
       "requires": {}
     },

+ 1 - 1
package.json

@@ -21,7 +21,7 @@
   },
   "devDependencies": {
     "@types/js-cookie": "^3.0.6",
-    "@vitejs/plugin-vue": "^5.0.5",
+    "@vitejs/plugin-vue": "^4.5.0",
     "typescript": "^5.5.3",
     "vite": "^4.5.3",
     "vue-tsc": "^2.0.26"

+ 6 - 2
src/api/flow/instance.ts

@@ -6,8 +6,8 @@ export function listMyInstance(params?: PageQuery): Promise<PageResult<FlowInsta
   return request.get('/flow/instance/mine', { params })
 }
 
-export function startInstance(processDefinitionId: number, businessKey?: string, formData?: Record<string, unknown>): Promise<void> {
-  return request.post('/flow/instance/start', { processDefinitionId, businessKey, formData })
+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 })
 }
 
 export function getInstance(id: number): Promise<FlowInstance> {
@@ -21,3 +21,7 @@ export function getProgress(id: number): Promise<ProcessProgress> {
 export function participatedList(params?: PageQuery): Promise<PageResult<FlowInstance>> {
   return request.get('/flow/instance/participated', { params })
 }
+
+export function revokeInstance(id: number): Promise<void> {
+  return request.post(`/flow/instance/${id}/revoke`)
+}

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

@@ -33,3 +33,15 @@ export function transferTask(id: number, data: ApprovalAction): Promise<void> {
 export function todoCount(): Promise<number> {
   return request.get('/flow/task/todo-count')
 }
+
+export function addSignTask(taskId: number, assigneeId: number): Promise<void> {
+  return request.post(`/flow/task/${taskId}/add-sign?assigneeId=${assigneeId}`)
+}
+
+export function listCc(params?: PageQuery): Promise<PageResult<FlowTask>> {
+  return request.get('/flow/task/cc', { params })
+}
+
+export function readCc(taskId: number): Promise<void> {
+  return request.post(`/flow/task/cc/${taskId}/read`)
+}

+ 0 - 22
src/api/system/menu.ts

@@ -1,22 +0,0 @@
-import request from '@/api/request'
-import type { Menu } from '@/types/system'
-
-export function listMenu(): Promise<Menu[]> {
-  return request.get('/system/menu/list')
-}
-
-export function getMenu(id: number): Promise<Menu> {
-  return request.get(`/system/menu/${id}`)
-}
-
-export function addMenu(data: Partial<Menu>): Promise<void> {
-  return request.post('/system/menu', data)
-}
-
-export function updateMenu(data: Partial<Menu>): Promise<void> {
-  return request.put('/system/menu', data)
-}
-
-export function deleteMenu(id: number): Promise<void> {
-  return request.delete(`/system/menu/${id}`)
-}

+ 2 - 0
src/components/Navbar/index.vue

@@ -61,6 +61,8 @@ function handleCommand(command: string) {
       await userStore.logoutAction()
       router.push('/login')
     })
+  } else if (command === 'profile') {
+    router.push('/profile')
   }
 }
 

+ 47 - 4
src/router/index.ts

@@ -13,6 +13,25 @@ const router = createRouter({
       meta: { hidden: true }
     },
     {
+      path: '/profile',
+      component: () => import('@/components/Layout/index.vue'),
+      meta: { hidden: true },
+      children: [
+        {
+          path: '',
+          name: 'Profile',
+          component: () => import('@/views/profile/index.vue'),
+          meta: { title: '个人中心' }
+        }
+      ]
+    },
+    {
+      path: '/:pathMatch(.*)*',
+      name: 'NotFound',
+      component: () => import('@/views/error/404.vue'),
+      meta: { hidden: true }
+    },
+    {
       path: '/',
       component: () => import('@/components/Layout/index.vue'),
       redirect: '/dashboard',
@@ -35,15 +54,15 @@ const router = createRouter({
           path: 'user',
           name: 'User',
           component: () => import('@/views/system/user/index.vue'),
-          meta: { title: '用户管理', icon: 'UserFilled' }
+          meta: { title: '管理员管理', icon: 'UserFilled' }
         },
         {
           path: 'role',
           name: 'Role',
           component: () => import('@/views/system/role/index.vue'),
-          meta: { title: '角色管理', icon: 'User' }
+          meta: { title: '员工管理', icon: 'User' }
         },
-        // 菜单管理已移除(功能已整合)
+
       ]
     },
     {
@@ -85,6 +104,12 @@ const router = createRouter({
           meta: { title: '我的已办', icon: 'CircleCheck' }
         },
         {
+          path: 'cc',
+          name: 'Cc',
+          component: () => import('@/views/flow/task/cc.vue'),
+          meta: { title: '我的抄送', icon: 'Message' }
+        },
+        {
           path: 'mine',
           name: 'MyInstance',
           component: () => import('@/views/flow/instance/mine.vue'),
@@ -101,7 +126,15 @@ const router = createRouter({
   ]
 })
 
-const roleAllowedPaths = ['/', '/dashboard', '/login', '/approval', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute']
+const roleAllowedPaths = ['/', '/dashboard', '/login', '/profile', '/approval', '/approval/todo', '/approval/handled', '/approval/mine', '/approval/execute']
+
+// 按员工类型限制的路径
+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']
+}
 
 router.beforeEach(async (to, from, next) => {
   const token = getToken()
@@ -126,6 +159,16 @@ router.beforeEach(async (to, from, next) => {
           next('/approval/todo')
           return
         }
+        next()
+        return
+      }
+      // 普通用户按 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')
+        return
       }
       next()
     }

+ 0 - 40
src/stores/permission-store.ts

@@ -1,40 +0,0 @@
-import { defineStore } from 'pinia'
-import { ref, computed } from 'vue'
-import { listMenu } from '@/api/system/menu'
-import type { Menu } from '@/types/system'
-import type { RouteRecordRaw } from 'vue-router'
-
-export const usePermissionStore = defineStore('permission', () => {
-  const menus = ref<Menu[]>([])
-  const routes = ref<RouteRecordRaw[]>([])
-
-  const sidebarMenus = computed(() => menus.value)
-
-  async function generateRoutes() {
-    const res = await listMenu()
-    menus.value = res
-    return res
-  }
-
-  function hasPermission(permission: string): boolean {
-    const permissions = menus.value
-      .filter(m => m.type === 2)
-      .map(m => m.permission)
-      .filter(Boolean) as string[]
-    return permissions.includes(permission)
-  }
-
-  function resetPermission() {
-    menus.value = []
-    routes.value = []
-  }
-
-  return {
-    menus,
-    routes,
-    sidebarMenus,
-    generateRoutes,
-    hasPermission,
-    resetPermission
-  }
-})

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

@@ -37,7 +37,11 @@ export const useUserStore = defineStore('user', () => {
     token.value = undefined
     userInfo.value = null
     roles.value = []
+    userType.value = 'SYSTEM'
     removeToken()
+    localStorage.removeItem('login_username')
+    localStorage.removeItem('login_remember')
+    localStorage.removeItem('login_type')
   }
 
   return {

+ 2 - 0
src/types/flow.ts

@@ -59,6 +59,7 @@ export interface ApprovalAction {
   action: 'pass' | 'reject' | 'rollback' | 'transfer'
   comment?: string
   transferTo?: number
+  attachmentUrls?: string
 }
 
 export interface NodeProgress {
@@ -89,5 +90,6 @@ export interface ApprovalRecord {
   actionType: string
   actionResult: string
   comment?: string
+  attachmentUrls?: string
   createTime?: string
 }

+ 0 - 16
src/types/system.ts

@@ -29,22 +29,6 @@ export interface Role {
   createTime?: string
 }
 
-export interface Menu {
-  id: number
-  parentId: number
-  name: string
-  title: string
-  icon?: string
-  path?: string
-  component?: string
-  type: number // 0-目录 1-菜单 2-按钮
-  permission?: string
-  sort: number
-  status: number // 0-正常 1-禁用
-  hidden?: boolean
-  children?: Menu[]
-}
-
 export interface Dept {
   id: number
   parentId: number

+ 60 - 0
src/views/flow/designer/index.vue

@@ -497,10 +497,70 @@ const saveFormRules: FormRules = {
   name: [{ required: true, message: '请输入流程名称', trigger: 'blur' }]
 }
 
+function validateFlow(graphData: any): string | null {
+  const nodes = graphData.nodes || []
+  const edges = graphData.edges || []
+  if (nodes.length === 0) return '流程图不能为空'
+  const startNodes = nodes.filter((n: any) => n.type === 'start-node' || n.type === 'start')
+  const endNodes = nodes.filter((n: any) => n.type === 'end-node' || n.type === 'end')
+  if (startNodes.length === 0) return '流程图缺少开始节点'
+  if (endNodes.length === 0) return '流程图缺少结束节点'
+  if (startNodes.length > 1) return '流程图只能有一个开始节点'
+  if (endNodes.length > 1) return '流程图只能有一个结束节点'
+  // 检查审批节点是否配置了审批人
+  for (const node of nodes) {
+    if (node.type === 'approval-node' || node.type === 'approval') {
+      const props = node.properties || {}
+      const 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}" 未选择具体的审批人/角色`
+      }
+    }
+  }
+  // 检查孤立节点
+  const connectedNodeIds = new Set<string>()
+  for (const edge of edges) {
+    connectedNodeIds.add(edge.sourceNodeId)
+    connectedNodeIds.add(edge.targetNodeId)
+  }
+  for (const node of nodes) {
+    if (!connectedNodeIds.has(node.id) && node.type !== 'start-node' && node.type !== 'start' && node.type !== 'end-node' && node.type !== 'end') {
+      return `节点 "${node.text || node.name}" 是孤立节点,请删除或连接`
+    }
+  }
+  // 检查条件分支是否有默认分支
+  const sourceMap: Record<string, any[]> = {}
+  for (const edge of edges) {
+    if (!sourceMap[edge.sourceNodeId]) sourceMap[edge.sourceNodeId] = []
+    sourceMap[edge.sourceNodeId].push(edge)
+  }
+  for (const node of nodes) {
+    if (node.type === 'condition-node' || node.type === 'condition') {
+      const outEdges = sourceMap[node.id] || []
+      if (outEdges.length > 1) {
+        const hasDefault = outEdges.some((e: any) => !e.properties?.condition && !e.condition)
+        if (!hasDefault) {
+          return `条件节点 "${node.text || node.name}" 需要至少一条无条件的默认分支`
+        }
+      }
+    }
+  }
+  return null
+}
+
 async function handleSave() {
   if (!lf) return
   saveCurrentNodeProps()
   const graphData = convertToBackendFormat(lf.getGraphData())
+  const error = validateFlow(lf.getGraphData())
+  if (error) {
+    ElMessage.warning(error)
+    return
+  }
 
   if (mode.value === 'create') {
     // 新增模式:直接保存

+ 232 - 14
src/views/flow/execute/InstanceDetail.vue

@@ -2,7 +2,7 @@
   <el-dialog
     v-model="visible"
     title="流程详情"
-    width="850px"
+    width="900px"
     :close-on-click-modal="false"
     @close="handleClose"
   >
@@ -25,6 +25,36 @@
         </el-descriptions>
       </div>
 
+      <!-- 表单数据 -->
+      <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>
+      </div>
+
+      <!-- 附件列表 -->
+      <div v-if="instanceAttachments.length > 0" 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">
+              <el-icon><Document /></el-icon>
+              {{ getFileName(url) }}
+            </el-link>
+            <el-image
+              v-if="isImage(url)"
+              :src="getFileUrl(url)"
+              :preview-src-list="imagePreviewList"
+              fit="cover"
+              style="width: 60px; height: 60px; margin-left: 8px;"
+            />
+          </div>
+        </div>
+      </div>
+
       <el-divider />
 
       <div class="detail-body">
@@ -75,14 +105,38 @@
                   <el-radio label="transfer">转办</el-radio>
                 </el-radio-group>
               </el-form-item>
+              <el-form-item v-if="form.action === 'rollback'" label="回退到">
+                <el-select v-model="form.targetNodeId" filterable placeholder="请选择回退节点" style="width: 100%">
+                  <el-option
+                    v-for="node in rollbackNodeOptions"
+                    :key="node.nodeId"
+                    :label="node.nodeName"
+                    :value="node.nodeId"
+                  />
+                </el-select>
+              </el-form-item>
               <el-form-item v-if="form.action === 'transfer'" label="转给人">
-                <el-input v-model="form.transferTo" placeholder="请输入用户ID" />
+                <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-item>
                 <el-button type="primary" @click="submitApprove">提交</el-button>
+                <el-button @click="handleAddSign">加签</el-button>
               </el-form-item>
             </el-form>
           </template>
@@ -108,6 +162,18 @@
                 <span class="record-time">{{ record.createTime }}</span>
               </div>
               <div v-if="record.comment" class="record-comment">{{ record.comment }}</div>
+              <div v-if="record.attachmentUrls" class="record-attachments">
+                <el-link
+                  v-for="(url, idx) in parseAttachments(record.attachmentUrls)"
+                  :key="idx"
+                  type="primary"
+                  :href="getFileUrl(url)"
+                  target="_blank"
+                  size="small"
+                >
+                  附件{{ idx + 1 }}: {{ getFileName(url) }}
+                </el-link>
+              </div>
             </div>
           </div>
           <el-empty v-else description="暂无审批记录" />
@@ -121,9 +187,12 @@
 import { ref, reactive, computed, watch } from 'vue'
 import { ElMessage } from 'element-plus'
 import { getProgress } from '@/api/flow/instance'
-import { approveTask } from '@/api/flow/task'
-import type { ProcessProgress, FlowTask } from '@/types/flow'
-import { Check, CircleCheck, Clock, Warning } from '@element-plus/icons-vue'
+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 type { User } from '@/types/system'
+import { Check, CircleCheck, Clock, Warning, Document } from '@element-plus/icons-vue'
 
 const props = defineProps<{
   modelValue: boolean
@@ -146,9 +215,88 @@ const progress = ref<ProcessProgress | null>(null)
 const form = reactive({
   action: 'pass',
   comment: '',
-  transferTo: ''
+  transferTo: undefined as number | undefined,
+  targetNodeId: undefined as string | undefined
+})
+
+const attachmentList = ref<any[]>([])
+
+const rollbackNodeOptions = computed<NodeProgress[]>(() => {
+  if (!progress.value) return []
+  return progress.value.nodes.filter(n => n.status === 'completed')
+})
+
+const userList = ref<User[]>([])
+
+async function loadUsers() {
+  try {
+    const res = await listUser({ pageNum: 1, pageSize: 9999 })
+    userList.value = res.list
+  } catch {
+    // ignore
+  }
+}
+
+// 解析表单数据
+const formDataDisplay = computed(() => {
+  const formDataStr = (progress.value?.instance as any)?.formData
+  if (!formDataStr) return null
+  try {
+    return JSON.parse(formDataStr)
+  } catch {
+    return { 原始数据: formDataStr }
+  }
+})
+
+// 实例附件列表
+const instanceAttachments = computed(() => {
+  const urlsStr = (progress.value?.instance as any)?.attachmentUrls
+  if (!urlsStr) return []
+  try {
+    return JSON.parse(urlsStr) as string[]
+  } catch {
+    return []
+  }
 })
 
+// 图片预览列表
+const imagePreviewList = computed(() => {
+  return instanceAttachments.value.filter(isImage).map(getFileUrl)
+})
+
+function isImage(url: string): boolean {
+  return /\.(png|jpe?g|gif|webp|bmp)$/i.test(url)
+}
+
+function getFileUrl(url: string): string {
+  if (url.startsWith('http')) return url
+  // 相对路径,拼接当前页面 origin(通过 /uploads 代理到后端)
+  return window.location.origin + url
+}
+
+function getFileName(url: string): string {
+  const parts = url.split('/')
+  return parts[parts.length - 1] || url
+}
+
+function parseAttachments(urlsStr?: string): string[] {
+  if (!urlsStr) return []
+  try {
+    return JSON.parse(urlsStr) as string[]
+  } catch {
+    return urlsStr ? [urlsStr] : []
+  }
+}
+
+async function handleUpload(options: any) {
+  try {
+    const res = await uploadFile(options.file)
+    options.onSuccess(res)
+  } catch (e) {
+    options.onError(e)
+  }
+}
+
 // 查找当前登录用户需要处理的待办任务
 const myPendingTask = computed<FlowTask | null>(() => {
   if (!progress.value) return null
@@ -163,11 +311,19 @@ const myPendingTask = computed<FlowTask | null>(() => {
 
 function statusText(status?: number) {
   if (status === undefined) return '未知'
-  return ['运行中', '已完成', '已终止'][status] || '未知'
+  const map: Record<number, string> = {
+    0: '待接收', 1: '运行中', 2: '已通过', 3: '已拒绝',
+    4: '已回退', 5: '已完成', 6: '已撤回', 7: '已终止'
+  }
+  return map[status] || '未知'
 }
 
 function statusTagType(status?: number) {
-  return ['primary', 'success', 'danger'][status ?? 0] || 'info'
+  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) {
@@ -220,24 +376,57 @@ async function loadProgress() {
 async function submitApprove() {
   const task = myPendingTask.value
   if (!task) return
-  const data = {
-    action: form.action as any,
+  const data: any = {
+    action: form.action,
     comment: form.comment
   }
   if (form.action === 'transfer' && form.transferTo) {
-    (data as any).transferTo = Number(form.transferTo)
+    data.transferTo = Number(form.transferTo)
+  }
+  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
   }
-  await approveTask(task.id, data)
   ElMessage.success('审批提交成功')
   form.action = 'pass'
   form.comment = ''
-  form.transferTo = ''
+  form.transferTo = undefined
+  form.targetNodeId = undefined
+  attachmentList.value = []
+  // 刷新进度展示最新状态,不关闭弹窗
+  await loadProgress()
   emit('approved')
-  loadProgress()
 }
 
 function handleClose() {
   progress.value = null
+  attachmentList.value = []
+}
+
+async 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()
 }
 
 watch(() => props.instanceId, (id) => {
@@ -249,6 +438,7 @@ watch(() => props.instanceId, (id) => {
 watch(() => props.modelValue, (val) => {
   if (val && props.instanceId) {
     loadProgress()
+    loadUsers()
   }
 })
 </script>
@@ -261,6 +451,28 @@ watch(() => props.modelValue, (val) => {
 .info-section {
   margin-bottom: 10px;
 }
+.form-data-section,
+.attachment-section {
+  margin-bottom: 10px;
+}
+.form-data-section h4,
+.attachment-section h4 {
+  margin: 0 0 8px 0;
+  font-size: 15px;
+  color: #303133;
+}
+.attachment-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+.attachment-item {
+  display: flex;
+  align-items: center;
+  padding: 6px 10px;
+  background: #f5f7fa;
+  border-radius: 4px;
+}
 .detail-body {
   display: flex;
   gap: 24px;
@@ -344,5 +556,11 @@ watch(() => props.modelValue, (val) => {
   background: #f5f7fa;
   padding: 6px 10px;
   border-radius: 4px;
+  margin-bottom: 6px;
+}
+.record-attachments {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
 }
 </style>

+ 54 - 10
src/views/flow/execute/index.vue

@@ -78,7 +78,21 @@
           <el-input v-model="startForm.title" placeholder="请输入流程标题" />
         </el-form-item>
         <el-form-item label="表单数据">
-          <el-input v-model="startForm.formData" type="textarea" placeholder="JSON格式表单数据(可选)" />
+          <el-input 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-remove="() => true"
+            multiple
+          >
+            <el-button type="primary" size="small">上传附件</el-button>
+            <template #tip>
+              <div class="el-upload__tip">支持 Excel、图片等常见格式</div>
+            </template>
+          </el-upload>
         </el-form-item>
       </el-form>
       <template #footer>
@@ -102,6 +116,7 @@ import { ElMessage } from 'element-plus'
 import type { FormInstance, FormRules } from 'element-plus'
 import { listEnabled } from '@/api/flow/definition'
 import { participatedList, startInstance } from '@/api/flow/instance'
+import { uploadFile } from '@/api/file'
 import type { FlowDefinition, FlowInstance } from '@/types/flow'
 import InstanceDetail from './InstanceDetail.vue'
 
@@ -121,6 +136,7 @@ const startForm = ref({
   title: '',
   formData: ''
 })
+const attachmentList = ref<any[]>([])
 const startFormRules: FormRules = {
   title: [{ required: true, message: '请输入标题', trigger: 'blur' }]
 }
@@ -129,11 +145,19 @@ const detailVisible = ref(false)
 const currentInstanceId = ref(0)
 
 function statusText(status: number) {
-  return ['运行中', '已完成', '已终止'][status] || '未知'
+  const map: Record<number, string> = {
+    0: '待接收', 1: '运行中', 2: '已通过', 3: '已拒绝',
+    4: '已回退', 5: '已完成', 6: '已撤回', 7: '已终止'
+  }
+  return map[status] || '未知'
 }
 
 function statusTagType(status: number) {
-  return ['primary', 'success', 'danger'][status] || 'info'
+  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 loadDefinitions() {
@@ -162,22 +186,42 @@ function handleStart(def: FlowDefinition) {
   startForm.value.definitionName = def.name
   startForm.value.title = ''
   startForm.value.formData = ''
+  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
   let formData: Record<string, unknown> | undefined
   if (startForm.value.formData.trim()) {
-    try {
-      formData = JSON.parse(startForm.value.formData)
-    } catch {
-      ElMessage.warning('表单数据格式不正确,请输入有效的JSON')
-      return
-    }
+    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
+        }
+      }
+    })
   }
-  await startInstance(startForm.value.processDefinitionId, startForm.value.title || undefined, formData)
+  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'

+ 64 - 6
src/views/flow/instance/mine.vue

@@ -7,6 +7,16 @@
         </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="流程名称" />
@@ -21,14 +31,15 @@
         <el-table-column prop="currentNode" label="当前节点" />
         <el-table-column prop="startTime" label="发起时间" />
         <el-table-column prop="endTime" label="结束时间" />
-        <el-table-column label="操作" width="120">
+        <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>
           </template>
         </el-table-column>
       </el-table>
 
-      <InstanceDetail v-model="detailVisible" :instance-id="selectedInstanceId" />
+      <InstanceDetail v-model="detailVisible" :instance-id="selectedInstanceId" @approved="loadData" />
 
       <el-pagination
         v-model:current-page="queryParams.pageNum"
@@ -45,9 +56,10 @@
 
 <script setup lang="ts">
 import { ref, reactive, onMounted } from 'vue'
-import { listMyInstance } from '@/api/flow/instance'
+import { listMyInstance, revokeInstance } from '@/api/flow/instance'
 import InstanceDetail from '@/views/flow/execute/InstanceDetail.vue'
 import type { FlowInstance } from '@/types/flow'
+import { ElMessage, ElMessageBox } from 'element-plus'
 
 const loading = ref(false)
 const tableData = ref<FlowInstance[]>([])
@@ -56,7 +68,8 @@ const detailVisible = ref(false)
 const selectedInstanceId = ref<number>(0)
 const queryParams = reactive({
   pageNum: 1,
-  pageSize: 10
+  pageSize: 10,
+  processName: undefined as string | undefined
 })
 
 function handleDetail(row: FlowInstance) {
@@ -64,12 +77,54 @@ function handleDetail(row: FlowInstance) {
   detailVisible.value = true
 }
 
+async function handleRevoke(row: FlowInstance) {
+  try {
+    await ElMessageBox.confirm(`确认撤回流程 "${row.definitionName || row.instanceNo}" 吗?`, '提示', { type: 'warning' })
+    await revokeInstance(row.id)
+    ElMessage.success('撤回成功')
+    loadData()
+  } catch {
+    // cancel
+  }
+}
+
+function handleQuery() {
+  queryParams.pageNum = 1
+  loadData()
+}
+
+function resetQuery() {
+  queryParams.processName = undefined
+  queryParams.pageNum = 1
+  loadData()
+}
+
 function statusText(status: number) {
-  return ['运行中', '已完成', '已终止'][status] || '未知'
+  const map: Record<number, string> = {
+    0: '待接收',
+    1: '运行中',
+    2: '已通过',
+    3: '已拒绝',
+    4: '已回退',
+    5: '已完成',
+    6: '已撤回',
+    7: '已终止'
+  }
+  return map[status] || '未知'
 }
 
 function statusTagType(status: number) {
-  return ['primary', 'success', 'danger'][status] || 'info'
+  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() {
@@ -96,4 +151,7 @@ onMounted(loadData)
   margin-top: 20px;
   justify-content: flex-end;
 }
+.search-form {
+  margin-bottom: 20px;
+}
 </style>

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

@@ -7,6 +7,16 @@
         </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="流程名称" />
@@ -39,13 +49,15 @@
 import { ref, reactive, onMounted } from 'vue'
 import { listHandled } from '@/api/flow/task'
 import type { FlowTask } from '@/types/flow'
+import { ElMessage } from 'element-plus'
 
 const loading = ref(false)
 const tableData = ref<FlowTask[]>([])
 const total = ref(0)
 const queryParams = reactive({
   pageNum: 1,
-  pageSize: 10
+  pageSize: 10,
+  processName: undefined as string | undefined
 })
 
 function actionText(action?: string) {
@@ -79,6 +91,17 @@ async function loadData() {
   }
 }
 
+function handleQuery() {
+  queryParams.pageNum = 1
+  loadData()
+}
+
+function resetQuery() {
+  queryParams.processName = undefined
+  queryParams.pageNum = 1
+  loadData()
+}
+
 onMounted(loadData)
 </script>
 
@@ -92,4 +115,7 @@ onMounted(loadData)
   margin-top: 20px;
   justify-content: flex-end;
 }
+.search-form {
+  margin-bottom: 20px;
+}
 </style>

+ 110 - 4
src/views/flow/task/todo.vue

@@ -7,6 +7,16 @@
         </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="流程名称" />
@@ -15,6 +25,7 @@
         <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>
@@ -42,7 +53,20 @@
           </el-radio-group>
         </el-form-item>
         <el-form-item label="转办人" v-if="form.action === 'transfer'">
-          <el-input v-model="form.transferTo" placeholder="请输入转办人ID" />
+          <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="请输入审批意见" />
@@ -53,6 +77,21 @@
         <el-button type="primary" @click="submitApprove">确认</el-button>
       </template>
     </el-dialog>
+
+    <!-- 加签对话框 -->
+    <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" @click="submitAddSign">确认</el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -60,17 +99,23 @@
 import { ref, reactive, onMounted } from 'vue'
 import { ElMessage } from 'element-plus'
 import type { FormInstance } from 'element-plus'
-import { listTodo, approveTask, rejectTask, returnTask, transferTask } from '@/api/flow/task'
+import { listTodo, approveTask, rejectTask, returnTask, transferTask, addSignTask } from '@/api/flow/task'
+import { listUser } from '@/api/system/user'
+import { uploadFile } from '@/api/file'
 import type { FlowTask, ApprovalAction } from '@/types/flow'
+import type { User } from '@/types/system'
 
 const loading = ref(false)
 const tableData = ref<FlowTask[]>([])
 const total = ref(0)
 const queryParams = reactive({
   pageNum: 1,
-  pageSize: 10
+  pageSize: 10,
+  processName: undefined as string | undefined
 })
 
+const userList = ref<User[]>([])
+
 const dialogVisible = ref(false)
 const currentTask = ref<FlowTask | null>(null)
 const formRef = ref<FormInstance>()
@@ -79,6 +124,11 @@ const form = reactive<ApprovalAction & { transferTo?: string | number }>({
   comment: '',
   transferTo: undefined
 })
+const attachmentList = ref<any[]>([])
+
+const addSignVisible = ref(false)
+const addSignTaskId = ref<number>(0)
+const addSignUserId = ref<number | undefined>(undefined)
 
 async function loadData() {
   loading.value = true
@@ -91,14 +141,61 @@ async function loadData() {
   }
 }
 
+async function loadUsers() {
+  try {
+    const res = await listUser({ pageNum: 1, pageSize: 9999 })
+    userList.value = res.list
+  } catch {
+    // ignore
+  }
+}
+
+function handleQuery() {
+  queryParams.pageNum = 1
+  loadData()
+}
+
+function resetQuery() {
+  queryParams.processName = undefined
+  queryParams.pageNum = 1
+  loadData()
+}
+
 function handleApprove(row: FlowTask) {
   currentTask.value = row
   form.action = 'pass'
   form.comment = ''
   form.transferTo = undefined
+  attachmentList.value = []
   dialogVisible.value = true
 }
 
+async function handleUpload(options: any) {
+  try {
+    const res = await uploadFile(options.file)
+    options.onSuccess(res)
+  } catch (e) {
+    options.onError(e)
+  }
+}
+
+function handleAddSign(row: FlowTask) {
+  addSignTaskId.value = row.id
+  addSignUserId.value = undefined
+  addSignVisible.value = true
+}
+
+async function submitAddSign() {
+  if (!addSignUserId.value) {
+    ElMessage.warning('请选择加签人')
+    return
+  }
+  await addSignTask(addSignTaskId.value, addSignUserId.value)
+  ElMessage.success('加签成功')
+  addSignVisible.value = false
+  loadData()
+}
+
 async function submitApprove() {
   if (!currentTask.value) return
   const data: ApprovalAction = {
@@ -108,6 +205,9 @@ async function submitApprove() {
   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))
+  }
   const taskId = currentTask.value.id
   switch (form.action) {
     case 'pass':
@@ -128,7 +228,10 @@ async function submitApprove() {
   loadData()
 }
 
-onMounted(loadData)
+onMounted(() => {
+  loadData()
+  loadUsers()
+})
 </script>
 
 <style scoped>
@@ -141,4 +244,7 @@ onMounted(loadData)
   margin-top: 20px;
   justify-content: flex-end;
 }
+.search-form {
+  margin-bottom: 20px;
+}
 </style>

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

@@ -92,7 +92,6 @@ async function handleLogin() {
     await userStore.fetchUserInfo()
     if (remember.value) {
       localStorage.setItem('login_username', loginForm.username)
-      localStorage.setItem('login_password', loginForm.password)
       localStorage.setItem('login_remember', 'true')
       localStorage.setItem('login_type', loginType.value)
     } else {
@@ -113,12 +112,10 @@ async function handleLogin() {
 
 onMounted(() => {
   const savedUsername = localStorage.getItem('login_username')
-  const savedPassword = localStorage.getItem('login_password')
   const savedRemember = localStorage.getItem('login_remember')
   const savedType = localStorage.getItem('login_type')
   if (savedRemember === 'true' && savedUsername) {
     loginForm.username = savedUsername
-    loginForm.password = savedPassword || ''
     remember.value = true
     if (savedType === 'ROLE' || savedType === 'SYSTEM') {
       loginType.value = savedType as 'SYSTEM' | 'ROLE'

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

@@ -18,26 +18,36 @@
             highlight-current
             default-expand-all
             @node-click="handleDeptClick"
-          />
+          >
+            <template #default="{ node, data }">
+              <span class="custom-tree-node">
+                <span>{{ node.label }}</span>
+                <span v-if="data.id !== 0" class="tree-actions">
+                  <el-button link type="primary" size="small" @click.stop="handleEditDept(data)">编辑</el-button>
+                  <el-button link type="danger" size="small" @click.stop="handleDeleteDept(data)">删除</el-button>
+                </span>
+              </span>
+            </template>
+          </el-tree>
         </el-card>
       </el-col>
 
-      <!-- 右侧角色列表 -->
+      <!-- 右侧员工列表 -->
       <el-col :span="19">
         <el-card>
           <template #header>
             <div class="card-header">
-              <span>角色列表</span>
-              <el-button type="primary" @click="handleAdd">新增角色</el-button>
+              <span>员工列表</span>
+              <el-button type="primary" @click="handleAdd">新增员工</el-button>
             </div>
           </template>
 
           <el-form :inline="true" :model="queryParams" class="search-form">
-            <el-form-item label="角色编码">
-              <el-input v-model="queryParams.roleCode" placeholder="请输入角色编码" clearable />
+            <el-form-item label="员工编码">
+              <el-input v-model="queryParams.roleCode" placeholder="请输入员工编码" clearable />
             </el-form-item>
-            <el-form-item label="角色名称">
-              <el-input v-model="queryParams.roleName" placeholder="请输入角色名称" clearable />
+            <el-form-item label="员工姓名">
+              <el-input v-model="queryParams.roleName" placeholder="请输入员工姓名" clearable />
             </el-form-item>
             <el-form-item>
               <el-button type="primary" @click="handleQuery">查询</el-button>
@@ -47,8 +57,8 @@
 
           <el-table v-loading="loading" :data="tableData" border>
             <el-table-column type="index" label="序号" width="60" />
-            <el-table-column prop="roleCode" label="角色编码" />
-            <el-table-column prop="roleName" label="角色名称" />
+            <el-table-column prop="roleCode" label="员工编码" />
+            <el-table-column prop="roleName" label="员工姓名" />
             <el-table-column prop="username" label="登录账号" />
             <el-table-column prop="deptName" label="所属部门" />
             <el-table-column prop="roleScope" label="描述" />
@@ -82,17 +92,17 @@
       </el-col>
     </el-row>
 
-    <!-- 角色弹窗 -->
+    <!-- 员工弹窗 -->
     <el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
       <el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
-        <el-form-item label="角色编码" prop="roleCode">
+        <el-form-item label="员工编码" prop="roleCode">
           <el-input v-model="form.roleCode" :disabled="isEdit" />
         </el-form-item>
-        <el-form-item label="角色名称" prop="roleName">
+        <el-form-item label="员工姓名" prop="roleName">
           <el-input v-model="form.roleName" />
         </el-form-item>
         <el-form-item label="登录账号" prop="username">
-          <el-input v-model="form.username" :disabled="isEdit" placeholder="角色用户的登录账号" />
+          <el-input v-model="form.username" :disabled="isEdit" placeholder="员工登录账号" />
         </el-form-item>
         <el-form-item label="密码" prop="password">
           <el-input v-model="form.password" type="password" show-password :placeholder="isEdit ? '不填表示不修改密码' : '请输入密码'" />
@@ -198,7 +208,7 @@ const deptTreeForSelect = computed(() => {
   return [{ id: 0, parentId: 0, name: '根部门', sort: 0, status: 1 }, ...deptList.value]
 })
 
-// 角色弹窗
+// 员工弹窗
 const dialogVisible = ref(false)
 const dialogTitle = ref('')
 const isEdit = ref(false)
@@ -215,8 +225,8 @@ const form = reactive<Partial<Role>>({
 })
 
 const formRules: FormRules = {
-  roleCode: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
-  roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }]
+  roleCode: [{ required: true, message: '请输入员工编码', trigger: 'blur' }],
+  roleName: [{ required: true, message: '请输入员工姓名', trigger: 'blur' }]
 }
 
 // 部门弹窗
@@ -290,7 +300,7 @@ function handleDeptClick(data: Dept) {
 
 function handleAdd() {
   isEdit.value = false
-  dialogTitle.value = '新增角色'
+  dialogTitle.value = '新增员工'
   Object.assign(form, {
     id: undefined,
     roleCode: '',
@@ -306,14 +316,14 @@ function handleAdd() {
 
 function handleEdit(row: Role) {
   isEdit.value = true
-  dialogTitle.value = '编辑角色'
+  dialogTitle.value = '编辑员工'
   Object.assign(form, row)
   dialogVisible.value = true
 }
 
 async function handleDelete(row: Role) {
   try {
-    await ElMessageBox.confirm(`确认删除角色 "${row.roleName}" 吗?`, '提示', { type: 'warning' })
+    await ElMessageBox.confirm(`确认删除员工 "${row.roleName}" 吗?`, '提示', { type: 'warning' })
     await deleteRole(row.id)
     ElMessage.success('删除成功')
     loadData()
@@ -336,6 +346,31 @@ async function submitForm() {
   loadData()
 }
 
+function handleEditDept(data: Dept) {
+  isEditDept.value = true
+  deptDialogTitle.value = '编辑部门'
+  Object.assign(deptForm, {
+    id: data.id,
+    parentId: data.parentId ?? 0,
+    name: data.name,
+    deptCode: data.deptCode,
+    sort: data.sort,
+    status: data.status
+  })
+  deptDialogVisible.value = true
+}
+
+async function handleDeleteDept(data: Dept) {
+  try {
+    await ElMessageBox.confirm(`确认删除部门 "${data.name}" 吗?`, '提示', { type: 'warning' })
+    await deleteDept(data.id)
+    ElMessage.success('删除成功')
+    loadDepts()
+  } catch {
+    // cancel
+  }
+}
+
 // 部门管理
 function handleAddDept() {
   isEditDept.value = false
@@ -379,4 +414,18 @@ onMounted(() => {
   margin-top: 20px;
   justify-content: flex-end;
 }
+.custom-tree-node {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 14px;
+  padding-right: 8px;
+}
+.tree-actions {
+  display: none;
+}
+.custom-tree-node:hover .tree-actions {
+  display: inline;
+}
 </style>

+ 34 - 25
src/views/system/user/index.vue

@@ -3,14 +3,14 @@
     <el-card>
       <template #header>
         <div class="card-header">
-          <span>用户列表</span>
-          <el-button type="primary" @click="handleAdd">新增用户</el-button>
+          <span>管理员列表</span>
+          <el-button type="primary" @click="handleAdd">新增管理员</el-button>
         </div>
       </template>
 
       <el-form :inline="true" :model="queryParams" class="search-form">
-        <el-form-item label="用户名">
-          <el-input v-model="queryParams.username" placeholder="请输入用户名" clearable />
+        <el-form-item label="账号">
+          <el-input v-model="queryParams.username" placeholder="请输入账号" clearable />
         </el-form-item>
         <el-form-item label="状态">
           <el-select v-model="queryParams.status" placeholder="全部" clearable>
@@ -26,9 +26,9 @@
 
       <el-table v-loading="loading" :data="tableData" border>
         <el-table-column type="index" label="序号" width="60" />
-        <el-table-column prop="username" label="用户名" />
-        <el-table-column prop="realName" label="昵称" />
-        <el-table-column prop="employeeType" label="员类型">
+        <el-table-column prop="username" label="账号" />
+        <el-table-column prop="realName" label="姓名" />
+        <el-table-column prop="employeeType" label="管理员类型">
           <template #default="{ row }">
             {{ employeeTypeLabel(row.employeeType) }}
           </template>
@@ -64,16 +64,16 @@
       />
     </el-card>
 
-    <!-- 用户弹窗 -->
+    <!-- 管理员弹窗 -->
     <el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
       <el-form ref="formRef" :model="form" :rules="formRules" label-width="80px">
-        <el-form-item label="用户名" prop="username">
+        <el-form-item label="账号" prop="username">
           <el-input v-model="form.username" :disabled="isEdit" />
         </el-form-item>
         <el-form-item label="密码" prop="password">
           <el-input v-model="form.password" type="password" :placeholder="isEdit ? '不填表示不修改密码' : '请输入密码'" show-password />
         </el-form-item>
-        <el-form-item label="昵称" prop="realName">
+        <el-form-item label="姓名" prop="realName">
           <el-input v-model="form.realName" />
         </el-form-item>
         <el-form-item label="邮箱" prop="email">
@@ -93,12 +93,12 @@
             style="width: 100%"
           />
         </el-form-item>
-        <el-form-item label="员类型" prop="employeeType">
-          <el-select v-model="form.employeeType" placeholder="请选择员类型" style="width: 100%">
-            <el-option label="普通用户" value="common_user" />
-            <el-option label="部门经理" value="dept_manager" />
+        <el-form-item label="管理员类型" prop="employeeType">
+          <el-select v-model="form.employeeType" placeholder="请选择管理员类型" style="width: 100%">
+            <el-option label="普通管理员" value="common_user" />
+            <el-option label="部门管理员" value="dept_manager" />
             <el-option label="流程管理员" value="flow_manager" />
-            <el-option label="超级管理员" value="super_admin" />
+            <el-option label="系统管理员" value="super_admin" />
           </el-select>
         </el-form-item>
         <el-form-item label="状态">
@@ -140,7 +140,7 @@ const deptTreeForSelect = computed(() => {
   return [{ id: 0, parentId: 0, name: '根部门', sort: 0, status: 0 }, ...deptList.value]
 })
 
-// 用户弹窗
+// 管理员弹窗
 const dialogVisible = ref(false)
 const dialogTitle = ref('')
 const isEdit = ref(false)
@@ -160,10 +160,10 @@ const form = reactive<Partial<User>>({
 const PASSWORD_PLACEHOLDER = '********'
 
 const employeeTypeMap: Record<string, string> = {
-  common_user: '普通用户',
-  dept_manager: '部门经理',
+  common_user: '普通管理员',
+  dept_manager: '部门管理员',
   flow_manager: '流程管理员',
-  super_admin: '超级管理员'
+  super_admin: '系统管理员'
 }
 
 function employeeTypeLabel(type?: string) {
@@ -171,9 +171,18 @@ function employeeTypeLabel(type?: string) {
 }
 
 const formRules: FormRules = {
-  username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
-  password: [{ required: !isEdit.value, message: '请输入密码', trigger: 'blur' }],
-  employeeType: [{ required: true, message: '请选择员工类型', trigger: 'change' }]
+  username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
+  password: [{
+    validator: (rule: any, value: any, callback: any) => {
+      if (!isEdit.value && (!value || value.trim() === '')) {
+        callback(new Error('请输入密码'))
+      } else {
+        callback()
+      }
+    },
+    trigger: 'blur'
+  }],
+  employeeType: [{ required: true, message: '请选择管理员类型', trigger: 'change' }]
 }
 
 async function loadDepts() {
@@ -219,7 +228,7 @@ function resetQuery() {
 
 function handleAdd() {
   isEdit.value = false
-  dialogTitle.value = '新增用户'
+  dialogTitle.value = '新增管理员'
   Object.assign(form, {
     id: undefined,
     username: '',
@@ -236,7 +245,7 @@ function handleAdd() {
 
 function handleEdit(row: User) {
   isEdit.value = true
-  dialogTitle.value = '编辑用户'
+  dialogTitle.value = '编辑管理员'
   Object.assign(form, {
     id: row.id,
     username: row.username,
@@ -253,7 +262,7 @@ function handleEdit(row: User) {
 
 async function handleDelete(row: User) {
   try {
-    await ElMessageBox.confirm(`确认删除用户 "${row.username}" 吗?`, '提示', { type: 'warning' })
+    await ElMessageBox.confirm(`确认删除管理员 "${row.username}" 吗?`, '提示', { type: 'warning' })
     await deleteUser(row.id)
     ElMessage.success('删除成功')
     loadData()

+ 4 - 0
vite.config.ts

@@ -16,6 +16,10 @@ export default defineConfig({
         target: 'http://localhost:8080',
         changeOrigin: true,
         rewrite: (path) => path.replace(/^\/api/, '')
+      },
+      '/uploads': {
+        target: 'http://localhost:8080',
+        changeOrigin: true
       }
     }
   }