Prechádzať zdrojové kódy

feat: 软件交付任务页面增强 + 交付事件编辑器修复

- 软件交付任务: 增加任务编号搜索、导出功能、项目名列前移
- 列设置面板支持可视化排序字段展示和移除
- 复制功能兼容非 HTTPS 环境(fallback execCommand)
- 任务详情对话框统一为只读模式
- 交付项目事件编辑: 修复 wangEditor v5 富文本初始值同步
- 业务选择器: 增加产品线列、列表打开时重置状态
程健 1 týždeň pred
rodič
commit
9096d735cd

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

@@ -106,4 +106,9 @@ export default {
   getScheduleStats(data) {
     return micro_request.postRequest(basePath, 'OpsEventTask', 'GetScheduleStats', data)
   },
+
+  // 导出任务列表
+  export(query) {
+    return micro_request.postRequest(basePath, 'OpsEventTask', 'Export', query)
+  },
 }

+ 11 - 2
src/components/select/SelectBusiness.vue

@@ -1,5 +1,5 @@
 <template>
-  <el-dialog append-to-body :title="title" :visible.sync="innerVisible" @close="close">
+  <el-dialog append-to-body :title="title" :visible.sync="innerVisible" width="1200px" @close="close">
     <el-row v-if="add" style="margin-top: -30px">
       <el-col :span="24">
         <div style="float: right">
@@ -168,6 +168,11 @@
             width: 'auto',
             prop: 'nboType',
           },
