浏览代码

feat: refine devops delivery and software workflows

程健 17 小时之前
父节点
当前提交
c57bc2dbaa

+ 2 - 2
.env.development

@@ -23,5 +23,5 @@ VUE_APP_ParentPath=dashoo.opms.parent-0.0.2-cj
 VUE_APP_UPLOAD_WEED='/dir/'
 # 文件一步上传
 VUE_APP_UPLOAD_FILE_WEED=/weedfs/
-VUE_APP_RICHTEXT_UPLOAD_API=/weed_filer/
-VUE_APP_RICHTEXT_UPLOAD_TARGET=/weed_filer/
+VUE_APP_RICHTEXT_UPLOAD_API=http://192.168.0.221:9189/weed_filer/
+VUE_APP_RICHTEXT_UPLOAD_TARGET=http://192.168.0.221:9189/weed_filer/

+ 7 - 0
src/api/devops/opsEventTask.js

@@ -79,6 +79,13 @@ export default {
   },
 
   // 添加工时登记
+  // data 参数:
+  //   taskId: 任务ID(必填)
+  //   workDate: 工作日期
+  //   actualHour: 工时(常规任务)或增量工时(已完成任务)
+  //   remark: 备注
+  //   isCompletedTask: 是否为已完成任务的工时调整
+  //   newTotalWorkHour: 调整后总工时(仅完成状态需要)
   addWorkHour(data) {
     return micro_request.postRequest(basePath, 'OpsEventTask', 'AddWorkHour', data)
   },

+ 7 - 8
src/utils/index.js

@@ -278,17 +278,16 @@ export function formatPrice(price, currency = 'CNY', maximumFractionDigits = 0)
 
 // 回显数据字典
 export function selectDictLabel(datas, value) {
-  if (!datas) {
+  if (!datas || value === undefined || value === null) {
     return value
   }
-  var actions = []
-  Object.keys(datas).map((key) => {
-    if (datas[key].key === '' + value) {
-      actions.push(datas[key].value)
-      return false
+  const valueStr = String(value)
+  for (let i = 0; i < datas.length; i++) {
+    if (String(datas[i].key) === valueStr) {
+      return datas[i].value
     }
-  })
-  return actions.join('')
+  }
+  return value
 }
 
 // 获取字典默认值

+ 6 - 1
src/views/devops/deliveryHardware/components/DeliveryHardwareEventDetail.vue

@@ -18,7 +18,9 @@
         </div>
         <div class="header-actions">
           <el-button
-            v-if="detailData && ['10', '20'].includes(String(detailData.deliveryEventStatus))"
+            v-if="
+              !readOnly && isProcessMode && detailData && ['10', '20'].includes(String(detailData.deliveryEventStatus))
+            "
             size="small"
             type="danger"
             @click="handleCloseEvent">
@@ -646,6 +648,9 @@
         })
       },
       async handleCloseEvent() {
+        if (this.readOnly || !this.isProcessMode) {
+          return
+        }
         // 校验事件状态
         const currentStatus = this.detailData.deliveryEventStatus || this.detailData.delivery_event_status
         if (String(currentStatus) === '30') {

+ 280 - 25
src/views/devops/deliveryHardware/components/DeliveryHardwareEventEdit.vue

@@ -81,21 +81,111 @@
         </el-col>
       </el-row>
 
-      <el-form-item label="附件上传">
-        <el-upload
-          ref="uploadRef"
-          action=""
-          :auto-upload="false"
-          :before-upload="beforeUpload"
-          :file-list="fileList"
-          :limit="5"
-          :multiple="true"
-          :on-change="handleFileChange"
-          :on-remove="handleFileRemove">
-          <el-button size="small" type="primary">选择文件1</el-button>
-          <div slot="tip" class="el-upload__tip">最多上传5个文件,单个文件不超过20MB</div>
-        </el-upload>
-      </el-form-item>
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="执行人" prop="opsUserId">
+            <el-select
+              v-model="form.opsUserId"
+              clearable
+              filterable
+              :loading="loadingUsers"
+              placeholder="请选择执行人"
+              remote
+              :remote-method="remoteFetchUserList"
+              reserve-keyword
+              style="width: 100%"
+              @change="handleUserChange"
+              @visible-change="handleUserVisibleChange">
+              <el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
+            </el-select>
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="执行时间" prop="completeTime">
+            <el-date-picker
+              v-model="form.completeTime"
+              placeholder="请选择执行时间"
+              style="width: 100%"
+              type="date"
+              value-format="yyyy-MM-dd" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="物流单号" prop="attribute1">
+            <el-input v-model="form.attribute1" placeholder="请输入物流单号" />
+          </el-form-item>
+        </el-col>
+        <el-col :span="12">
+          <el-form-item label="实际工作量" prop="actualWorkHour">
+            <el-input-number
+              v-model="form.actualWorkHour"
+              :min="0"
+              placeholder="请输入实际工作量"
+              :precision="1"
+              :step="0.5"
+              style="width: 100%" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <!-- 硬件发货附件:固定上传测试报告和发货清单 -->
+      <template v-if="form.deliveryEventType === '40'">
+        <el-form-item label="测试报告" prop="testReportFile">
+          <el-upload
+            action=""
+            :auto-upload="false"
+            :before-upload="beforeUpload"
+            :file-list="testReportFileList"
+            :limit="1"
+            :multiple="false"
+            :on-change="(file, fileList) => handleFixedFileChange('testReport', file, fileList)"
+            :on-remove="(file, fileList) => handleFixedFileRemove('testReport', file, fileList)">
+            <el-button size="small" type="primary">选择文件</el-button>
+            <div slot="tip" class="el-upload__tip">请上传测试报告文件</div>
+          </el-upload>
+        </el-form-item>
+        <el-form-item label="发货清单" prop="deliveryListFile">
+          <el-upload
+            action=""
+            :auto-upload="false"
+            :before-upload="beforeUpload"
+            :file-list="deliveryListFileList"
+            :limit="1"
+            :multiple="false"
+            :on-change="(file, fileList) => handleFixedFileChange('deliveryList', file, fileList)"
+            :on-remove="(file, fileList) => handleFixedFileRemove('deliveryList', file, fileList)">
+            <el-button size="small" type="primary">选择文件</el-button>
+            <div slot="tip" class="el-upload__tip">请上传发货清单文件</div>
+          </el-upload>
+        </el-form-item>
+      </template>
+      <!-- 硬件安装附件:最多上传5个文件 -->
+      <template v-else-if="form.deliveryEventType === '41'">
+        <el-form-item label="附件上传">
+          <el-upload
+            ref="uploadRef"
+            action=""
+            :auto-upload="false"
+            :before-upload="beforeUpload"
+            :file-list="fileList"
+            :limit="5"
+            :multiple="true"
+            :on-change="handleFileChange"
+            :on-remove="handleFileRemove">
+            <el-button size="small" type="primary">选择文件</el-button>
+            <div slot="tip" class="el-upload__tip">最多上传5个文件,单个文件不超过20MB</div>
+          </el-upload>
+        </el-form-item>
+      </template>
+      <!-- 未选择类型时不显示 -->
+      <template v-else>
+        <el-form-item label="附件上传">
+          <el-button disabled size="small">请先选择事件类型</el-button>
+        </el-form-item>
+      </template>
     </el-form>
 
     <div slot="footer">
@@ -111,8 +201,10 @@
   import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
   import deliveryProjectApi from '@/api/devops/deliveryProject'
   import deliveryProjectEventApi from '@/api/devops/deliveryProjectEvent'
+  import userApi from '@/api/system/user'
   import store from '@/store'
   import { uploadFileToRichtextServer, uploadRichtextImage } from '@/utils/richtextUpload'
+  import { DEVOPS_DEV_DEPT_ID } from '@/config/devops.config'
   import debounce from 'lodash/debounce'
 
   export default {
@@ -173,6 +265,11 @@
           feedbackSource: '',
           feedbackReporter: '',
           onSite: '20',
+          opsUserId: null,
+          opsUserName: '',
+          attribute1: '',
+          completeTime: '',
+          actualWorkHour: 0,
           attachments: [],
         },
         rules: {
@@ -189,6 +286,13 @@
         deliveryEventTypeOptions: [],
         feedbackSourceOptions: [],
         onSiteOptions: [],
+        userOptions: [],
+        loadingUsers: false,
+        testReportFileList: [],
+        deliveryListFileList: [],
+        testReportUploadFiles: [],
+        deliveryListUploadFiles: [],
+        shipmentExtraAttachments: [],
       }
     },
     computed: {
@@ -216,11 +320,17 @@
       this.remoteSearchProject = debounce((query) => {
         this.searchAllProjectsByStatus(query)
       }, 300)
+      this.remoteFetchUserList = debounce((query) => {
+        this.fetchUserList(query)
+      }, 300)
     },
     beforeDestroy() {
       if (this.remoteSearchProject && this.remoteSearchProject.cancel) {
         this.remoteSearchProject.cancel()
       }
+      if (this.remoteFetchUserList && this.remoteFetchUserList.cancel) {
+        this.remoteFetchUserList.cancel()
+      }
       if (this.editor) {
         this.editor.destroy()
         this.editor = null
@@ -256,6 +366,11 @@
           feedbackSource: data.feedbackSource || data.feedback_source || '',
           feedbackReporter: data.feedbackReporter || data.feedback_reporter || '',
           onSite: data.onSite || data.on_site || '20',
+          opsUserId: data.opsUserId || data.ops_user_id || null,
+          opsUserName: data.opsUserName || data.ops_user_name || '',
+          attribute1: data.attribute1 || '',
+          completeTime: data.completeTime || data.complete_time || '',
+          actualWorkHour: data.actualWorkHour || data.actual_work_hour || 0,
           attachments: data.attachments || [],
         }
       },
@@ -266,6 +381,54 @@
           projectName: project.projectName || project.project_name || '',
         }
       },
