Browse Source

feat: 会议管理页面 + 排期分组显示 + 工时统计会议列 + 快速选择按钮 + Bug修复

- 新增会议管理CRUD页面(devops/meeting),含参会人快速选择按钮(全员/研发/交付/质量)
- 排期统计表(schedule.vue)增加分组列+rowspan合并+按组排序
- 工时报表(workHour/index.vue)每日增加会议列+会议合计列+Excel导出
- 软件交付页(software/index.vue)去掉左侧项目状态筛选
- 发版完成对话框(ReleaseCompleteDialog)修复projectId为空时查询不到数据
- 合同申请相关API及组件
- 交付项目/运营事件优化
程健 3 weeks ago
parent
commit
b8e57a2587

+ 10 - 0
src/api/contract/application.js

@@ -0,0 +1,10 @@
+import micro_request from '@/utils/micro_request'
+
+const basePath = process.env.VUE_APP_ParentPath
+
+export default {
+  // 从项目创建合同申请
+  createFromBusiness(query) {
+    return micro_request.postRequest(basePath, 'CtrContractApplication', 'CreateFromBusiness', query)
+  },
+}

+ 16 - 0
src/api/devops/deliveryProject.js

@@ -30,4 +30,20 @@ export default {
   assignDeliveryUser(data) {
     return micro_request.postRequest(basePath, 'DeliveryProject', 'AssignDeliveryUser', data)
   },
+
+  getDelegatedProjectList(params) {
+    return micro_request.postRequest(basePath, 'DeliveryProject', 'GetDelegatedProjectList', params)
+  },
+
+  getDelegatesByProjectId(projectId) {
+    return micro_request.postRequest(basePath, 'DeliveryProject', 'GetDelegatesByProjectId', { projectId })
+  },
+
+  addDelegate(data) {
+    return micro_request.postRequest(basePath, 'DeliveryProject', 'AddDelegate', data)
+  },
+
+  removeDelegate(id) {
+    return micro_request.postRequest(basePath, 'DeliveryProject', 'RemoveDelegate', { id })
+  },
 }

+ 37 - 0
src/api/devops/meeting.js

@@ -0,0 +1,37 @@
+import micro_request from '@/utils/micro_request'
+
+const basePath = process.env.VUE_APP_ParentPath
+
+export default {
+  getList(query) {
+    return micro_request.postRequest(basePath, 'PlatMeeting', 'GetList', query)
+  },
+
+  getById(id) {
+    return micro_request.postRequest(basePath, 'PlatMeeting', 'GetById', { id })
+  },
+
+  create(data) {
+    return micro_request.postRequest(basePath, 'PlatMeeting', 'Create', data)
+  },
+
+  update(data) {
+    return micro_request.postRequest(basePath, 'PlatMeeting', 'UpdateById', data)
+  },
+
+  deleteByIds(ids) {
+    return micro_request.postRequest(basePath, 'PlatMeeting', 'DeleteByIds', { ids })
+  },
+
+  addAttendees(data) {
+    return micro_request.postRequest(basePath, 'PlatMeeting', 'AddAttendees', data)
+  },
+
+  complete(id) {
+    return micro_request.postRequest(basePath, 'PlatMeeting', 'Complete', { id })
+  },
+
+  getWorkHourList(meetingId) {
+    return micro_request.postRequest(basePath, 'PlatMeeting', 'GetWorkHourList', { id: meetingId })
+  },
+}

+ 8 - 6
src/api/operation/operationEvent.js

@@ -28,13 +28,15 @@ export default {
     return micro_request.postRequest(basePath, 'Operation', 'AssignOpsUser', query)
   },
   updateStatus(query) {
-    const operateType = query.operateType || eventStatusToOperateType[query.eventStatus] || '20'
+    const { id, eventStatus, operateType, handleContent, handleResult, adjustWorkHour, ...extraFields } = query
+    const finalOperateType = operateType || eventStatusToOperateType[eventStatus] || '20'
     return micro_request.postRequest(basePath, 'Operation', 'Process', {
-      id: query.id,
-      operateType: operateType,
-      handleContent: query.handleContent || '',
-      handleResult: query.handleResult || '',
-      ...(query.adjustWorkHour !== undefined ? { adjustWorkHour: query.adjustWorkHour } : {}),
+      id,
+      operateType: finalOperateType,
+      handleContent: handleContent || '',
+      handleResult: handleResult || '',
+      ...(adjustWorkHour !== undefined ? { adjustWorkHour } : {}),
+      ...extraFields,
     })
   },
   getRecordList(query) {

+ 292 - 14
src/views/devops/components/ProjectInfoDialog.vue

@@ -116,7 +116,7 @@
                   :loading="opsManagerLoading"
                   placeholder="选择运维负责人"
                   remote
-                  :remote-method="fetchOpsManagerUsers"
+                  :remote-method="fetchOpsManagerUsersDebounced"
                   size="small"
                   style="width: 140px"
                   @change="handleOpsManagerChange">
@@ -197,6 +197,75 @@
         </el-row>
       </div>
 
+      <!-- 授权人 -->
+      <div class="info-section">
+        <div class="section-title">
+          <i class="el-icon-user" />
+          <span>授权人</span>
+        </div>
+        <div v-loading="delegateLoading" class="delegate-list">
+          <template v-if="delegates.length > 0">
+            <div v-for="item in delegates" :key="item.id" class="delegate-item">
+              <div class="delegate-info">
+                <span class="delegate-name">{{ item.userName }}</span>
+                <span v-if="item.createdName" class="delegate-creator">由 {{ item.createdName }} 授权</span>
+              </div>
+              <el-button
+                v-if="canManageDelegates"
+                class="delegate-remove-btn"
+                icon="el-icon-delete"
+                size="mini"
+                type="text"
+                @click="handleRemoveDelegate(item)">
+                移除
+              </el-button>
+            </div>
+          </template>
+          <div v-else class="delegate-empty">暂无授权人</div>
+        </div>
+        <div v-if="canManageDelegates" class="delegate-actions-section">
+          <template v-if="!addingDelegate">
+            <el-button icon="el-icon-plus" plain size="small" type="primary" @click="handleStartAddDelegate">
+              添加授权人
+            </el-button>
+          </template>
+          <template v-else>
+            <div class="delegate-add-form">
+              <el-select
+                v-model="selectedDelegateUser"
+                clearable
+                filterable
+                :loading="delegateUserLoading"
+                placeholder="搜索并选择用户"
+                remote
+                :remote-method="fetchDelegateUserOptionsDebounced"
+                size="small"
+                style="width: 220px"
+                @change="handleDelegateUserSelect">
+                <el-option
+                  v-for="user in delegateUserOptions"
+                  :key="user.id"
+                  :label="user.nickName || user.userName"
+                  :value="user.id">
+                  <span>{{ user.nickName || user.userName }}</span>
+                  <span v-if="user.deptName" class="user-dept">({{ user.deptName }})</span>
+                </el-option>
+              </el-select>
+              <el-button
+                :disabled="!selectedDelegateUser"
+                icon="el-icon-check"
+                plain
+                size="small"
+                type="primary"
+                @click="handleSaveDelegate">
+                确认
+              </el-button>
+              <el-button icon="el-icon-close" plain size="small" @click="handleCancelAddDelegate">取消</el-button>
+            </div>
+          </template>
+        </div>
+      </div>
+
       <!-- 备注信息 -->
       <div class="info-section">
         <div class="section-title">
@@ -243,6 +312,7 @@
   import userApi from '@/api/system/user'
   import { parseTime } from '@/utils'
   import { projectStatusTagTypes, getTagType } from '@/config/devopsTagTypes'
+  import debounce from 'lodash/debounce'
 
   export default {
     name: 'ProjectInfoDialog',
@@ -279,6 +349,13 @@
         opsManagerLoading: false,
         // 时间变更记录(从备注中解析或单独存储)
         timeChangeRecords: [],
+        // 授权人
+        delegates: [],
+        delegateLoading: false,
+        addingDelegate: false,
+        selectedDelegateUser: null,
+        delegateUserOptions: [],
+        delegateUserLoading: false,
       }
     },
     computed: {
@@ -308,6 +385,13 @@
         const node = String(this.projectData.deliveryNode)
         return ['30', '40', '50'].includes(node) && hasRole
       },
