Просмотр исходного кода

feat: update devops and contract workflows

程健 3 месяцев назад
Родитель
Сommit
4b76d411d3

+ 3 - 0
src/api/consult/index.js

@@ -22,4 +22,7 @@ export default {
   getOperateEntity(query) {
     return micro_request.postRequest(basePath, 'ProductConsultRecord', 'GetOperateEntity', query)
   },
+  changeIncharge(query) {
+    return micro_request.postRequest(basePath, 'ProductConsultRecord', 'ChangeIncharge', query)
+  },
 }

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

@@ -94,4 +94,10 @@ export default {
   getWorkHourList(taskId) {
     return micro_request.postRequest(basePath, 'OpsEventTask', 'GetWorkHourList', { taskId })
   },
+
+  // 获取工作台周视图数据
+  // query: { startDate, endDate }
+  getWorkHourDashboardData(query) {
+    return micro_request.postRequest(basePath, 'OpsEventTask', 'GetWorkHourDashboardData', query)
+  },
 }

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

@@ -57,8 +57,12 @@ export default {
   getStats() {
     return micro_request.postRequest(basePath, 'Operation', 'GetStats', {})
   },
-  getHistoryList(query) {
-    return micro_request.postRequest(basePath, 'Operation', 'GetHistoryList', query)
+  getHistoryList(query = {}) {
+    const params = { ...query }
+    if (!params.contractId) {
+      delete params.contractId
+    }
+    return micro_request.postRequest(basePath, 'Operation', 'GetHistoryList', params)
   },
   export(query) {
     return micro_request.postRequest(basePath, 'Operation', 'Export', query)

+ 7 - 4
src/components/select/SelectBusiness.vue

@@ -22,8 +22,8 @@
           placeholder="项目名称"
           style="width: 30%; margin-right: 10px"
           suffix-icon="el-icon-search"
-          @keyup.enter.native="fetchData" />
-        <el-button icon="el-icon-search" type="primary" @click="fetchData">查询</el-button>
+          @keyup.enter.native="fetchData(true)" />
+        <el-button icon="el-icon-search" type="primary" @click="fetchData(true)">查询</el-button>
 
         <!--        <span>显示:</span>-->
         <!--        <el-radio-group v-model="queryForm.type">-->
@@ -243,9 +243,12 @@
       handleAdd() {
         this.$refs.businessEdit.showEdit()
       },
-      async fetchData() {
+      async fetchData(isSearch = false) {
+        if (isSearch) {
+          this.queryForm.pageNum = 1
+        }
         this.listLoading = true
-        let query = Object.assign(this.queryForm, this.queryParams)
+        let query = { ...this.queryForm, ...this.queryParams }
         const {
           data: { list, total },
         } = await businessApi.getList(query)

+ 96 - 0
src/views/consult/components/ChangeIncharge.vue

@@ -0,0 +1,96 @@
+<template>
+  <div>
+    <el-dialog append-to-body title="批量变更对接人" :visible.sync="visible" width="500px" @close="close">
+      <el-form ref="form" label-width="100px" :model="form" :rules="rules">
+        <el-form-item label="选中记录">
+          <el-input disabled :value="`已选择 ${form.ids.length} 条记录`" />
+        </el-form-item>
+        <el-form-item label="新对接人" prop="inchargeName">
+          <el-input
+            v-model="form.inchargeName"
+            placeholder="请选择新对接人"
+            readonly
+            suffix-icon="el-icon-search"
+            @focus="handleSelectSale" />
+        </el-form-item>
+      </el-form>
+      <span slot="footer">
+        <el-button @click="visible = false">取消</el-button>
+        <el-button type="primary" @click="save">确定</el-button>
+      </span>
+    </el-dialog>
+    <!-- 选择销售工程师弹窗 -->
+    <select-user
+      ref="selectSales"
+      :query-params="{
+        roles: ['SalesEngineer', 'SalesDirector', 'StrategicProjectTeamLeader', 'StrategicProjectTeam', 'qudaozhuguan'],
+      }"
+      @save="selectSales" />
+  </div>
+</template>
+
+<script>
+  import to from 'await-to-js'
+  import consultApi from '@/api/consult'
+  import SelectUser from '@/components/select/SelectUser'
+
+  export default {
+    components: { SelectUser },
+    data() {
+      return {
+        visible: false,
+        form: {
+          ids: [],
+          inchargeId: '',
+          inchargeName: '',
+        },
+        rules: {
+          inchargeName: [{ required: true, trigger: 'change', message: '请选择新对接人' }],
+        },
+      }
+    },
+    methods: {
+      init(rows) {
+        this.form.ids = (rows || []).map((r) => r.id)
+        this.form.inchargeId = ''
+        this.form.inchargeName = ''
+        this.visible = true
+      },
+      selectSales(val) {
+        if (val && val.length > 0) {
+          this.form.inchargeId = val[0].id
+          this.form.inchargeName = val.map((item) => item.nickName).join()
+        }
+      },
+      handleSelectSale() {
+        this.$refs.selectSales.open()
+      },
+      async save() {
+        this.$refs.form.validate(async (valid) => {
+          if (valid) {
+            const params = {
+              ids: this.form.ids,
+              inchargeId: parseInt(this.form.inchargeId),
+              inchargeName: this.form.inchargeName,
+            }
+            const [err, res] = await to(consultApi.changeIncharge(params))
+            if (err) return
+            this.$message.success(res.msg || '变更成功')
+            this.visible = false
+            this.$emit('success')
+          }
+        })
+      },
+      close() {
+        this.form = {
+          ids: [],
+          inchargeId: '',
+          inchargeName: '',
+        }
+        this.$refs.form && this.$refs.form.resetFields()
+      },
+    },
+  }
+</script>
+
+<style></style>

+ 56 - 1
src/views/consult/index.vue

@@ -9,6 +9,7 @@
   <div class="contract-container">
     <FollowUp ref="followUpAdd" @consultSave="consultSave" />
     <Edit ref="edit" @consultSave="consultSave" />
+    <ChangeIncharge ref="changeIncharge" @success="queryData" />
 
     <vab-query-form>
       <vab-query-form-top-panel>
@@ -27,6 +28,16 @@
               <el-option v-for="state in statusOptions" :key="state.value" :label="state.label" :value="state.value" />
             </el-select>
           </el-form-item>
+          <el-form-item class="province-item" prop="province">
+            <el-select
+              v-model="queryForm.province"
+              clearable
+              filterable
+              placeholder="省份"
+              @keyup.enter.native="queryData">
+              <el-option v-for="item in provinceOptions" :key="item.id" :label="item.distName" :value="item.distName" />
+            </el-select>
+          </el-form-item>
           <el-form-item>
             <el-button icon="el-icon-search" type="primary" @click="queryData">查询</el-button>
           </el-form-item>
@@ -36,12 +47,25 @@
         <el-button v-permissions="['consult:manage:add']" icon="el-icon-plus" type="primary" @click="handleEdit()">
           新建
         </el-button>
+        <el-button
+          v-permissions="['consult:manage:edit']"
+          :disabled="selectRows.length === 0"
+          type="primary"
+          @click="handleBatchChangeIncharge">
+          变更对接人
+        </el-button>
       </vab-query-form-left-panel>
       <vab-query-form-right-panel :span="12">
         <table-tool :columns="columns" :show-columns.sync="showColumns" table-type="contractTable" />
       </vab-query-form-right-panel>
     </vab-query-form>
-    <el-table ref="table" v-loading="listLoading" :data="list" :height="height">
+    <el-table
+      ref="table"
+      v-loading="listLoading"
+      :data="list"
+      :height="height"
+      @selection-change="handleSelectionChange">
+      <el-table-column align="center" type="selection" width="55" />
       <el-table-column
         v-for="(item, index) in showColumns"
         :key="index"
@@ -100,8 +124,10 @@
 <script>
   import to from 'await-to-js'
   import consultApi from '@/api/consult'
+  import customerApi from '@/api/customer'
   import FollowUp from '@/views/consult/components/FollowUp'
   import Edit from './components/Edit'
+  import ChangeIncharge from './components/ChangeIncharge'
   import TableTool from '@/components/table/TableTool'
 
   export default {
@@ -109,6 +135,7 @@
     components: {
       Edit,
       FollowUp,
+      ChangeIncharge,
       TableTool,
     },
     data() {
@@ -133,7 +160,9 @@
           inchargeName: '', //对接人
           unit: '', //单位
           state: '', // 状态
+          province: '', // 省份
         },
+        provinceOptions: [], // 省份选项
         selectRows: [], //选择的表格数据
         contractOptions: {}, //合同类型
         productLineOptions: {}, //产品线
@@ -229,6 +258,7 @@
       this.getParams()
       this.queryData()
       this.getOptions()
+      this.getProvinceOptions()
       this.scheduleTableLayout()
       window.addEventListener('resize', this.updateTableHeight)
     },
@@ -286,6 +316,14 @@
           })
           .catch((err) => console.log(err))
       },
+      getProvinceOptions() {
+        customerApi
+          .getProvinceDetail()
+          .then((res) => {
+            this.provinceOptions = res.data.list || []
+          })
+          .catch((err) => console.log(err))
+      },
       async queryData() {
         this.listLoading = true
         const params = { ...this.queryForm }
@@ -304,6 +342,7 @@
           inchargeName: '', //对接人
           unit: '', //单位
           state: '', // 状态
+          province: '', // 省份
         }
         this.queryData()
       },
@@ -323,6 +362,16 @@
           row ? this.$refs.followUpAdd.init(row.id) : this.$refs.followUpAdd.init()
         })
       },
+      handleSelectionChange(val) {
+        this.selectRows = val
+      },
+      handleBatchChangeIncharge() {
+        if (this.selectRows.length === 0) {
+          this.$message.warning('请先选择要变更的记录')
+          return
+        }
+        this.$refs.changeIncharge.init(this.selectRows)
+      },
       handleDetail(row) {
         this.storeParams()
         this.$router.push({
@@ -369,6 +418,7 @@
           inchargeName: this.queryForm.inchargeName,
           unit: this.queryForm.unit,
           state: this.queryForm.state,
+          province: this.queryForm.province,
           total: this.total,
         }
         localStorage.setItem('consultSearchParams', JSON.stringify(data))
@@ -385,6 +435,7 @@
             this.queryForm.inchargeName = data.inchargeName
             this.queryForm.unit = data.unit
             this.queryForm.state = data.state
+            this.queryForm.province = data.province || ''
             this.total = data.total
           }
           localStorage.removeItem('consultSearchParams')
@@ -432,6 +483,10 @@
     width: 120px;
   }
 
+  #{$base} :deep(.province-item .el-select) {
+    width: 150px;
+  }
+
   #{$base} :deep(.el-pagination) {
     margin-top: 8px;
     min-height: 28px;

+ 2 - 1
src/views/contract/components/DetailsEnclosure.vue

@@ -20,7 +20,8 @@
             }
           "
           :file-list="fileList"
-          :http-request="uploadrequest">
+          :http-request="uploadrequest"
+          multiple>
           <el-button v-permissions="['contract:detail:enclosure:add']" size="mini" type="primary">点击上传</el-button>
         </el-upload>
       </el-col>

+ 11 - 0
src/views/contract/components/Edit.vue

@@ -469,6 +469,17 @@
         nickName: 'user/nickName',
       }),
     },
+    watch: {
+      'editForm.contractStartTime': {
+        handler(val) {
+          if (!val || this.editForm.id) return
+          if (!this.editForm.contractSignTime) {
+            this.editForm.contractSignTime = val
+          }
+        },
+        immediate: true,
+      },
+    },
     mounted() {
       this.getOptions()
     },

+ 17 - 1
src/views/contract/components/ProductTable.vue

@@ -7,7 +7,7 @@
 -->
 <template>
   <div class="table-container">
-    <el-table border :data="data">
+    <el-table border :data="data" show-summary :summary-method="getSummaries">
       <el-table-column
         v-for="(item, index) in columns"
         :key="index"
@@ -142,6 +142,22 @@
           })
           .catch((err) => console.log(err))
       },