+      createExistingFileItem(item = {}) {
+        return {
+          name: item.fileName || item.name || '',
+          url: item.fileUrl || item.url || '',
+          ...item,
+        }
+      },
+      splitShipmentAttachments(attachments = []) {
+        const testReportMatchers = ['测试报告', 'test report', 'test_report', 'testreport']
+        const deliveryListMatchers = ['发货清单', 'delivery list', 'delivery_list', 'packing list']
+        const result = {
+          testReportFileList: [],
+          deliveryListFileList: [],
+          shipmentExtraAttachments: [],
+        }
+        const remaining = []
+
+        attachments.forEach((attachment) => {
+          const normalizedAttachment = this.normalizeAttachmentItem(attachment)
+          const fileItem = this.createExistingFileItem(normalizedAttachment)
+          const fileName = String(normalizedAttachment.fileName || '').toLowerCase()
+
+          if (!result.testReportFileList.length && testReportMatchers.some((matcher) => fileName.includes(matcher))) {
+            result.testReportFileList = [fileItem]
+            return
+          }
+
+          if (
+            !result.deliveryListFileList.length &&
+            deliveryListMatchers.some((matcher) => fileName.includes(matcher))
+          ) {
+            result.deliveryListFileList = [fileItem]
+            return
+          }
+
+          remaining.push(fileItem)
+        })
+
+        if (!result.testReportFileList.length && remaining.length) {
+          result.testReportFileList = [remaining.shift()]
+        }
+        if (!result.deliveryListFileList.length && remaining.length) {
+          result.deliveryListFileList = [remaining.shift()]
+        }
+
+        result.shipmentExtraAttachments = remaining.map((item) => this.normalizeAttachmentItem(item))
+        return result
+      },
       async ensureProjectOption(projectId) {
         if (!projectId) return
 
@@ -352,16 +515,33 @@
       initForm() {
         this.form = this.normalizeFormData(this.data)
         this.searchAllProjectsByStatus('')
-        if (this.form.attachments && this.form.attachments.length) {
-          this.fileList = this.form.attachments.map((item) => ({
-            name: item.fileName,
-            url: item.fileUrl,
-            ...item,
-          }))
-        } else {
+        if (this.form.deliveryEventType === '40') {
+          const shipmentAttachments = this.splitShipmentAttachments(this.form.attachments || [])
+          this.testReportFileList = shipmentAttachments.testReportFileList
+          this.deliveryListFileList = shipmentAttachments.deliveryListFileList
+          this.shipmentExtraAttachments = shipmentAttachments.shipmentExtraAttachments
           this.fileList = []
+        } else {
+          this.shipmentExtraAttachments = []
+          this.testReportFileList = []
+          this.deliveryListFileList = []
+          this.fileList = (this.form.attachments || []).map((item) => this.createExistingFileItem(item))
         }
         this.uploadFiles = []
+        this.testReportUploadFiles = []
+        this.deliveryListUploadFiles = []
+        // 加载运维人员列表并确保当前执行人可选
+        this.fetchUserList().then(() => {
+          if (this.form.opsUserId && this.form.opsUserName) {
+            const exists = this.userOptions.some((u) => u.value === this.form.opsUserId)
+            if (!exists) {
+              this.userOptions.push({
+                value: this.form.opsUserId,
+                label: this.form.opsUserName,
+              })
+            }
+          }
+        })
       },
       resetForm() {
         const userName = store.getters['user/username']
@@ -376,11 +556,21 @@
           feedbackSource: '',
           feedbackReporter: nickName || userName || '',
           onSite: '20',
+          opsUserId: null,
+          opsUserName: '',
+          attribute1: '',
+          completeTime: '',
+          actualWorkHour: 0,
           attachments: [],
         }
         this.searchAllProjectsByStatus('')
         this.fileList = []
         this.uploadFiles = []
+        this.testReportFileList = []
+        this.testReportUploadFiles = []
+        this.deliveryListFileList = []
+        this.deliveryListUploadFiles = []
+        this.shipmentExtraAttachments = []
         if (this.editor) {
           this.editor.clear()
         }
@@ -390,6 +580,51 @@
         this.resetForm()
         this.$emit('update:visible', false)
       },
+      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
+        }
+      },
+      handleUserChange(val) {
+        const user = this.userOptions.find((u) => u.value === val)
+        this.form.opsUserName = user ? user.label : ''
+      },
+      handleUserVisibleChange(visible) {
+        if (visible && !this.loadingUsers && !this.userOptions.length) {
+          this.fetchUserList('')
+        }
+      },
+      handleFixedFileChange(type, file, fileList) {
+        if (type === 'testReport') {
+          this.testReportFileList = fileList
+          this.testReportUploadFiles = fileList.filter((f) => f.raw).map((f) => f.raw)
+        } else if (type === 'deliveryList') {
+          this.deliveryListFileList = fileList
+          this.deliveryListUploadFiles = fileList.filter((f) => f.raw).map((f) => f.raw)
+        }
+      },
+      handleFixedFileRemove(type, file, fileList) {
+        if (type === 'testReport') {
+          this.testReportFileList = fileList
+          this.testReportUploadFiles = fileList.filter((f) => f.raw).map((f) => f.raw)
+        } else if (type === 'deliveryList') {
+          this.deliveryListFileList = fileList
+          this.deliveryListUploadFiles = fileList.filter((f) => f.raw).map((f) => f.raw)
+        }
+      },
       handleFileChange(file, fileList) {
         this.fileList = fileList
         this.uploadFiles = fileList.filter((f) => f.raw).map((f) => f.raw)
@@ -416,9 +651,15 @@
       getExistingAttachments() {
         return this.fileList.filter((item) => !item.raw).map((item) => this.normalizeAttachmentItem(item))
       },
-      async uploadAttachments() {
+      getExistingShipmentAttachments() {
+        const fixedAttachments = [...this.testReportFileList, ...this.deliveryListFileList]
+          .filter((item) => item && !item.raw)
+          .map((item) => this.normalizeAttachmentItem(item))
+        return [...fixedAttachments, ...this.shipmentExtraAttachments]
+      },
+      async uploadTypeAttachments(files) {
         const uploadedFiles = []
-        for (const file of this.uploadFiles) {
+        for (const file of files) {
           try {
             const result = await uploadFileToRichtextServer(file)
             uploadedFiles.push(
@@ -434,7 +675,20 @@
             throw err
           }
         }
-        return [...this.getExistingAttachments(), ...uploadedFiles]
+        return uploadedFiles
+      },
+      async uploadAttachments() {
+        // 硬件发货类型:上传固定两个文件
+        if (this.form.deliveryEventType === '40') {
+          const uploadedFiles = [...this.getExistingShipmentAttachments()]
+          const testReportFiles = await this.uploadTypeAttachments(this.testReportUploadFiles)
+          const deliveryListFiles = await this.uploadTypeAttachments(this.deliveryListUploadFiles)
+          return [...uploadedFiles, ...testReportFiles, ...deliveryListFiles]
+        }
+        // 硬件安装类型:上传通用文件
+        const uploadedFiles = [...this.getExistingAttachments()]
+        const newFiles = await this.uploadTypeAttachments(this.uploadFiles)
+        return [...uploadedFiles, ...newFiles]
       },
       async handleSubmit() {
         this.$refs.form.validate(async (valid) => {
@@ -448,6 +702,7 @@
             const submitData = {
               ...this.form,
               projectId: this.form.projectId ? parseInt(this.form.projectId) : null,
+              opsUserId: this.form.opsUserId ? parseInt(this.form.opsUserId) : 0,
               attachments: uploadedAttachments,
             }
 

+ 187 - 9
src/views/devops/deliveryHardware/index.vue

@@ -25,7 +25,21 @@
             <el-input v-model="queryForm.feedbackReporter" clearable placeholder="请输入" style="width: 120px" />
           </el-form-item>
           <el-form-item label="负责人">
-            <el-input v-model="queryForm.opsUserName" clearable placeholder="请输入" style="width: 120px" />
+            <el-select
+              v-model="queryForm.opsUserName"
+              clearable
+              collapse-tags
+              filterable
+              :loading="opsUsersLoading"
+              multiple
+              placeholder="请选择"
+              remote
+              :remote-method="remoteFetchOpsUsers"
+              reserve-keyword
+              style="width: 160px"
+              @visible-change="handleOpsUserVisibleChange">
+              <el-option v-for="u in opsUserOptions" :key="u.value" :label="u.label" :value="u.label" />
+            </el-select>
           </el-form-item>
           <el-form-item label="反馈时间">
             <el-date-picker
@@ -133,7 +147,7 @@
             height="100%"
             stripe
             style="width: 100%"
-            @row-click="handleRowClick"
+            @row-dblclick="handleRowClick"
             @sort-change="handleSortChange">
             <el-table-column align="center" type="index" width="50" />
             <el-table-column label="事件标题" min-width="320" show-overflow-tooltip>
@@ -201,7 +215,7 @@
                 {{ selectDictLabel(feedbackSourceOptions, row.feedbackSource || row.feedback_source) }}
               </template>
             </el-table-column>
-            <el-table-column fixed="right" header-align="center" label="操作" width="160">
+            <el-table-column fixed="right" header-align="center" label="操作" width="220">
               <template slot-scope="{ row }">
                 <el-button
                   v-if="String(row.deliveryEventStatus || row.delivery_event_status) !== '30'"
@@ -217,6 +231,17 @@
                   @click.stop="handleProcess(row)">
                   处理
                 </el-button>
+                <el-button
+                  v-if="
+                    String(row.deliveryEventType || row.delivery_event_type) === '40' &&
+                    String(row.deliveryEventStatus || row.delivery_event_status) === '20'
+                  "
+                  size="mini"
+                  style="color: #67c23a"
+                  type="text"
+                  @click.stop="handleArrival(row)">
+                  到货
+                </el-button>
                 <el-button
                   v-if="['10', '20'].includes(String(row.deliveryEventStatus || row.delivery_event_status))"
                   size="mini"
@@ -295,6 +320,32 @@
         <el-button :loading="submitLoading" size="small" type="primary" @click="handleSaveCloseEvent">确定</el-button>
       </div>
     </el-dialog>
+
+    <!-- 到货信息弹窗 -->
+    <el-dialog :close-on-click-modal="false" title="到货信息" :visible.sync="arrivalDialogVisible" width="500px">
+      <el-form ref="arrivalForm" label-width="100px" :model="arrivalForm" :rules="arrivalRules" size="small">
+        <el-form-item label="到货说明" prop="arrivalDesc">
+          <el-input v-model="arrivalForm.arrivalDesc" placeholder="请输到货说明..." :rows="4" type="textarea" />
+        </el-form-item>
+        <el-form-item label="到货确认单" prop="arrivalFile">
+          <el-upload
+            action=""
+            :auto-upload="false"
+            :file-list="arrivalFileList"
+            :limit="1"
+            :multiple="false"
+            :on-change="handleArrivalFileChange"
+            :on-remove="handleArrivalFileRemove">
+            <el-button size="small" type="primary">选择文件</el-button>
+            <div slot="tip" class="el-upload__tip">请上传到货确认单文件</div>
+          </el-upload>
+        </el-form-item>
+      </el-form>
+      <div slot="footer">
+        <el-button size="small" @click="handleArrivalDialogCancel">取消</el-button>
+        <el-button :loading="submitLoading" size="small" type="primary" @click="handleSaveArrival">确定</el-button>
+      </div>
+    </el-dialog>
   </div>
 </template>
 
@@ -308,7 +359,8 @@
   import { escapeHtml, sanitizeHtml } from '@/utils/safeHtml'
   import { deliveryEventStatusTagTypes, getTagType } from '@/config/devopsTagTypes'
   import dictApi from '@/api/system/dict'
-  import store from '@/store'
+  import userApi from '@/api/system/user'
+  import { DEVOPS_DEV_DEPT_ID } from '@/config/devops.config'
 
   export default {
     name: 'DeliveryHardware',
@@ -324,7 +376,7 @@
           deliveryEventType: ['40', '41'],
           deliveryEventStatus: [],
           deliveryEventResult: [],
-          opsUserName: '',
+          opsUserName: [],
           feedbackReporter: '',
           feedbackDateRange: [],
           completeDateRange: [],
@@ -347,6 +399,8 @@
         deliveryEventResultOptions: [],
         feedbackSourceOptions: [],
         onSiteOptions: [],
+        opsUserOptions: [],
+        opsUsersLoading: false,
         detailVisible: false,
         detailMode: 'view',
         currentRow: null,
@@ -362,6 +416,16 @@
         },
         closeEventFileList: [],
         closeEventUploadFiles: [],
+        arrivalDialogVisible: false,
+        arrivalRow: null,
+        arrivalForm: {
+          arrivalDesc: '',
+        },
+        arrivalRules: {
+          arrivalDesc: [{ required: true, message: '请输入到货说明', trigger: 'blur' }],
+        },
+        arrivalFileList: [],
+        arrivalUploadFiles: [],
       }
     },
     computed: {
@@ -418,15 +482,14 @@
           .catch((err) => console.log(err))
       },
 
-      // 获取项目列表 - 只查询当前用户负责的项目
+      // 获取项目列表 - 查询所有人的项目,限制产品线
       async fetchProjectList() {
         try {
-          const currentUserId = store.getters['user/id']
           const params = {
             pageNum: 1,
             pageSize: 999,
             projectStatus: '10,20,30,40,50',
-            deliveryUserId: currentUserId,
+            productLine: '10,20,30,40,50,60',
           }
           const res = await deliveryProjectApi.getList(params)
           if (res.code === 200 && res.data && res.data.list) {
@@ -446,6 +509,31 @@
         }
       },
 
+      // 远程搜索负责人
+      async remoteFetchOpsUsers(search) {
+        this.opsUsersLoading = 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.opsUserOptions = 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.opsUserOptions = []
+        } finally {
+          this.opsUsersLoading = false
+        }
+      },
+      handleOpsUserVisibleChange(visible) {
+        if (visible && !this.opsUsersLoading && !this.opsUserOptions.length) {
+          this.remoteFetchOpsUsers('')
+        }
+      },
+
       // 获取事件列表数据 - 只查询硬件发货(40)和硬件安装(41)类型
       async fetchEventData() {
         this.loading = true
@@ -512,7 +600,7 @@
           deliveryEventType: ['40', '41'],
           deliveryEventStatus: [],
           deliveryEventResult: [],
-          opsUserName: '',
+          opsUserName: [],
           feedbackReporter: '',
           feedbackDateRange: [],
           completeDateRange: [],
@@ -785,6 +873,96 @@
           }
         })
       },
+
+      // 到货相关方法
+      handleArrival(row) {
+        this.arrivalRow = row
+        this.arrivalForm = { arrivalDesc: '' }
+        this.arrivalFileList = []
+        this.arrivalUploadFiles = []
+        this.arrivalDialogVisible = true
+      },
+
+      handleArrivalFileChange(file, fileList) {
+        this.arrivalFileList = fileList
+        this.arrivalUploadFiles = fileList.filter((f) => f.raw).map((f) => f.raw)
+      },
+
+      handleArrivalFileRemove(file, fileList) {
+        this.arrivalFileList = fileList
+        this.arrivalUploadFiles = fileList.filter((f) => f.raw).map((f) => f.raw)
+      },
+
+      handleArrivalDialogCancel() {
+        this.arrivalDialogVisible = false
+        this.arrivalForm = { arrivalDesc: '' }
+        this.arrivalFileList = []
+        this.arrivalUploadFiles = []
+      },
+
+      async uploadArrivalAttachments() {
+        const uploadedFiles = []
+        for (const file of this.arrivalUploadFiles) {
+          try {
+            const result = await uploadFileToRichtextServer(file)
+            uploadedFiles.push({
+              fileName: result.name || file.name,
+              fileUrl: result.url,
+              fileType: file.type || 'application/octet-stream',
+            })
+          } catch (err) {
+            this.$message.error(`文件 ${file.name} 上传失败`)
+            console.error(err)
+            throw err
+          }
+        }
+        return uploadedFiles
+      },
+
+      async handleSaveArrival() {
+        this.$refs.arrivalForm.validate(async (valid) => {
+          if (!valid) return
+
+          this.submitLoading = true
+          try {
+            const uploadedAttachments = await this.uploadArrivalAttachments()
+
+            // 更新事件状态为已完成
+            const updateData = {
+              id: this.arrivalRow.id,
+              deliveryEventStatus: '30',
+              completeDesc: this.arrivalForm.arrivalDesc,
+            }
+
+            const updateRes = await deliveryProjectEventApi.update(updateData)
+            if (updateRes.code === 200) {
+              const arrivalDesc = escapeHtml(this.arrivalForm.arrivalDesc)
+              const recordContent = `<p><strong>到货确认</strong></p><p><strong>到货说明:</strong>${arrivalDesc}</p>`
+
+              const recordRes = await deliveryProjectEventApi.addRecord({
+                deliveryEventId: this.arrivalRow.id,
+                handleContent: recordContent,
+                attachments: uploadedAttachments,
+              })
+
+              if (recordRes.code === 200) {
+                this.$message.success('到货确认成功')
+                this.handleArrivalDialogCancel()
+                this.fetchEventData()
+              } else {
+                this.$message.error(recordRes.msg || '保存过程记录失败')
+              }
+            } else {
+              this.$message.error(updateRes.msg || '到货确认失败')
+            }
+          } catch (error) {
+            console.error('到货确认失败:', error)
+            this.$message.error('到货确认失败')
+          } finally {
+            this.submitLoading = false
+          }
+        })
+      },
     },
   }
 </script>