+          {
+            label: '产品线',
+            width: 'auto',
+            prop: 'productLine',
+          },
           {
             label: '项目来源',
             prop: 'nboSource',
@@ -228,8 +233,12 @@
           .catch((err) => console.log(err))
       },
       open() {
+        this.list = []
+        this.listLoading = true
         this.innerVisible = true
-        this.fetchData()
+        this.$nextTick(() => {
+          this.fetchData()
+        })
       },
       close() {
         this.selectRows = []

+ 11 - 0
src/views/devops/deliveryProject/components/DeliveryProjectEventEdit.vue

@@ -304,6 +304,11 @@
       },
       onEditorCreated(editor) {
         this.editor = editor
+        // wangEditor v5 在 Vue 2 中存在 v-model 初始值同步时序问题,
+        // 需要在编辑器创建后显式设置 HTML 内容
+        if (this.form.deliveryEventDesc) {
+          editor.setHtml(this.form.deliveryEventDesc)
+        }
       },
       normalizeFormData(data = {}) {
         return {
@@ -387,6 +392,12 @@
         }
         this.searchProject('')
         this.initAttachmentFiles(this.form.attachments || [])
+        // 编辑器已创建后再次打开对话框时,需要显式设置 HTML 内容
+        this.$nextTick(() => {
+          if (this.editor && this.form.deliveryEventDesc) {
+            this.editor.setHtml(this.form.deliveryEventDesc)
+          }
+        })
       },
       resetForm() {
         const userName = store.getters['user/username']

+ 248 - 34
src/views/devops/software/index.vue

@@ -9,7 +9,16 @@
               v-model="queryForm.taskTitle"
               class="query-input query-input--title"
               clearable
-              placeholder="请输入" />
+              placeholder="请输入"
+              @keyup.enter.native="handleSearch" />
+          </el-form-item>
+          <el-form-item label="任务编号">
+            <el-input
+              v-model="queryForm.taskNo"
+              class="query-input query-input--title"
+              clearable
+              placeholder="请输入"
+              @keyup.enter.native="handleSearch" />
           </el-form-item>
           <el-form-item label="任务类型">
             <el-select
@@ -68,6 +77,9 @@
           </el-button>
           <el-divider direction="vertical" />
           <el-button icon="el-icon-plus" type="success" @click="handleAdd">新增</el-button>
+          <el-button icon="el-icon-download" size="small" style="margin-left: 8px" type="warning" @click="handleExport">
+            导出
+          </el-button>
         </div>
       </div>
 
@@ -258,6 +270,16 @@
                 </span>
               </template>
             </el-table-column>
+            <el-table-column
+              v-if="isColumnVisible('projectName')"
+              label="项目名称"
+              :render-header="renderSortableHeader('项目名称', 'projectName')"
+              show-overflow-tooltip
+              width="180">
+              <template slot-scope="{ row }">
+                {{ row.projectName || '-' }}
+              </template>
+            </el-table-column>
             <el-table-column
               v-if="isColumnVisible('functionName')"
               label="功能模块"
@@ -379,11 +401,6 @@
                 </div>
               </template>
             </el-table-column>
-            <el-table-column v-if="isColumnVisible('projectName')" label="项目名称" show-overflow-tooltip width="180">
-              <template slot-scope="{ row }">
-                {{ row.projectName || '-' }}
-              </template>
-            </el-table-column>
             <el-table-column
               v-if="isColumnVisible('createdTime')"
               label="创建时间"
@@ -402,23 +419,48 @@
               <template slot="header">
                 <div class="table-operation-header">
                   <span>操作</span>
-                  <el-popover placement="bottom-end" trigger="click" width="320">
+                  <el-popover placement="bottom-end" trigger="click" width="360">
                     <div class="column-setting-panel">
-                      <div class="column-setting-header">
-                        <span class="column-setting-title">列设置</span>
-                        <div class="column-setting-actions">
-                          <el-button size="mini" type="text" @click="selectAllColumns">全选</el-button>
-                          <el-button size="mini" type="text" @click="resetVisibleColumns">重置</el-button>
+                      <!-- 字段排序区域 -->
+                      <div v-if="sortFields.length > 0" class="column-sort-section">
+                        <div class="column-setting-header">
+                          <span class="column-setting-title">当前排序</span>
+                          <el-button size="mini" type="text" @click="clearAllSorts">清除全部</el-button>
                         </div>
+                        <div class="sort-list">
+                          <div v-for="(sf, idx) in sortFields" :key="sf.field" class="sort-item">
+                            <span class="sort-item-index">{{ idx + 1 }}</span>
+                            <span class="sort-item-label">{{ getColumnSortLabel(sf.field) }}</span>
+                            <span :class="['sort-item-order', sf.order]">
+                              {{ sf.order === 'asc' ? '升序' : '降序' }}
+                            </span>
+                            <el-button
+                              class="sort-item-remove"
+                              icon="el-icon-close"
+                              size="mini"
+                              type="text"
+                              @click="removeSortField(sf.field)" />
+                          </div>
+                        </div>
+                      </div>
+                      <!-- 列显示设置区域 -->
+                      <div class="column-display-section">
+                        <div class="column-setting-header">
+                          <span class="column-setting-title">列设置</span>
+                          <div class="column-setting-actions">
+                            <el-button size="mini" type="text" @click="selectAllColumns">全选</el-button>
+                            <el-button size="mini" type="text" @click="resetVisibleColumns">重置</el-button>
+                          </div>
+                        </div>
+                        <el-checkbox-group
+                          v-model="visibleColumnKeys"
+                          class="column-setting-list"
+                          @change="handleVisibleColumnsChange">
+                          <el-checkbox v-for="column in columnOptions" :key="column.key" :label="column.key">
+                            {{ column.label }}
+                          </el-checkbox>
+                        </el-checkbox-group>
                       </div>
-                      <el-checkbox-group
-                        v-model="visibleColumnKeys"
-                        class="column-setting-list"
-                        @change="handleVisibleColumnsChange">
-                        <el-checkbox v-for="column in columnOptions" :key="column.key" :label="column.key">
-                          {{ column.label }}
-                        </el-checkbox>
-                      </el-checkbox-group>
                     </div>
                     <el-tooltip slot="reference" content="列设置" placement="top">
                       <el-button class="table-column-setting-btn" icon="el-icon-setting" type="text" @click.stop />
@@ -565,6 +607,7 @@
   const TABLE_COLUMN_OPTIONS = [
     { key: 'taskNo', label: '任务编号' },
     { key: 'taskTitle', label: '任务标题' },
+    { key: 'projectName', label: '项目名称' },
     { key: 'functionName', label: '功能模块' },
     { key: 'taskType', label: '任务类型' },
     { key: 'taskStatus', label: '任务状态' },
@@ -577,7 +620,6 @@
     { key: 'defectType', label: '缺陷类型' },
     { key: 'attribute2', label: '历史遗留' },
     { key: 'releaseVersion', label: '发布版本' },
-    { key: 'projectName', label: '项目名称' },
     { key: 'createdTime', label: '创建时间' },
     { key: 'createdName', label: '创建人' },
   ]
@@ -601,6 +643,7 @@
           pageNum: 1,
           pageSize: 20,
           projectId: '',
+          taskNo: '',
           taskTitle: '',
           taskType: [],
           taskStatus: [],
@@ -934,6 +977,7 @@
             pageNum: this.queryForm.pageNum,
             pageSize: this.queryForm.pageSize,
             projectId: this.selectedProject ? parseInt(this.selectedProject) : 0,
+            taskNo: this.queryForm.taskNo,
             taskTitle: this.queryForm.taskTitle,
             taskType: this.queryForm.taskType,
             taskStatus: this.queryForm.taskStatus,
@@ -1010,12 +1054,67 @@
         this.queryForm.pageNum = 1
         this.fetchData()
       },