+      // 是否可管理授权人:拥有研发总监或研发主管角色
+      canManageDelegates() {
+        return (
+          this.roleKeys.includes('ResearchAndDevelopmentDirector') ||
+          this.roleKeys.includes('ResearchAndDevelopmentSupervisor')
+        )
+      },
       normalizedRemark() {
         const remark = this.projectData && this.projectData.remark
         if (remark == null) return ''
@@ -373,6 +457,20 @@
     },
     mounted() {
       this.getOptions()
+      this.fetchDelegateUserOptionsDebounced = debounce((query) => {
+        this.fetchDelegateUserOptions(query)
+      }, 300)
+      this.fetchOpsManagerUsersDebounced = debounce((query) => {
+        this.fetchOpsManagerUsers(query)
+      }, 300)
+    },
+    beforeDestroy() {
+      if (this.fetchDelegateUserOptionsDebounced && this.fetchDelegateUserOptionsDebounced.cancel) {
+        this.fetchDelegateUserOptionsDebounced.cancel()
+      }
+      if (this.fetchOpsManagerUsersDebounced && this.fetchOpsManagerUsersDebounced.cancel) {
+        this.fetchOpsManagerUsersDebounced.cancel()
+      }
     },
     methods: {
       getTagType,
@@ -402,6 +500,7 @@
           if (res.code === 200 && res.data) {
             this.projectData = res.data
             this.parseTimeChangeRecords(res.data.remark)
+            this.fetchDelegates()
           } else {
             this.$message.error('获取项目详情失败')
           }
@@ -582,23 +681,16 @@
       },
 
       // 获取运维负责人候选列表
-      async fetchOpsManagerUsers() {
+      async fetchOpsManagerUsers(search) {
         this.opsManagerLoading = true
         try {
-          const roleKeys = ['ResearchAndDevelopmentDirector', 'ResearchAndDevelopmentSupervisor']
-          const roleIds = [1009, 1010]
-          const res = await userApi.getUsersByRoleKeys(roleKeys, roleIds)
-          if (res.code === 200 && res.data) {
-            const newUsers = res.data || []
-            const existingIds = this.opsManagerOptions.map((u) => u.id)
-            newUsers.forEach((user) => {
-              if (!existingIds.includes(user.id)) {
-                this.opsManagerOptions.push(user)
-              }
-            })
-          }
+          const payload = { roles: ['OperationsEngineer'], pageNum: 1, pageSize: 999 }
+          if (search) payload.keyWords = search
+          const res = await userApi.getList(payload)
+          this.opsManagerOptions = res.data?.list || []
         } catch (error) {
           console.error('获取运维负责人列表失败:', error)
+          this.opsManagerOptions = []
         } finally {
           this.opsManagerLoading = false
         }
@@ -673,6 +765,10 @@
         }
         this.opsManagerOptions = []
         this.timeChangeRecords = []
+        this.delegates = []
+        this.addingDelegate = false
+        this.selectedDelegateUser = null
+        this.delegateUserOptions = []
       },
 
       // 格式化时间(年月日格式)
@@ -715,6 +811,122 @@
         return label || productLine || '-'
       },
 
+      // 获取授权人列表
+      async fetchDelegates() {
+        if (!this.projectId) return
+        this.delegateLoading = true
+        try {
+          const res = await deliveryProjectApi.getDelegatesByProjectId(parseInt(this.projectId))
+          if (res.code === 200) {
+            this.delegates = res.data || []
+          }
+        } catch (error) {
+          console.error('获取授权人列表失败:', error)
+        } finally {
+          this.delegateLoading = false
+        }
+      },
+
+      // 开始添加授权人
+      handleStartAddDelegate() {
+        this.addingDelegate = true
+        this.selectedDelegateUser = null
+        this.delegateUserOptions = []
+        this.fetchDelegateUserOptions('')
+      },
+
+      // 取消添加
+      handleCancelAddDelegate() {
+        this.addingDelegate = false
+        this.selectedDelegateUser = null
+        this.delegateUserOptions = []
+      },
+
+      // 搜索用户
+      async fetchDelegateUserOptions(keywords) {
+        this.delegateUserLoading = true
+        try {
+          const payload = { roles: ['ProjectDeliveryManager'], pageNum: 1, pageSize: 20 }
+          if (keywords) payload.keyWords = keywords
+          const res = await userApi.getList(payload)
+          if (res.code === 200 && res.data && res.data.list) {
+            this.delegateUserOptions = res.data.list || []
+          } else {
+            this.delegateUserOptions = []
+          }
+        } catch (error) {
+          console.error('搜索用户失败:', error)
+          this.delegateUserOptions = []
+        } finally {
+          this.delegateUserLoading = false
+        }
+      },
+
+      // 选择用户后获取用户名
+      handleDelegateUserSelect(val) {
+        if (!val) return
+        const user = this.delegateUserOptions.find((u) => u.id === val)
+        if (user) {
+          this.selectedDelegateUser = val
+        }
+      },
+
+      // 保存授权人
+      async handleSaveDelegate() {
+        if (!this.selectedDelegateUser) {
+          this.$message.warning('请选择要授权的用户')
+          return
+        }
+        const user = this.delegateUserOptions.find((u) => u.id === this.selectedDelegateUser)
+        if (!user) {
+          this.$message.warning('未找到所选用户')
+          return
+        }
+        try {
+          const res = await deliveryProjectApi.addDelegate({
+            projectId: parseInt(this.projectId),
+            projectName: this.projectData.projectName,
+            userId: user.id,
+            userName: user.nickName || user.userName,
+          })
+          if (res.code === 200) {
+            this.$message.success('添加授权人成功')
+            this.handleCancelAddDelegate()
+            this.fetchDelegates()
+          } else {
+            this.$message.error(res.msg || '添加授权人失败')
+          }
+        } catch (error) {
+          console.error('添加授权人失败:', error)
+          this.$message.error('添加授权人失败')
+        }
+      },
+
+      // 移除授权人
+      async handleRemoveDelegate(item) {
+        try {
+          await this.$confirm(`确定要移除授权人「${item.userName}」吗?`, '提示', {
+            confirmButtonText: '确定',
+            cancelButtonText: '取消',
+            type: 'warning',
+          })
+        } catch {
+          return
+        }
+        try {
+          const res = await deliveryProjectApi.removeDelegate(item.id)
+          if (res.code === 200) {
+            this.$message.success('移除授权人成功')
+            this.fetchDelegates()
+          } else {
+            this.$message.error(res.msg || '移除授权人失败')
+          }
+        } catch (error) {
+          console.error('移除授权人失败:', error)
+          this.$message.error('移除授权人失败')
+        }
+      },
+
       handleClose() {
         this.projectData = {}
         this.resetEditState()
@@ -1038,5 +1250,71 @@
       color: #909399;
       font-size: 12px;
     }
+
+    // 授权人列表
+    .delegate-list {
+      background: #f8f9fa;
+      border-radius: 8px;
+      padding: 12px 16px;
+      margin-bottom: 12px;
+    }
+
+    .delegate-item {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      padding: 8px 0;
+      border-bottom: 1px solid #ebeef5;
+      font-size: 13px;
+
+      &:last-child {
+        border-bottom: none;
+        padding-bottom: 0;
+      }
+
+      &:first-child {
+        padding-top: 0;
+      }
+    }
+
+    .delegate-info {
+      display: flex;
+      align-items: center;
+      gap: 12px;
+    }
+
+    .delegate-name {
+      color: #303133;
+      font-weight: 500;
+    }
+
+    .delegate-creator {
+      color: #909399;
+      font-size: 12px;
+    }
+
+    .delegate-remove-btn {
+      color: #f56c6c;
+      font-size: 12px;
+
+      &:hover {
+        color: #f56c6c;
+      }
+    }
+
+    .delegate-empty {
+      color: #c0c4cc;
+      font-size: 13px;
+      text-align: center;
+      padding: 8px 0;
+    }
+
+    .delegate-actions-section {
+      .delegate-add-form {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+      }
+    }
   }
 </style>

+ 23 - 15
src/views/devops/deliveryProject/components/DeliveryProjectAssign.vue

@@ -14,9 +14,10 @@
           v-model="formData.deliveryUserId"
           clearable
           filterable
+          :loading="loadingUsers"
           placeholder="请选择交付负责人"
           remote
-          :remote-method="fetchUserList"
+          :remote-method="remoteFetchUserList"
           style="width: 100%">
           <el-option
             v-for="user in userOptions"
@@ -62,6 +63,7 @@
   import deliveryProjectApi from '@/api/devops/deliveryProject'
   import userApi from '@/api/system/user'
   import { parseTime } from '@/utils'
+  import debounce from 'lodash/debounce'
 
   export default {
     name: 'DeliveryProjectAssign',
@@ -95,6 +97,7 @@
           deliveryUserId: [{ required: true, message: '请选择交付负责人', trigger: 'change' }],
         },
         userOptions: [],