+ 212 - 9
src/views/devops/deliveryProject/components/DeliveryProjectEventDetail.vue

@@ -18,7 +18,9 @@
         </div>
         <div class="header-actions">
           <el-button
-            v-if="detailData && ['10', '20'].includes(String(detailData.deliveryEventStatus))"
+            v-if="
+              !readOnly && processMode && detailData && ['10', '20'].includes(String(detailData.deliveryEventStatus))
+            "
             size="small"
             type="primary"
             @click="handleCloseEvent">
@@ -130,7 +132,47 @@
               </div>
               <div class="property-item">
                 <span class="property-label">负责人</span>
-                <span class="property-value">{{ (detailData && detailData.opsUserName) || '-' }}</span>
+                <span v-if="!isEditingOwner" class="property-value">
+                  {{ (detailData && detailData.opsUserName) || '-' }}
+                  <el-button
+                    v-if="!readOnly && detailData && ['10', '20'].includes(String(detailData.deliveryEventStatus))"
+                    icon="el-icon-edit"
+                    size="mini"
+                    style="margin-left: 8px"
+                    type="text"
+                    @click="handleEditOwner" />
+                </span>
+                <span v-else class="property-value" style="display: flex; align-items: center; gap: 8px">
+                  <el-select
+                    v-model="ownerEditForm.opsUserId"
+                    clearable
+                    filterable
+                    :loading="ownerUserLoading"
+                    placeholder="选择负责人"
+                    remote
+                    :remote-method="remoteSearchOwnerUsers"
+                    size="mini"
+                    style="width: 150px"
+                    @change="handleOwnerUserChange">
+                    <el-option
+                      v-for="user in ownerUserOptions"
+                      :key="user.value"
+                      :label="user.label"
+                      :value="user.value" />
+                  </el-select>
+                  <el-button
+                    icon="el-icon-check"
+                    size="mini"
+                    style="color: #409eff"
+                    type="text"
+                    @click="handleSaveOwner" />
+                  <el-button
+                    icon="el-icon-close"
+                    size="mini"
+                    style="color: #f56c6c"
+                    type="text"
+                    @click="cancelEditOwner" />
+                </span>
               </div>
               <div class="property-item">
                 <span class="property-label">是否现场</span>
@@ -304,13 +346,17 @@
             value-format="yyyy-MM-dd" />
         </el-form-item>
         <el-form-item label="工作量" prop="actualWorkHour">
-          <el-input-number
-            v-model="closeEventForm.actualWorkHour"
-            controls-position="right"
-            :min="0"
-            :precision="1"
-            :step="0.5"
-            style="width: 100%" />
+          <div style="display: flex; gap: 8px; align-items: center">
+            <el-input-number
+              v-model="closeEventForm.actualWorkHour"
+              controls-position="right"
+              :min="0"
+              :precision="1"
+              :step="0.5"
+              style="flex: 1" />
+            <el-button size="small" @click="addWorkHour(0.5)">+0.5</el-button>
+            <el-button size="small" @click="addWorkHour(2)">+2</el-button>
+          </div>
           <div class="form-tip">到客户现场工时包含来回路上时间</div>
         </el-form-item>
         <el-form-item label="处理说明" prop="closeReason">
@@ -343,9 +389,12 @@
 <script>
   import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
   import deliveryProjectEventApi from '@/api/devops/deliveryProjectEvent'
+  import userApi from '@/api/system/user'
   import { parseTime } from '@/utils'
   import { uploadRichtextImage, uploadFileToRichtextServer } from '@/utils/richtextUpload'
   import { escapeHtml, openSafeUrl, sanitizeHtml } from '@/utils/safeHtml'
+  import debounce from 'lodash/debounce'
+  import { DEVOPS_DEV_DEPT_ID } from '@/config/devops.config'
 
   export default {
     name: 'DeliveryProjectEventDetail',
@@ -417,6 +466,14 @@
         },
         closeEventFileList: [],
         closeEventUploadFiles: [],
+        // 负责人行内编辑相关数据
+        isEditingOwner: false,
+        ownerEditForm: {
+          opsUserId: null,
+          opsUserName: '',
+        },
+        ownerUserOptions: [],
+        ownerUserLoading: false,
         productLineDict: [],
         deliveryEventTypeOptions: [],
         deliveryEventStatusOptions: [],
@@ -471,8 +528,14 @@
     },
     created() {
       this.getOptions()
+      this.remoteSearchOwnerUsers = debounce((query) => {
+        this.fetchOwnerUserList(query)
+      }, 300)
     },
     beforeDestroy() {
+      if (this.remoteSearchOwnerUsers && this.remoteSearchOwnerUsers.cancel) {
+        this.remoteSearchOwnerUsers.cancel()
+      }
       if (this.quickEditor) {
         this.quickEditor.destroy()
         this.quickEditor = null
@@ -499,6 +562,10 @@
           })
           .catch((err) => console.log(err))
       },
+      addWorkHour(hour) {
+        const current = this.closeEventForm.actualWorkHour || 0
+        this.closeEventForm.actualWorkHour = Math.round((current + hour) * 10) / 10
+      },
       onQuickEditorCreated(editor) {
         this.quickEditor = editor
       },
@@ -563,6 +630,9 @@
         }
       },
       async handleCloseEvent() {
+        if (this.readOnly || !this.processMode) {
+          return
+        }
         // 校验事件状态
         const currentStatus = this.detailData.deliveryEventStatus || this.detailData.delivery_event_status
         if (String(currentStatus) === '30') {
@@ -680,6 +750,9 @@
         this.isDescExpanded = !this.isDescExpanded
       },
       handleClose() {
+        this.isEditingOwner = false
+        this.ownerEditForm = { opsUserId: null, opsUserName: '' }
+        this.ownerUserOptions = []
         this.resetDialogState()
         this.isDescExpanded = false
         this.$emit('update:visible', false)
@@ -756,6 +829,136 @@
           this.quickEditor.clear()
         }
       },