+      // 底部合计行
+      getSummaries({ columns, data }) {
+        const totalAmount = data.reduce((sum, row) => {
+          return sum + (Number(row.price) || 0) * 100 * (Number(row.count) || 0)
+        }, 0)
+        const totalCount = data.reduce((sum, row) => {
+          return sum + (Number(row.count) || 0)
+        }, 0)
+
+        return columns.map((column, index) => {
+          if (index === 0) return '合计'
+          if (column.property === 'count') return totalCount
+          if (column.label === '合计') return this.formatPrice(totalAmount / 100)
+          return ''
+        })
+      },
       // 计算总价
       calculatedDiscount(price, count) {
         let intPrice = price * 100

+ 9 - 1
src/views/contract/index.vue

@@ -166,6 +166,7 @@
         :min-width="item.width"
         :prop="item.prop"
         show-overflow-tooltip
+        :sort-method="item.sortMethod"
         :sortable="item.sortable">
         <template #default="{ row }">
           <el-button
@@ -347,8 +348,15 @@
             label: '合同编号',
             width: '160px',
             prop: 'contractCode',
-            sortable: false,
+            sortable: true,
             disableCheck: false,
+            sortMethod: (a, b) => {
+              const getLast8 = (code) => {
+                if (!code) return ''
+                return code.slice(-8)
+              }
+              return getLast8(a.contractCode).localeCompare(getLast8(b.contractCode))
+            },
           },
           {
             label: '合同类型',

+ 235 - 24
src/views/devops/components/ProjectInfoDialog.vue

@@ -93,6 +93,48 @@
             </div>
           </el-col>
         </el-row>
+        <el-row class="info-row" :gutter="20">
+          <el-col :span="12">
+            <div class="info-item editable-item">
+              <span class="info-label">运维负责人:</span>
+              <div v-if="!editingOpsManager" class="info-value-with-action">
+                <span class="info-value">{{ projectData.attribute3 || '-' }}</span>
+                <el-button
+                  v-if="canEditOpsManager"
+                  icon="el-icon-edit"
+                  size="mini"
+                  type="text"
+                  @click="startEditOpsManager">
+                  修改
+                </el-button>
+              </div>
+              <div v-else class="edit-field">
+                <el-select
+                  v-model="opsManagerEditForm.attribute4"
+                  clearable
+                  filterable
+                  :loading="opsManagerLoading"
+                  placeholder="选择运维负责人"
+                  remote
+                  :remote-method="fetchOpsManagerUsers"
+                  size="small"
+                  style="width: 140px"
+                  @change="handleOpsManagerChange">
+                  <el-option
+                    v-for="user in opsManagerOptions"
+                    :key="user.id"
+                    :label="user.nickName || user.userName"
+                    :value="user.id">
+                    <span>{{ user.nickName || user.userName }}</span>
+                    <span v-if="user.deptName" class="user-dept">({{ user.deptName }})</span>
+                  </el-option>
+                </el-select>
+                <el-button icon="el-icon-check" size="mini" type="text" @click="saveOpsManager">保存</el-button>
+                <el-button icon="el-icon-close" size="mini" type="text" @click="cancelEditOpsManager">取消</el-button>
+              </div>
+            </div>
+          </el-col>
+        </el-row>
         <el-row class="info-row" :gutter="20">
           <el-col :span="12">
             <div class="info-item editable-item">
@@ -101,7 +143,14 @@
                 <span class="info-value">
                   {{ formatTime(projectData.planDeliveryTime || projectData.plan_delivery_time) }}
                 </span>
-                <el-button icon="el-icon-edit" size="mini" type="text" @click="startEditPlanDelivery">修改</el-button>
+                <el-button
+                  v-if="canEditPlanTime"
+                  icon="el-icon-edit"
+                  size="mini"
+                  type="text"
+                  @click="startEditPlanDelivery">
+                  修改
+                </el-button>
               </div>
               <div v-else class="edit-field">
                 <el-date-picker
@@ -123,7 +172,14 @@
                 <span class="info-value">
                   {{ formatTime(projectData.planAcceptTime || projectData.plan_accept_time) }}
                 </span>
-                <el-button icon="el-icon-edit" size="mini" type="text" @click="startEditPlanAccept">修改</el-button>
+                <el-button
+                  v-if="canEditPlanTime"
+                  icon="el-icon-edit"
+                  size="mini"
+                  type="text"
+                  @click="startEditPlanAccept">
+                  修改
+                </el-button>
               </div>
               <div v-else class="edit-field">
                 <el-date-picker
@@ -183,6 +239,8 @@
 
 <script>
   import deliveryProjectApi from '@/api/devops/deliveryProject'
+  import { mapGetters } from 'vuex'
+  import userApi from '@/api/system/user'
   import { parseTime } from '@/utils'
   import { projectStatusTagTypes, getTagType } from '@/config/devopsTagTypes'
 
@@ -208,15 +266,25 @@
         // 编辑状态
         editingPlanDelivery: false,
         editingPlanAccept: false,
+        editingOpsManager: false,
         editForm: {
           planDeliveryTime: '',
           planAcceptTime: '',
         },
+        opsManagerEditForm: {
+          attribute4: null,
+          attribute3: '',
+        },
+        opsManagerOptions: [],
+        opsManagerLoading: false,
         // 时间变更记录(从备注中解析或单独存储)
         timeChangeRecords: [],
       }
     },
     computed: {
+      ...mapGetters({
+        roleKeys: 'user/roleKeys',
+      }),
       dialogVisible: {
         get() {
           return this.visible
@@ -225,6 +293,21 @@
           this.$emit('update:visible', val)
         },
       },
+      // 是否可编辑运维负责人:项目状态为验收(50)且拥有研发总监或研发主管角色
+      canEditOpsManager() {
+        const hasRole =
+          this.roleKeys.includes('ResearchAndDevelopmentDirector') ||
+          this.roleKeys.includes('ResearchAndDevelopmentSupervisor')
+        return String(this.projectData.projectStatus) === '50' && hasRole
+      },
+      // 是否可编辑计划时间:项目节点为交付中(deliveryNode 30/40/50)且拥有研发总监或研发主管角色
+      canEditPlanTime() {
+        const hasRole =
+          this.roleKeys.includes('ResearchAndDevelopmentDirector') ||
+          this.roleKeys.includes('ResearchAndDevelopmentSupervisor')
+        const node = String(this.projectData.deliveryNode)
+        return ['30', '40', '50'].includes(node) && hasRole
+      },
       normalizedRemark() {
         const remark = this.projectData && this.projectData.remark
         if (remark == null) return ''
@@ -234,31 +317,35 @@
         const data = this.projectData
         return [
           { key: 'created', label: '创建', desc: '项目生成', time: data.createdTime },
-          { key: 'assigned', label: '指派', desc: '负责人确认', time: data.assignedTime },
-          { key: 'internalKickoff', label: '内启', desc: '内部启动会', time: data.internalKickoffTime },
-          { key: 'externalKickoff', label: '外启', desc: '外部启动会', time: data.externalKickoffTime },
-          { key: 'planSubmit', label: '计划提交', desc: '交付计划提交', time: data.deliveryPlanSubmitTime },
-          { key: 'deployment', label: '部署', desc: '系统部署上线前', time: data.deploymentTime },
-          { key: 'trialRun', label: '试运行', desc: '进入试运行阶段', time: data.trialRunTime },
-          { key: 'goLive', label: '上线', desc: '正式交付完成', time: data.goLiveTime },
+          { key: 'assigned', label: '已指派', desc: '负责人确认', time: data.assignedTime },
+          { key: 'internalKickoff', label: '内部启动会', desc: '内启', time: data.internalKickoffTime },
+          { key: 'externalKickoff', label: '外部启动会', desc: '外启', time: data.externalKickoffTime },
+          { key: 'planSubmit', label: '制定计划', desc: '计划提交', time: data.deliveryPlanSubmitTime },
+          { key: 'inProgress', label: '交付中', desc: '项目实施/部署/试运行', time: data.inProgressTime },
+          { key: 'delivery', label: '交付完成', desc: '正式交付', time: data.deliveryTime },
         ]
       },
-      completedTimelineCount() {
-        return this.timelinePoints.filter((item) => item.time).length
-      },
-      lastCompletedTimelineIndex() {
-        return this.timelinePoints.reduce((lastIndex, item, index) => (item.time ? index : lastIndex), -1)
-      },
-      nextTimelineIndex() {
-        if (this.lastCompletedTimelineIndex >= this.timelinePoints.length - 1) return -1
-        return this.lastCompletedTimelineIndex + 1
+      currentDeliveryNodeIndex() {
+        // deliveryNode 到节点索引的映射 (7个节点:创建 + 6个交付节点)
+        const nodeIndexMap = {
+          '05': 1, // 已指派
+          10: 2, // 内部启动会
+          15: 3, // 外部启动会
+          20: 4, // 制定计划
+          30: 5, // 项目实施 -> 交付中
+          40: 5, // 完成部署 -> 交付中
+          50: 5, // 试运行 -> 交付中
+          60: 6, // 交付完成
+        }
+        return nodeIndexMap[this.projectData.deliveryNode] || 0
       },
       timelineProgressStyle() {
-        if (this.lastCompletedTimelineIndex <= 0) {
+        const currentIndex = this.currentDeliveryNodeIndex
+        if (currentIndex <= 0) {
           return { width: '0%' }
         }
         return {
-          width: `${(this.lastCompletedTimelineIndex / this.timelinePoints.length) * 100}%`,
+          width: `${(currentIndex / this.timelinePoints.length) * 100}%`,
         }
       },
       // 解析备注为数组(处理多种换行符:\n, \r\n, <br>, <br/>, </br>)
@@ -474,14 +561,117 @@
         }
       },
 
+      // 开始编辑运维负责人
+      startEditOpsManager() {
+        this.opsManagerEditForm = {
+          attribute4: this.projectData.attribute4 ? parseInt(this.projectData.attribute4) : null,
+          attribute3: this.projectData.attribute3 || '',
+        }
+        this.editingOpsManager = true
+        this.fetchOpsManagerUsers('')
+      },
+
+      // 取消编辑运维负责人
+      cancelEditOpsManager() {
+        this.editingOpsManager = false
+        this.opsManagerEditForm = {
+          attribute4: null,
+          attribute3: '',
+        }
+        this.opsManagerOptions = []
+      },
+
+      // 获取运维负责人候选列表
+      async fetchOpsManagerUsers() {
+        this.opsManagerLoading = true
+        try {
+          const roleKeys = ['ResearchAndDevelopmentDirector', 'ResearchAndDevelopmentSupervisor']
+          const roleIds = [1009, 1010]
+          const res = await userApi.getUsersByRoleKeys(roleKeys, roleIds)
+          if (res.code === 200 && res.data) {
+            const newUsers = res.data || []
+            const existingIds = this.opsManagerOptions.map((u) => u.id)
+            newUsers.forEach((user) => {
+              if (!existingIds.includes(user.id)) {
+                this.opsManagerOptions.push(user)
+              }
+            })
+          }
+        } catch (error) {
+          console.error('获取运维负责人列表失败:', error)
+        } finally {
+          this.opsManagerLoading = false
+        }
+      },
+
+      // 运维负责人选择变更
+      handleOpsManagerChange(val) {
+        if (val === null || val === undefined || val === '') {
+          this.opsManagerEditForm.attribute3 = ''
+          return
+        }
+        const user = this.opsManagerOptions.find((u) => u.id === val)
+        this.opsManagerEditForm.attribute3 = user ? user.nickName || user.userName : ''
+      },
+
+      // 保存运维负责人
+      async saveOpsManager() {
+        if (!this.opsManagerEditForm.attribute4) {
+          this.$message.warning('请选择运维负责人')
+          return
+        }
+
+        try {
+          const updateData = {
+            id: parseInt(this.projectId),
+            projectName: this.projectData.projectName,
+            projectStatus: this.projectData.projectStatus,
+            contractId: this.projectData.contractId,
+            contractNo: this.projectData.contractNo,
+            custId: this.projectData.custId,
+            custName: this.projectData.custName,
+            productLine: this.projectData.productLine,
+            deliveryUserId: this.projectData.deliveryUserId,
+            deliveryUserName: this.projectData.deliveryUserName,
+            salesUserId: this.projectData.salesUserId,
+            salesUserName: this.projectData.salesUserName,
+            salesRegionId: this.projectData.salesRegionId,
+            deliveryNode: this.projectData.deliveryNode,
+            remark: this.projectData.remark,
+            planDeliveryTime: this.projectData.planDeliveryTime || this.projectData.plan_delivery_time || '',
+            planAcceptTime: this.projectData.planAcceptTime || this.projectData.plan_accept_time || '',
+            attribute4: this.opsManagerEditForm.attribute4,
+            attribute3: this.opsManagerEditForm.attribute3,
+          }
+
+          const res = await deliveryProjectApi.update(updateData)
+          if (res.code === 200) {
+            this.$message.success('运维负责人修改成功')
+            this.editingOpsManager = false
+            this.fetchProjectDetail()
+          } else {
+            this.$message.error(res.msg || '修改失败')
+          }
+        } catch (error) {
+          console.error('修改运维负责人失败:', error)
+          this.$message.error('修改失败')
+        }
+      },
+
       // 重置编辑状态
       resetEditState() {
         this.editingPlanDelivery = false
         this.editingPlanAccept = false
+        this.editingOpsManager = false
         this.editForm = {
           planDeliveryTime: '',
           planAcceptTime: '',
         }
+        this.opsManagerEditForm = {
+          attribute4: null,
+          attribute3: '',
+        }
+        this.opsManagerOptions = []
         this.timeChangeRecords = []
       },
 
@@ -496,9 +686,23 @@
       },
 
       getTimelineNodeState(item, index) {
-        if (item.time) return 'completed'
-        if (index === this.nextTimelineIndex) return 'active'
-        if (this.lastCompletedTimelineIndex === -1 && index === 0) return 'active'
+        // 根据 deliveryNode 判断当前节点状态
+        const deliveryNode = this.projectData.deliveryNode
+        // deliveryNode 到节点索引的映射 (7个节点:创建 + 6个交付节点)
+        const nodeIndexMap = {
+          '05': 1, // 已指派
+          10: 2, // 内部启动会
+          15: 3, // 外部启动会
+          20: 4, // 制定计划
+          30: 5, // 项目实施 -> 交付中
+          40: 5, // 完成部署 -> 交付中
+          50: 5, // 试运行 -> 交付中
+          60: 6, // 交付完成
+        }
+        const currentNodeIndex = nodeIndexMap[deliveryNode] || 0
+
+        if (index < currentNodeIndex) return 'completed'
+        if (index === currentNodeIndex) return 'active'
         return 'pending'
       },
       getTimelineNodeTitle(item) {
@@ -552,7 +756,7 @@
     .linear-timeline {
       position: relative;
       display: grid;
-      grid-template-columns: repeat(8, minmax(0, 1fr));
+      grid-template-columns: repeat(7, minmax(0, 1fr));
       gap: 0;
       align-items: center;
       min-width: 0;
@@ -626,6 +830,7 @@
         font-size: 11px;
         line-height: 1.3;
         min-height: 14px;
+        white-space: nowrap;
       }
 
       &__node.is-completed {
@@ -827,5 +1032,11 @@
         flex: 1;
       }
     }