+      // 导出数据
+      async handleExport() {
+        this.loading = true
+        try {
+          const params = {
+            projectId: this.selectedProject ? parseInt(this.selectedProject) : 0,
+            taskNo: this.queryForm.taskNo,
+            taskTitle: this.queryForm.taskTitle,
+            taskType: this.queryForm.taskType,
+            taskStatus: this.queryForm.taskStatus,
+            opsUserName: this.queryForm.opsUserName,
+            planStartDateStart: this.queryForm.planStartDateRange?.[0] || '',
+            planStartDateEnd: this.queryForm.planStartDateRange?.[1] || '',
+            planEndDateStart: this.queryForm.planEndDateRange?.[0] || '',
+            planEndDateEnd: this.queryForm.planEndDateRange?.[1] || '',
+            createdTimeStart: this.queryForm.createdTimeRange?.[0] || '',
+            createdTimeEnd: this.queryForm.createdTimeRange?.[1] || '',
+            completeTimeStart: this.queryForm.completeTimeRange?.[0] || '',
+            completeTimeEnd: this.queryForm.completeTimeRange?.[1] || '',
+            scheduleStatus: this.queryForm.scheduleStatus || '',
+          }
+          if (this.queryForm.productLine && !this.selectedProject) {
+            params.productLine = this.queryForm.productLine
+          }
+          const res = await opsEventTaskApi.export(params)
+          if (res.data?.content) {
+            const binaryString = window.atob(res.data.content)
+            const len = binaryString.length
+            const bytes = new Uint8Array(len)
+            for (let i = 0; i < len; i++) {
+              bytes[i] = binaryString.charCodeAt(i)
+            }
+            const blob = new Blob([bytes], {
+              type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+            })
+            const link = document.createElement('a')
+            const url = window.URL.createObjectURL(blob)
+            link.href = url
+            link.download = `软件交付任务_${new Date().getTime()}.xlsx`
+            document.body.appendChild(link)
+            link.click()
+            document.body.removeChild(link)
+            window.URL.revokeObjectURL(url)
+            this.$message.success('导出成功')
+          } else {
+            this.$message.warning('没有可导出的数据')
+          }
+        } catch (error) {
+          this.$message.error('导出失败')
+          console.error(error)
+        } finally {
+          this.loading = false
+        }
+      },
       // 重置
       handleReset() {
         this.queryForm = {
           pageNum: 1,
           pageSize: 20,
           projectId: '',
+          taskNo: '',
           taskTitle: '',
           taskType: [],
           taskStatus: [],
@@ -1066,6 +1165,22 @@
         const option = this.columnOptions.find((c) => c.key === field)
         return option ? option.label : field
       },
+      // 清除所有排序
+      clearAllSorts() {
+        this.sortFields = []
+        this.$nextTick(() => {
+          this.$refs.taskTable && this.$refs.taskTable.clearSort()
+        })
+        this.fetchData()
+      },
+      // 移除单个排序字段
+      removeSortField(field) {
+        const idx = this.sortFields.findIndex((s) => s.field === field)
+        if (idx !== -1) {
+          this.sortFields.splice(idx, 1)
+          this.fetchData()
+        }
+      },
       renderSortableHeader(label, field) {
         const vm = this
         return function (h) {
@@ -1265,9 +1380,7 @@
             // 优先使用 res.data.data(某些接口包装在data中),否则直接使用res.data
             const taskData = res.data.data || res.data
             this.detailData = this.normalizeTaskDetail(taskData)
-            this.detailDialogMode = ['30', '90'].includes(String(taskData.taskStatus || taskData.task_status))
-              ? 'view'
-              : 'edit'
+            this.detailDialogMode = 'view'
             this.detailDialogVisible = true
           } else {
             this.$message.error('获取任务详情失败')
@@ -1424,10 +1537,9 @@
         try {
           const res = await opsEventTaskApi.getById(row.id)
           if (res.code === 200) {
-            this.detailData = this.normalizeTaskDetail({
-              ...row,
-              ...(res.data || {}),
-            })
+            // 优先使用 res.data.data(某些接口包装在data中),否则直接使用res.data
+            const taskData = res.data.data || res.data
+            this.detailData = this.normalizeTaskDetail(taskData)
             this.detailDialogMode = 'schedule'
             this.detailDialogVisible = true
           }
@@ -1535,14 +1647,42 @@
       // 复制文本到剪贴板
       handleCopy(text) {
         if (!text) return
-        navigator.clipboard
-          .writeText(text)
-          .then(() => {
+        // 优先使用 navigator.clipboard API(需要 HTTPS 或 localhost)
+        if (navigator.clipboard && window.isSecureContext) {
+          navigator.clipboard
+            .writeText(text)
+            .then(() => {
+              this.$message.success('已复制: ' + text)
+            })
+            .catch(() => {
+              this.copyWithFallback(text)
+            })
+        } else {
+          // 非 HTTPS 环境使用 fallback 方案
+          this.copyWithFallback(text)
+        }
+      },
+      // Fallback 复制方法(兼容非 HTTPS 环境)
+      copyWithFallback(text) {
+        const textarea = document.createElement('textarea')
+        textarea.value = text
+        textarea.style.position = 'fixed'
+        textarea.style.left = '-9999px'
+        textarea.style.top = '-9999px'
+        document.body.appendChild(textarea)
+        textarea.focus()
+        textarea.select()
+        try {
+          const successful = document.execCommand('copy')
+          if (successful) {
             this.$message.success('已复制: ' + text)
-          })
-          .catch(() => {
+          } else {
             this.$message.error('复制失败')
-          })
+          }
+        } catch (err) {
+          this.$message.error('复制失败')
+        }
+        document.body.removeChild(textarea)
       },
     },
   }
@@ -1641,6 +1781,16 @@
   }
 
   .column-setting-panel {
+    .column-sort-section {
+      margin-bottom: 12px;
+      padding-bottom: 12px;
+      border-bottom: 1px solid #ebeef5;
+    }
+
+    .column-display-section {
+      // 列显示区域
+    }
+
     .column-setting-header {
       display: flex;
       align-items: center;
@@ -1659,6 +1809,70 @@
       align-items: center;
       gap: 8px;
     }
+
+    .sort-list {
+      display: flex;
+      flex-direction: column;
+      gap: 6px;
+    }
+
+    .sort-item {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding: 6px 10px;
+      background: #f5f7fa;
+      border-radius: 4px;
+      font-size: 13px;
+    }
+
+    .sort-item-index {
+      display: inline-flex;
+      align-items: center;
+      justify-content: center;
+      width: 18px;
+      height: 18px;
+      border-radius: 50%;
+      background: #409eff;
+      color: #fff;
+      font-size: 11px;
+      font-weight: 700;
+      line-height: 1;
+    }
+
+    .sort-item-label {
+      flex: 1;
+      color: #303133;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    .sort-item-order {
+      padding: 2px 6px;
+      border-radius: 3px;
+      font-size: 11px;
+
+      &.asc {
+        background: #e1f3d8;
+        color: #67c23a;
+      }
+
+      &.desc {
+        background: #fef0f0;
+        color: #f56c6c;
+      }
+    }
+
+    .sort-item-remove {
+      padding: 0;
+      font-size: 14px;
+      color: #909399;
+
+      &:hover {
+        color: #f56c6c;
+      }
+    }
   }
 
   .column-setting-list {