+      handleEditOwner() {
+        this.isEditingOwner = true
+        this.ownerEditForm = {
+          opsUserId: this.displayData.opsUserId || this.displayData.ops_user_id || null,
+          opsUserName: this.displayData.opsUserName || this.displayData.ops_user_name || '',
+        }
+        this.ownerUserOptions = []
+
+        // 加载用户列表(debounce 防抖 + 部门过滤)
+        this.fetchOwnerUserList('').then(() => {
+          // 确保当前负责人在选项列表中(用于 label 回显)
+          if (this.ownerEditForm.opsUserId && this.ownerEditForm.opsUserName) {
+            const exists = this.ownerUserOptions.some((u) => String(u.value) === String(this.ownerEditForm.opsUserId))
+            if (!exists) {
+              this.ownerUserOptions.push({
+                value: this.ownerEditForm.opsUserId,
+                label: this.ownerEditForm.opsUserName,
+              })
+            }
+          }
+        })
+      },
+      cancelEditOwner() {
+        this.isEditingOwner = false
+        this.ownerEditForm = {
+          opsUserId: null,
+          opsUserName: '',
+        }
+        this.ownerUserOptions = []
+      },
+      async fetchOwnerUserList(search) {
+        this.ownerUserLoading = 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.ownerUserOptions = 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.ownerUserOptions = []
+        } finally {
+          this.ownerUserLoading = false
+        }
+      },
+      handleOwnerUserChange(val) {
+        if (val === null || val === undefined || val === '') {
+          this.ownerEditForm.opsUserName = ''
+          return
+        }
+        const user = this.ownerUserOptions.find((u) => u.value === val)
+        this.ownerEditForm.opsUserName = user ? user.label : ''
+      },
+      async handleSaveOwner() {
+        const newUserIdRaw = this.ownerEditForm.opsUserId
+        if (newUserIdRaw === null || newUserIdRaw === undefined || newUserIdRaw === '') {
+          this.$message.warning('请选择负责人')
+          return
+        }
+
+        // 检查是否选择了新负责人(与当前不同)
+        const currentOwnerIdRaw = this.displayData.opsUserId || this.displayData.ops_user_id
+        if (String(newUserIdRaw) === String(currentOwnerIdRaw)) {
+          this.$message.warning('请选择新的负责人')
+          return
+        }
+
+        // 从 option 列表中查找对应的用户,找不到时退而求其次直接使用表单值
+        const selectedUser = this.ownerUserOptions.find((u) => String(u.value) === String(newUserIdRaw))
+        const newOwnerName = (selectedUser && selectedUser.label) || this.ownerEditForm.opsUserName || ''
+        if (!newOwnerName) {
+          this.$message.error('无法获取负责人姓名')
+          return
+        }
+
+        // 准备数字类型 userId(后端要求 OpsUserId int > 0)
+        const asNum = Number(newUserIdRaw)
+        const payloadOpsUserId = Number.isFinite(asNum) && asNum > 0 ? asNum : newUserIdRaw
+
+        this.submitLoading = true
+        try {
+          const oldOwnerName = this.displayData.opsUserName || this.displayData.ops_user_name || '未分配'
+          const currentUserName = this.$store.state.user.nickName || this.$store.state.user.username || '系统管理员'
+          const currentTime = parseTime(new Date(), '{y}年{m}月{d}日')
+
+          // 1. 更新事件负责人
+          const updateRes = await deliveryProjectEventApi.update({
+            id: this.displayData.id,
+            opsUserId: payloadOpsUserId,
+            opsUserName: newOwnerName,
+          })
+
+          if (updateRes.code !== 200) {
+            this.$message.error(updateRes.msg || '更新负责人失败')
+            return
+          }
+
+          // 2. 添加变更过程记录
+          let recordContent = `<p><strong>${currentUserName}</strong>于<strong>${currentTime}</strong>变更负责人为<strong>${newOwnerName}</strong></p>`
+          recordContent += `<p><strong>变更记录:</strong>${oldOwnerName} → ${newOwnerName}</p>`
+
+          const recordRes = await deliveryProjectEventApi.addRecord({
+            deliveryEventId: this.displayData.id,
+            handleContent: recordContent,
+          })
+
+          if (recordRes.code !== 200) {
+            this.$message.error(recordRes.msg || '保存负责人变更记录失败')
+            return
+          }
+
+          this.$message.success('负责人变更成功')
+          this.isEditingOwner = false
+          this.ownerEditForm = {
+            opsUserId: null,
+            opsUserName: '',
+          }
+          this.fetchDetail()
+          this.fetchRecordList()
+          this.$emit('refresh')
+        } catch (error) {
+          console.error('变更负责人失败:', error)
+          this.$message.error('变更负责人失败')
+        } finally {
+          this.submitLoading = false
+        }
+      },
       async submitQuickRecord() {
         if (!this.quickRecordContent.trim()) {
           this.$message.warning('请输入记录内容')

+ 218 - 0
src/views/devops/deliveryProject/components/DeliveryProjectEventProgress.vue

@@ -0,0 +1,218 @@
+<template>
+  <el-dialog
+    append-to-body
+    :close-on-click-modal="false"
+    destroy-on-close
+    :title="dialogTitle"
+    :visible.sync="dialogVisible"
+    width="900px">
+    <div class="progress-container">
+      <!-- 事件信息摘要 -->
+      <div class="event-summary">
+        <div class="summary-item">
+          <span class="summary-label">事件标题</span>
+          <span class="summary-value">{{ event.deliveryEventTitle || event.delivery_event_title || '-' }}</span>
+        </div>
+        <div class="summary-item">
+          <span class="summary-label">事件编号</span>
+          <span class="summary-value">{{ event.deliveryEventNo || event.delivery_event_no || '-' }}</span>
+        </div>
+        <div class="summary-item">
+          <span class="summary-label">事件类型</span>
+          <span class="summary-value">{{ eventTypeLabel }}</span>
+        </div>
+      </div>
+
+      <!-- 任务列表 -->
+      <el-table v-loading="loading" border :data="taskList" max-height="400" size="small" style="width: 100%">
+        <el-table-column label="任务编号" prop="taskNo" width="150" />
+        <el-table-column label="任务标题" min-width="180" prop="taskTitle" show-overflow-tooltip />
+        <el-table-column label="任务类型" width="120">
+          <template slot-scope="{ row }">
+            {{ selectDictLabel(taskTypeOptions, row.taskType) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="任务状态" width="100">
+          <template slot-scope="{ row }">
+            <el-tag disable-transitions size="mini" :type="getTaskStatusTagType(row.taskStatus)">
+              {{ selectDictLabel(taskStatusOptions, row.taskStatus) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="执行人" width="100">
+          <template slot-scope="{ row }">
+            {{ row.opsUserName || '-' }}
+          </template>
+        </el-table-column>
+        <el-table-column label="计划开始" width="110">
+          <template slot-scope="{ row }">
+            {{ parseTime(row.planStartTime, '{y}-{m}-{d}') }}
+          </template>
+        </el-table-column>
+        <el-table-column label="计划结束" width="110">
+          <template slot-scope="{ row }">
+            {{ parseTime(row.planEndTime, '{y}-{m}-{d}') }}
+          </template>
+        </el-table-column>
+        <el-table-column label="完成时间" width="110">
+          <template slot-scope="{ row }">
+            {{ parseTime(row.completeTime, '{y}-{m}-{d}') }}
+          </template>
+        </el-table-column>
+        <el-table-column fixed="right" label="操作" width="80">
+          <template slot-scope="{ row }">
+            <el-button size="mini" type="text" @click="handleViewTask(row)">详情</el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </div>
+    <!-- 任务详情弹窗 -->
+    <task-detail-dialog :detail-data="selectedTask" mode="view" :visible.sync="taskDetailDialogVisible" />
+  </el-dialog>
+</template>
+
+<script>
+  import opsEventTaskApi from '@/api/devops/opsEventTask'
+  import TaskDetailDialog from '@/views/devops/software/components/TaskDetailDialog'
+  import { parseTime } from '@/utils'
+
+  export default {
+    name: 'DeliveryProjectEventProgress',
+    components: { TaskDetailDialog },
+    props: {
+      event: { type: Object, default: null },
+      visible: { type: Boolean, default: false },
+    },
+    data() {
+      return {
+        taskList: [],
+        taskTypeOptions: [],
+        taskStatusOptions: [],
+        deliveryEventTypeOptions: [],
+        taskDetailDialogVisible: false,
+        selectedTask: null,
+        loading: false,
+      }
+    },
+    computed: {
+      dialogVisible: {
+        get() {
+          return this.visible
+        },
+        set(val) {
+          this.$emit('update:visible', val)
+        },
+      },
+      dialogTitle() {
+        const title = this.event?.deliveryEventTitle || this.event?.delivery_event_title || ''
+        return `研发任务进展 - ${title}`
+      },
+      eventTypeLabel() {
+        const rawType = this.event?.deliveryEventType || this.event?.delivery_event_type
+        return rawType ? this.selectDictLabel(this.deliveryEventTypeOptions, rawType) : '-'
+      },
+    },
+    watch: {
+      visible(val) {
+        if (val) {
+          this.handleOpen()
+        }
+      },
+    },
+    methods: {
+      parseTime,
+
+      async handleOpen() {
+        await this.fetchDicts()
+        await this.fetchTasks()
+      },
+
+      async fetchDicts() {
+        try {
+          const [typeRes, statusRes, eventTypeRes] = await Promise.all([
+            this.getDicts('ops_task_type'),
+            this.getDicts('ops_task_status'),
+            this.getDicts('delivery_event_type'),
+          ])
+          this.taskTypeOptions = (typeRes && typeRes.data && typeRes.data.values) || []
+          this.taskStatusOptions = (statusRes && statusRes.data && statusRes.data.values) || []
+          this.deliveryEventTypeOptions = (eventTypeRes && eventTypeRes.data && eventTypeRes.data.values) || []
+        } catch (e) {
+          console.error('获取字典数据失败:', e)
+        }
+      },
+
+      async fetchTasks() {
+        if (!this.event || !this.event.id) return
+        this.loading = true
+        try {
+          const res = await opsEventTaskApi.getList({
+            pageNum: 1,
+            pageSize: 999,
+            eventId: this.event.id,
+          })
+          if (res.code === 200 && res.data) {
+            this.taskList = res.data.list || []
+          } else {
+            this.taskList = []
+          }
+        } catch (e) {
+          console.error('查询研发任务失败:', e)
+          this.taskList = []
+        } finally {
+          this.loading = false
+        }
+      },
+
+      getTaskStatusTagType(status) {
+        const map = {
+          10: 'info',
+          20: '',
+          25: 'warning',
+          30: 'success',
+          70: 'danger',
+          90: 'danger',
+        }
+        return map[status] || 'info'
+      },
+
+      handleViewTask(row) {
+        if (row.id) {
+          this.selectedTask = row
+          this.taskDetailDialogVisible = true
+        }
+      },
+    },
+  }
+</script>
+
+<style lang="scss" scoped>
+  .progress-container {
+    .event-summary {
+      display: flex;
+      gap: 24px;
+      padding: 8px 12px;
+      margin-bottom: 16px;
+      background: #f5f7fa;
+      border-radius: 4px;
+      flex-wrap: wrap;
+
+      .summary-item {
+        display: flex;
+        gap: 8px;
+        font-size: 13px;
+        line-height: 28px;
+
+        .summary-label {
+          color: #909399;
+          flex-shrink: 0;
+        }
+
+        .summary-value {
+          color: #303133;
+          font-weight: 500;
+        }
+      }
+    }
+  }
+</style>

+ 36 - 3
src/views/devops/deliveryProject/index.vue

@@ -222,7 +222,7 @@
             height="100%"
             stripe
             style="width: 100%"
-            @row-click="handleRowClick">
+            @row-dblclick="handleRowClick">
             <el-table-column align="center" type="index" width="50" />
             <el-table-column label="事件标题" min-width="320" show-overflow-tooltip>
               <template slot-scope="{ row }">
@@ -231,6 +231,11 @@
                 </span>
               </template>
             </el-table-column>
+            <el-table-column label="项目名称" min-width="200" show-overflow-tooltip>
+              <template slot-scope="{ row }">
+                {{ row.projectName || row.project_name || '-' }}
+              </template>
+            </el-table-column>
             <el-table-column
               label="事件类型"
               :render-header="renderSortableHeader('事件类型', 'deliveryEventType')"
@@ -312,7 +317,7 @@
                 {{ selectDictLabel(feedbackSourceOptions, row.feedbackSource || row.feedback_source) }}
               </template>
             </el-table-column>
-            <el-table-column fixed="right" header-align="center" label="操作" width="160">
+            <el-table-column fixed="right" header-align="center" label="操作" width="200">
               <template slot-scope="{ row }">
                 <el-button
                   v-if="String(row.deliveryEventStatus || row.delivery_event_status) !== '30'"
@@ -328,6 +333,9 @@
                   @click.stop="handleProcess(row)">
                   处理
                 </el-button>
+                <el-button v-if="showProgressButton(row)" size="mini" type="text" @click.stop="handleProgress(row)">
+                  进展
+                </el-button>
                 <el-button
                   v-if="['10', '20'].includes(String(row.deliveryEventStatus || row.delivery_event_status))"
                   size="mini"
@@ -411,6 +419,9 @@
 
     <!-- 项目信息弹窗 -->
     <project-info-dialog :project-id="selectedProjectId" :visible.sync="projectInfoDialogVisible" />
+
+    <!-- 研发任务进展弹窗 -->
+    <delivery-project-event-progress :event="progressEventRow" :visible.sync="progressDialogVisible" />
   </div>
 </template>
 
@@ -421,6 +432,7 @@
   import { DEVOPS_DEV_DEPT_ID } from '@/config/devops.config'
   import DeliveryProjectEventEdit from './components/DeliveryProjectEventEdit'
   import DeliveryProjectEventDetail from './components/DeliveryProjectEventDetail'
+  import DeliveryProjectEventProgress from './components/DeliveryProjectEventProgress'
   import DeliveryProjectAssign from './components/DeliveryProjectAssign'
   import ProjectInfoDialog from '../components/ProjectInfoDialog'
   import { parseTime } from '@/utils'
@@ -431,7 +443,13 @@
 
   export default {
     name: 'DeliveryProject',
-    components: { DeliveryProjectEventEdit, DeliveryProjectEventDetail, DeliveryProjectAssign, ProjectInfoDialog },
+    components: {
+      DeliveryProjectEventEdit,
+      DeliveryProjectEventDetail,
+      DeliveryProjectEventProgress,
+      DeliveryProjectAssign,
+      ProjectInfoDialog,
+    },
     data() {
       return {
         queryForm: {
@@ -491,6 +509,9 @@
         // 项目信息弹窗
         projectInfoDialogVisible: false,
         selectedProjectId: '',
+        // 进展弹窗相关数据
+        progressDialogVisible: false,
+        progressEventRow: null,
       }
     },
     computed: {
@@ -795,6 +816,18 @@
         this.detailVisible = true
       },
 
+      // 是否显示进展按钮(特定事件类型)
+      showProgressButton(row) {
+        const eventType = String(row.deliveryEventType || row.delivery_event_type)
+        return ['31', '32', '33', '35', '38', '40', '41'].includes(eventType)
+      },
+
+      // 查看研发任务进展
+      handleProgress(row) {
+        this.progressEventRow = row
+        this.progressDialogVisible = true
+      },
+
       // 作废事件 - 直接弹出作废事件弹窗
       async handleCancelEvent(row) {
         // 校验事件状态

+ 22 - 5
src/views/devops/software/components/CompleteDialog.vue

@@ -7,8 +7,16 @@
     @update:visible="$emit('update:visible', $event)">
     <el-form ref="form" label-position="right" label-width="140px" :model="form" :rules="rules" size="medium">
       <el-form-item label="实际工作量 (小时)" prop="actualWorkHour">
-        <el-input-number v-model="form.actualWorkHour" controls-position="right" :min="0" :precision="1" :step="0.5" />
-        <span class="suffix">小时</span>
+        <div class="actual-hour-row">
+          <el-input-number
+            v-model="form.actualWorkHour"
+            controls-position="right"
+            :min="0"
+            :precision="1"
+            :step="0.5" />
+          <el-button size="mini" @click="addActualWorkHour(0.5)">+0.5</el-button>
+          <el-button size="mini" @click="addActualWorkHour(2)">+2</el-button>
+        </div>
       </el-form-item>
 
       <el-form-item label="备注" prop="remark">
@@ -73,6 +81,10 @@
       }
     },
     methods: {
+      addActualWorkHour(hours) {
+        const current = Number(this.form.actualWorkHour) || 0
+        this.form.actualWorkHour = Math.round((current + hours) * 10) / 10
+      },
       formatRemark(remark) {
         if (!remark) return remark
         // 将内容按句子或逻辑段落分段,增加换行符以便阅读
@@ -128,9 +140,14 @@
 </script>
 
 <style scoped>
-  .suffix {
-    margin-left: 6px;
-    color: #606266;
+  .actual-hour-row {
+    display: flex;
+    gap: 4px;
+    align-items: center;
+
+    .el-button--mini {
+      flex-shrink: 0;
+    }
   }
   .upload-demo {
     display: block;

+ 284 - 16
src/views/devops/software/components/ReleaseCompleteDialog.vue

@@ -2,20 +2,41 @@
   <el-dialog
     title="发版完成"
     :visible="visible"
-    width="700px"
+    width="800px"
     @close="handleClose"
     @update:visible="$emit('update:visible', $event)">
     <el-form ref="form" label-position="right" label-width="120px" :model="form" :rules="rules" size="medium">
       <!-- 关联研发任务 -->
       <el-form-item label="关联研发任务" prop="devTaskIds">
-        <el-select v-model="form.devTaskIds" filterable multiple placeholder="请选择关联的研发任务" style="width: 100%">
-          <el-option
-            v-for="task in devTaskList"
-            :key="task.id"
-            :label="task.taskNo + ' - ' + task.taskTitle"
-            :value="task.id" />
-        </el-select>
-        <div class="form-tip">选择本次发版包含的研发任务</div>
+        <div class="dev-tasks-section">
+          <div class="dev-tasks-header">
+            <el-button icon="el-icon-plus" size="small" type="primary" @click="handleAddDevTask">新增任务</el-button>
+            <span class="dev-tasks-count">已选择 {{ selectedDevTasks.length }} 个任务</span>
+          </div>
+          <el-table
+            v-if="selectedDevTasks.length > 0"
+            border
+            class="dev-tasks-table"
+            :data="selectedDevTasks"
+            size="small">
+            <el-table-column align="center" label="序号" type="index" width="50" />
+            <el-table-column label="任务编号" prop="taskNo" show-overflow-tooltip width="140" />
+            <el-table-column label="任务标题" min-width="180" prop="taskTitle" show-overflow-tooltip />
+            <el-table-column label="任务类型" width="100">
+              <template slot-scope="{ row }">
+                <el-tag size="mini" type="info">{{ getTaskTypeLabel(row.taskType) }}</el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="负责人" prop="opsUserName" show-overflow-tooltip width="100" />
+            <el-table-column align="center" fixed="right" label="操作" width="80">
+              <template slot-scope="{ $index }">
+                <el-button size="mini" type="danger" @click="handleRemoveDevTask($index)">删除</el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+          <el-empty v-else description="暂无关联任务,请点击上方按钮添加" :image-size="60" />
+        </div>
+        <div class="form-tip">选择本次发版包含的研发任务(仅显示未发版的任务)</div>
       </el-form-item>
 
       <!-- 发版工时 -->
@@ -52,12 +73,76 @@
       <el-button @click="handleClose">取消</el-button>
       <el-button :loading="submitLoading" type="primary" @click="handleSubmit">提交</el-button>
     </div>
+
+    <!-- 选择研发任务弹窗 -->
+    <el-dialog append-to-body title="选择研发任务" :visible.sync="taskSelectDialogVisible" width="900px">
+      <div class="task-select-content">
+        <div class="task-select-header">
+          <el-input
+            v-model="taskSearchKeyword"
+            clearable
+            placeholder="搜索任务编号或标题"
+            prefix-icon="el-icon-search"
+            size="small"
+            style="width: 300px"
+            @input="handleTaskSearch" />
+        </div>
+        <el-table
+          ref="taskSelectTable"
+          v-loading="taskSelectLoading"
+          border
+          :data="filteredDevTaskList"
+          height="400"
+          size="small"
+          @selection-change="handleTaskSelectionChange">
+          <el-table-column align="center" type="selection" width="55" />
+          <el-table-column label="任务编号" prop="taskNo" show-overflow-tooltip width="140" />
+          <el-table-column label="任务标题" min-width="200" prop="taskTitle" show-overflow-tooltip />
+          <el-table-column label="任务类型" width="100">
+            <template slot-scope="{ row }">
+              <el-tag size="mini" type="info">{{ getTaskTypeLabel(row.taskType || row.task_type) }}</el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="任务状态" width="100">
+            <template slot-scope="{ row }">
+              <el-tag size="mini" :type="getTaskStatusTagType(row.taskStatus || row.task_status)">
+                {{ getTaskStatusLabel(row.taskStatus || row.task_status) }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column label="负责人" prop="opsUserName" show-overflow-tooltip width="100" />
+          <el-table-column label="创建时间" width="150">
+            <template slot-scope="{ row }">
+              {{ row.createdTime ? parseTime(row.createdTime, '{y}-{m}-{d}') : '-' }}
+            </template>
+          </el-table-column>
+        </el-table>
+        <div class="task-select-pagination">
+          <el-pagination
+            background
+            :current-page.sync="taskSelectPageNum"
+            layout="total, sizes, prev, pager, next"
+            :page-size="taskSelectPageSize"
+            :page-sizes="[10, 20, 50]"
+            :total="taskSelectTotal"
+            @current-change="handleTaskSelectPageChange"
+            @size-change="handleTaskSelectSizeChange" />
+        </div>
+      </div>
+      <div slot="footer">
+        <el-button @click="taskSelectDialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="confirmTaskSelection">确定 (已选 {{ taskSelection.length }} 个)</el-button>
+      </div>
+    </el-dialog>
   </el-dialog>
 </template>
 
 <script>
   import attachmentUploadMixin from '@/mixins/attachmentUpload'
   import opsEventTaskApi from '@/api/devops/opsEventTask'
+  import dictApi from '@/api/system/dict'
+  import { parseTime } from '@/utils'
+  import { taskStatusTagTypes, getTagType } from '@/config/devopsTagTypes'
 
   export default {
     name: 'ReleaseCompleteDialog',
@@ -76,23 +161,82 @@
           remark: '',
           attachments: [],
         },
-        devTaskList: [],
+        selectedDevTasks: [], // 已选择的研发任务列表
+        devTaskList: [], // 所有可选的研发任务
+        taskTypeOptions: [],
+        taskStatusOptions: [],
         submitLoading: false,
         rules: {
           actualWorkHour: [{ required: true, message: '请输入发版工时', trigger: 'blur' }],
-          devTaskIds: [{ required: true, message: '请选择关联的研发任务', trigger: 'change' }],
+          devTaskIds: [{ required: true, message: '请选择关联的研发任务', trigger: 'change', type: 'array', min: 1 }],
         },
+        // 任务选择弹窗
+        taskSelectDialogVisible: false,
+        taskSelectLoading: false,
+        taskSelectPageNum: 1,
+        taskSelectPageSize: 20,
+        taskSelectTotal: 0,
+        taskSearchKeyword: '',
+        taskSelection: [], // 弹窗中选中的任务
       }
     },
+    computed: {
+      // 过滤后的任务列表(用于搜索)
+      filteredDevTaskList() {
+        if (!this.taskSearchKeyword.trim()) {
+          return this.devTaskList
+        }
+        const keyword = this.taskSearchKeyword.trim().toLowerCase()
+        return this.devTaskList.filter(
+          (task) =>
+            (task.taskNo && task.taskNo.toLowerCase().includes(keyword)) ||
+            (task.taskTitle && task.taskTitle.toLowerCase().includes(keyword))
+        )
+      },
+    },
     watch: {
       visible(val) {
         if (val) {
-          this.fetchDevTasks()
           this.resetForm()
+          // 先加载字典,再加载任务列表,确保字典数据就绪
+          this.getDicts().then(() => {
+            this.fetchDevTasks()
+          })
         }
       },
     },
     methods: {
+      parseTime,
+      getTaskStatusTagType(status) {
+        return getTagType(taskStatusTagTypes, status, 'info')
+      },
+      // 获取任务类型标签
+      getTaskTypeLabel(taskType) {
+        if (!taskType || !this.taskTypeOptions || this.taskTypeOptions.length === 0) {
+          return taskType || '-'
+        }
+        const item = this.taskTypeOptions.find((opt) => String(opt.key) === String(taskType))
+        return item ? item.value : taskType
+      },
+      // 获取任务状态标签
+      getTaskStatusLabel(taskStatus) {
+        if (!taskStatus || !this.taskStatusOptions || this.taskStatusOptions.length === 0) {
+          return taskStatus || '-'
+        }
+        const item = this.taskStatusOptions.find((opt) => String(opt.key) === String(taskStatus))
+        return item ? item.value : taskStatus
+      },
+      // 获取字典数据
+      async getDicts() {
+        try {
+          const res = await dictApi.getDictDataByTypes(['ops_task_type', 'ops_task_status'])
+          const dicts = res.data || {}
+          this.taskTypeOptions = (dicts.ops_task_type && dicts.ops_task_type.values) || []
+          this.taskStatusOptions = (dicts.ops_task_status && dicts.ops_task_status.values) || []
+        } catch (error) {
+          console.error('获取字典失败', error)
+        }
+      },
       resetForm() {
         this.form = {
           devTaskIds: [],
@@ -100,6 +244,7 @@
           remark: '',
           attachments: [],
         }
+        this.selectedDevTasks = []
         this.resetAttachmentFiles()
         if (this.$refs.form) {
           this.$refs.form.resetFields()
@@ -107,20 +252,109 @@
       },
       async fetchDevTasks() {
         if (!this.projectId) return
+        this.taskSelectLoading = true
         try {
           const params = {
-            pageNum: 1,
-            pageSize: 999,
+            pageNum: this.taskSelectPageNum,
+            pageSize: this.taskSelectPageSize,
             projectId: this.projectId,
-            taskType: ['20'], // 功能开发任务
-            taskStatus: ['30'], // 已完成的任务
+            // 任务类型:10需求评审 20功能开发 25缺陷修复 35 BUG
+            taskType: ['10', '20', '25', '35'],
+            // 只查询已完成状态的任务
+            taskStatus: ['30'],
+            // 查询release_version为空的任务(未发版的任务)
+            releaseVersionEmpty: true,
           }
           const res = await opsEventTaskApi.getList(params)
           this.devTaskList = res.data?.list || []
+          this.taskSelectTotal = res.data?.total || 0
         } catch (error) {
           console.error('获取研发任务失败', error)
           this.$message.error('获取研发任务列表失败')
+        } finally {
+          this.taskSelectLoading = false
+        }
+      },
+      // 打开新增任务弹窗
+      handleAddDevTask() {
+        this.taskSelectDialogVisible = true
+        this.taskSearchKeyword = ''
+        this.taskSelection = []
+        this.taskSelectPageNum = 1
+        // 确保字典数据已加载后再加载任务列表
+        if (this.taskTypeOptions.length === 0 || this.taskStatusOptions.length === 0) {
+          this.getDicts().then(() => {
+            this.fetchDevTasks()
+          })
+        } else {
+          this.fetchDevTasks()
+        }
+        // 回显已选择的任务
+        this.$nextTick(() => {
+          if (this.$refs.taskSelectTable) {
+            this.devTaskList.forEach((row) => {
+              if (this.form.devTaskIds.includes(row.id)) {
+                this.$refs.taskSelectTable.toggleRowSelection(row, true)
+              }
+            })
+          }
+        })
+      },
+      // 任务选择变化
+      handleTaskSelectionChange(selection) {
+        this.taskSelection = selection
+      },
+      // 确认任务选择
+      confirmTaskSelection() {
+        // 合并已选择的任务(避免重复)
+        const existingIds = this.selectedDevTasks.map((t) => t.id)
+        const newTasks = this.taskSelection.filter((t) => !existingIds.includes(t.id)).map((t) => this.normalizeTask(t))
+        this.selectedDevTasks = [...this.selectedDevTasks, ...newTasks]
+        this.form.devTaskIds = this.selectedDevTasks.map((t) => t.id)
+        this.taskSelectDialogVisible = false
+        // 触发验证
+        this.$refs.form.validateField('devTaskIds')
+      },
+      // 标准化任务数据(转换字段名)
+      normalizeTask(data = {}) {
+        const pick = (...keys) => {
+          for (const key of keys) {
+            if (data[key] !== undefined && data[key] !== null) {
+              return data[key]
+            }
+          }
+          return ''
         }
+        return {
+          ...data,
+          id: pick('id'),
+          taskNo: pick('taskNo', 'task_no'),
+          taskTitle: pick('taskTitle', 'task_title'),
+          taskType: pick('taskType', 'task_type'),
+          taskStatus: pick('taskStatus', 'task_status'),
+          opsUserName: pick('opsUserName', 'ops_user_name'),
+        }
+      },
+      // 删除已选择的任务
+      handleRemoveDevTask(index) {
+        this.selectedDevTasks.splice(index, 1)
+        this.form.devTaskIds = this.selectedDevTasks.map((t) => t.id)
+        this.$refs.form.validateField('devTaskIds')
+      },
+      // 搜索任务
+      handleTaskSearch() {
+        // 前端搜索,无需重新请求
+      },
+      // 分页变化
+      handleTaskSelectPageChange(val) {
+        this.taskSelectPageNum = val
+        this.fetchDevTasks()
+      },
+      // 每页条数变化
+      handleTaskSelectSizeChange(val) {
+        this.taskSelectPageSize = val
+        this.taskSelectPageNum = 1
+        this.fetchDevTasks()
       },
       formatRemark(remark) {
         if (!remark) return remark
@@ -137,6 +371,10 @@
             this.$message.error('任务ID不能为空')
             return
           }
+          if (this.selectedDevTasks.length === 0) {
+            this.$message.error('请至少选择一个关联研发任务')
+            return
+          }
           this.submitLoading = true
           try {
             // 上传附件
@@ -184,4 +422,34 @@
     color: #909399;
     margin-top: 4px;
   }
+  .dev-tasks-section {
+    border: 1px solid #dcdfe6;
+    border-radius: 4px;
+    padding: 12px;
+    background-color: #fafafa;
+  }
+  .dev-tasks-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 12px;
+  }
+  .dev-tasks-count {
+    font-size: 13px;
+    color: #606266;
+  }
+  .dev-tasks-table {
+    background-color: #fff;
+  }
+  .task-select-content {
+    padding: 0;
+  }
+  .task-select-header {
+    margin-bottom: 16px;
+  }
+  .task-select-pagination {
+    margin-top: 16px;
+    display: flex;
+    justify-content: flex-end;
+  }
 </style>

+ 8 - 8
src/views/devops/software/components/ScheduleDialog.vue

@@ -61,22 +61,22 @@
             <el-form-item label="计划开始时间" prop="planStartTime">
               <el-date-picker
                 v-model="form.planStartTime"
-                format="yyyy-MM-dd HH:mm"
-                placeholder="选择日期时间"
+                format="yyyy-MM-dd"
+                placeholder="选择日期"
                 style="width: 100%"
-                type="datetime"
-                value-format="yyyy-MM-dd HH:mm:ss" />
+                type="date"
+                value-format="yyyy-MM-dd" />
             </el-form-item>
           </el-col>
           <el-col :span="12">
             <el-form-item label="计划结束时间" prop="planEndTime">
               <el-date-picker
                 v-model="form.planEndTime"
-                format="yyyy-MM-dd HH:mm"
-                placeholder="选择日期时间"
+                format="yyyy-MM-dd"
+                placeholder="选择日期"
                 style="width: 100%"
-                type="datetime"
-                value-format="yyyy-MM-dd HH:mm:ss" />
+                type="date"
+                value-format="yyyy-MM-dd" />
             </el-form-item>
           </el-col>
         </el-row>

+ 127 - 12
src/views/devops/software/components/TaskDetailDialog.vue

@@ -1,6 +1,7 @@
 <template>
   <div>
     <el-dialog
+      append-to-body
       class="task-detail-dialog"
       :close-on-click-modal="false"
       :show-close="false"
@@ -189,8 +190,8 @@
                       format="yyyy-MM-dd"
                       placeholder="选择日期"
                       style="width: 100%"
-                      type="datetime"
-                      value-format="yyyy-MM-dd HH:mm:ss" />
+                      type="date"
+                      value-format="yyyy-MM-dd" />
                   </el-form-item>
                 </div>
                 <div class="property-item">
@@ -200,8 +201,8 @@
                       format="yyyy-MM-dd"
                       placeholder="选择日期"
                       style="width: 100%"
-                      type="datetime"
-                      value-format="yyyy-MM-dd HH:mm:ss" />
+                      type="date"
+                      value-format="yyyy-MM-dd" />
                   </el-form-item>
                 </div>
                 <!-- 动态显示的属性 -->
@@ -226,6 +227,19 @@
                     {{ (detailData && selectDictLabel(defectTypeOptions, detailData.defectType)) || '-' }}
                   </span>
                 </div>
+                <!-- BUG类型显示历史遗留 -->
+                <div v-if="isBugTask" class="property-item">
+                  <span class="property-label">历史遗留</span>
+                  <span class="property-value">
+                    {{
+                      detailData && detailData.attribute2 === '10'
+                        ? '是'
+                        : detailData && detailData.attribute2 === '20'
+                        ? '否'
+                        : '-'
+                    }}
+                  </span>
+                </div>
                 <div class="property-item">
                   <span class="property-label">创建人</span>
                   <span class="property-value">{{ (detailData && detailData.createdName) || '-' }}</span>
@@ -287,6 +301,24 @@
                 <span class="property-label">完成日期</span>
                 <span class="property-value">{{ (detailData && formatDate(detailData.completeTime)) || '-' }}</span>
               </div>
+              <!-- 需求评审类型显示参会人员 -->
+              <div v-if="isRequirementReviewType" class="property-item property-item--full">
+                <span class="property-label">参会人员</span>
+                <span class="property-value">
+                  <template v-if="participantList.length">
+                    <el-tag
+                      v-for="p in participantList"
+                      :key="p.userId || p.user_id"
+                      size="mini"
+                      style="margin-right: 8px; margin-bottom: 4px"
+                      type="info">
+                      {{ p.userName || p.user_name }}
+                      <template v-if="p.workHour || p.work_hour">({{ p.workHour || p.work_hour }}h)</template>
+                    </el-tag>
+                  </template>
+                  <template v-else>-</template>
+                </span>
+              </div>
               <!-- 动态显示的属性(换行展示) -->
               <!-- 发版类型显示发布版本 -->
               <div v-if="isReleaseTask" class="property-item property-item--full">
@@ -309,6 +341,19 @@
                   {{ (detailData && selectDictLabel(defectTypeOptions, detailData.defectType)) || '-' }}
                 </span>
               </div>
+              <!-- BUG类型显示历史遗留 -->
+              <div v-if="isBugTask" class="property-item property-item--full">
+                <span class="property-label">历史遗留</span>
+                <span class="property-value">
+                  {{
+                    detailData && detailData.attribute2 === '10'
+                      ? '是'
+                      : detailData && detailData.attribute2 === '20'
+                      ? '否'
+                      : '-'
+                  }}
+                </span>
+              </div>
               <div class="property-item">
                 <span class="property-label">创建人</span>
                 <span class="property-value">{{ (detailData && detailData.createdName) || '-' }}</span>
@@ -478,6 +523,8 @@
         relatedTaskList: [],
         releaseDialogVisible: false,
         workHourListDialogVisible: false,
+        // 参会人员列表(需求评审类型)
+        participantList: [],
         // 快速登记相关
         showQuickInput: false,
         quickEditor: null,
@@ -564,7 +611,11 @@
         return this.detailData && String(this.detailData.taskType) === '35'
       },
       isReleaseTask() {
-        return this.detailData && String(this.detailData.taskType) === '40'
+        return this.detailData && String(this.detailData.taskType) === '38'
+      },
+      // 是否为需求评审类型
+      isRequirementReviewType() {
+        return this.detailData && String(this.detailData.taskType) === '10'
       },
     },
     watch: {
@@ -635,8 +686,9 @@
         this.activeTab = 'record'
         this.fetchRecordList()
         this.fetchAttachmentList()
+        this.fetchParticipantList()
         // 如果是发版类型,查询关联任务
-        if (String(this.detailData.taskType) === '40') {
+        if (String(this.detailData.taskType) === '38') {
           this.fetchRelatedTaskList()
         } else {
           this.relatedTaskList = []
@@ -680,7 +732,10 @@
       handleClose() {
         this.$emit('update:visible', false)
         this.releaseDialogVisible = false
+        this.workHourListDialogVisible = false
         this.isTaskDescExpanded = false
+        // 重置参会人员
+        this.participantList = []
         // 重置排期表单
         this.scheduleForm = {
           taskDesc: '',
@@ -749,17 +804,61 @@
           this.$message.error('任务ID不能为空')
           return
         }
+        let latestDetail = null
+        const normalizeTaskDetail = (data = {}) => {
+          const pick = (...keys) => {
+            for (const key of keys) {
+              if (data[key] !== undefined && data[key] !== null) {
+                return data[key]
+              }
+            }
+            return ''
+          }
+
+          return {
+            ...data,
+            taskStatus: pick('taskStatus', 'task_status'),
+            opsUserId: pick('opsUserId', 'ops_user_id'),
+            opsUserName: pick('opsUserName', 'ops_user_name'),
+            planStartTime: pick('planStartTime', 'plan_start_time'),
+            planEndTime: pick('planEndTime', 'plan_end_time'),
+            estimateWorkHour: pick('estimateWorkHour', 'estimate_work_hour'),
+          }
+        }
+
+        try {
+          const res = await opsEventTaskApi.getById(this.detailData.id)
+          if (res.code === 200 && res.data) {
+            latestDetail = normalizeTaskDetail({
+              ...this.detailData,
+              ...res.data,
+            })
+          }
+        } catch (error) {
+          this.$message.error('获取任务最新状态失败')
+          return
+        }
+
+        const latestStatus = String((latestDetail && latestDetail.taskStatus) || this.detailData.taskStatus || '')
+        if (['30', '90'].includes(latestStatus)) {
+          const statusLabel = latestStatus === '30' ? '已完成' : '作废'
+          this.$message.warning(`当前任务状态为${statusLabel},不允许编辑`)
+          this.$emit('refresh')
+          this.handleClose()
+          return
+        }
+
         this.submitLoading = true
 
         // 保存旧值用于变更记录(仅对比非描述字段)
         const oldData = {
-          opsUserName: this.detailData.opsUserName || '',
-          opsUserId: this.detailData.opsUserId || '',
-          planStartTime: this.detailData.planStartTime || '',
-          planEndTime: this.detailData.planEndTime || '',
+          opsUserName: (latestDetail && latestDetail.opsUserName) || this.detailData.opsUserName || '',
+          opsUserId: (latestDetail && latestDetail.opsUserId) || this.detailData.opsUserId || '',
+          planStartTime: (latestDetail && latestDetail.planStartTime) || this.detailData.planStartTime || '',
+          planEndTime: (latestDetail && latestDetail.planEndTime) || this.detailData.planEndTime || '',
           estimateWorkHour:
-            this.detailData.estimateWorkHour !== undefined && this.detailData.estimateWorkHour !== null
-              ? Number(this.detailData.estimateWorkHour)
+            latestDetail && latestDetail.estimateWorkHour !== undefined && latestDetail.estimateWorkHour !== null
+              ? Number(latestDetail.estimateWorkHour)
               : null,
         }
 
@@ -821,6 +920,22 @@
         }
         return changes
       },
+      async fetchParticipantList() {
+        if (!this.detailData || !this.detailData.id) return
+        if (!this.isRequirementReviewType) {
+          this.participantList = []
+          return
+        }
+        try {
+          const res = await opsEventTaskApi.getWorkHourList(this.detailData.id)
+          if (res.code === 200) {
+            this.participantList = res.data?.list || []
+          }
+        } catch (error) {
+          console.error('获取参会人员列表失败:', error)
+          this.participantList = []
+        }
+      },
       handleViewReleaseDetail() {
         this.releaseDialogVisible = true
       },

+ 128 - 11
src/views/devops/software/components/TaskEditDialog.vue

@@ -135,13 +135,11 @@
       <el-row :gutter="20">
         <el-col :span="12">
           <el-form-item label="预估工时" prop="estimateWorkHour">
-            <el-input-number
-              v-model="form.estimateWorkHour"
-              :min="0"
-              placeholder="请输入预估工时"
-              :precision="1"
-              :step="0.5"
-              style="width: 100%" />
+            <div class="estimate-hour-row">
+              <el-input-number v-model="form.estimateWorkHour" :min="0" :precision="1" style="width: 120px" />
+              <el-button size="mini" @click="addEstimateHour(0.5)">+0.5</el-button>
+              <el-button size="mini" @click="addEstimateHour(2)">+2</el-button>
+            </div>
           </el-form-item>
         </el-col>
         <el-col v-if="isBugType" :span="12">
@@ -158,6 +156,36 @@
         </el-col>
       </el-row>
 
+      <el-row v-if="isBugType" :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="历史遗留" prop="attribute2">
+            <el-radio-group v-model="form.attribute2">
+              <el-radio label="10">是</el-radio>
+              <el-radio label="20">否</el-radio>
+            </el-radio-group>
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <!-- 参会人员选择(仅需求评审类型显示) -->
+      <el-form-item v-if="isRequirementReviewType" label="参会人员" prop="participantIds">
+        <el-select
+          v-model="form.participantIds"
+          clearable
+          filterable
+          :loading="loadingUsers"
+          multiple
+          placeholder="请选择参会人员"
+          remote
+          :remote-method="remoteFetchUserList"
+          reserve-keyword
+          style="width: 100%"
+          @change="handleParticipantChange">
+          <el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
+        </el-select>
+        <div style="font-size: 12px; color: #909399; margin-top: 4px">参会人员将自动记录工时,工时与预估工时相同</div>
+      </el-form-item>
+
       <!-- 附件上传 -->
       <el-form-item label="附件上传">
         <el-upload
@@ -267,7 +295,12 @@
           estimateWorkHour: 0,
           defectType: '',
           releaseVersion: '',
+          // in data resetForm:
+          attribute2: '20',
           taskParentId: 0,
+          // 参会人员(需求评审类型)
+          participantIds: [],
+          participantList: [],
         },
         rules: {
           projectId: [{ required: true, message: '请选择所属项目', trigger: 'change' }],
@@ -287,7 +320,11 @@
         return this.form.taskType === '35'
       },
       isReleaseType() {
-        return this.form.taskType === '40'
+        return this.form.taskType === '38'
+      },
+      // 是否为需求评审类型
+      isRequirementReviewType() {
+        return this.form.taskType === '10'
       },
     },
     watch: {
@@ -295,6 +332,21 @@
         this.internalVisible = val
         if (val) {
           this.initForm()
+          this.$nextTick(() => {
+            // 确保BUG登记时带入测试任务信息
+            if (this.taskData && this.taskData.taskTitle) {
+              this.form.taskTitle = this.taskData.taskTitle
+            }
+            if (this.taskData && this.taskData.functionName) {
+              this.form.functionName = this.taskData.functionName
+            }
+            if (this.taskData && this.taskData.opsUserId) {
+              this.form.opsUserId = parseInt(this.taskData.opsUserId)
+            }
+            if (this.taskData && this.taskData.opsUserName) {
+              this.form.opsUserName = this.taskData.opsUserName
+            }
+          })
         }
       },
       internalVisible(val) {
@@ -302,11 +354,17 @@
       },
       isBugType(val) {
         if (val) {
-          // BUG 类型时要求填写缺陷类型
+          // BUG 类型时要求填写缺陷类型和是否遗留
           this.$set(this.rules, 'defectType', [{ required: true, message: '请选择缺陷类型', trigger: 'change' }])
+          this.$set(this.rules, 'attribute2', [{ required: true, message: '请选择是否历史遗留', trigger: 'change' }])
+          // 默认设置为"否"
+          if (!this.form.attribute2) {
+            this.form.attribute2 = '20'
+          }
         } else {
           // 当不是 BUG 类型时,移除校验并清空值
           this.$delete(this.rules, 'defectType')
+          this.$delete(this.rules, 'attribute2')
           this.form.defectType = ''
         }
         this.clearDynamicFieldValidate()
@@ -320,6 +378,18 @@
         }
         this.clearDynamicFieldValidate()
       },
+      isRequirementReviewType(val) {
+        if (val) {
+          this.$set(this.rules, 'participantIds', [
+            { required: true, message: '请选择参会人员', trigger: 'change', type: 'array' },
+          ])
+        } else {
+          this.$delete(this.rules, 'participantIds')
+          this.form.participantIds = []
+          this.form.participantList = []
+        }
+        this.clearDynamicFieldValidate()
+      },
     },
     created() {
       this.getOptions()
@@ -346,7 +416,9 @@
       getOptions() {
         Promise.all([this.getDicts('ops_task_type'), this.getDicts('ops_priority'), this.getDicts('ops_defect_type')])
           .then(([taskType, priority, defectType]) => {
-            this.taskTypeOptions = taskType.data.values || []
+            const allTaskTypes = taskType.data.values || []
+            const allowedTypes = ['10', '20', '21', '30', '35', '38']
+            this.taskTypeOptions = allTaskTypes.filter((dict) => allowedTypes.includes(dict.key))
             this.priorityOptions = priority.data.values || []
             this.defectTypeOptions = defectType.data.values || []
           })
@@ -486,6 +558,15 @@
         const user = this.userOptions.find((u) => u.value === val)
         this.form.opsUserName = user ? user.label : ''
       },
+      handleParticipantChange(selectedIds) {
+        this.form.participantList = selectedIds.map((id) => {
+          const user = this.userOptions.find((u) => u.value === id)
+          return {
+            userId: id,
+            userName: user ? user.label : '',
+          }
+        })
+      },
       onEditorCreated(editor) {
         this.editor = editor
       },
@@ -500,10 +581,14 @@
       handleTaskTypeChange(val) {
         if (val === '35') {
           this.$message.info('已选择BUG类型,请填写缺陷类型')
-        } else if (val === '40') {
+        } else if (val === '38') {
           this.$message.info('已选择系统发版,请填写版本信息')
         }
       },
+      addEstimateHour(hours) {
+        const current = Number(this.form.estimateWorkHour) || 0
+        this.form.estimateWorkHour = Math.round((current + hours) * 10) / 10
+      },
       initForm() {
         if (this.taskData && this.taskData.id) {
           const d = this.taskData
@@ -522,7 +607,10 @@
             estimateWorkHour: d.estimateWorkHour || 0,
             defectType: d.defectType != null ? String(d.defectType) : '',
             releaseVersion: d.releaseVersion || '',
+            attribute2: d.attribute2 != null ? String(d.attribute2) : '20',
             taskParentId: d.taskParentId || 0,
+            participantIds: d.participants ? d.participants.map((p) => p.userId) : [],
+            participantList: d.participants || [],
           }
           this.initAttachmentFiles(d.attachments || [])
         } else {
@@ -533,6 +621,12 @@
           }
           // 处理从测试不通过跳转过来的情况
           if (this.taskData) {
+            if (this.taskData.taskTitle) {
+              this.form.taskTitle = this.taskData.taskTitle
+            }
+            if (this.taskData.functionName) {
+              this.form.functionName = this.taskData.functionName
+            }
             if (this.taskData.taskParentId) {
               this.form.taskParentId = this.taskData.taskParentId
             }
@@ -543,6 +637,12 @@
             if (this.taskData.projectId) {
               this.form.projectId = String(this.taskData.projectId)
             }
+            if (this.taskData.opsUserId) {
+              this.form.opsUserId = parseInt(this.taskData.opsUserId)
+            }
+            if (this.taskData.opsUserName) {
+              this.form.opsUserName = this.taskData.opsUserName
+            }
           }
         }
         this.searchAllProjectsByStatus('')
@@ -576,7 +676,11 @@
           estimateWorkHour: 0,
           defectType: '',
           releaseVersion: '',
+          // in data resetForm:
+          attribute2: '20',
           taskParentId: 0,
+          participantIds: [],
+          participantList: [],
         }
         this.isBugTaskFromFail = false // 重置标记
         this.projectOptions = []
@@ -630,8 +734,11 @@
             estimateWorkHour: this.form.estimateWorkHour,
             defectType: this.form.defectType,
             releaseVersion: this.form.releaseVersion,
+            attribute2: this.form.attribute2,
             taskParentId: this.form.taskParentId || 0,
             attachments: uploadedAttachments,
+            // 参会人员(仅需求评审类型提交)
+            participants: this.isRequirementReviewType ? this.form.participantList : [],
           }
 
           if (this.form.id) {
@@ -661,4 +768,14 @@
   .task-edit-form {
     padding-right: 10px;
   }
+
+  .estimate-hour-row {
+    display: flex;
+    gap: 4px;
+    align-items: center;
+
+    .el-button--mini {
+      flex-shrink: 0;
+    }
+  }
 </style>

+ 100 - 11
src/views/devops/software/components/WorkHourDialog.vue

@@ -6,7 +6,7 @@
     width="480px"
     @close="handleClose"
     @update:visible="$emit('update:visible', $event)">
-    <el-form ref="form" class="work-hour-dialog-form" label-width="90px" :model="form" :rules="rules">
+    <el-form ref="form" class="work-hour-dialog-form" label-width="110px" :model="form" :rules="dynamicRules">
       <el-form-item label="工作日期" prop="workDate">
         <el-date-picker
           v-model="form.workDate"
@@ -16,7 +16,29 @@
           type="date"
           value-format="yyyy-MM-dd" />
       </el-form-item>
-      <el-form-item label="实际工时" prop="actualHour">
+
+      <!-- 已完成任务:显示当前实际工时(只读)和调整后工时 -->
+      <template v-if="isTaskCompleted">
+        <el-form-item label="当前实际工时">
+          <el-input disabled style="width: 120px" :value="currentActualWorkHour + 'h'" />
+        </el-form-item>
+        <el-form-item label="调整后工时" prop="adjustedWorkHour">
+          <div class="actual-hour-row">
+            <el-input-number
+              v-model="form.adjustedWorkHour"
+              controls-position="right"
+              :min="currentActualWorkHour"
+              :precision="1"
+              style="width: 120px" />
+            <el-button size="mini" @click="addAdjustedHour(0.5)">+0.5</el-button>
+            <el-button size="mini" @click="addAdjustedHour(2)">+2</el-button>
+            <span class="hint-text">只能增加不能减少</span>
+          </div>
+        </el-form-item>
+      </template>
+
+      <!-- 常规任务:显示新增工时 -->
+      <el-form-item v-else label="实际工时" prop="actualHour">
         <div class="actual-hour-row">
           <el-input-number
             v-model="form.actualHour"
@@ -28,6 +50,7 @@
           <el-button size="mini" @click="addActualHour(2)">+2</el-button>
         </div>
       </el-form-item>
+
       <el-form-item label="工作进展" prop="remark">
         <el-input v-model="form.remark" placeholder="请输入工作进展" :rows="3" type="textarea" />
       </el-form-item>
@@ -54,23 +77,58 @@
         type: [Number, String],
         default: null,
       },
+      // 任务状态(用于判断是否已完成)
+      taskStatus: {
+        type: [Number, String],
+        default: '',
+      },
+      // 当前实际工时(已完成任务使用)
+      currentActualWorkHour: {
+        type: Number,
+        default: 0,
+      },
     },
     data() {
       return {
         form: {
           workDate: parseTime(new Date(), '{y}-{m}-{d}'),
           actualHour: null,
+          adjustedWorkHour: null,
           remark: '',
         },
-        rules: {
+      }
+    },
+    computed: {
+      isTaskCompleted() {
+        return String(this.taskStatus) === '30'
+      },
+      dynamicRules() {
+        const rules = {
           workDate: [{ required: true, message: '请选择工作日期', trigger: 'change' }],
-          actualHour: [{ required: true, type: 'number', message: '请输入实际工时', trigger: 'blur' }],
           remark: [
             { required: true, message: '请输入工作进展', trigger: 'blur' },
             { max: 500, message: '长度不能超过500字符', trigger: 'blur' },
           ],
-        },
-      }
+        }
+        if (this.isTaskCompleted) {
+          rules.adjustedWorkHour = [
+            { required: true, type: 'number', message: '请输入调整后实际工作量', trigger: 'blur' },
+            {
+              validator: (rule, value, callback) => {
+                if (value < this.currentActualWorkHour) {
+                  callback(new Error(`实际工作量不能小于当前值 ${this.currentActualWorkHour}h`))
+                } else {
+                  callback()
+                }
+              },
+              trigger: 'blur',
+            },
+          ]
+        } else {
+          rules.actualHour = [{ required: true, type: 'number', message: '请输入实际工时', trigger: 'blur' }]
+        }
+        return rules
+      },
     },
     watch: {
       visible(val) {
@@ -84,6 +142,7 @@
         this.form = {
           workDate: parseTime(new Date(), '{y}-{m}-{d}'),
           actualHour: null,
+          adjustedWorkHour: this.isTaskCompleted ? this.currentActualWorkHour : null,
           remark: '',
         }
         this.$nextTick(() => {
@@ -94,6 +153,10 @@
         const current = Number(this.form.actualHour) || 0
         this.form.actualHour = Math.round((current + hours) * 10) / 10
       },
+      addAdjustedHour(hours) {
+        const current = Number(this.form.adjustedWorkHour) || this.currentActualWorkHour
+        this.form.adjustedWorkHour = Math.round((current + hours) * 10) / 10
+      },
       handleSubmit() {
         this.$refs.form.validate(async (valid) => {
           if (!valid) return
@@ -102,11 +165,31 @@
             return
           }
           try {
-            const payload = {
-              taskId: this.taskId,
-              workDate: this.form.workDate,
-              actualHour: this.form.actualHour,
-              remark: this.form.remark,
+            let payload
+            if (this.isTaskCompleted) {
+              // 已完成任务:计算增量
+              const delta = this.form.adjustedWorkHour - this.currentActualWorkHour
+              if (delta <= 0) {
+                this.$message.warning('调整后工时必须大于当前实际工时')
+                return
+              }
+              payload = {
+                taskId: this.taskId,
+                workDate: this.form.workDate,
+                actualHour: delta,
+                remark: this.form.remark,
+                isCompletedTask: true,
+                newTotalWorkHour: this.form.adjustedWorkHour,
+              }
+            } else {
+              // 常规任务
+              payload = {
+                taskId: this.taskId,
+                workDate: this.form.workDate,
+                actualHour: this.form.actualHour,
+                remark: this.form.remark,
+                isCompletedTask: false,
+              }
             }
             await opsEventTaskApi.addWorkHour(payload)
             this.$message.success('工时登记成功')
@@ -139,4 +222,10 @@
       flex-shrink: 0;
     }
   }
+
+  .hint-text {
+    margin-left: 8px;
+    font-size: 12px;
+    color: #f56c6c;
+  }
 </style>

+ 155 - 11
src/views/devops/software/index.vue

@@ -202,11 +202,14 @@
             height="100%"
             stripe
             style="width: 100%"
-            @row-click="handleRowClick">
+            @row-dblclick="handleRowClick">
             <el-table-column align="center" type="index" width="50" />
-            <el-table-column v-if="isColumnVisible('taskNo')" label="任务编号" show-overflow-tooltip width="140">
+            <el-table-column v-if="isColumnVisible('taskNo')" label="任务编号" show-overflow-tooltip width="180">
               <template slot-scope="{ row }">
-                {{ row.taskNo || '-' }}
+                <span class="cell-with-copy">
+                  <span>{{ row.taskNo || '-' }}</span>
+                  <i v-if="row.taskNo" class="el-icon-document-copy copy-btn" @click.stop="handleCopy(row.taskNo)" />
+                </span>
               </template>
             </el-table-column>
             <el-table-column
@@ -216,7 +219,13 @@
               :render-header="renderSortableHeader('任务标题', 'taskTitle')"
               show-overflow-tooltip>
               <template slot-scope="{ row }">
-                <span class="task-title-text">{{ row.taskTitle || '-' }}</span>
+                <span class="cell-with-copy">
+                  <span class="task-title-text">{{ row.taskTitle || '-' }}</span>
+                  <i
+                    v-if="row.taskTitle"
+                    class="el-icon-document-copy copy-btn"
+                    @click.stop="handleCopy(row.taskTitle)" />
+                </span>
               </template>
             </el-table-column>
             <el-table-column
@@ -324,6 +333,11 @@
                 {{ selectDictLabel(defectTypeOptions, row.defectType) }}
               </template>
             </el-table-column>
+            <el-table-column v-if="isColumnVisible('attribute2')" label="历史遗留" width="100">
+              <template slot-scope="{ row }">
+                {{ row.attribute2 === '10' ? '是' : row.attribute2 === '20' ? '否' : '-' }}
+              </template>
+            </el-table-column>
             <el-table-column
               v-if="isColumnVisible('releaseVersion')"
               label="发布版本"
@@ -334,7 +348,7 @@
                 <div class="release-version-cell">
                   <span>{{ row.releaseVersion || '-' }}</span>
                   <el-tooltip
-                    v-if="String(row.taskType) === '40' && row.releaseVersion"
+                    v-if="String(row.taskType) === '38' && row.releaseVersion"
                     content="查看关联任务"
                     placement="top">
                     <el-button
@@ -449,7 +463,7 @@
       :preset-project="currentProjectOption"
       :task-data="editData"
       :visible.sync="editDialogVisible"
-      @refresh="fetchData" />
+      @refresh="handleEditDialogRefresh" />
 
     <!-- 排期使用 TaskDetailDialog 的排期模式 -->
     <complete-dialog
@@ -482,7 +496,9 @@
 
     <work-hour-dialog
       v-if="workHourDialogVisible"
+      :current-actual-work-hour="currentRow ? currentRow.actualWorkHour || 0 : 0"
       :task-id="currentTaskId"
+      :task-status="currentRow ? currentRow.taskStatus : ''"
       :visible.sync="workHourDialogVisible"
       @refresh="fetchData" />
 
@@ -540,6 +556,7 @@
     { key: 'estimateWorkHour', label: '预估工时' },
     { key: 'actualWorkHour', label: '实际工时' },
     { key: 'defectType', label: '缺陷类型' },
+    { key: 'attribute2', label: '历史遗留' },
     { key: 'releaseVersion', label: '发布版本' },
     { key: 'projectName', label: '项目名称' },
     { key: 'createdTime', label: '创建时间' },
@@ -614,6 +631,8 @@
         isOpeningFailTask: false, // 标记是否是从不通过按钮打开的流程
         failTaskRow: null, // 保存不通过时的任务行数据
         testResult: '', // 测试结果:pass/fail
+        pendingBugParentTaskId: null, // 待更新attribute1的父测试任务ID
+        editDialogOpenedForBug: false, // 编辑弹窗是否是为BUG登记打开的
         // 项目信息弹窗
         projectInfoDialogVisible: false,
         selectedProjectId: '',
@@ -662,6 +681,15 @@
         return this.projects.find((project) => project.id === this.selectedProject) || null
       },
     },
+    watch: {
+      editDialogVisible(val) {
+        // BUG登记弹窗关闭时(取消未保存),清理pending状态
+        if (!val && this.editDialogOpenedForBug) {
+          this.pendingBugParentTaskId = null
+          this.editDialogOpenedForBug = false
+        }
+      },
+    },
     mounted() {
       this.initVisibleColumns()
       this.getOptions()
@@ -956,6 +984,8 @@
       getRowActions(row) {
         const status = String(row.taskStatus)
         const taskType = String(row.taskType)
+        // 支持工时登记的任务类型:20功能开发 25缺陷修复 30功能测试 35BUG
+        const ALLOW_WORK_HOUR_TYPES = ['20', '25', '30', '35']
         const actionMap = {
           10: [{ key: 'schedule', label: '排期' }],
           20: [
@@ -965,7 +995,8 @@
                   { key: 'fail', label: '不通过', tone: 'warning' },
                 ]
               : [{ key: 'complete', label: '完成' }]),
-            { key: 'workHour', label: '工时' },
+            // 只对支持工时登记的任务类型显示工时按钮
+            ...(ALLOW_WORK_HOUR_TYPES.includes(taskType) ? [{ key: 'workHour', label: '工时' }] : []),
             { key: 'pause', label: '暂停' },
             { key: 'block', label: '阻塞', tone: 'warning' },
             { key: 'cancel', label: '作废', tone: 'danger' },
@@ -975,9 +1006,12 @@
             { key: 'block', label: '阻塞', tone: 'warning' },
             { key: 'cancel', label: '作废', tone: 'danger' },
           ],
+          30: [
+            // 功能测试已完成且未登记BUG,显示BUG按钮
+            ...(taskType === '30' && String(row.attribute1) !== '10' ? [{ key: 'bug', label: 'BUG' }] : []),
+          ],
           70: [
             { key: 'start', label: '开始' },
-            { key: 'pause', label: '暂停' },
             { key: 'cancel', label: '作废', tone: 'danger' },
           ],
         }
@@ -1045,6 +1079,8 @@
           estimateWorkHour: pick('estimateWorkHour', 'estimate_work_hour'),
           actualWorkHour: pick('actualWorkHour', 'actual_work_hour'),
           defectType: pick('defectType', 'defect_type'),
+          attribute1: pick('attribute1'),
+          attribute2: pick('attribute2'),
           releaseVersion: pick('releaseVersion', 'release_version'),
           projectName: pick('projectName', 'project_name'),
           createdName: pick('createdName', 'created_name'),
@@ -1060,6 +1096,7 @@
           schedule: this.handleSchedule,
           complete: this.handleComplete,
           workHour: this.handleWorkHour,
+          bug: this.handleBug,
           pass: () => {
             this.handleComplete(row, 'pass')
           }, // 通过按钮传参数
@@ -1086,7 +1123,7 @@
       // 点击行显示详情(支持编辑)
       handleRowClick(row) {
         this.detailData = this.normalizeTaskDetail(row)
-        this.detailDialogMode = 'edit'
+        this.detailDialogMode = ['30', '90'].includes(String(row.taskStatus)) ? 'view' : 'edit'
         this.detailDialogVisible = true
       },
       // 查看发布版本关联任务详情
@@ -1107,10 +1144,79 @@
         this.editData = null
         this.editDialogVisible = true
       },
+      // BUG登记弹窗保存成功后,刷新列表并更新父测试任务attribute1
+      handleEditDialogRefresh() {
+        this.fetchData()
+        this.$nextTick(() => {
+          if (this.pendingBugParentTaskId && this.editDialogOpenedForBug) {
+            this.updateTestTaskAttribute1(this.pendingBugParentTaskId)
+            this.pendingBugParentTaskId = null
+            this.editDialogOpenedForBug = false
+          }
+        })
+      },
+      // 通过当前任务ID逐级查找父父功能开发任务的负责人
+      async fetchParentTaskOwner(currentTaskId) {
+        if (!currentTaskId) return { opsUserId: null, opsUserName: '' }
+        try {
+          // 1)取当前任务完整详情(确保拿到 taskParentId)
+          const curRes = await opsEventTaskApi.getById(currentTaskId)
+          if (curRes.code !== 200 || !curRes.data) return { opsUserId: null, opsUserName: '' }
+          // 响应格式为 { code, data: { data: { ...任务数据... } } }
+          const taskDetail = curRes.data && curRes.data.data ? curRes.data.data : curRes.data
+          const firstPid = taskDetail.taskParentId || taskDetail.task_parent_id
+          if (!firstPid) return { opsUserId: null, opsUserName: '' }
+          // 2)取父任务
+          const parentRes = await opsEventTaskApi.getById(firstPid)
+          if (parentRes.code !== 200 || !parentRes.data) return { opsUserId: null, opsUserName: '' }
+          const parentDetail = parentRes.data && parentRes.data.data ? parentRes.data.data : parentRes.data
+          const secondPid = parentDetail.taskParentId || parentDetail.task_parent_id
+          if (!secondPid) return { opsUserId: null, opsUserName: '' }
+          // 3)取祖父任务(功能开发任务)的负责人
+          const grandRes = await opsEventTaskApi.getById(secondPid)
+          if (grandRes.code === 200 && grandRes.data) {
+            const grandDetail = grandRes.data && grandRes.data.data ? grandRes.data.data : grandRes.data
+            return {
+              opsUserId: grandDetail.opsUserId || grandDetail.ops_user_id || null,
+              opsUserName: grandDetail.opsUserName || grandDetail.ops_user_name || '',
+            }
+          }
+        } catch (error) {
+          console.error('获取父任务负责人失败:', error)
+        }
+        return { opsUserId: null, opsUserName: '' }
+      },
+      // 从操作栏直接打开BUG登记
+      async handleBug(row) {
+        this.pendingBugParentTaskId = row.id
+        this.editDialogOpenedForBug = true
+        // 两层查找获取功能开发任务的负责人(当前→父测试→父功能开发)
+        const owner = await this.fetchParentTaskOwner(row.id)
+        this.editData = {
+          taskParentId: row.id,
+          projectId: row.projectId,
+          taskTitle: row.taskTitle || '',
+          functionName: row.functionName || '',
+          taskType: '35',
+          isBugTask: true,
+          opsUserId: owner.opsUserId ? parseInt(owner.opsUserId) : null,
+          opsUserName: owner.opsUserName,
+        }
+        this.editDialogVisible = true
+      },
+      // 更新测试任务的attribute1为10(标记已登记BUG)
+      async updateTestTaskAttribute1(taskId) {
+        try {
+          await opsEventTaskApi.update({ id: taskId, attribute1: '10' })
+        } catch (error) {
+          console.error('更新测试任务attribute1失败:', error)
+        }
+      },
       // 测试不通过:先完成当前任务,再创建新的缺陷任务
       handleFail(row) {
         this.isOpeningFailTask = true
         this.failTaskRow = row
+        this.pendingBugParentTaskId = row.id // 记录待更新attribute1的父任务
         // Add testResult to currentRow
         this.currentRow = { ...row, testResult: 'fail' }
         this.currentTaskId = row.id
@@ -1118,17 +1224,24 @@
         this.completeDialogVisible = true
       },
       // 完成任务成功后,打开新增缺陷任务弹窗
-      handleCompleteSuccess() {
+      async handleCompleteSuccess() {
         this.testResult = '' // 重置测试结果
         if (this.isOpeningFailTask && this.failTaskRow) {
+          // 两层查找获取功能开发任务的负责人(当前→父测试→父功能开发)
+          const owner = await this.fetchParentTaskOwner(this.failTaskRow.id)
           this.editData = {
             taskParentId: this.failTaskRow.id,
             projectId: this.failTaskRow.projectId,
+            taskTitle: this.failTaskRow.taskTitle || '',
+            functionName: this.failTaskRow.functionName || '',
             taskType: '35', // 默认是BUG类型
             isBugTask: true, // 标记这是从不通过按钮创建的BUG任务,用于禁用任务类型选择
+            opsUserId: owner.opsUserId ? parseInt(owner.opsUserId) : null,
+            opsUserName: owner.opsUserName,
           }
           this.isOpeningFailTask = false
           this.failTaskRow = null
+          this.editDialogOpenedForBug = true // 标记为BUG登记
           this.editDialogVisible = true
         }
       },
@@ -1161,7 +1274,7 @@
         this.testResult = testResult
 
         // 系统发版任务使用专门的完成弹窗
-        if (String(row.taskType) === '40') {
+        if (String(row.taskType) === '38') {
           this.releaseCompleteDialogVisible = true
         } else {
           this.completeDialogVisible = true
@@ -1239,6 +1352,18 @@
         this.selectedProjectId = project.id
         this.projectInfoDialogVisible = true
       },
+      // 复制文本到剪贴板
+      handleCopy(text) {
+        if (!text) return
+        navigator.clipboard
+          .writeText(text)
+          .then(() => {
+            this.$message.success('已复制: ' + text)
+          })
+          .catch(() => {
+            this.$message.error('复制失败')
+          })
+      },
     },
   }
 </script>
@@ -1916,4 +2041,23 @@
     padding: 0;
     line-height: 32px;
   }
+
+  .cell-with-copy {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    max-width: 100%;
+  }
+
+  .copy-btn {
+    flex-shrink: 0;
+    font-size: 13px;
+    color: #c0c4cc;
+    cursor: pointer;
+    transition: color 0.2s;
+
+    &:hover {
+      color: #409eff;
+    }
+  }
 </style>

+ 1 - 1
vue.config.js

@@ -38,7 +38,7 @@ const resolve = (dir) => {
 
 const microServiceProxyPrefix = '/micro-srv-proxy'
 const restApiProxyPrefix = '/api'
-const richTextUploadProxyPrefix = '/richtext-upload'
+const richTextUploadProxyPrefix = '/weed_filer'
 const isHttpUrl = (url) => /^https?:\/\//i.test(url || '')
 
 const proxy = {}