+
+    .user-dept {
+      margin-left: 8px;
+      color: #909399;
+      font-size: 12px;
+    }
   }
 </style>

+ 1 - 1
src/views/devops/deliveryProject/components/DeliveryProjectAssign.vue

@@ -224,7 +224,7 @@
             // 如果是首次指派(非改派),需要更新状态等信息
             if (!this.isReassign) {
               assignData.projectStatus = '20' // 交付中
-              assignData.deliveryNode = '10' // 指派节点
+              assignData.deliveryNode = '05' // 指派
               assignData.assignedTime = parseTime(new Date(), '{y}-{m}-{d} {h}:{i}:{s}')
               // 添加计划交付时间和计划验收时间
               if (this.formData.planDeliveryTime) {

+ 16 - 5
src/views/devops/deliveryProject/index.vue

@@ -198,7 +198,7 @@
                     {{ project.deliveryOwner || '-' }}
                   </span>
                   <i
-                    v-if="project.deliveryOwner"
+                    v-if="project.deliveryOwner && canManageDelivery"
                     class="el-icon-refresh-right reassign-btn"
                     title="改派"
                     @click.stop="handleReassign(project)" />
@@ -429,6 +429,7 @@
   import deliveryProjectApi from '@/api/devops/deliveryProject'
   import deliveryProjectEventApi from '@/api/devops/deliveryProjectEvent'
   import userApi from '@/api/system/user'
+  import { mapGetters } from 'vuex'
   import { DEVOPS_DEV_DEPT_ID } from '@/config/devops.config'
   import DeliveryProjectEventEdit from './components/DeliveryProjectEventEdit'
   import DeliveryProjectEventDetail from './components/DeliveryProjectEventDetail'
@@ -475,9 +476,9 @@
         loading: false,
         submitLoading: false,
         sidebarCollapsed: false,
-        showSidebarFilters: false,
+        showSidebarFilters: true,
         projectSearch: '',
-        projectStatusFilter: '',
+        projectStatusFilter: 'delivering',
         selectedProject: '',
         projects: [{ id: '', name: '全部' }],
         editDialogVisible: false,
@@ -515,6 +516,15 @@
       }
     },
     computed: {
+      ...mapGetters({
+        roleKeys: 'user/roleKeys',
+      }),
+      canManageDelivery() {
+        return (
+          this.roleKeys.includes('ResearchAndDevelopmentDirector') ||
+          this.roleKeys.includes('ResearchAndDevelopmentSupervisor')
+        )
+      },
       filteredProjects() {
         const keyword = this.projectSearch.trim().toLowerCase()
         const statusFilter = this.projectStatusFilter
@@ -605,6 +615,7 @@
             productLine: '10,20,30,40,50,60',
             sortField: 'contract_no',
             sortOrder: 'desc',
+            attribute9: '10',
           }
           // 状态筛选
           if (this.projectStatusFilter) {
@@ -919,8 +930,8 @@
       },
 
       selectProject(projectId, project) {
-        // 如果点击的是具体项目卡片且状态为待分配(10),弹出指派弹窗
-        if (project && String(project.status) === '10') {
+        // 如果点击的是具体项目卡片且状态为待分配(10),且用户有研发总监/研发主管角色,弹出指派弹窗
+        if (project && String(project.status) === '10' && this.canManageDelivery) {
           this.handleAssign(project)
           return
         }

+ 20 - 25
src/views/devops/operation/components/OperationEdit.vue

@@ -49,12 +49,10 @@
           v-model="form.contractId"
           clearable
           filterable
-          placeholder="请输入合同编号、客户名称或签约单位搜索"
-          remote
-          :remote-method="remoteSearchContract"
-          reserve-keyword
+          placeholder="点击选择合同"
           style="width: 100%"
-          @change="handleContractChange">
+          @change="handleContractChange"
+          @focus="handleContractFocus">
           <el-option
             v-for="item in contractList"
             :key="item.id"
@@ -123,7 +121,6 @@
   import contractApi from '@/api/contract/index'
   import store from '@/store'
   import { uploadRichtextImage, uploadFileToRichtextServer } from '@/utils/richtextUpload'
-  import debounce from 'lodash/debounce'
 
   export default {
     name: 'OperationEdit',
@@ -224,14 +221,8 @@
     },
     created() {
       this.getOptions()
-      this.remoteSearchContract = debounce((query) => {
-        this.searchContract(query)
-      }, 300)
     },
     beforeDestroy() {
-      if (this.remoteSearchContract && this.remoteSearchContract.cancel) {
-        this.remoteSearchContract.cancel()
-      }
       if (this.editor) {
         this.editor.destroy()
         this.editor = null
@@ -364,21 +355,25 @@
           this.form.isOps = '20'
         }
       },
+      handleContractFocus() {
+        // 点击下拉框时加载合同列表(只加载当前用户交付的合同)
+        if (this.contractList.length === 0) {
+          this.searchContract('')
+        }
+      },
       searchContract(query) {
-        if (query !== '') {
-          contractApi
-            .searchContract({ searchText: query })
-            .then((res) => {
-              if (res.code === 200 && res.data && res.data.list) {
-                this.contractList = res.data.list
-              }
-            })
-            .catch(() => {
+        contractApi
+          .searchContract({ searchText: query })
+          .then((res) => {
+            if (res.code === 200 && res.data && res.data.list) {
+              this.contractList = res.data.list
+            } else {
               this.contractList = []
-            })
-        } else {
-          this.contractList = []
-        }
+            }
+          })
+          .catch(() => {
+            this.contractList = []
+          })
       },
       handleFileChange(file, fileList) {
         this.fileList = fileList

+ 743 - 150
src/views/devops/operationHistory/index.vue

@@ -1,128 +1,232 @@
 <template>
-  <div class="operation-history-container">
-    <vab-query-form>
-      <vab-query-form-top-panel>
-        <el-form ref="queryForm" :inline="true" :model="queryForm" @submit.native.prevent>
-          <el-form-item prop="scopeType">
-            <el-radio-group v-model="queryForm.scopeType" size="small" @change="queryData">
-              <el-radio-button label="my">个人</el-radio-button>
-              <el-radio-button label="all">全部</el-radio-button>
-            </el-radio-group>
-          </el-form-item>
-          <el-form-item>
-            <el-checkbox v-model="queryForm.includeClosed" size="small" @change="queryData">未关闭</el-checkbox>
-          </el-form-item>
-          <el-form-item prop="eventTitle">
-            <el-input
-              v-model="queryForm.eventTitle"
-              clearable
-              placeholder="事件标题"
-              size="small"
-              @keyup.enter.native="queryData" />
-          </el-form-item>
-          <el-form-item prop="custName">
-            <el-input
-              v-model="queryForm.custName"
-              clearable
-              placeholder="客户名称"
-              size="small"
-              @keyup.enter.native="queryData" />
-          </el-form-item>
-          <el-form-item prop="feedbackReporter">
-            <el-input
-              v-model="queryForm.feedbackReporter"
-              clearable
-              placeholder="反馈人"
-              size="small"
-              @keyup.enter.native="queryData" />
-          </el-form-item>
-          <el-form-item prop="opsUserName">
-            <el-input
-              v-model="queryForm.opsUserName"
-              clearable
-              placeholder="处理人"
-              size="small"
-              @keyup.enter.native="queryData" />
-          </el-form-item>
-          <el-form-item prop="dateRange">
-            <el-date-picker
-              v-model="queryForm.dateRange"
-              end-placeholder="结束日期"
-              range-separator="至"
-              size="small"
-              start-placeholder="反馈开始日期"
-              type="daterange"
-              value-format="yyyy-MM-dd" />
-          </el-form-item>
-          <el-form-item>
-            <el-button icon="el-icon-search" size="small" type="primary" @click="queryData">查询</el-button>
-            <el-button icon="el-icon-refresh-right" size="small" @click="reset">重置</el-button>
-          </el-form-item>
-        </el-form>
-      </vab-query-form-top-panel>
-    </vab-query-form>
-
-    <div class="export-btn-fixed">
-      <el-button icon="el-icon-download" size="small" type="success" @click="handleExport">导出</el-button>
+  <div class="operation-history-page">
+    <div class="query-form-container">
+      <vab-query-form>
+        <vab-query-form-top-panel>
+          <el-form ref="queryForm" :inline="true" :model="queryForm" @submit.native.prevent>
+            <el-form-item prop="scopeType">
+              <el-radio-group v-model="queryForm.scopeType" size="small" @change="queryData">
+                <el-radio-button label="my">个人</el-radio-button>
+                <el-radio-button label="all">全部</el-radio-button>
+              </el-radio-group>
+            </el-form-item>
+            <el-form-item>
+              <el-checkbox v-model="queryForm.includeClosed" size="small" @change="queryData">未关闭</el-checkbox>
+            </el-form-item>
+            <el-form-item prop="eventTitle">
+              <el-input
+                v-model="queryForm.eventTitle"
+                clearable
+                placeholder="事件标题"
+                size="small"
+                @keyup.enter.native="queryData" />
+            </el-form-item>
+            <el-form-item prop="custName">
+              <el-input
+                v-model="queryForm.custName"
+                clearable
+                placeholder="客户名称"
+                size="small"
+                @keyup.enter.native="queryData" />
+            </el-form-item>
+            <el-form-item prop="feedbackReporter">
+              <el-input
+                v-model="queryForm.feedbackReporter"
+                clearable
+                placeholder="反馈人"
+                size="small"
+                @keyup.enter.native="queryData" />
+            </el-form-item>
+            <el-form-item prop="opsUserName">
+              <el-input
+                v-model="queryForm.opsUserName"
+                clearable
+                placeholder="处理人"
+                size="small"
+                @keyup.enter.native="queryData" />
+            </el-form-item>
+            <el-form-item prop="dateRange">
+              <el-date-picker
+                v-model="queryForm.dateRange"
+                end-placeholder="结束日期"
+                range-separator="至"
+                size="small"
+                start-placeholder="反馈开始日期"
+                type="daterange"
+                value-format="yyyy-MM-dd" />
+            </el-form-item>
+            <el-form-item>
+              <el-button icon="el-icon-search" size="small" type="primary" @click="queryData">查询</el-button>
+              <el-button icon="el-icon-refresh-right" size="small" @click="reset">重置</el-button>
+              <el-button icon="el-icon-download" size="small" type="success" @click="handleExport">导出</el-button>
+            </el-form-item>
+          </el-form>
+        </vab-query-form-top-panel>
+      </vab-query-form>
     </div>
 
-    <div ref="tableWrapper" class="table-wrapper">
-      <el-table ref="table" v-loading="listLoading" border :data="list" :height="height">
-        <el-table-column align="center" label="序号" show-overflow-tooltip width="60">
-          <template #default="{ $index }">
-            {{ (queryForm.pageNum - 1) * queryForm.pageSize + $index + 1 }}
-          </template>
-        </el-table-column>
-        <el-table-column align="center" label="事件编号" min-width="130" prop="eventNo" show-overflow-tooltip />
-        <el-table-column align="center" label="事件标题" min-width="150" prop="eventTitle" show-overflow-tooltip />
-        <el-table-column align="center" label="客户名称" min-width="120" prop="custName" show-overflow-tooltip />
-        <el-table-column align="center" label="反馈人" min-width="100" prop="feedbackReporter" show-overflow-tooltip />
-        <el-table-column align="center" label="反馈时间" min-width="110" prop="feedbackDate" show-overflow-tooltip>
-          <template #default="{ row }">
-            {{ parseTime(row.feedbackDate, '{y}-{m}-{d}') }}
-          </template>
-        </el-table-column>
-        <el-table-column align="center" label="处理人" min-width="100" prop="opsUserName" show-overflow-tooltip />
-        <el-table-column align="center" label="事件类型" min-width="90" prop="eventType" show-overflow-tooltip>
-          <template #default="{ row }">
-            {{ selectDictLabel(eventTypeOptions, row.eventType) }}
-          </template>
-        </el-table-column>
-        <el-table-column align="center" label="优先级" min-width="80" prop="priorityLevel" show-overflow-tooltip>
-          <template #default="{ row }">
-            {{ selectDictLabel(priorityLevelOptions, row.priorityLevel) }}
-          </template>
-        </el-table-column>
-        <el-table-column align="center" label="事件状态" min-width="100" prop="eventStatus" show-overflow-tooltip>
-          <template #default="{ row }">
-            {{ selectDictLabel(eventStatusOptions, row.eventStatus) }}
-          </template>
-        </el-table-column>
-        <el-table-column align="center" label="关闭时间" min-width="110" prop="completeTime" show-overflow-tooltip>
-          <template #default="{ row }">
-            {{ parseTime(row.completeTime, '{y}-{m}-{d}') }}
-          </template>
-        </el-table-column>
-        <el-table-column align="center" fixed="right" label="操作" width="80">
-          <template #default="{ row }">
-            <el-button type="text" @click="handleView(row)">详情</el-button>
-          </template>
-        </el-table-column>
-        <template #empty>
-          <el-image class="vab-data-empty" :src="require('@/assets/empty_images/data_empty.png')" />
+    <div class="main-content">
+      <div :class="['project-sidebar', { collapsed: sidebarCollapsed }]">
+        <div class="sidebar-header">
+          <div v-if="!sidebarCollapsed" class="sidebar-title-group">
+            <span class="sidebar-title">项目列表</span>
+            <button
+              :class="['sidebar-action-btn', { active: showSidebarFilters }]"
+              :title="showSidebarFilters ? '隐藏项目筛选' : '显示项目筛选'"
+              type="button"
+              @click="toggleSidebarFilters">
+              <i class="el-icon-search" />
+            </button>
+          </div>
+          <div class="sidebar-actions">
+            <button
+              class="collapse-trigger"
+              :title="sidebarCollapsed ? '展开项目列表' : '折叠项目列表'"
+              type="button"
+              @click="toggleSidebar">
+              <i :class="sidebarCollapsed ? 'el-icon-d-arrow-right' : 'el-icon-d-arrow-left'" />
+            </button>
+          </div>
+        </div>
+        <div v-if="sidebarCollapsed" class="sidebar-collapsed-label">项目列表</div>
+        <template v-else>
+          <div v-if="showSidebarFilters" class="project-search-wrapper">
+            <el-input
+              v-model="projectSearch"
+              class="project-search"
+              clearable
+              placeholder="搜索项目名称/合同编号"
+              prefix-icon="el-icon-search"
+              size="small" />
+          </div>
+          <div class="project-list">
+            <div
+              :class="['project-item', 'project-item--all', { active: selectedContractId === '' }]"
+              @click="selectProject('')">
+              <div>
+                <div class="project-overview-label">全部</div>
+                <div class="project-overview-desc">查看全部运维历史</div>
+              </div>
+            </div>
+            <div
+              v-for="project in filteredProjects"
+              :key="project.contractId || project.id"
+              :class="['project-card', { active: selectedContractId === project.contractId }]"
+              @click="selectProject(project.contractId)">
+              <div class="project-card-top">
+                <div class="project-card-tags">
+                  <span class="project-contract">{{ project.contractNo || '-' }}</span>
+                  <span class="project-line-tag">{{ getProductLineLabel(project.productLine) }}</span>
+                </div>
+                <span :class="['project-status-tag', 'project-status-tag--' + project.status]">
+                  {{ selectDictLabel(projectStatusOptions, project.status) || '-' }}
+                </span>
+              </div>
+              <div class="project-card-title" :title="project.name">{{ project.name || '-' }}</div>
+              <div class="project-card-meta">
+                <div class="project-card-meta-item">
+                  <i class="el-icon-user project-card-meta-icon" title="销售负责人" />
+                  <span class="project-card-meta-value" :title="project.salesOwner || '-'">
+                    {{ project.salesOwner || '-' }}
+                  </span>
+                </div>
+                <div class="project-card-meta-item">
+                  <i
+                    class="el-icon-s-custom project-card-meta-icon project-card-meta-icon--delivery"
+                    title="交付负责人" />
+                  <span class="project-card-meta-value" :title="project.deliveryOwner || '-'">
+                    {{ project.deliveryOwner || '-' }}
+                  </span>
+                </div>
+              </div>
+              <i v-if="selectedContractId === project.contractId" class="el-icon-check project-card-check" />
+            </div>
+          </div>
         </template>
-      </el-table>
-    </div>
+      </div>
 
-    <el-pagination
-      ref="paginationRef"
-      background
-      :current-page="queryForm.pageNum"
-      :layout="layout"
-      :page-size="queryForm.pageSize"
-      :total="total"
-      @current-change="handleCurrentChange"
-      @size-change="handleSizeChange" />
+      <div class="content-panel">
+        <div ref="tableWrapper" class="table-container">
+          <div class="table-scroll-wrapper">
+            <el-table ref="table" v-loading="listLoading" border :data="list" :height="height">
+              <el-table-column align="center" label="序号" show-overflow-tooltip width="60">
+                <template #default="{ $index }">
+                  {{ (queryForm.pageNum - 1) * queryForm.pageSize + $index + 1 }}
+                </template>
+              </el-table-column>
+              <el-table-column align="center" label="事件编号" min-width="130" prop="eventNo" show-overflow-tooltip />
+              <el-table-column
+                align="center"
+                label="事件标题"
+                min-width="150"
+                prop="eventTitle"
+                show-overflow-tooltip />
+              <el-table-column align="center" label="客户名称" min-width="120" prop="custName" show-overflow-tooltip />
+              <el-table-column
+                align="center"
+                label="反馈人"
+                min-width="100"
+                prop="feedbackReporter"
+                show-overflow-tooltip />
+              <el-table-column
+                align="center"
+                label="反馈时间"
+                min-width="110"
+                prop="feedbackDate"
+                show-overflow-tooltip>
+                <template #default="{ row }">
+                  {{ parseTime(row.feedbackDate, '{y}-{m}-{d}') }}
+                </template>
+              </el-table-column>
+              <el-table-column align="center" label="处理人" min-width="100" prop="opsUserName" show-overflow-tooltip />
+              <el-table-column align="center" label="事件类型" min-width="90" prop="eventType" show-overflow-tooltip>
+                <template #default="{ row }">
+                  {{ selectDictLabel(eventTypeOptions, row.eventType) }}
+                </template>
+              </el-table-column>
+              <el-table-column align="center" label="优先级" min-width="80" prop="priorityLevel" show-overflow-tooltip>
+                <template #default="{ row }">
+                  {{ selectDictLabel(priorityLevelOptions, row.priorityLevel) }}
+                </template>
+              </el-table-column>
+              <el-table-column align="center" label="事件状态" min-width="100" prop="eventStatus" show-overflow-tooltip>
+                <template #default="{ row }">
+                  {{ selectDictLabel(eventStatusOptions, row.eventStatus) }}
+                </template>
+              </el-table-column>
+              <el-table-column
+                align="center"
+                label="关闭时间"
+                min-width="110"
+                prop="completeTime"
+                show-overflow-tooltip>
+                <template #default="{ row }">
+                  {{ parseTime(row.completeTime, '{y}-{m}-{d}') }}
+                </template>
+              </el-table-column>
+              <el-table-column align="center" fixed="right" label="操作" width="80">
+                <template #default="{ row }">
+                  <el-button type="text" @click="handleView(row)">详情</el-button>
+                </template>
+              </el-table-column>
+              <template #empty>
+                <el-image class="vab-data-empty" :src="require('@/assets/empty_images/data_empty.png')" />
+              </template>
+            </el-table>
+          </div>
+
+          <el-pagination
+            ref="paginationRef"
+            background
+            :current-page="queryForm.pageNum"
+            :layout="layout"
+            :page-size="queryForm.pageSize"
+            :total="total"
+            @current-change="handleCurrentChange"
+            @size-change="handleSizeChange" />
+        </div>
+      </div>
+    </div>
 
     <operation-detail
       v-if="detailVisible"
@@ -135,7 +239,10 @@
 
 <script>
   import to from 'await-to-js'
+  import { mapGetters } from 'vuex'
+  import deliveryProjectApi from '@/api/devops/deliveryProject'
   import operationEventApi from '@/api/operation/operationEvent'
+  import dictApi from '@/api/system/dict'
   import OperationDetail from '@/views/devops/operation/components/OperationDetail'
 
   export default {
@@ -156,6 +263,13 @@
         eventTypeOptions: [],
         eventStatusOptions: [],
         priorityLevelOptions: [],
+        projectStatusOptions: [],
+        productLineDict: [],
+        projects: [],
+        selectedContractId: '',
+        projectSearch: '',
+        sidebarCollapsed: false,
+        showSidebarFilters: true,
         queryForm: {
           scopeType: 'my',
           includeClosed: false,
@@ -164,11 +278,33 @@
           feedbackReporter: '',
           opsUserName: '',
           dateRange: [],
+          contractId: '',
           pageNum: 1,
           pageSize: 10,
         },
       }
     },
+    computed: {
+      ...mapGetters({
+        userId: 'user/id',
+      }),
+      currentUserId() {
+        return this.userId || (this.$store.state.user && this.$store.state.user.id) || ''
+      },
+      filteredProjects() {
+        const keyword = this.projectSearch.trim().toLowerCase()
+
+        return this.projects.filter((project) => {
+          if (!keyword) {
+            return true
+          }
+
+          return [project.name, project.contractNo]
+            .filter(Boolean)
+            .some((field) => String(field).toLowerCase().includes(keyword))
+        })
+      },
+    },
     activated() {
       if (this.hasLoaded) {
         this.scheduleTableLayout()
@@ -179,9 +315,8 @@
       this.clearTableLayoutTasks()
       window.removeEventListener('resize', this.scheduleTableLayout)
     },
-    mounted() {
-      this.getOptions()
-      this.fetchData()
+    async mounted() {
+      await this.initializePage()
       this.scheduleTableLayout()
     },
     beforeDestroy() {
@@ -189,18 +324,29 @@
       window.removeEventListener('resize', this.scheduleTableLayout)
     },
     methods: {
-      getOptions() {
-        Promise.all([
-          this.getDicts('ops_event_type'),
-          this.getDicts('ops_event_status'),
-          this.getDicts('ops_priority_level'),
-        ])
-          .then(([eventType, eventStatus, priorityLevel]) => {
-            this.eventTypeOptions = eventType.data.values || []
-            this.eventStatusOptions = eventStatus.data.values || []
-            this.priorityLevelOptions = priorityLevel.data.values || []
-          })
-          .catch((err) => console.log(err))
+      async initializePage() {
+        await this.getOptions()
+        await this.fetchProjects()
+        await this.fetchData()
+      },
+      async getOptions() {
+        try {
+          const res = await dictApi.getDictDataByTypes([
+            'ops_event_type',
+            'ops_event_status',
+            'ops_priority_level',
+            'delivery_project_status',
+            'sys_product_line',
+          ])
+          const dicts = res.data || {}
+          this.eventTypeOptions = (dicts.ops_event_type && dicts.ops_event_type.values) || []
+          this.eventStatusOptions = (dicts.ops_event_status && dicts.ops_event_status.values) || []
+          this.priorityLevelOptions = (dicts.ops_priority_level && dicts.ops_priority_level.values) || []
+          this.projectStatusOptions = (dicts.delivery_project_status && dicts.delivery_project_status.values) || []
+          this.productLineDict = (dicts.sys_product_line && dicts.sys_product_line.values) || []
+        } catch (err) {
+          console.log(err)
+        }
       },
       clearTableLayoutTasks() {
         this.tableLayoutTimers.forEach((timer) => clearTimeout(timer))
@@ -234,16 +380,66 @@
         const tableEl = this.$refs.table && this.$refs.table.$el
         const paginationEl = this.$refs.paginationRef && this.$refs.paginationRef.$el
 
-        if (!tableEl || !paginationEl) return
+        if (!wrapperEl || !tableEl || !paginationEl) return
 
-        const footerHeight = 60
-        const bufferSpace = 10
-        const tableTop = (wrapperEl || tableEl).getBoundingClientRect().top
+        const wrapperRect = wrapperEl.getBoundingClientRect()
         const paginationHeight = paginationEl.getBoundingClientRect().height
-        const availableHeight = window.innerHeight - tableTop - footerHeight - paginationHeight - bufferSpace
+        const availableHeight = wrapperRect.height - paginationHeight - 12
 
         this.height = Math.max(availableHeight, 240)
       },
+      normalizeProject(item) {
+        return {
+          id: String(item.id || item.projectId || item.project_id || item.contractId || item.contract_id || ''),
+          contractId: String(item.contractId || item.contract_id || ''),
+          contractNo: item.contractNo || item.contract_no || '-',
+          productLine: item.productLine || item.product_line || '',
+          status: String(item.projectStatus || item.project_status || ''),
+          name: item.projectName || item.project_name || item.name || '-',
+          salesOwner: item.salesUserName || item.sales_user_name || '-',
+          deliveryOwner: item.deliveryUserName || item.delivery_user_name || '-',
+          attribute4: item.attribute4 || item.attribute_4 || '',
+        }
+      },
+      async fetchProjects() {
+        try {
+          const params = {
+            pageNum: 1,
+            pageSize: 999,
+            productLine: '10,20,30,40,50,60',
+            sortField: 'contract_no',
+            sortOrder: 'desc',
+            attribute9: '10',
+            attribute4: this.currentUserId,
+            projectStatus: '50',
+          }
+
+          const res = await deliveryProjectApi.getList(params)
+          const projectList = (res.data && res.data.list ? res.data.list : [])
+            .map(this.normalizeProject)
+            .filter((project) => {
+              if (!project.contractId) return false
+              if (!this.currentUserId) return true
+              return String(project.attribute4 || '') === String(this.currentUserId)
+            })
+
+          projectList.sort((a, b) => String(b.contractNo || '').localeCompare(String(a.contractNo || '')))
+          this.projects = projectList
+
+          if (
+            this.selectedContractId &&
+            !this.projects.some((project) => project.contractId === this.selectedContractId)
+          ) {
+            this.selectedContractId = ''
+            this.queryForm.contractId = ''
+            this.queryForm.pageNum = 1
+            await this.fetchData()
+          }
+        } catch (error) {
+          console.error('获取项目列表失败:', error)
+          this.projects = []
+        }
+      },
       async fetchData() {
         this.listLoading = true
         const params = {
@@ -259,6 +455,10 @@
           pageNum: this.queryForm.pageNum,
           pageSize: this.queryForm.pageSize,
         }
+        if (this.queryForm.contractId) {
+          params.contractId = this.queryForm.contractId
+        }
+
         const [err, res] = await to(operationEventApi.getHistoryList(params))
         if (err) {
           this.listLoading = false
@@ -270,6 +470,12 @@
         this.hasLoaded = true
         this.scheduleTableLayout()
       },
+      async selectProject(contractId) {
+        this.selectedContractId = contractId || ''
+        this.queryForm.contractId = contractId || ''
+        this.queryForm.pageNum = 1
+        await this.fetchData()
+      },
       queryData() {
         this.queryForm.pageNum = 1
         this.fetchData()
@@ -283,11 +489,20 @@
           feedbackReporter: '',
           opsUserName: '',
           dateRange: [],
+          contractId: this.selectedContractId,
           pageNum: 1,
           pageSize: 10,
         }
         this.fetchData()
       },
+      toggleSidebar() {
+        this.sidebarCollapsed = !this.sidebarCollapsed
+        this.scheduleTableLayout()
+      },
+      toggleSidebarFilters() {
+        this.showSidebarFilters = !this.showSidebarFilters
+        this.scheduleTableLayout()
+      },
       handleSizeChange(val) {
         this.queryForm.pageSize = val
         this.fetchData()
@@ -300,6 +515,9 @@
         this.currentRow = row
         this.detailVisible = true
       },
+      getProductLineLabel(productLine) {
+        return this.selectDictLabel(this.productLineDict, productLine) || productLine || '-'
+      },
       async handleExport() {
         const params = {
           scopeType: this.queryForm.scopeType,
@@ -312,6 +530,10 @@
             this.queryForm.dateRange && this.queryForm.dateRange.length === 2 ? this.queryForm.dateRange[0] : '',
           endTime: this.queryForm.dateRange && this.queryForm.dateRange.length === 2 ? this.queryForm.dateRange[1] : '',
         }
+        if (this.queryForm.contractId) {
+          params.contractId = this.queryForm.contractId
+        }
+
         const [err, res] = await to(operationEventApi.export(params))
         if (err) {
           return
@@ -344,25 +566,396 @@
   }
 </script>
 
-<style scoped>
-  .operation-history-container {
+<style lang="scss" scoped>
+  .operation-history-page {
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+    height: calc(100vh - 122px);
+    padding: 4px;
+    background: #f5f7fa;
+    overflow: hidden;
+  }
+
+  .query-form-container {
+    flex-shrink: 0;
+    padding: 8px 12px;
+    background: #fff;
+    border-radius: 6px;
+    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+  }
+
+  .main-content {
+    display: flex;
+    flex: 1;
+    gap: 4px;
+    min-height: 0;
+    margin-top: 4px;
+    overflow: hidden;
+  }
+
+  .project-sidebar {
+    display: flex;
+    flex-direction: column;
+    width: 280px;
+    min-height: 0;
+    background: #fff;
+    border-radius: 4px;
+    padding: 8px;
+    flex-shrink: 0;
+    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+    transition: width 0.2s ease, padding 0.2s ease;
+    overflow: hidden;
+
+    &.collapsed {
+      width: 44px;
+      padding: 8px 4px;
+      align-items: center;
+    }
+  }
+
+  .sidebar-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 6px;
+  }
+
+  .sidebar-title-group {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+  }
+
+  .sidebar-actions {
+    display: flex;
+    align-items: center;
+  }
+
+  .project-sidebar.collapsed .sidebar-header {
+    justify-content: center;
+    margin-bottom: 0;
+    width: 100%;
+  }
+
+  .sidebar-title {
+    font-size: 14px;
+    font-weight: 500;
+    color: #303133;
+  }
+
+  .sidebar-collapsed-label {
+    display: flex;
+    flex: 1;
+    align-items: center;
+    justify-content: flex-start;
+    padding-top: 12px;
+    writing-mode: vertical-rl;
+    text-orientation: upright;
+    white-space: nowrap;
+    font-size: 14px;
+    font-weight: 500;
+    color: #606266;
+    letter-spacing: 2px;
+    user-select: none;
+  }
+
+  .collapse-trigger {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 24px;
+    height: 24px;
+    padding: 0;
+    background: #f5f7fa;
+    border: none;
+    border-radius: 4px;
+    cursor: pointer;
+    color: #909399;
+    transition: all 0.2s;
+
+    &:hover {
+      background: #ecf5ff;
+      color: #409eff;
+    }
+  }
+
+  .sidebar-action-btn {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    width: 24px;
+    height: 24px;
     padding: 0;
+    background: #f5f7fa;
+    border: none;
+    border-radius: 4px;
+    cursor: pointer;
+    color: #909399;
+    transition: all 0.2s;
+
+    &:hover {
+      background: #ecf5ff;
+      color: #409eff;
+    }
+
+    &.active {
+      background: #ecf5ff;
+      color: #409eff;
+    }
+
+    i {
+      font-size: 12px;
+      line-height: 1;
+    }
+  }
+
+  .project-search-wrapper {
+    margin-bottom: 6px;
+  }
+
+  .project-search {
+    margin-bottom: 6px;
+  }
+
+  .project-list {
+    flex: 1;
+    min-height: 0;
+    overflow-y: auto;
+    padding-right: 2px;
+  }
+
+  .project-item {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 10px 12px;
+    cursor: pointer;
+    border-radius: 12px;
+    transition: all 0.2s;
+    border: 1px solid transparent;
+
+    &:hover {
+      background: #f8fbff;
+    }
+
+    &.active {
+      background: linear-gradient(180deg, #eef6ff 0%, #e6f0ff 100%);
+      border-color: #bfd9ff;
+      box-shadow: 0 8px 18px rgba(64, 158, 255, 0.12);
+    }
+  }
+
+  .project-item--all {
+    margin-bottom: 8px;
+    background: linear-gradient(135deg, #f8fbff 0%, #f3f7fd 100%);
+    border-color: #e4edf7;
+  }
+
+  .project-overview-label {
+    font-size: 15px;
+    font-weight: 600;
+    color: #303133;
+  }
+
+  .project-overview-desc {
+    font-size: 12px;
+    color: #909399;
+    margin-top: 2px;
+  }
+
+  .project-card {
     position: relative;
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+    min-height: 98px;
+    padding: 8px 10px;
+    border-radius: 14px;
+    border: 1px solid #ebeef5;
+    background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
+    box-shadow: 0 8px 18px rgba(15, 23, 42, 0.05);
+    cursor: pointer;
+    transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
+
+    &:not(:last-child) {
+      margin-bottom: 8px;
+    }
+
+    &:hover {
+      transform: translateY(-1px);
+      border-color: #d5e7ff;
+      box-shadow: 0 12px 22px rgba(64, 158, 255, 0.12);
+    }
+
+    &.active {
+      border-color: #8bb8ff;
+      background: linear-gradient(180deg, #eff6ff 0%, #f7fbff 100%);
+      box-shadow: 0 14px 26px rgba(64, 158, 255, 0.16);
+    }
   }
 
-  .table-wrapper {
-    min-height: 240px;
+  .project-card-top {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    margin-bottom: 6px;
   }
 
-  .operation-history-container :deep(.el-pagination) {
-    margin-top: 8px;
-    margin-bottom: 0;
+  .project-card-tags {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    flex: 1;
+    min-width: 0;
+  }
+
+  .project-contract {
+    max-width: 108px;
+    padding: 2px 8px;
+    font-size: 11px;
+    color: #5f6b7a;
+    background: #f2f6fc;
+    border-radius: 999px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
   }
 
-  .export-btn-fixed {
+  .project-line-tag {
+    flex-shrink: 0;
+    padding: 2px 8px;
+    font-size: 11px;
+    color: #409eff;
+    background: #ecf5ff;
+    border-radius: 999px;
+    white-space: nowrap;
+  }
+
+  .project-status-tag {
+    flex-shrink: 0;
+    padding: 2px 8px;
+    font-size: 11px;
+    color: #fff;
+    background: #909399;
+    border-radius: 999px;
+    white-space: nowrap;
+
+    &--10 {
+      background: #909399;
+    }
+
+    &--20 {
+      background: #409eff;
+    }
+
+    &--30 {
+      background: #e6a23c;
+    }
+
+    &--40 {
+      background: #67c23a;
+    }
+
+    &--50 {
+      background: #1f9d55;
+    }
+
+    &--90 {
+      background: #f56c6c;
+    }
+  }
+
+  .project-card-title {
+    margin-bottom: 8px;
+    font-size: 14px;
+    font-weight: 600;
+    line-height: 1.3;
+    color: #303133;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .project-card-meta {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+  }
+
+  .project-card-meta-item {
+    display: flex;
+    align-items: center;
+    flex: 1;
+    gap: 6px;
+    min-width: 0;
+    padding: 4px 8px;
+    background: #f7f9fc;
+    border-radius: 10px;
+  }
+
+  .project-card-meta-icon {
+    flex-shrink: 0;
+    font-size: 14px;
+    color: #409eff;
+  }
+
+  .project-card-meta-icon--delivery {
+    color: #67c23a;
+  }
+
+  .project-card-meta-value {
+    flex: 1;
+    min-width: 0;
+    font-size: 13px;
+    color: #606266;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  .project-card-check {
     position: absolute;
     top: 12px;
-    right: 10px;
-    z-index: 100;
+    right: 12px;
+    font-size: 14px;
+    color: #409eff;
+  }
+
+  .content-panel {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    min-width: 0;
+    min-height: 0;
+    overflow: hidden;
+  }
+
+  .table-container {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    min-width: 0;
+    min-height: 0;
+    padding: 8px;
+    background: #fff;
+    border-radius: 4px;
+    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+    overflow: hidden;
+  }
+
+  .table-scroll-wrapper {
+    display: flex;
+    flex: 1;
+    min-width: 0;
+    min-height: 0;
+    overflow: hidden;
+  }
+
+  .operation-history-page :deep(.el-pagination) {
+    margin-top: 8px;
+    margin-bottom: 0;
   }
 </style>

+ 24 - 8
src/views/devops/project/index.vue

@@ -4,14 +4,15 @@
     <div class="query-form-container">
       <div class="query-form-basic">
         <el-form class="query-form-fields" :inline="true" :model="queryForm" size="small">
+          <el-form-item>
+            <el-radio-group v-model="queryForm.projectStatusType" size="small">
+              <el-radio-button label="delivery">交付</el-radio-button>
+              <el-radio-button label="operation">运维</el-radio-button>
+            </el-radio-group>
+          </el-form-item>
           <el-form-item label="项目名称">
             <el-input v-model="queryForm.projectName" clearable placeholder="请输入" style="width: 180px" />
           </el-form-item>
-          <el-form-item label="项目状态">
-            <el-select v-model="queryForm.projectStatus" clearable placeholder="请选择" style="width: 120px">
-              <el-option v-for="item in projectStatusOptions" :key="item.key" :label="item.value" :value="item.key" />
-            </el-select>
-          </el-form-item>
           <el-form-item label="计划交付时间">
             <el-date-picker
               v-model="queryForm.planDeliveryTimeRange"
@@ -238,7 +239,7 @@
           contractNo: '',
           projectName: '',
           productLine: '',
-          projectStatus: '',
+          projectStatusType: 'delivery',
           deliveryUserId: null,
           salesUserId: null,
           planDeliveryTimeRange: [],
@@ -302,6 +303,14 @@
         }
         delete params.planDeliveryTimeRange
 
+        // 根据项目状态类型设置对应的状态列表
+        if (params.projectStatusType === 'delivery') {
+          params.projectStatusList = [10, 20, 30, 40]
+        } else if (params.projectStatusType === 'operation') {
+          params.projectStatusList = [50]
+        }
+        delete params.projectStatusType
+
         if (this.selectedPerson !== 'all') {
           params.deliveryUserId = parseInt(this.selectedPerson)
         }
@@ -329,7 +338,7 @@
           contractNo: '',
           projectName: '',
           productLine: '',
-          projectStatus: '',
+          projectStatusType: 'delivery',
           deliveryUserId: null,
           salesUserId: null,
           planDeliveryTimeRange: [],
@@ -370,9 +379,16 @@
           contractNo: this.queryForm.contractNo,
           projectName: this.queryForm.projectName,
           productLine: this.queryForm.productLine,
-          projectStatus: this.queryForm.projectStatus,
           deliveryUserId: this.selectedPerson !== 'all' ? parseInt(this.selectedPerson) : 0,
         }
+
+        // 根据项目状态类型设置对应的状态列表
+        if (this.queryForm.projectStatusType === 'delivery') {
+          params.projectStatusList = [10, 20, 30, 40]
+        } else if (this.queryForm.projectStatusType === 'operation') {
+          params.projectStatusList = [50]
+        }
+
         if (this.queryForm.planDeliveryTimeRange && this.queryForm.planDeliveryTimeRange.length === 2) {
           params.planDeliveryTimeStart = this.queryForm.planDeliveryTimeRange[0]
           params.planDeliveryTimeEnd = this.queryForm.planDeliveryTimeRange[1]

+ 47 - 2
src/views/devops/software/components/TaskDetailDialog.vue

@@ -4,6 +4,7 @@
       append-to-body
       class="task-detail-dialog"
       :close-on-click-modal="false"
+      :lock-scroll="false"
       :show-close="false"
       top="5vh"
       :visible="visible"
@@ -26,7 +27,11 @@
             <el-button size="small" @click="handleClose">取消</el-button>
             <el-button :loading="submitLoading" size="small" type="primary" @click="handleEditSubmit">保存</el-button>
           </template>
-          <el-button v-else size="small" @click="handleClose">关闭</el-button>
+          <template v-else>
+            <el-button v-if="isProcessingTask" size="small" @click="handleAddWorkHour">工时</el-button>
+            <el-button v-if="isProcessingTask" size="small" type="primary" @click="handleCompleteTask">完成</el-button>
+            <el-button size="small" @click="handleClose">关闭</el-button>
+          </template>
         </div>
       </div>
 
@@ -482,6 +487,21 @@
       :task-id="detailData && detailData.id"
       :visible.sync="workHourListDialogVisible"
       @close="workHourListDialogVisible = false" />
+
+    <!-- 工时登记弹窗 -->
+    <work-hour-dialog
+      :current-actual-work-hour="detailData && detailData.actualWorkHour"
+      :task-id="detailData && detailData.id"
+      :task-status="detailData && detailData.taskStatus"
+      :visible.sync="workHourDialogVisible"
+      @refresh="handleRefreshData" />
+
+    <!-- 完成任务弹窗 -->
+    <complete-dialog
+      :task-data="detailData"
+      :task-id="detailData && detailData.id"
+      :visible.sync="completeDialogVisible"
+      @refresh="handleRefreshData" />
   </div>
 </template>
 
@@ -497,10 +517,12 @@
   import debounce from 'lodash/debounce'
   import ReleaseTaskListDialog from './ReleaseTaskListDialog.vue'
   import WorkHourListDialog from './WorkHourListDialog.vue'
+  import WorkHourDialog from './WorkHourDialog.vue'
+  import CompleteDialog from './CompleteDialog.vue'
 
   export default {
     name: 'TaskDetailDialog',
-    components: { Editor, Toolbar, ReleaseTaskListDialog, WorkHourListDialog },
+    components: { Editor, Toolbar, ReleaseTaskListDialog, WorkHourListDialog, WorkHourDialog, CompleteDialog },
     props: {
       visible: {
         type: Boolean,
@@ -523,6 +545,8 @@
         relatedTaskList: [],
         releaseDialogVisible: false,
         workHourListDialogVisible: false,
+        workHourDialogVisible: false,
+        completeDialogVisible: false,
         // 参会人员列表(需求评审类型)
         participantList: [],
         // 快速登记相关
@@ -617,6 +641,9 @@
       isRequirementReviewType() {
         return this.detailData && String(this.detailData.taskType) === '10'
       },
+      isProcessingTask() {
+        return this.detailData && String(this.detailData.taskStatus) === '20'
+      },
     },
     watch: {
       visible(val) {
@@ -666,6 +693,22 @@
       getTaskStatusTagType(status) {
         return getTagType(taskStatusTagTypes, status, 'info')
       },
+      handleAddWorkHour() {
+        this.workHourDialogVisible = true
+      },
+      handleCompleteTask() {
+        this.completeDialogVisible = true
+      },
+      handleRefreshData() {
+        this.workHourDialogVisible = false
+        this.completeDialogVisible = false
+        if (this.detailData && this.detailData.id) {
+          this.fetchRecordList()
+          this.fetchAttachmentList()
+        }
+        // Emit refresh so parent (e.g. dashboard) can reload too
+        this.$emit('refresh')
+      },
       getOptions() {
         Promise.all([
           this.getDicts('ops_task_type'),
@@ -733,6 +776,8 @@
         this.$emit('update:visible', false)
         this.releaseDialogVisible = false
         this.workHourListDialogVisible = false
+        this.workHourDialogVisible = false
+        this.completeDialogVisible = false
         this.isTaskDescExpanded = false
         // 重置参会人员
         this.participantList = []

+ 13 - 2
src/views/devops/software/components/TaskEditDialog.vue

@@ -41,6 +41,7 @@
         <el-select
           v-model="form.projectId"
           clearable
+          :disabled="isFromDevTask"
           filterable
           :loading="loadingProjects"
           placeholder="请输入项目编码、项目名称搜索"
@@ -60,7 +61,7 @@
         <el-col :span="12">
           <!-- 功能模块 -->
           <el-form-item label="功能模块" prop="functionName">
-            <el-input v-model="form.functionName" placeholder="请输入功能模块" />
+            <el-input v-model="form.functionName" :disabled="isFromDevTask" placeholder="请输入功能模块" />
           </el-form-item>
         </el-col>
         <el-col :span="12">
@@ -68,7 +69,7 @@
           <el-form-item label="任务类型" prop="taskType">
             <el-select
               v-model="form.taskType"
-              :disabled="isBugTaskFromFail"
+              :disabled="isBugTaskFromFail || isFromDevTask"
               placeholder="请选择任务类型"
               style="width: 100%"
               @change="handleTaskTypeChange">
@@ -326,6 +327,10 @@
       isRequirementReviewType() {
         return this.form.taskType === '10'
       },
+      // 是否为从需求评审"研发"按钮跳转创建的功能开发任务
+      isFromDevTask() {
+        return this.taskData && this.taskData.isFromDevTask === true
+      },
     },
     watch: {
       visible(val) {
@@ -624,6 +629,9 @@
             if (this.taskData.taskTitle) {
               this.form.taskTitle = this.taskData.taskTitle
             }
+            if (this.taskData.taskDesc) {
+              this.form.taskDesc = this.taskData.taskDesc
+            }
             if (this.taskData.functionName) {
               this.form.functionName = this.taskData.functionName
             }
@@ -634,6 +642,9 @@
               this.isBugTaskFromFail = true
               this.form.taskType = '35' // 强制设置为BUG类型
             }
+            if (this.taskData.taskType) {
+              this.form.taskType = this.taskData.taskType
+            }
             if (this.taskData.projectId) {
               this.form.projectId = String(this.taskData.projectId)
             }

+ 709 - 0
src/views/devops/software/dashboard.vue

@@ -0,0 +1,709 @@
+<template>
+  <div class="dashboard-container">
+    <!-- 顶部栏 -->
+    <div class="top-bar">
+      <div class="top-bar-left">
+        <span class="today-badge">Today</span>
+        <div class="week-info">
+          <div class="week-range">{{ weekRangeText }}</div>
+          <div class="week-total">
+            本周工时:
+            <span class="highlight">{{ weekTotalDisplay }}</span>
+            / {{ weeklyTargetHours }}h
+          </div>
+        </div>
+        <div v-if="overdueTaskCount > 0" class="overdue-badge">
+          <i class="el-icon-warning" />
+          超期任务:
+          <span class="overdue-count">{{ overdueTaskCount }}</span>
+        </div>
+      </div>
+      <div class="top-bar-right">
+        <el-button-group>
+          <el-button icon="el-icon-arrow-left" size="mini" @click="prevWeek" />
+          <el-button icon="el-icon-arrow-right" size="mini" @click="nextWeek" />
+        </el-button-group>
+        <el-button-group style="margin-left: 12px">
+          <el-button
+            icon="el-icon-date"
+            size="mini"
+            :type="viewMode === 'calendar' ? 'primary' : 'default'"
+            @click="viewMode = 'calendar'" />
+          <el-button
+            icon="el-icon-s-grid"
+            size="mini"
+            :type="viewMode === 'grid' ? 'primary' : 'default'"
+            @click="viewMode = 'grid'" />
+        </el-button-group>
+      </div>
+    </div>
+
+    <!-- 周视图 -->
+    <div v-if="viewMode === 'calendar'" class="week-view">
+      <div v-for="day in weekDays" :key="day.dateStr" class="day-column" :class="{ 'is-today': day.isToday }">
+        <!-- 列头 -->
+        <div class="day-header">
+          <div class="day-header-row">
+            <span class="day-label">{{ day.weekLabel }}</span>
+            <span class="day-date">{{ day.dateLabel }}</span>
+          </div>
+          <div class="day-hours">
+            <span>{{ day.hoursDisplay }}</span>
+            <span v-if="!day.isWeekend" class="day-target">of {{ day.dailyTarget }}h</span>
+            <span v-else class="day-target">0h</span>
+          </div>
+          <div class="day-progress-bar">
+            <div class="progress-track">
+              <div
+                class="progress-fill"
+                :class="{
+                  'is-complete': day.percentage >= 100,
+                  'is-overdue': day.isWorkday && day.percentage < 100,
+                }"
+                :style="{ width: day.percentage + '%' }" />
+            </div>
+          </div>
+        </div>
+
+        <!-- 日志区域 -->
+        <div class="day-content">
+          <div class="logs-divider">
+            <span class="divider-text">工作日志</span>
+          </div>
+          <div class="logs-list">
+            <div v-for="log in day.logs" :key="log.id" class="log-card" @click="handleEdit(log)">
+              <div class="log-card-body">
+                <div class="log-title" :title="log.taskTitle">{{ log.taskTitle }}</div>
+                <div class="log-subtitle">
+                  <span :class="['status-dot', log.statusClass]"></span>
+                  <span v-if="log.taskNo" class="log-no">{{ log.taskNo }}</span>
+                </div>
+                <div v-if="log.planEndTime || log.timeDisplay" class="log-meta-row">
+                  <span v-if="log.planEndTime" class="log-end-date">{{ log.planEndTime }}</span>
+                  <span v-if="log.timeDisplay" class="log-time">{{ log.timeDisplay }}</span>
+                </div>
+              </div>
+            </div>
+            <div v-if="day.logs.length === 0" class="empty-logs">
+              <i class="el-icon-document" />
+              <span>暂无工作日志</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 列表视图 -->
+    <div v-else class="grid-view">
+      <el-table border :data="flatTaskList" size="small" style="width: 100%">
+        <el-table-column label="任务标题" min-width="260" prop="taskTitle" show-overflow-tooltip>
+          <template #default="{ row }">
+            <el-button type="text" @click="handleEdit(row)">{{ row.taskTitle }}</el-button>
+          </template>
+        </el-table-column>
+        <el-table-column label="编号" prop="taskNo" show-overflow-tooltip width="140" />
+        <el-table-column align="center" label="任务类型" prop="taskType" width="100">
+          <template #default="{ row }">
+            <el-tag size="mini" :type="getTypeTagType(row.taskType)">
+              {{ selectDictLabel(taskTypeOptions, row.taskType) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="状态" prop="taskStatus" width="100">
+          <template #default="{ row }">
+            <el-tag size="mini" :type="getTaskStatusTagType(row.taskStatus)">
+              {{ selectDictLabel(taskStatusOptions, row.taskStatus) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column align="center" label="优先级" prop="priority" width="90">
+          <template #default="{ row }">
+            <el-tag size="mini" :type="getPriorityTagType(row.priority)">
+              {{ selectDictLabel(priorityOptions, row.priority) }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="项目" prop="projectName" show-overflow-tooltip width="160" />
+      </el-table>
+    </div>
+
+    <TaskDetailDialog
+      :detail-data="currentTaskData"
+      :mode="detailMode"
+      :visible.sync="detailDialogVisible"
+      @refresh="fetchData" />
+  </div>
+</template>
+
+<script>
+  import TaskDetailDialog from './components/TaskDetailDialog'
+  import opsEventTaskApi from '@/api/devops/opsEventTask'
+  import { parseTime } from '@/utils'
+  import { opsPriorityTagTypes, taskStatusTagTypes, getTagType } from '@/config/devopsTagTypes'
+
+  const WEEK_LABELS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
+  const TASK_STATUS_MAP = {
+    10: { label: '待处理', class: 'status-pending' },
+    20: { label: '处理中', class: 'status-progress' },
+    25: { label: '暂停', class: 'status-suspended' },
+    30: { label: '已完成', class: 'status-closed' },
+    70: { label: '阻塞', class: 'status-dev' },
+    90: { label: '作废', class: 'status-suspended' },
+  }
+
+  export default {
+    name: 'SoftwareDashboard',
+    components: { TaskDetailDialog },
+    data() {
+      const now = new Date()
+      const dayOfWeek = now.getDay() || 7
+      const monday = new Date(now)
+      monday.setDate(now.getDate() - dayOfWeek + 1)
+      monday.setHours(0, 0, 0, 0)
+
+      return {
+        currentWeekStart: monday,
+        viewMode: 'calendar',
+        dashboardData: null,
+        taskStatusOptions: [],
+        priorityOptions: [],
+        detailDialogVisible: false,
+        detailMode: 'view',
+        currentTaskData: {},
+        weeklyTargetHours: 40,
+      }
+    },
+    computed: {
+      overdueTaskCount() {
+        return (this.dashboardData && this.dashboardData.overdueCount) || 0
+      },
+      weekDays() {
+        if (!this.dashboardData || !this.dashboardData.days) return this.buildEmptyWeek()
+
+        return this.dashboardData.days.map((day, i) => {
+          const isWeekend = i >= 5
+          const dailyTarget = isWeekend ? 0 : day.targetHours || 8
+          const totalHours = day.totalHours || 0
+          const percentage = dailyTarget > 0 ? Math.min(Math.round((totalHours / dailyTarget) * 100), 100) : 0
+          const logs = (day.tasks || []).map((task) => this.mapTaskToLog(task))
+
+          return {
+            dateStr: day.date || '',
+            weekLabel: WEEK_LABELS[i],
+            dateLabel: this.formatDateLabel(day.date),
+            isToday: this.isDateToday(day.date),
+            isWeekend: isWeekend,
+            isWorkday: !isWeekend,
+            dailyTarget: dailyTarget,
+            logs: logs,
+            totalMinutes: Math.round(totalHours * 60),
+            percentage: percentage,
+            hoursDisplay: totalHours > 0 ? this.formatHours(totalHours) : '0h',
+          }
+        })
+      },
+      weekRangeText() {
+        const start = this.currentWeekStart
+        const end = new Date(start)
+        end.setDate(end.getDate() + 6)
+        return `${start.getDate()}/${start.getMonth() + 1}月/${String(start.getFullYear()).slice(
+          2
+        )} - ${end.getDate()}/${end.getMonth() + 1}月/${String(end.getFullYear()).slice(2)}`
+      },
+      weekTotalMinutes() {
+        if (this.dashboardData && this.dashboardData.weekTotalHours) {
+          return Math.round(this.dashboardData.weekTotalHours * 60)
+        }
+        return this.weekDays.reduce((sum, day) => sum + day.totalMinutes, 0)
+      },
+      weekTotalDisplay() {
+        return this.formatMinutes(this.weekTotalMinutes)
+      },
+      flatTaskList() {
+        if (!this.dashboardData || !this.dashboardData.days) return []
+        const allItems = []
+        this.dashboardData.days.forEach((day) => {
+          if (day.tasks) {
+            day.tasks.forEach((task) => {
+              allItems.push({ ...task })
+            })
+          }
+        })
+        return allItems
+      },
+    },
+    created() {
+      this.getOptions()
+      this.fetchData()
+    },
+    methods: {
+      getTagType,
+      getTaskStatusTagType(status) {
+        return getTagType(taskStatusTagTypes, status, 'info')
+      },
+      getPriorityTagType(level) {
+        return getTagType(opsPriorityTagTypes, level, 'success')
+      },
+      getTypeTagType(type) {
+        return getTagType(taskStatusTagTypes, type, '')
+      },
+      getOptions() {
+        Promise.all([this.getDicts('ops_task_type'), this.getDicts('ops_task_status'), this.getDicts('ops_priority')])
+          .then(([taskType, taskStatus, priority]) => {
+            this.taskTypeOptions = taskType.data.values || []
+            this.taskStatusOptions = taskStatus.data.values || []
+            this.priorityOptions = priority.data.values || []
+          })
+          .catch((err) => console.log(err))
+      },
+      async fetchData() {
+        try {
+          const startDate = this.formatDateKey(this.currentWeekStart)
+          const endDate = this.formatDateKey(new Date(this.currentWeekStart.getTime() + 6 * 86400000))
+          const res = await opsEventTaskApi.getWorkHourDashboardData({ startDate, endDate })
+          if (res.code === 200 && res.data) {
+            this.dashboardData = res.data
+            this.weeklyTargetHours = res.data.weekTargetHours || 40
+          }
+        } catch (error) {
+          console.error('获取工作台数据失败:', error)
+        }
+      },
+      mapTaskToLog(task) {
+        const statusInfo = TASK_STATUS_MAP[String(task.taskStatus)] || { label: '未知', class: 'status-pending' }
+        const actual = task.actualWorkHour || 0
+        const estimate = task.estimateWorkHour || 0
+        const timeDisplay =
+          estimate > 0
+            ? `${this.formatHours(actual).replace(/h$/, '')} / ${this.formatHours(estimate)}`
+            : this.formatHours(actual)
+
+        return {
+          id: task.id || task.taskId,
+          taskId: task.taskId || task.id,
+          taskTitle: task.taskTitle || '无标题',
+          taskNo: task.taskNo || '',
+          statusClass: statusInfo.class,
+          timeDisplay: timeDisplay,
+          planEndTime: task.planEndTime || '',
+          raw: task,
+        }
+      },
+      buildEmptyWeek() {
+        const days = []
+        for (let i = 0; i < 7; i++) {
+          const date = new Date(this.currentWeekStart)
+          date.setDate(date.getDate() + i)
+          const dateStr = this.formatDateKey(date)
+          const isWeekend = i >= 5
+          days.push({
+            dateStr: dateStr,
+            weekLabel: WEEK_LABELS[i],
+            dateLabel: this.formatDateLabel(dateStr),
+            isToday: this.isDateToday(dateStr),
+            isWeekend: isWeekend,
+            isWorkday: !isWeekend,
+            dailyTarget: isWeekend ? 0 : 8,
+            logs: [],
+            totalMinutes: 0,
+            percentage: 0,
+            hoursDisplay: '0h',
+          })
+        }
+        return days
+      },
+      isDateToday(dateStr) {
+        return dateStr === this.formatDateKey(new Date())
+      },
+      formatDateLabel(dateStr) {
+        if (!dateStr) return ''
+        const parts = dateStr.split('-')
+        if (parts.length !== 3) return dateStr
+        return `${parts[1]}.${parts[2]}`
+      },
+      formatHours(hours) {
+        const h = Number(hours)
+        if (!h || h <= 0) return '0h'
+        return h % 1 === 0 ? `${h}h` : `${h.toFixed(1)}h`
+      },
+      formatMinutes(minutes) {
+        const h = Math.floor(minutes / 60)
+        const m = minutes % 60
+        if (h === 0 && m === 0) return '0h'
+        if (m === 0) return `${h}h`
+        return `${h}h ${m}m`
+      },
+      formatDateKey(date) {
+        if (!date || !(date instanceof Date) || isNaN(date.getTime())) return ''
+        const y = date.getFullYear()
+        const m = String(date.getMonth() + 1).padStart(2, '0')
+        const d = String(date.getDate()).padStart(2, '0')
+        return `${y}-${m}-${d}`
+      },
+      formatTime(time) {
+        return time ? parseTime(time, '{y}-{m}-{d}') : '-'
+      },
+      prevWeek() {
+        const newStart = new Date(this.currentWeekStart)
+        newStart.setDate(newStart.getDate() - 7)
+        this.currentWeekStart = newStart
+        this.fetchData()
+      },
+      nextWeek() {
+        const newStart = new Date(this.currentWeekStart)
+        newStart.setDate(newStart.getDate() + 7)
+        this.currentWeekStart = newStart
+        this.fetchData()
+      },
+      async handleEdit(log) {
+        const taskId = (log.raw && log.raw.taskId) || (log.raw && log.raw.id) || log.taskId
+        if (!taskId) return
+        try {
+          const res = await opsEventTaskApi.getById(taskId)
+          if (res.code === 200 && res.data && res.data.data) {
+            this.currentTaskData = res.data.data
+          } else {
+            this.currentTaskData = log.raw || log
+          }
+        } catch (e) {
+          this.currentTaskData = log.raw || log
+        }
+        this.detailMode = 'view'
+        this.detailDialogVisible = true
+      },
+    },
+  }
+</script>
+
+<style lang="scss" scoped>
+  .dashboard-container {
+    padding: 0;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+    height: calc(100vh - 60px - 12px * 2 - 40px - 10px);
+  }
+
+  .top-bar {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 12px 0;
+    border-bottom: 1px solid #ebeef5;
+    flex-shrink: 0;
+  }
+
+  .top-bar-left {
+    display: flex;
+    align-items: center;
+    gap: 16px;
+  }
+
+  .today-badge {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    padding: 4px 14px;
+    background: #4d96ff;
+    color: #fff;
+    border-radius: 14px;
+    font-size: 13px;
+    font-weight: 600;
+    letter-spacing: 0.5px;
+  }
+
+  .overdue-badge {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    padding: 4px 14px;
+    background: #fff1f0;
+    color: #f56c6c;
+    border: 1px solid #fbc4c4;
+    border-radius: 14px;
+    font-size: 13px;
+    font-weight: 500;
+
+    .overdue-count {
+      font-weight: 700;
+      color: #f56c6c;
+    }
+
+    i {
+      font-size: 14px;
+    }
+  }
+
+  .week-info {
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+  }
+
+  .week-range {
+    font-size: 15px;
+    font-weight: 600;
+    color: #303133;
+    letter-spacing: 0.3px;
+  }
+
+  .week-total {
+    font-size: 12px;
+    color: #909399;
+
+    .highlight {
+      color: #4d96ff;
+      font-weight: 600;
+    }
+  }
+
+  .top-bar-right {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .week-view {
+    display: flex;
+    gap: 10px;
+    flex: 1;
+    min-height: 0;
+    overflow-x: auto;
+    padding-top: 12px;
+  }
+
+  .day-column {
+    flex: 1;
+    min-width: 180px;
+    max-width: 1 / 7 * 100%;
+    display: flex;
+    flex-direction: column;
+    background: #f7f9fc;
+    border-radius: 10px;
+    overflow: hidden;
+    transition: box-shadow 0.2s ease;
+
+    &.is-today {
+      background: #eef4ff;
+      box-shadow: 0 0 0 2px #4d96ff inset;
+    }
+  }
+
+  .day-header {
+    padding: 12px 14px 10px;
+    background: #ffffff;
+    flex-shrink: 0;
+  }
+
+  .day-header-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 4px;
+  }
+
+  .day-label {
+    font-size: 14px;
+    font-weight: 600;
+    color: #303133;
+  }
+
+  .day-date {
+    font-size: 13px;
+    color: #606266;
+  }
+
+  .day-hours {
+    font-size: 12px;
+    color: #909399;
+    margin-bottom: 6px;
+  }
+
+  .day-target {
+    color: #c0c4cc;
+  }
+
+  .day-progress-bar {
+    .progress-track {
+      height: 4px;
+      background: #e8ecf1;
+      border-radius: 2px;
+      overflow: hidden;
+    }
+
+    .progress-fill {
+      height: 100%;
+      border-radius: 2px;
+      background: #4d96ff;
+      transition: width 0.4s ease;
+
+      &.is-complete {
+        background: #52c41a;
+      }
+
+      &.is-overdue {
+        background: #4d96ff;
+      }
+    }
+  }
+
+  .day-content {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+    padding: 6px 10px 10px;
+    overflow-y: auto;
+
+    &::-webkit-scrollbar {
+      width: 4px;
+    }
+    &::-webkit-scrollbar-track {
+      background: transparent;
+    }
+    &::-webkit-scrollbar-thumb {
+      background: #d0d5dd;
+      border-radius: 2px;
+    }
+  }
+
+  .logs-divider {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    margin-bottom: 10px;
+    flex-shrink: 0;
+
+    .divider-text {
+      font-size: 12px;
+      color: #b0b8c5;
+      white-space: nowrap;
+      letter-spacing: 0.5px;
+    }
+
+    &::after {
+      content: '';
+      flex: 1;
+      height: 1px;
+      background: #e4e7ed;
+    }
+  }
+
+  .logs-list {
+    flex: 1;
+    min-height: 0;
+  }
+
+  .log-card {
+    position: relative;
+    background: #ffffff;
+    border: 1px solid #e8ecf1;
+    border-radius: 10px;
+    padding: 14px 16px;
+    margin-bottom: 12px;
+    cursor: pointer;
+    transition: all 0.2s ease;
+
+    &:hover {
+      border-color: #4d96ff;
+      box-shadow: 0 6px 16px rgba(77, 150, 255, 0.15);
+      transform: translateY(-2px);
+    }
+
+    &:active {
+      transform: translateY(0);
+    }
+  }
+
+  .log-card-body {
+    margin-bottom: 10px;
+  }
+
+  .log-title {
+    font-size: 13px;
+    font-weight: 500;
+    color: #303133;
+    line-height: 1.5;
+    margin-bottom: 6px;
+    display: -webkit-box;
+    -webkit-line-clamp: 2;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  .log-subtitle {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    font-size: 12px;
+    color: #909399;
+    line-height: 1;
+  }
+
+  .status-dot {
+    display: inline-block;
+    width: 6px;
+    height: 6px;
+    border-radius: 50%;
+    flex-shrink: 0;
+
+    &.status-pending {
+      background: #909399;
+    }
+    &.status-progress {
+      background: #4d96ff;
+    }
+    &.status-dev {
+      background: #e6a23c;
+    }
+    &.status-suspended {
+      background: #f56c6c;
+    }
+    &.status-closed {
+      background: #52c41a;
+    }
+  }
+
+  .log-no {
+    margin-left: 2px;
+  }
+
+  .log-meta-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-top: 6px;
+    font-size: 12px;
+    line-height: 1.4;
+
+    .log-end-date {
+      color: #606266;
+    }
+
+    .log-time {
+      color: #4d96ff;
+      font-weight: 500;
+    }
+  }
+
+  .empty-logs {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 24px 8px;
+    color: #c0c4cc;
+    font-size: 13px;
+    gap: 6px;
+
+    i {
+      font-size: 24px;
+    }
+  }
+
+  .grid-view {
+    flex: 1;
+    padding-top: 16px;
+  }
+</style>

+ 25 - 18
src/views/devops/software/index.vue

@@ -128,7 +128,8 @@
             <div class="project-status-filter">
               <el-radio-group v-model="projectStatusFilter" size="small" @change="handleProjectStatusChange">
                 <el-radio-button label="">全部</el-radio-button>
-                <el-radio-button label="undelivered">未验收</el-radio-button>
+                <el-radio-button label="pending">待分配</el-radio-button>
+                <el-radio-button label="delivering">交付中</el-radio-button>
                 <el-radio-button label="delivered">已验收</el-radio-button>
               </el-radio-group>
             </div>
@@ -260,17 +261,6 @@
                 </el-tag>
               </template>
             </el-table-column>
-            <el-table-column
-              v-if="isColumnVisible('priority')"
-              label="优先级"
-              :render-header="renderSortableHeader('优先级', 'priority')"
-              width="104">
-              <template slot-scope="{ row }">
-                <el-tag size="small" :type="getPriorityTagType(row.priority)">
-                  {{ selectDictLabel(priorityOptions, row.priority) }}
-                </el-tag>
-              </template>
-            </el-table-column>
             <el-table-column
               v-if="isColumnVisible('opsUserName')"
               label="负责人"
@@ -548,7 +538,6 @@
     { key: 'functionName', label: '功能模块' },
     { key: 'taskType', label: '任务类型' },
     { key: 'taskStatus', label: '任务状态' },
-    { key: 'priority', label: '优先级' },
     { key: 'opsUserName', label: '负责人' },
     { key: 'planStartTime', label: '计划开始时间' },
     { key: 'planEndTime', label: '计划结束时间' },
@@ -603,9 +592,9 @@
         loading: false,
         showAdvanced: false,
         sidebarCollapsed: false,
-        showSidebarFilters: false,
+        showSidebarFilters: true,
         projectSearch: '',
-        projectStatusFilter: '',
+        projectStatusFilter: 'delivering',
         selectedProject: '',
         projects: [{ id: '', name: '全部' }],
         productLineOptions: [],
@@ -648,8 +637,10 @@
 
           if (statusFilter) {
             let statusList = []
-            if (statusFilter === 'undelivered') {
-              statusList = ['10', '20', '30', '40']
+            if (statusFilter === 'pending') {
+              statusList = ['10']
+            } else if (statusFilter === 'delivering') {
+              statusList = ['20', '30', '40']
             } else if (statusFilter === 'delivered') {
               statusList = ['50']
             } else {
@@ -785,7 +776,8 @@
           }
           if (this.projectStatusFilter) {
             const statusMap = {
-              undelivered: '10,20,30,40',
+              pending: '10',
+              delivering: '20,30,40',
               delivered: '50',
             }
             params.projectStatus = statusMap[this.projectStatusFilter] || this.projectStatusFilter
@@ -1009,6 +1001,8 @@
           30: [
             // 功能测试已完成且未登记BUG,显示BUG按钮
             ...(taskType === '30' && String(row.attribute1) !== '10' ? [{ key: 'bug', label: 'BUG' }] : []),
+            // 需求评审已完成,显示研发按钮
+            ...(taskType === '10' ? [{ key: 'develop', label: '研发' }] : []),
           ],
           70: [
             { key: 'start', label: '开始' },
@@ -1107,6 +1101,7 @@
           block: this.handleBlock,
           cancel: this.handleCancel,
           start: this.handleStart,
+          develop: this.handleDevelop,
         }
         const handler = actionHandlers[actionKey]
         if (handler) {
@@ -1312,6 +1307,18 @@
           }
         }
       },
+      // 需求评审完成后,创建研发子任务
+      handleDevelop(row) {
+        this.editData = {
+          taskParentId: row.id,
+          projectId: row.projectId,
+          functionName: row.functionName || '',
+          taskType: '20', // 功能开发
+          taskDesc: row.taskDesc || '',
+          isFromDevTask: true,
+        }
+        this.editDialogVisible = true
+      },
       // 辅助方法 — 标签映射已改用 getDicts + selectDictLabel,保留 badge class 映射(UI 呈现)
       getTaskStatusBadgeClass(status) {
         const map = {