+        loadingUsers: false,
       }
     },
     computed: {
@@ -112,6 +115,16 @@
         }
       },
     },
+    created() {
+      this.remoteFetchUserList = debounce((query) => {
+        this.fetchUserList(query)
+      }, 300)
+    },
+    beforeDestroy() {
+      if (this.remoteFetchUserList && this.remoteFetchUserList.cancel) {
+        this.remoteFetchUserList.cancel()
+      }
+    },
     methods: {
       initData() {
         if (this.data) {
@@ -147,23 +160,18 @@
         }
       },
 
-      async fetchUserList(keywords = '') {
-        void keywords
+      async fetchUserList(search = '') {
+        this.loadingUsers = true
         try {
-          const roleKeys = ['ProjectManager', 'ProjectDeliveryManager']
-          const roleIds = [1011, 1012]
-          const res = await userApi.getUsersByRoleKeys(roleKeys, roleIds)
-          if (res.code === 200 && res.data) {
-            const newUsers = res.data
-            const existingIds = this.userOptions.map((u) => u.id)
-            newUsers.forEach((user) => {
-              if (!existingIds.includes(user.id)) {
-                this.userOptions.push(user)
-              }
-            })
-          }
+          const payload = { roles: ['ProjectDeliveryManager'], pageNum: 1, pageSize: 999 }
+          if (search) payload.keyWords = search
+          const res = await userApi.getList(payload)
+          this.userOptions = res.data?.list || []
         } catch (error) {
           console.error('获取用户列表失败:', error)
+          this.userOptions = []
+        } finally {
+          this.loadingUsers = false
         }
       },
 

+ 1 - 1
src/views/devops/deliveryProject/index.vue

@@ -672,7 +672,7 @@
             params.keyWords = keyword
           }
 
-          const res = await deliveryProjectApi.getList(params)
+          const res = await deliveryProjectApi.getDelegatedProjectList(params)
           if (res.code === 200 && res.data && res.data.list) {
             const projectList = (res.data?.list || []).map((item) => ({
               id: String(item.id),

+ 751 - 0
src/views/devops/meeting/index.vue

@@ -0,0 +1,751 @@
+<template>
+  <div class="meeting-management-page">
+    <!-- 查询表单 -->
+    <div class="query-form-container">
+      <div class="query-form-basic">
+        <el-form class="query-form-fields" :inline="true" :model="queryForm" size="small">
+          <el-form-item label="会议标题">
+            <el-input v-model="queryForm.meetingTitle" clearable placeholder="请输入" style="width: 220px" />
+          </el-form-item>
+          <el-form-item label="会议日期">
+            <el-date-picker
+              v-model="queryForm.meetingDateRange"
+              end-placeholder="结束日期"
+              range-separator="至"
+              start-placeholder="开始日期"
+              style="width: 260px"
+              type="daterange"
+              value-format="yyyy-MM-dd" />
+          </el-form-item>
+        </el-form>
+        <div class="query-form-actions">
+          <el-button icon="el-icon-search" type="primary" @click="handleSearch">查询</el-button>
+          <el-button icon="el-icon-refresh-right" @click="handleReset">重置</el-button>
+          <el-divider direction="vertical" />
+          <el-button icon="el-icon-plus" type="success" @click="handleAdd">新增会议</el-button>
+        </div>
+      </div>
+    </div>
+
+    <!-- 主体内容 -->
+    <div class="main-content">
+      <div class="table-container">
+        <div class="table-scroll-wrapper">
+          <el-table
+            v-loading="loading"
+            border
+            class="meeting-table"
+            :data="tableData"
+            :fit="false"
+            :height="$baseTableHeight(1)"
+            stripe
+            style="width: 100%">
+            <el-table-column align="center" type="index" width="50" />
+            <el-table-column label="会议标题" min-width="200" prop="meetingTitle" show-overflow-tooltip />
+            <el-table-column label="会议日期" width="120">
+              <template slot-scope="{ row }">
+                {{ row.meetingDate ? parseTime(row.meetingDate, '{y}-{m}-{d}') : '-' }}
+              </template>
+            </el-table-column>
+            <el-table-column align="right" label="时长(h)" width="100">
+              <template slot-scope="{ row }">
+                {{ row.duration !== null && row.duration !== undefined ? row.duration.toFixed(1) : '-' }}
+              </template>
+            </el-table-column>
+            <el-table-column label="组织人" prop="organizerName" show-overflow-tooltip width="120" />
+            <el-table-column align="center" label="参会人数" width="100">
+              <template slot-scope="{ row }">
+                {{ row.attendees ? row.attendees.length : 0 }}
+              </template>
+            </el-table-column>
+            <el-table-column align="center" fixed="right" label="操作" width="240">
+              <template slot-scope="{ row }">
+                <el-button size="mini" type="text" @click="handleViewDetail(row)">详情</el-button>
+                <el-button size="mini" type="text" @click="handleEdit(row)">编辑</el-button>
+                <el-button size="mini" type="text" @click="handleDelete(row)">删除</el-button>
+                <el-button size="mini" type="text" @click="handleAddAttendees(row)">追加人员</el-button>
+                <el-button size="mini" type="text" @click="handleViewWorkHour(row)">查看工时</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+
+        <!-- 分页 -->
+        <div class="pagination-wrapper">
+          <el-pagination
+            background
+            :current-page.sync="queryForm.pageNum"
+            layout="total, sizes, prev, pager, next, jumper"
+            :page-size.sync="queryForm.pageSize"
+            :page-sizes="[10, 20, 50, 100]"
+            :total="total"
+            @current-change="handleCurrentChange"
+            @size-change="handleSizeChange" />
+        </div>
+      </div>
+    </div>
+
+    <!-- 新增/编辑弹窗 -->
+    <el-dialog
+      :close-on-click-modal="false"
+      :title="dialogTitle"
+      :visible.sync="editDialogVisible"
+      width="680px"
+      @close="handleDialogClose">
+      <el-form ref="editForm" label-width="100px" :model="editForm" :rules="editRules" size="small">
+        <el-form-item label="会议标题" prop="meetingTitle">
+          <el-input v-model="editForm.meetingTitle" placeholder="请输入会议标题" />
+        </el-form-item>
+        <el-form-item label="会议内容" prop="meetingContent">
+          <el-input v-model="editForm.meetingContent" placeholder="请输入会议内容" :rows="4" type="textarea" />
+        </el-form-item>
+        <el-form-item label="组织人" prop="organizerName">
+          <el-select
+            v-model="editForm.organizerId"
+            filterable
+            :loading="userSearchLoading"
+            placeholder="请选择组织人"
+            remote
+            :remote-method="remoteSearchUsers"
+            reserve-keyword
+            @change="handleOrganizerChange">
+            <el-option v-for="user in userOptions" :key="user.value" :label="user.label" :value="user.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="会议日期" prop="meetingDate">
+          <el-date-picker
+            v-model="editForm.meetingDate"
+            placeholder="选择会议日期"
+            style="width: 100%"
+            type="date"
+            value-format="yyyy-MM-dd" />
+        </el-form-item>
+        <el-form-item label="会议时长(h)" prop="duration">
+          <el-input-number
+            v-model="editForm.duration"
+            :min="0.5"
+            placeholder="请输入会议时长"
+            :precision="1"
+            :step="0.5"
+            style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="备注" prop="remark">
+          <el-input v-model="editForm.remark" placeholder="请输入备注" :rows="3" type="textarea" />
+        </el-form-item>
+        <el-form-item label="参会人员">
+          <div class="quick-select-btns">
+            <el-button size="mini" @click="quickSelectAll('edit')">全员</el-button>
+            <el-button size="mini" @click="quickSelectByRole('DevelopmentEngineer', 'edit')">研发</el-button>
+            <el-button size="mini" @click="quickSelectByRole('ProjectDeliveryManager', 'edit')">交付</el-button>
+            <el-button size="mini" @click="quickSelectByRole('TestEngineer', 'edit')">质量</el-button>
+          </div>
+          <el-select
+            v-model="editForm.selectedUserIds"
+            class="user-select"
+            collapse-tags
+            filterable
+            :loading="userSearchLoading"
+            multiple
+            placeholder="请选择参会人员"
+            remote
+            :remote-method="remoteSearchUsers"
+            reserve-keyword
+            @change="handleSelectedUsersChange">
+            <el-option v-for="user in userOptions" :key="user.value" :label="user.label" :value="user.value" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <span slot="footer" class="dialog-footer">
+        <el-button size="small" @click="editDialogVisible = false">取消</el-button>
+        <el-button :loading="submitLoading" size="small" type="primary" @click="handleSubmit">确定</el-button>
+      </span>
+    </el-dialog>
+
+    <!-- 追加参会人员弹窗 -->
+    <el-dialog
+      :close-on-click-modal="false"
+      title="追加参会人员"
+      :visible.sync="addAttendeesDialogVisible"
+      width="500px"
+      @close="handleAttendeesDialogClose">
+      <el-form ref="attendeesForm" label-width="0" :model="attendeesForm" size="small">
+        <el-form-item>
+          <div class="attendees-meeting-info">会议:{{ currentRow ? currentRow.meetingTitle : '' }}</div>
+        </el-form-item>
+        <el-form-item>
+          <div class="quick-select-btns">
+            <el-button size="mini" @click="quickSelectAll('attendee')">全员</el-button>
+            <el-button size="mini" @click="quickSelectByRole('DevelopmentEngineer', 'attendee')">研发</el-button>
+            <el-button size="mini" @click="quickSelectByRole('ProjectDeliveryManager', 'attendee')">交付</el-button>
+            <el-button size="mini" @click="quickSelectByRole('TestEngineer', 'attendee')">质量</el-button>
+          </div>
+          <el-select
+            v-model="attendeesForm.selectedUserIds"
+            collapse-tags
+            filterable
+            :loading="userSearchLoading"
+            multiple
+            placeholder="请选择要追加的参会人员"
+            remote
+            :remote-method="remoteSearchUsers"
+            reserve-keyword
+            style="width: 100%">
+            <el-option v-for="user in userOptions" :key="user.value" :label="user.label" :value="user.value" />
+          </el-select>
+        </el-form-item>
+      </el-form>
+      <span slot="footer" class="dialog-footer">
+        <el-button size="small" @click="addAttendeesDialogVisible = false">取消</el-button>
+        <el-button :loading="attendeeSubmitLoading" size="small" type="primary" @click="handleAttendeesSubmit">
+          确定
+        </el-button>
+      </span>
+    </el-dialog>
+
+    <!-- 查看工时弹窗 -->
+    <el-dialog :close-on-click-modal="false" title="工时记录" :visible.sync="workHourDialogVisible" width="700px">
+      <el-table v-loading="workHourLoading" border :data="workHourList" stripe>
+        <el-table-column align="center" type="index" width="50" />
+        <el-table-column label="姓名" prop="userName" show-overflow-tooltip width="120" />
+        <el-table-column label="工时日期" width="160">
+          <template slot-scope="{ row }">
+            {{ row.workDate ? parseTime(row.workDate, '{y}-{m}-{d}') : '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column align="right" label="工时(h)" prop="workHours" width="120">
+          <template slot-scope="{ row }">
+            {{ row.workHours !== null && row.workHours !== undefined ? row.workHours.toFixed(1) : '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="备注" prop="remark" show-overflow-tooltip />
+      </el-table>
+      <span slot="footer" class="dialog-footer">
+        <el-button size="small" @click="workHourDialogVisible = false">关闭</el-button>
+      </span>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+  import meetingApi from '@/api/devops/meeting'
+  import micro_request from '@/utils/micro_request'
+  import { parseTime } from '@/utils'
+  import { DEVOPS_DEV_DEPT_ID } from '@/config/devops.config'
+
+  export default {
+    name: 'MeetingManagement',
+    data() {
+      return {
+        queryForm: {
+          pageNum: 1,
+          pageSize: 20,
+          meetingTitle: '',
+          meetingDateRange: [],
+        },
+        tableData: [],
+        total: 0,
+        loading: false,
+        // 新增/编辑弹窗
+        editDialogVisible: false,
+        dialogTitle: '新增会议',
+        isEdit: false,
+        submitLoading: false,
+        editForm: {
+          id: null,
+          meetingTitle: '',
+          meetingContent: '',
+          organizerId: null,
+          organizerName: '',
+          meetingDate: '',
+          duration: null,
+          remark: '',
+          selectedUserIds: [],
+          selectedUserNames: [],
+        },
+        editRules: {
+          meetingTitle: [{ required: true, message: '请输入会议标题', trigger: 'blur' }],
+          organizerName: [{ required: true, message: '请选择组织人', trigger: 'change' }],
+          meetingDate: [{ required: true, message: '请选择会议日期', trigger: 'change' }],
+          duration: [{ required: true, message: '请输入会议时长', trigger: 'blur' }],
+        },
+        // 追加参会人员弹窗
+        addAttendeesDialogVisible: false,
+        attendeeSubmitLoading: false,
+        attendeesForm: {
+          selectedUserIds: [],
+        },
+        // 查看工时弹窗
+        workHourDialogVisible: false,
+        workHourList: [],
+        workHourLoading: false,
+        // 用户搜索
+        userOptions: [],
+        userSearchLoading: false,
+        userSearchCache: {},
+        // 当前操作行
+        currentRow: null,
+      }
+    },
+    created() {
+      this.fetchData()
+    },
+    methods: {
+      parseTime,
+      async fetchData() {
+        this.loading = true
+        try {
+          const params = {
+            pageNum: this.queryForm.pageNum,
+            pageSize: this.queryForm.pageSize,
+          }
+          if (this.queryForm.meetingTitle) {
+            params.meetingTitle = this.queryForm.meetingTitle
+          }
+          if (this.queryForm.meetingDateRange && this.queryForm.meetingDateRange.length === 2) {
+            params.meetingDateStart = this.queryForm.meetingDateRange[0]
+            params.meetingDateEnd = this.queryForm.meetingDateRange[1]
+          }
+          const res = await meetingApi.getList(params)
+          this.tableData = res.data?.list || []
+          this.total = res.data?.total || 0
+        } catch (error) {
+          this.$message.error('获取会议列表失败')
+        } finally {
+          this.loading = false
+        }
+      },
+      handleSearch() {
+        this.queryForm.pageNum = 1
+        this.fetchData()
+      },
+      handleReset() {
+        this.queryForm = {
+          pageNum: 1,
+          pageSize: 20,
+          meetingTitle: '',
+          meetingDateRange: [],
+        }
+        this.fetchData()
+      },
+      handleCurrentChange(val) {
+        this.queryForm.pageNum = val
+        this.fetchData()
+      },
+      handleSizeChange(val) {
+        this.queryForm.pageSize = val
+        this.queryForm.pageNum = 1
+        this.fetchData()
+      },
+      handleAdd() {
+        this.dialogTitle = '新增会议'
+        this.isEdit = false
+        this.resetEditForm()
+        this.editDialogVisible = true
+      },
+      async handleEdit(row) {
+        this.dialogTitle = '编辑会议'
+        this.isEdit = true
+        this.editForm.id = row.id
+        this.editDialogVisible = true
+        try {
+          const res = await meetingApi.getById(row.id)
+          const data = res.data?.data || res.data || {}
+          this.editForm.meetingTitle = data.meetingTitle || ''
+          this.editForm.meetingContent = data.meetingContent || ''
+          this.editForm.organizerId = data.organizerId || null
+          this.editForm.organizerName = data.organizerName || ''
+          this.editForm.meetingDate = data.meetingDate || ''
+          this.editForm.duration = data.duration || null
+          this.editForm.remark = data.remark || ''
+          this.editForm.deptId = data.deptId || null
+          const attendees = data.attendees || []
+          this.editForm.selectedUserIds = attendees.map((a) => a.userId)
+          this.editForm.selectedUserNames = attendees.map((a) => a.userName)
+          // 预加载已有参会人员到选项
+          this.userOptions = attendees.map((a) => ({
+            value: a.userId,
+            label: a.userName,
+          }))
+          this.userSearchCache = {}
+          attendees.forEach((a) => {
+            this.userSearchCache[a.userId] = { userId: a.userId, nickName: a.userName }
+          })
+          // 预加载组织人
+          if (data.organizerId) {
+            this.userOptions.unshift({
+              value: data.organizerId,
+              label: data.organizerName,
+            })
+            this.userSearchCache[data.organizerId] = {
+              userId: data.organizerId,
+              nickName: data.organizerName,
+            }
+          }
+        } catch (error) {
+          this.$message.error('获取会议详情失败')
+        }
+      },
+      handleViewDetail(row) {
+        this.handleEdit(row)
+      },
+      handleDelete(row) {
+        this.$confirm('确认删除该会议?', '提示', { type: 'warning' })
+          .then(async () => {
+            try {
+              await meetingApi.deleteByIds([row.id])
+              this.$message.success('删除成功')
+              this.fetchData()
+            } catch (error) {
+              this.$message.error('删除失败')
+            }
+          })
+          .catch(() => {})
+      },
+      resetEditForm() {
+        this.editForm = {
+          id: null,
+          meetingTitle: '',
+          meetingContent: '',
+          organizerId: null,
+          organizerName: '',
+          meetingDate: '',
+          duration: null,
+          remark: '',
+          selectedUserIds: [],
+          selectedUserNames: [],
+        }
+        this.userOptions = []
+        this.userSearchCache = {}
+      },
+      handleDialogClose() {
+        this.$refs.editForm && this.$refs.editForm.resetFields()
+        this.resetEditForm()
+      },
+      handleSelectedUsersChange(val) {
+        const names = []
+        val.forEach((id) => {
+          const cached = this.userSearchCache[id]
+          if (cached) {
+            names.push(cached.nickName)
+          }
+        })
+        this.editForm.selectedUserNames = names
+      },
+      handleOrganizerChange(id) {
+        if (id) {
+          const cached = this.userSearchCache[id]
+          this.editForm.organizerName = cached ? cached.nickName : ''
+        } else {
+          this.editForm.organizerName = ''
+        }
+      },
+      async remoteSearchUsers(query) {
+        this.userSearchLoading = true
+        try {
+          const payload = { deptId: DEVOPS_DEV_DEPT_ID, pageNum: 1, pageSize: 50 }
+          if (query) payload.keyWords = query
+          const res = await micro_request.postRequest(process.env.VUE_APP_AdminPath, 'User', 'GetList', payload)
+          const list = res.data?.list || []
+          this.userOptions = list.map((u) => {
+            const id = u.userId !== undefined ? u.userId : u.id
+            const name = u.nickName || u.name || ''
+            this.userSearchCache[id] = { userId: id, nickName: name }
+            return { value: id, label: name }
+          })
+        } catch (error) {
+          this.userOptions = []
+        } finally {
+          this.userSearchLoading = false
+        }
+      },
+      async quickSelectAll(target) {
+        try {
+          const res = await micro_request.postRequest(process.env.VUE_APP_AdminPath, 'User', 'GetList', {
+            deptId: DEVOPS_DEV_DEPT_ID,
+            pageNum: 1,
+            pageSize: 200,
+          })
+          const list = res.data?.list || []
+          const ids = []
+          const names = []
+          list.forEach((u) => {
+            const id = u.userId !== undefined ? u.userId : u.id
+            const name = u.nickName || u.name || ''
+            this.userSearchCache[id] = { userId: id, nickName: name }
+            ids.push(id)
+            names.push(name)
+          })
+          this.userOptions = list.map((u) => {
+            const id = u.userId !== undefined ? u.userId : u.id
+            return { value: id, label: u.nickName || u.name || '' }
+          })
+          if (target === 'edit') {
+            this.editForm.selectedUserIds = ids
+            this.editForm.selectedUserNames = names
+          } else if (target === 'attendee') {
+            this.attendeesForm.selectedUserIds = ids
+          }
+        } catch (error) {
+          this.$message.error('获取人员列表失败')
+        }
+      },
+      async quickSelectByRole(roleKey, target) {
+        try {
+          const res = await micro_request.postRequest(process.env.VUE_APP_AdminPath, 'User', 'GetUsersByRoleKeys', {
+            roleKeys: [roleKey],
+            roleIds: [],
+          })
+          const list = res.data || []
+          const ids = []
+          const names = []
+          list.forEach((u) => {
+            const id = u.userId !== undefined ? u.userId : u.id
+            const name = u.nickName || u.name || ''
+            this.userSearchCache[id] = { userId: id, nickName: name }
+            ids.push(id)
+            names.push(name)
+          })
+          this.userOptions = list.map((u) => {
+            const id = u.userId !== undefined ? u.userId : u.id
+            return { value: id, label: u.nickName || u.name || '' }
+          })
+          if (target === 'edit') {
+            this.editForm.selectedUserIds = ids
+            this.editForm.selectedUserNames = names
+          } else if (target === 'attendee') {
+            this.attendeesForm.selectedUserIds = ids
+          }
+        } catch (error) {
+          this.$message.error('获取角色人员失败')
+        }
+      },
+      handleSubmit() {
+        this.$refs.editForm.validate(async (valid) => {
+          if (!valid) return
+          this.submitLoading = true
+          try {
+            const data = {
+              meetingTitle: this.editForm.meetingTitle,
+              meetingContent: this.editForm.meetingContent,
+              organizerId: this.editForm.organizerId,
+              organizerName: this.editForm.organizerName,
+              meetingDate: this.editForm.meetingDate,
+              duration: this.editForm.duration,
+              remark: this.editForm.remark,
+              userIds: this.editForm.selectedUserIds,
+              userNames: this.editForm.selectedUserNames,
+            }
+            if (this.isEdit) {
+              data.id = this.editForm.id
+              await meetingApi.update(data)
+              this.$message.success('更新成功')
+            } else {
+              await meetingApi.create(data)
+              this.$message.success('创建成功')
+            }
+            this.editDialogVisible = false
+            this.fetchData()
+          } catch (error) {
+            this.$message.error(this.isEdit ? '更新失败' : '创建失败')
+          } finally {
+            this.submitLoading = false
+          }
+        })
+      },
+      handleAddAttendees(row) {
+        this.currentRow = row
+        this.attendeesForm.selectedUserIds = []
+        this.userOptions = []
+        this.userSearchCache = {}
+        this.addAttendeesDialogVisible = true
+      },
+      handleAttendeesDialogClose() {
+        this.currentRow = null
+        this.attendeesForm.selectedUserIds = []
+        this.userOptions = []
+        this.userSearchCache = {}
+      },
+      async handleAttendeesSubmit() {
+        if (!this.attendeesForm.selectedUserIds.length) {
+          this.$message.warning('请选择参会人员')
+          return
+        }
+        this.attendeeSubmitLoading = true
+        try {
+          const userNames = this.attendeesForm.selectedUserIds.map((id) => {
+            const cached = this.userSearchCache[id]
+            return cached ? cached.nickName : ''
+          })
+          await meetingApi.addAttendees({
+            id: this.currentRow.id,
+            userIds: this.attendeesForm.selectedUserIds,
+            userNames,
+          })
+          this.$message.success('追加成功')
+          this.addAttendeesDialogVisible = false
+          this.fetchData()
+        } catch (error) {
+          this.$message.error('追加失败')
+        } finally {
+          this.attendeeSubmitLoading = false
+        }
+      },
+      async handleViewWorkHour(row) {
+        this.currentRow = row
+        this.workHourDialogVisible = true
+        this.workHourList = []
+        this.workHourLoading = true
+        try {
+          const res = await meetingApi.getWorkHourList(row.id)
+          this.workHourList = res.data?.list || []
+        } catch (error) {
+          this.$message.error('获取工时列表失败')
+        } finally {
+          this.workHourLoading = false
+        }
+      },
+    },
+  }
+</script>
+
+<style lang="scss" scoped>
+  .meeting-management-page {
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+    height: calc(100vh - 122px);
+    padding: 4px;
+    background: #f5f7fa;
+    overflow: hidden;
+
+    .query-form-container {
+      flex-shrink: 0;
+      margin-bottom: 4px;
+      padding: 8px 12px;
+      background: #fff;
+      border-radius: 6px;
+      box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+
+      .query-form-basic {
+        display: flex;
+        align-items: flex-start;
+        justify-content: space-between;
+        gap: 16px;
+
+        .query-form-fields {
+          flex: 1;
+          min-width: 0;
+        }
+
+        .query-form-actions {
+          display: flex;
+          align-items: center;
+          justify-content: flex-end;
+          flex-shrink: 0;
+          min-height: 32px;
+          white-space: nowrap;
+        }
+
+        ::v-deep .el-form {
+          display: flex;
+          flex-wrap: wrap;
+          align-items: center;
+          row-gap: 2px;
+        }
+
+        ::v-deep .el-form-item {
+          margin-right: 20px;
+          margin-bottom: 2px;
+        }
+
+        ::v-deep .el-form-item__label {
+          padding-right: 8px;
+          font-size: 13px;
+          color: #606266;
+        }
+
+        ::v-deep .el-divider--vertical {
+          margin: 0 12px;
+        }
+      }
+    }
+
+    .main-content {
+      flex: 1;
+      display: flex;
+      gap: 4px;
+      overflow: hidden;
+      min-height: 0;
+
+      .table-container {
+        flex: 1;
+        background: #fff;
+        border-radius: 4px;
+        padding: 6px 8px;
+        display: flex;
+        flex-direction: column;
+        box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+        min-width: 0;
+        min-height: 0;
+        overflow: hidden;
+
+        .table-scroll-wrapper {
+          display: flex;
+          flex: 1;
+          min-width: 0;
+          min-height: 0;
+          overflow: hidden;
+        }
+
+        .meeting-table {
+          flex: 1;
+          min-width: 0;
+
+          ::v-deep th .cell {
+            white-space: nowrap;
+          }
+
+          ::v-deep .el-button--text {
+            padding: 0;
+            font-size: 12px;
+          }
+        }
+
+        .pagination-wrapper {
+          margin-top: auto;
+          display: flex;
+          justify-content: flex-end;
+          padding-top: 4px;
+
+          ::v-deep .el-pagination {
+            padding: 0;
+            line-height: 32px;
+          }
+        }
+      }
+    }
+
+    .user-select {
+      width: 100%;
+    }
+
+    .quick-select-btns {
+      margin-bottom: 8px;
+
+      .el-button {
+        margin-right: 6px;
+        margin-bottom: 4px;
+      }
+    }
+
+    .attendees-meeting-info {
+      padding: 8px 12px;
+      background: #f5f7fa;
+      border-radius: 4px;
+      font-size: 14px;
+      font-weight: 500;
+      color: #303133;
+    }
+  }
+</style>

+ 139 - 33
src/views/devops/operation/components/OperationDetail.vue

@@ -280,6 +280,54 @@
       </div>
     </el-dialog>
 
+    <el-dialog
+      append-to-body
+      :close-on-click-modal="false"
+      title="转研发处理"
+      :visible.sync="transferDialogVisible"
+      width="600px">
+      <el-form ref="transferForm" label-width="120px" :model="transferForm" :rules="transferRules">
+        <el-form-item label="研发负责人" prop="opsUserId">
+          <el-select
+            v-model="transferForm.opsUserId"
+            clearable
+            filterable
+            :loading="loadingUsers"
+            placeholder="请选择研发负责人"
+            remote
+            :remote-method="remoteFetchUserListDebounced"
+            reserve-keyword
+            style="width: 100%"
+            @change="handleTransferUserChange">
+            <el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="计划开始时间" prop="planStartTime">
+          <el-date-picker
+            v-model="transferForm.planStartTime"
+            placeholder="选择计划开始时间"
+            style="width: 100%"
+            type="date"
+            value-format="yyyy-MM-dd" />
+        </el-form-item>
+        <el-form-item label="计划结束时间" prop="planEndTime">
+          <el-date-picker
+            v-model="transferForm.planEndTime"
+            placeholder="选择计划结束时间"
+            style="width: 100%"
+            type="date"
+            value-format="yyyy-MM-dd" />
+        </el-form-item>
+        <el-form-item label="转研发原因" prop="handleContent">
+          <el-input v-model="transferForm.handleContent" placeholder="请输入转研发原因" :rows="4" type="textarea" />
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button @click="transferDialogVisible = false">取消</el-button>
+        <el-button :loading="submitLoading" type="primary" @click="submitTransferDev">确定</el-button>
+      </div>
+    </el-dialog>
+
     <WorkHourListDialog :event-id="data.id" :visible.sync="showWorkHourList" />
     <WorkHourDialog :event-id="data.id" :visible.sync="showWorkHourDialog" @refresh="onWorkHourRefresh" />
   </div>
@@ -288,9 +336,12 @@
 <script>
   import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
   import operationEventApi from '@/api/operation/operationEvent'
+  import userApi from '@/api/system/user'
   import { parseTime } from '@/utils'
   import { uploadRichtextImage, uploadFileToRichtextServer } from '@/utils/richtextUpload'
   import { openSafeUrl, sanitizeHtml } from '@/utils/safeHtml'
+  import { DEVOPS_DEV_DEPT_ID } from '@/config/devops.config'
+  import debounce from 'lodash/debounce'
   import WorkHourListDialog from './WorkHourListDialog'
   import WorkHourDialog from './WorkHourDialog'
 
@@ -386,6 +437,23 @@
           adjustWorkHour: null,
         },
         handleResultOptions: [],
+        // 转研发相关数据
+        transferDialogVisible: false,
+        transferForm: {
+          opsUserId: null,
+          opsUserName: '',
+          planStartTime: '',
+          planEndTime: '',
+          handleContent: '',
+        },
+        transferRules: {
+          opsUserId: [{ required: true, message: '请选择研发负责人', trigger: 'change' }],
+          planStartTime: [{ required: true, message: '请选择计划开始时间', trigger: 'change' }],
+          planEndTime: [{ required: true, message: '请选择计划结束时间', trigger: 'change' }],
+          handleContent: [{ required: true, message: '请输入转研发原因', trigger: 'blur' }],
+        },
+        userOptions: [],
+        loadingUsers: false,
         totalWorkHour: 0,
         showWorkHourList: false,
         showWorkHourDialog: false,
@@ -428,7 +496,15 @@
         },
       },
     },
+    created() {
+      this.remoteFetchUserListDebounced = debounce((query) => {
+        this.fetchUserList(query)
+      }, 300)
+    },
     beforeDestroy() {
+      if (this.remoteFetchUserListDebounced && this.remoteFetchUserListDebounced.cancel) {
+        this.remoteFetchUserListDebounced.cancel()
+      }
       if (this.editor) {
         this.editor.destroy()
         this.editor = null
@@ -673,41 +749,71 @@
           })
           .catch(() => {})
       },
-      async handleTransferDev() {
-        this.$prompt('请填写转研发原因', '转研发处理', {
-          confirmButtonText: '确定',
-          cancelButtonText: '取消',
-          inputType: 'textarea',
-          inputPlaceholder: '请输入转研发原因',
-          inputValidator: (value) => {
-            if (!value || !value.trim()) {
-              return '转研发原因不能为空'
-            }
-            return true
-          },
+      handleTransferDev() {
+        this.transferForm = {
+          opsUserId: null,
+          opsUserName: '',
+          planStartTime: '',
+          planEndTime: '',
+          handleContent: '',
+        }
+        this.userOptions = []
+        this.transferDialogVisible = true
+        this.$nextTick(() => {
+          this.fetchUserList()
         })
-          .then(async ({ value }) => {
-            this.submitLoading = true
-            try {
-              const res = await operationEventApi.updateStatus({
-                id: this.data.id,
-                eventStatus: '40',
-                handleContent: value.trim(),
-              })
-              if (res.code === 200) {
-                this.$message.success('事件已转研发')
-                this.isProcessMode = false
-                this.isEditing = false
-                this.$emit('refresh')
-                this.handleClose()
-              }
-            } catch (error) {
-              console.error('转研发失败:', error)
-            } finally {
-              this.submitLoading = false
+      },
+      handleTransferUserChange(val) {
+        const user = this.userOptions.find((u) => u.value === val)
+        this.transferForm.opsUserName = user ? user.label : ''
+      },
+      async fetchUserList(search) {
+        this.loadingUsers = true
+        try {
+          const payload = { deptId: DEVOPS_DEV_DEPT_ID, pageNum: 1, pageSize: 999 }
+          if (search) payload.keyWords = search
+          const res = await userApi.getList(payload)
+          const list = res.data?.list || []
+          this.userOptions = list.map((u) => ({
+            value: u.userId ?? u.user_id ?? u.id ?? null,
+            label: u.nickName ?? u.nick_name ?? u.name ?? '',
+          }))
+        } catch (error) {
+          console.error('获取用户列表失败:', error)
+          this.userOptions = []
+        } finally {
+          this.loadingUsers = false
+        }
+      },
+      async submitTransferDev() {
+        this.$refs.transferForm.validate(async (valid) => {
+          if (!valid) return
+          this.submitLoading = true
+          try {
+            const res = await operationEventApi.updateStatus({
+              id: this.data.id,
+              eventStatus: '40',
+              handleContent: this.transferForm.handleContent.trim(),
+              opsUserId: this.transferForm.opsUserId,
+              opsUserName: this.transferForm.opsUserName,
+              planStartTime: this.transferForm.planStartTime,
+              planEndTime: this.transferForm.planEndTime,
+              taskStatus: '20',
+            })
+            if (res.code === 200) {
+              this.$message.success('事件已转研发')
+              this.transferDialogVisible = false
+              this.isProcessMode = false
+              this.isEditing = false
+              this.$emit('refresh')
+              this.handleClose()
             }
-          })
-          .catch(() => {})
+          } catch (error) {
+            console.error('转研发失败:', error)
+          } finally {
+            this.submitLoading = false
+          }
+        })
       },
       async handleResume() {
         this.$prompt('请填写转处理说明', '转处理', {

+ 2 - 18
src/views/devops/operation/components/OperationEdit.vue

@@ -62,22 +62,6 @@
         </el-select>
       </el-form-item>
 
-      <el-form-item v-if="['30', '40', '50'].includes(form.eventType)" label="负责人" prop="opsUserId">
-        <el-select
-          v-model="form.opsUserId"
-          clearable
-          filterable
-          :loading="loadingUsers"
-          placeholder="请选择负责人"
-          remote
-          :remote-method="remoteFetchUserListDebounced"
-          reserve-keyword
-          style="width: 100%"
-          @change="handleUserChange">
-          <el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
-        </el-select>
-      </el-form-item>
-
       <el-row :gutter="20">
         <el-col :span="12">
           <el-form-item label="客户" prop="custId">
@@ -349,8 +333,8 @@
           productLine: '',
           isBig: '20',
           isOps: '10',
-          opsUserId: null,
-          opsUserName: '',
+          opsUserId: userId || null,
+          opsUserName: nickName || userName || '',
         }
         this.contractList = []
         this.userOptions = []

+ 0 - 1
src/views/devops/software/components/ReleaseCompleteDialog.vue

@@ -251,7 +251,6 @@
         }
       },
       async fetchDevTasks() {
-        if (!this.projectId) return
         this.taskSelectLoading = true
         try {
           const params = {

+ 1 - 9
src/views/devops/software/index.vue

@@ -146,14 +146,6 @@
         <div v-if="sidebarCollapsed" class="sidebar-collapsed-label">项目列表</div>
         <template v-if="!sidebarCollapsed">
           <template v-if="showSidebarFilters">
-            <div class="project-status-filter">
-              <el-radio-group v-model="projectStatusFilter" size="small" @change="handleProjectStatusChange">
-                <el-radio-button label="">全部</el-radio-button>
-                <el-radio-button label="pending">待分配</el-radio-button>
-                <el-radio-button label="delivering">交付中</el-radio-button>
-                <el-radio-button label="delivered">已验收</el-radio-button>
-              </el-radio-group>
-            </div>
             <el-input
               v-model="projectSearch"
               class="project-search"
@@ -629,7 +621,7 @@
         projectTotal: 0,
         projectLoading: false,
         projectHasMore: true,
-        projectStatusFilter: 'delivering',
+        projectStatusFilter: '',
         selectedProject: '',
         projects: [{ id: '', name: '全部' }],
         productLineOptions: [],

+ 29 - 1
src/views/devops/software/schedule.vue

@@ -23,10 +23,12 @@
       <el-table
         v-else
         border
-        :data="tableData"
+        :data="sortedTableData"
         :header-cell-style="{ background: '#f5f7fa', color: '#303133', fontWeight: 'bold', textAlign: 'center' }"
         size="medium"
+        :span-method="getSpan"
         stripe>
+        <el-table-column align="center" fixed="left" label="分组" prop="groupName" width="100" />
         <el-table-column align="center" fixed="left" label="人员" prop="opsUserName" width="100" />
 
         <el-table-column
@@ -80,6 +82,7 @@
   import opsEventTaskApi from '@/api/devops/opsEventTask'
 
   const WEEK_LABELS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
+  const GROUP_ORDER = ['Biobank组', 'CellSop组', 'LIMS组', 'BiobankV4', '品质部']
 
   export default {
     name: 'ScheduleStats',
@@ -130,6 +133,19 @@
         monday.setHours(0, 0, 0, 0)
         return this.formatDateKey(monday) === this.formatDateKey(this.currentWeekStart)
       },
+      sortedTableData() {
+        if (!this.tableData || !this.tableData.length) return []
+        const orderMap = {}
+        GROUP_ORDER.forEach((name, i) => {
+          orderMap[name] = i
+        })
+        const sorted = [...this.tableData].sort((a, b) => {
+          const diff = (orderMap[a.groupName] || 999) - (orderMap[b.groupName] || 999)
+          if (diff !== 0) return diff
+          return (a.opsUserName || '').localeCompare(b.opsUserName || '')
+        })
+        return sorted
+      },
     },
     created() {
       this.fetchData()
@@ -150,6 +166,18 @@
         if (!h || h <= 0) return '0h'
         return h % 1 === 0 ? `${h}h` : `${h.toFixed(1)}h`
       },
+      getSpan({ row, columnIndex }) {
+        if (columnIndex === 0) {
+          const name = row.groupName
+          const rows = this.sortedTableData.filter((r) => r.groupName === name)
+          const isFirst =
+            this.sortedTableData.findIndex((r) => r.groupName === name) === this.sortedTableData.indexOf(row)
+          if (isFirst) {
+            return { rowspan: rows.length, colspan: 1 }
+          }
+          return { rowspan: 0, colspan: 0 }
+        }
+      },
       goCurrentWeek() {
         const now = new Date()
         const dayOfWeek = now.getDay()

+ 134 - 0
src/views/proj/business/components/ApplyContractDialog.vue

@@ -0,0 +1,134 @@
+<template>
+  <el-dialog
+    :close-on-click-modal="false"
+    title="申请合同 - 项目信息确认"
+    :visible.sync="visible"
+    width="600px"
+    @close="handleClose">
+    <div class="apply-contract-content">
+      <div class="info-section">
+        <h4>项目信息</h4>
+        <el-descriptions border :column="1">
+          <el-descriptions-item label="项目编码">
+            {{ rowData.nboCode }}
+          </el-descriptions-item>
+          <el-descriptions-item label="项目名称">
+            {{ rowData.nboName }}
+          </el-descriptions-item>
+          <el-descriptions-item label="产品线">
+            {{ selectDictLabel(productLineOptions, rowData.productLine) }}
+          </el-descriptions-item>
+          <el-descriptions-item label="客户名称">
+            {{ rowData.custName }}
+          </el-descriptions-item>
+          <el-descriptions-item label="经销商/代理商">
+            {{ rowData.distributorName || '-' }}
+          </el-descriptions-item>
+        </el-descriptions>
+      </div>
+
+      <div class="confirm-section">
+        <el-alert
+          :closable="false"
+          description="请确认以上项目信息无误,点击确认后将生成合同申请记录。"
+          show-icon
+          title="确认申请"
+          type="info" />
+      </div>
+    </div>
+
+    <div slot="footer" class="dialog-footer">
+      <el-button @click="handleClose">取消</el-button>
+      <el-button :loading="loading" type="primary" @click="handleConfirm">确认申请合同</el-button>
+    </div>
+  </el-dialog>
+</template>
+
+<script>
+  import contractApplicationApi from '@/api/contract/application'
+
+  export default {
+    name: 'ApplyContractDialog',
+    data() {
+      return {
+        visible: false,
+        loading: false,
+        rowData: {},
+        productLineOptions: [],
+      }
+    },
+    methods: {
+      // 打开弹窗
+      showDialog(row, productLineOptions) {
+        this.rowData = { ...row }
+        this.productLineOptions = productLineOptions || []
+        this.visible = true
+      },
+
+      // 关闭弹窗
+      handleClose() {
+        this.visible = false
+        this.rowData = {}
+        this.loading = false
+      },
+
+      // 确认申请
+      async handleConfirm() {
+        if (this.loading) return
+
+        this.loading = true
+        try {
+          const params = {
+            nboId: this.rowData.id,
+            nboName: this.rowData.nboName,
+            productLine: this.rowData.productLine,
+            custName: this.rowData.custName,
+            distributorName: this.rowData.distributorName,
+          }
+
+          const { msg } = await contractApplicationApi.createFromBusiness(params)
+          this.$baseMessage(msg || '合同申请已提交', 'success')
+          this.handleClose()
+          this.$emit('success')
+        } catch (error) {
+          console.error('申请合同失败:', error)
+          this.$baseMessage(error.message || '申请失败,请重试', 'error')
+        } finally {
+          this.loading = false
+        }
+      },
+
+      // 辅助函数:选择字典标签
+      selectDictLabel(dictOptions, value) {
+        if (!dictOptions || !value) return '-'
+        const item = dictOptions.find((item) => item.key === value)
+        return item ? item.value : value
+      },
+    },
+  }
+</script>
+
+<style lang="scss" scoped>
+  .apply-contract-content {
+    padding: 0 10px;
+
+    .info-section {
+      margin-bottom: 20px;
+
+      h4 {
+        margin-bottom: 15px;
+        color: #303133;
+        font-size: 16px;
+        font-weight: 600;
+      }
+    }
+
+    .confirm-section {
+      margin-top: 20px;
+    }
+  }
+
+  .dialog-footer {
+    text-align: right;
+  }
+</style>

+ 14 - 2
src/views/proj/business/index.vue

@@ -181,10 +181,11 @@
         </template>
       </el-table-column>
 
-      <el-table-column align="center" fixed="right" label="操作" width="68">
+      <el-table-column align="center" fixed="right" label="操作" width="140">
         <template #default="{ row }">
           <!--          <el-button type="text" @click="handleFollow(row)">跟进</el-button>-->
           <el-button v-permissions="['proj:business:edit']" type="text" @click="handleEdit(row)">编辑</el-button>
+          <el-button type="text" @click="handleApplyContract(row)">申请合同</el-button>
           <!--                    <el-button type="text" @click="handleDelete(row)">删除</el-button>-->
         </template>
       </el-table-column>
@@ -207,6 +208,8 @@
     <transfer ref="transfer" @fetch-data="fetchData" />
     <!-- 添加跟进记录 -->
     <follow-add ref="follow" @fetch-data="fetchData" />
+    <!-- 申请合同 -->
+    <apply-contract-dialog ref="applyContract" @success="fetchData" />
   </div>
 </template>
 
@@ -216,12 +219,13 @@
   import Add from './components/BusinessAdd'
   import Transfer from './components/Transfer'
   import FollowAdd from './components/FollowAdd'
+  import ApplyContractDialog from './components/ApplyContractDialog'
   import TableTool from '@/components/table/TableTool'
   import customerApi from '@/api/customer'
 
   export default {
     name: 'Business',
-    components: { Edit, Transfer, TableTool, FollowAdd, Add },
+    components: { Edit, Transfer, TableTool, FollowAdd, Add, ApplyContractDialog },
     data() {
       return {
         tableKey: 0,
@@ -624,6 +628,14 @@
           this.$refs['add'].showEdit(row)
         }
       },
+      // 申请合同
+      handleApplyContract(row) {
+        if (!row.id) {
+          this.$baseMessage('项目ID无效', 'error')
+          return
+        }
+        this.$refs['applyContract'].showDialog(row, this.productLineOptions)
+      },
       handleDelete(row) {
         if (row.id) {
           this.$baseConfirm('你确定要删除当前项吗', null, async () => {

+ 56 - 20
src/views/report/workHour/index.vue

@@ -74,24 +74,45 @@
         show-summary
         style="width: 100%"
         :summary-method="getSummaries">
-        <el-table-column align="center" label="" width="100">
+        <el-table-column align="center" label="" width="72">
           <el-table-column align="center" label="姓名" prop="userName" />
         </el-table-column>
         <el-table-column v-for="day in header" :key="day.date" align="center" :label="day.label">
-          <el-table-column align="center" label="运维" width="78">
+          <el-table-column align="center" label="运" width="50">
             <template #default="{ row }">
               <span class="cell-op">{{ formatHour(row.dailyHours?.[day.date]?.opHour) }}</span>
             </template>
           </el-table-column>
-          <el-table-column align="center" label="研发" width="78">
+          <el-table-column align="center" label="研" width="50">
             <template #default="{ row }">
               <span class="cell-rd">{{ formatHour(row.dailyHours?.[day.date]?.rdHour) }}</span>
             </template>
           </el-table-column>
+          <el-table-column align="center" label="会" width="50">
+            <template #default="{ row }">
+              <span class="cell-mt">{{ formatHour(row.dailyHours?.[day.date]?.mtHour) }}</span>
+            </template>
+          </el-table-column>
         </el-table-column>
-        <el-table-column align="center" label="合计" width="180">
-          <el-table-column align="center" label="运维合计" prop="totalOpHour" width="90" />
-          <el-table-column align="center" label="研发合计" prop="totalRdHour" width="90" />
+        <el-table-column align="center" label="合计" width="156">
+          <el-table-column
+            align="center"
+            :formatter="(row, col, val) => Number(val).toFixed(1)"
+            label="运合"
+            prop="totalOpHour"
+            width="52" />
+          <el-table-column
+            align="center"
+            :formatter="(row, col, val) => Number(val).toFixed(1)"
+            label="研合"
+            prop="totalRdHour"
+            width="52" />
+          <el-table-column
+            align="center"
+            :formatter="(row, col, val) => Number(val).toFixed(1)"
+            label="会合"
+            prop="totalMtHour"
+            width="52" />
         </el-table-column>
       </el-table>
     </el-card>
@@ -129,7 +150,7 @@
     methods: {
       formatHour(val) {
         const num = Number(val) || 0
-        return num % 1 === 0 ? num.toString() : num.toFixed(1)
+        return num.toFixed(1)
       },
 
       selectMode(mode) {
@@ -209,8 +230,8 @@
             sums[index] = ''
             return
           }
-          if (prop === 'totalOpHour' || prop === 'totalRdHour') {
-            sums[index] = data.reduce((sum, row) => sum + (row[prop] || 0), 0)
+          if (prop === 'totalOpHour' || prop === 'totalRdHour' || prop === 'totalMtHour') {
+            sums[index] = data.reduce((sum, row) => sum + (row[prop] || 0), 0).toFixed(1)
           } else {
             sums[index] = ''
           }
@@ -228,16 +249,16 @@
         // Row 1: day labels spanning 2 cols each
         const row1 = ['姓名']
         days.forEach((d) => {
-          row1.push(d.label, null) // will be merged
+          row1.push(d.label, null, null) // 日期标签覆盖 3 列
         })
-        row1.push('运维合计', '研发合计')
+        row1.push('运合', '研合', '会合')
 
         // Row 2: sub-labels
         const row2 = ['姓名']
         days.forEach(() => {
-          row2.push('运维', '研发')
+          row2.push('运', '研', '会')
         })
-        row2.push('', '')
+        row2.push('', '', '')
 
         // -- data rows --
         const dataRows = persons.map((p) => {
@@ -245,9 +266,11 @@
           days.forEach((d) => {
             r.push(p.dailyHours?.[d.date]?.opHour || 0)
             r.push(p.dailyHours?.[d.date]?.rdHour || 0)
+            r.push(p.dailyHours?.[d.date]?.mtHour || 0)
           })
           r.push(p.totalOpHour || 0)
           r.push(p.totalRdHour || 0)
+          r.push(p.totalMtHour || 0)
           return r
         })
 
@@ -256,11 +279,13 @@
         days.forEach((d) => {
           const sumOp = persons.reduce((s, p) => s + (p.dailyHours?.[d.date]?.opHour || 0), 0)
           const sumRd = persons.reduce((s, p) => s + (p.dailyHours?.[d.date]?.rdHour || 0), 0)
-          totalRow.push(sumOp, sumRd)
+          const sumMt = persons.reduce((s, p) => s + (p.dailyHours?.[d.date]?.mtHour || 0), 0)
+          totalRow.push(sumOp, sumRd, sumMt)
         })
         totalRow.push(
           persons.reduce((s, p) => s + (p.totalOpHour || 0), 0),
-          persons.reduce((s, p) => s + (p.totalRdHour || 0), 0)
+          persons.reduce((s, p) => s + (p.totalRdHour || 0), 0),
+          persons.reduce((s, p) => s + (p.totalMtHour || 0), 0)
         )
 
         // -- build worksheet --
@@ -268,12 +293,18 @@
         const ws = XLSX.utils.aoa_to_sheet(wsData)
 
         // column widths
-        ws['!cols'] = [{ wch: 10 }, ...days.map(() => ({ wch: 8 })), { wch: 10 }, { wch: 10 }]
-
-        // merge day header cells: row 1, each day spans 2 cols
+        ws['!cols'] = [
+          { wch: 8 },
+          ...days.flatMap(() => [{ wch: 6 }, { wch: 6 }, { wch: 6 }]),
+          { wch: 6 },
+          { wch: 6 },
+          { wch: 6 },
+        ]
+
+        // merge day header cells: row 1, each day spans 3 cols
         const merges = days.map((d, i) => ({
-          s: { r: 0, c: 1 + i * 2 },
-          e: { r: 0, c: 2 + i * 2 },
+          s: { r: 0, c: 1 + i * 3 },
+          e: { r: 0, c: 3 + i * 3 },
         }))
         ws['!merges'] = merges
 
@@ -381,6 +412,11 @@
           color: #67c23a;
           font-weight: 500;
         }
+
+        .cell-mt {
+          color: #e6a23c;
+          font-weight: 500;
+        }
       }
 
       :deep(.el-table__footer-wrapper) {