Эх сурвалжийг харах

feat: 运维事件附件功能及详情显示修复

程健 3 долоо хоног өмнө
parent
commit
75d139a0ab

+ 107 - 18
src/views/devops/deliveryProject/index.vue

@@ -154,9 +154,10 @@
               clearable
               placeholder="搜索项目/销售/交付"
               prefix-icon="el-icon-search"
-              size="small" />
+              size="small"
+              @input="onProjectSearchDebounced" />
           </template>
-          <div class="project-list">
+          <div ref="projectList" class="project-list">
             <div
               v-if="allProjectOption"
               :class="['project-item', 'project-item--all', { active: selectedProject === allProjectOption.id }]"
@@ -206,6 +207,11 @@
               </div>
               <i v-if="selectedProject === project.id" class="el-icon-check project-card-check" />
             </div>
+            <div v-if="projectLoading" class="project-list-loading">
+              <i class="el-icon-loading" />
+              加载中...
+            </div>
+            <div v-else-if="!projectHasMore && projects.length > 1" class="project-list-end">没有更多了</div>
           </div>
         </template>
       </div>
@@ -437,6 +443,7 @@
   import DeliveryProjectAssign from './components/DeliveryProjectAssign'
   import ProjectInfoDialog from '../components/ProjectInfoDialog'
   import { parseTime } from '@/utils'
+  import debounce from 'lodash/debounce'
   import { uploadFileToRichtextServer } from '@/utils/richtextUpload'
   import { sanitizeHtml } from '@/utils/safeHtml'
   import { deliveryEventStatusTagTypes, getTagType } from '@/config/devopsTagTypes'
@@ -478,6 +485,11 @@
         sidebarCollapsed: false,
         showSidebarFilters: true,
         projectSearch: '',
+        projectPageNum: 1,
+        projectPageSize: 20,
+        projectTotal: 0,
+        projectLoading: false,
+        projectHasMore: true,
         projectStatusFilter: 'delivering',
         selectedProject: '',
         projects: [{ id: '', name: '全部' }],
@@ -526,7 +538,6 @@
         )
       },
       filteredProjects() {
-        const keyword = this.projectSearch.trim().toLowerCase()
         const statusFilter = this.projectStatusFilter
 
         return this.projects.filter((project) => {
@@ -534,14 +545,13 @@
 
           // 状态筛选
           if (statusFilter) {
-            // 转换逻辑值为状态码列表
             let statusList = []
             if (statusFilter === 'pending') {
-              statusList = ['10'] // 待分配
+              statusList = ['10']
             } else if (statusFilter === 'delivering') {
-              statusList = ['20', '30', '40'] // 交付中
+              statusList = ['20', '30', '40']
             } else if (statusFilter === 'delivered') {
-              statusList = ['50'] // 已验收
+              statusList = ['50']
             } else {
               statusList = statusFilter.split(',')
             }
@@ -549,18 +559,12 @@
               return false
             }
           } else {
-            // 全部:排除90(作废)
             if (project.status === '90') {
               return false
             }
           }
 
-          // 关键词筛选(支持项目名称、销售负责人、交付负责人)
-          if (!keyword) return true
-
-          return [project.name, project.salesOwner, project.deliveryOwner]
-            .filter(Boolean)
-            .some((field) => field.toLowerCase().includes(keyword))
+          return true
         })
       },
       allProjectOption() {
@@ -573,6 +577,37 @@
     created() {
       this.getOptions()
       this.fetchData()
+      this.onProjectSearchDebounced = debounce(() => {
+        this.projectPageNum = 1
+        this.projects = [{ id: '', name: '全部' }]
+        this.projectHasMore = true
+        this.fetchProjectList()
+      }, 300)
+    },
+    mounted() {
+      this.$nextTick(() => {
+        const el = this.$refs.projectList
+        if (!el) return
+        this._onProjectListScroll = debounce(() => {
+          if (!this.projectHasMore || this.projectLoading) return
+          const { scrollHeight, scrollTop, clientHeight } = el
+          if (scrollHeight - scrollTop - clientHeight <= 80) {
+            this.projectPageNum++
+            this.fetchProjectList()
+          }
+        }, 200)
+        el.addEventListener('scroll', this._onProjectListScroll)
+      })
+    },
+    beforeDestroy() {
+      if (this._onProjectListScroll) {
+        this._onProjectListScroll.cancel()
+        const el = this.$refs.projectList
+        if (el) el.removeEventListener('scroll', this._onProjectListScroll)
+      }
+      if (this.onProjectSearchDebounced && this.onProjectSearchDebounced.cancel) {
+        this.onProjectSearchDebounced.cancel()
+      }
     },
     methods: {
       getTagType,
@@ -606,12 +641,15 @@
           .catch((err) => console.log(err))
       },
 
-      // 获取项目列表
+      // 获取项目列表(支持滚动分页)
       async fetchProjectList() {
+        if (this.projectLoading) return
+
+        this.projectLoading = true
         try {
           const params = {
-            pageNum: 1,
-            pageSize: 999,
+            pageNum: this.projectPageNum,
+            pageSize: this.projectPageSize,
             productLine: '10,20,30,40,50,60',
             sortField: 'contract_no',
             sortOrder: 'desc',
@@ -628,6 +666,12 @@
           } else {
             params.projectStatus = '10,20,30,40,50'
           }
+
+          const keyword = this.projectSearch.trim()
+          if (keyword) {
+            params.keyWords = keyword
+          }
+
           const res = await deliveryProjectApi.getList(params)
           if (res.code === 200 && res.data && res.data.list) {
             const projectList = (res.data?.list || []).map((item) => ({
@@ -640,11 +684,43 @@
               deliveryUserId: item.deliveryUserId || item.delivery_user_id,
               status: String(item.projectStatus || item.project_status),
             }))
+            const total = res.data?.total || 0
             projectList.sort((a, b) => (b.contractNo || '').localeCompare(a.contractNo || ''))
-            this.projects = [{ id: '', name: '全部' }, ...projectList]
+
+            if (this.projectPageNum === 1) {
+              this.projects = [{ id: '', name: '全部' }, ...projectList]
+            } else {
+              this.projects = this.projects.concat(projectList)
+            }
+
+            this.projectTotal = total
+            this.projectHasMore = this.projects.length - 1 < total
+
+            // 如果选中的项目不在当前列表中,清除选中
+            if (this.selectedProject && !this.projects.some((project) => project.id === this.selectedProject)) {
+              this.selectedProject = ''
+              this.queryForm.projectId = ''
+              this.queryForm.pageNum = 1
+              this.fetchEventData()
+            }
           }
         } catch (error) {
           console.error('获取项目列表失败:', error)
+          if (this.projectPageNum === 1) {
+            this.projects = [{ id: '', name: '全部' }]
+          }
+        } finally {
+          this.projectLoading = false
+          // 内容未填满容器时自动加载下一页
+          this.$nextTick(() => {
+            const el = this.$refs.projectList
+            if (el && this.projectHasMore && !this.projectLoading) {
+              if (el.scrollHeight <= el.clientHeight) {
+                this.projectPageNum++
+                this.fetchProjectList()
+              }
+            }
+          })
         }
       },
 
@@ -1074,6 +1150,9 @@
       // 项目状态筛选变化
       handleProjectStatusChange() {
         this.queryForm.pageNum = 1
+        this.projectPageNum = 1
+        this.projects = [{ id: '', name: '全部' }]
+        this.projectHasMore = true
         this.fetchProjectList()
       },
 
@@ -1481,6 +1560,16 @@
     padding-right: 2px;
   }
 
+  .project-list-loading,
+  .project-list-end {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 12px 0;
+    font-size: 13px;
+    color: #909399;
+  }
+
   .project-item {
     display: flex;
     align-items: center;

+ 94 - 38
src/views/devops/operation/components/OperationDetail.vue

@@ -38,6 +38,13 @@
             <template v-else-if="String(data.eventStatus) === '70'">
               <el-button size="small" type="success" @click="handleResume">转处理</el-button>
             </template>
+            <el-button
+              v-if="['20', '30'].includes(String(data.eventStatus))"
+              size="small"
+              type="primary"
+              @click="onAddWorkHour">
+              工时登记
+            </el-button>
             <el-button size="small" @click="handleClose">取消</el-button>
           </template>
         </div>
@@ -84,22 +91,6 @@
             <div class="card-label">客户名称</div>
           </div>
         </div>
-        <div class="info-card">
-          <div class="card-icon blue">
-            <i class="el-icon-time" />
-          </div>
-          <div class="card-content">
-            <div class="card-value">{{ totalWorkHour }}h</div>
-            <div class="card-label">
-              累计工时
-              <i
-                v-if="!readOnly && ['20', '30'].includes(String(data.eventStatus))"
-                class="el-icon-more-outline"
-                style="cursor: pointer; margin-left: 4px"
-                @click="showWorkHourList = true" />
-            </div>
-          </div>
-        </div>
       </div>
 
       <!-- 主内容区 -->
@@ -143,6 +134,26 @@
               </div>
             </div>
           </div>
+
+          <!-- 工作情况 -->
+          <div class="work-status-section">
+            <div class="section-title">工作情况</div>
+            <div class="work-status-content">
+              <div class="work-status-item">
+                <span class="work-status-label">累计工时</span>
+                <span class="work-status-value">{{ totalWorkHour }}h</span>
+                <el-button
+                  v-if="!readOnly"
+                  class="work-status-link"
+                  size="mini"
+                  type="text"
+                  @click="showWorkHourList = true">
+                  查看详情
+                  <i class="el-icon-arrow-right" />
+                </el-button>
+              </div>
+            </div>
+          </div>
         </div>
 
         <!-- 右侧动态区域 -->
@@ -269,7 +280,7 @@
       </div>
     </el-dialog>
 
-    <WorkHourListDialog :event-id="data.id" :visible.sync="showWorkHourList" @add-work-hour="onAddWorkHour" />
+    <WorkHourListDialog :event-id="data.id" :visible.sync="showWorkHourList" />
     <WorkHourDialog :event-id="data.id" :visible.sync="showWorkHourDialog" @refresh="onWorkHourRefresh" />
   </div>
 </template>
@@ -385,13 +396,17 @@
       }
     },
     watch: {
-      visible(val) {
-        if (val) {
-          this.getOptions()
-          this.initDialog()
-          this.fetchRecordList()
-          this.fetchAttachmentList()
-        }
+      visible: {
+        immediate: true,
+        async handler(val) {
+          if (val) {
+            this.totalWorkHour = (this.data && this.data.totalWorkHour) || 0
+            await this.getOptions()
+            this.initDialog()
+            this.fetchRecordList()
+            this.fetchAttachmentList()
+          }
+        },
       },
       mode: {
         immediate: true,
@@ -425,20 +440,21 @@
     },
     methods: {
       sanitizeHtml,
-      getOptions() {
-        Promise.all([
-          this.getDicts('ops_event_status'),
-          this.getDicts('ops_event_type'),
-          this.getDicts('ops_priority_level'),
-          this.getDicts('ops_handle_result'),
-        ])
-          .then(([eventStatus, eventType, priorityLevel, handleResult]) => {
-            this.eventStatusOptions = eventStatus.data.values || []
-            this.eventTypeOptions = eventType.data.values || []
-            this.priorityLevelOptions = priorityLevel.data.values || []
-            this.handleResultOptions = handleResult.data.values || []
-          })
-          .catch(() => {})
+      async getOptions() {
+        try {
+          const [eventStatus, eventType, priorityLevel, handleResult] = await Promise.all([
+            this.getDicts('ops_event_status'),
+            this.getDicts('ops_event_type'),
+            this.getDicts('ops_priority_level'),
+            this.getDicts('ops_handle_result'),
+          ])
+          this.eventStatusOptions = eventStatus.data.values || []
+          this.eventTypeOptions = eventType.data.values || []
+          this.priorityLevelOptions = priorityLevel.data.values || []
+          this.handleResultOptions = handleResult.data.values || []
+        } catch (_) {
+          // ignore
+        }
       },
       onEditorCreated(editor) {
         this.editor = editor
@@ -1091,6 +1107,46 @@
           }
         }
       }
+
+      .work-status-section {
+        margin-top: 16px;
+
+        .section-title {
+          font-size: 14px;
+          font-weight: 500;
+          color: #606266;
+          margin-bottom: 12px;
+        }
+
+        .work-status-content {
+          background: #f5f7fa;
+          border-radius: 8px;
+          padding: 12px 16px;
+
+          .work-status-item {
+            display: flex;
+            align-items: center;
+            gap: 8px;
+          }
+
+          .work-status-label {
+            font-size: 13px;
+            color: #909399;
+            flex-shrink: 0;
+          }
+
+          .work-status-value {
+            font-size: 18px;
+            font-weight: 600;
+            color: #409eff;
+          }
+
+          .work-status-link {
+            margin-left: auto;
+            font-size: 13px;
+          }
+        }
+      }
     }
 
     .right-panel {

+ 62 - 9
src/views/devops/operation/components/OperationEdit.vue

@@ -49,10 +49,11 @@
           v-model="form.contractId"
           clearable
           filterable
-          placeholder="点击选择合同"
+          placeholder="请搜索合同(输入合同编号/客户名称)"
+          remote
+          :remote-method="searchContract"
           style="width: 100%"
-          @change="handleContractChange"
-          @focus="handleContractFocus">
+          @change="handleContractChange">
           <el-option
             v-for="item in contractList"
             :key="item.id"
@@ -61,6 +62,22 @@
         </el-select>
       </el-form-item>
 
+      <el-form-item v-if="['30', '40', '50'].includes(form.eventType)" label="负责人" prop="opsUserId">
+        <el-select
+          v-model="form.opsUserId"
+          clearable
+          filterable
+          :loading="loadingUsers"
+          placeholder="请选择负责人"
+          remote
+          :remote-method="remoteFetchUserListDebounced"
+          reserve-keyword
+          style="width: 100%"
+          @change="handleUserChange">
+          <el-option v-for="u in userOptions" :key="u.value" :label="u.label" :value="u.value" />
+        </el-select>
+      </el-form-item>
+
       <el-row :gutter="20">
         <el-col :span="12">
           <el-form-item label="客户" prop="custId">
@@ -119,8 +136,11 @@
   import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
   import operationEventApi from '@/api/operation/operationEvent'
   import contractApi from '@/api/contract/index'
+  import userApi from '@/api/system/user'
   import store from '@/store'
+  import { DEVOPS_DEV_DEPT_ID } from '@/config/devops.config'
   import { uploadRichtextImage, uploadFileToRichtextServer } from '@/utils/richtextUpload'
+  import debounce from 'lodash/debounce'
 
   export default {
     name: 'OperationEdit',
@@ -181,6 +201,8 @@
           productLine: '',
           isBig: '20',
           isOps: '10',
+          opsUserId: null,
+          opsUserName: '',
         },
         rules: {
           eventTitle: [{ required: true, message: '请输入事件标题', trigger: 'blur' }],
@@ -199,6 +221,8 @@
         eventTypeOptions: [],
         priorityLevelOptions: [],
         feedbackSourceOptions: [],
+        userOptions: [],
+        loadingUsers: false,
       }
     },
     computed: {
@@ -221,8 +245,14 @@
     },
     created() {
       this.getOptions()
+      this.remoteFetchUserListDebounced = debounce((query) => {
+        this.fetchUserList(query)
+      }, 300)
     },
     beforeDestroy() {
+      if (this.remoteFetchUserListDebounced && this.remoteFetchUserListDebounced.cancel) {
+        this.remoteFetchUserListDebounced.cancel()
+      }
       if (this.editor) {
         this.editor.destroy()
         this.editor = null
@@ -319,8 +349,11 @@
           productLine: '',
           isBig: '20',
           isOps: '10',
+          opsUserId: null,
+          opsUserName: '',
         }
         this.contractList = []
+        this.userOptions = []
         this.fileList = []
         this.uploadFiles = []
         if (this.editor) {
@@ -355,13 +388,11 @@
           this.form.isOps = '20'
         }
       },
-      handleContractFocus() {
-        // 点击下拉框时加载合同列表(只加载当前用户交付的合同)
-        if (this.contractList.length === 0) {
-          this.searchContract('')
-        }
-      },
       searchContract(query) {
+        if (!query || query.length < 2) {
+          this.contractList = []
+          return
+        }
         contractApi
           .searchContract({ searchText: query })
           .then((res) => {
@@ -375,6 +406,28 @@
             this.contractList = []
           })
       },
+      async fetchUserList(search) {
+        this.loadingUsers = true
+        try {
+          const payload = { deptId: DEVOPS_DEV_DEPT_ID, pageNum: 1, pageSize: 999 }
+          if (search) payload.keyWords = search
+          const res = await userApi.getList(payload)
+          const list = res.data?.list || []
+          this.userOptions = list.map((u) => ({
+            value: u.userId ?? u.user_id ?? u.id ?? null,
+            label: u.nickName ?? u.nick_name ?? u.name ?? '',
+          }))
+        } catch (error) {
+          console.error('获取用户列表失败:', error)
+          this.userOptions = []
+        } finally {
+          this.loadingUsers = false
+        }
+      },
+      handleUserChange(val) {
+        const user = this.userOptions.find((u) => u.value === val)
+        this.form.opsUserName = user ? user.label : ''
+      },
       handleFileChange(file, fileList) {
         this.fileList = fileList
         this.uploadFiles = fileList.filter((f) => f.raw).map((f) => f.raw)

+ 10 - 0
src/views/devops/operation/components/WorkHourDialog.vue

@@ -69,6 +69,7 @@
         rules: {
           workDate: [{ required: true, message: '请选择工作日期', trigger: 'change' }],
           workHour: [{ required: true, type: 'number', message: '请输入工时', trigger: 'blur' }],
+          remark: [{ required: true, message: '请输入工作说明', trigger: 'blur' }],
         },
         pickerOptions: {
           disabledDate(time) {
@@ -114,6 +115,15 @@
               remark: this.form.remark,
             })
             if (res.code === 200) {
+              // 同时在过程记录中写入工时登记信息
+              try {
+                await operationEventApi.addRecord({
+                  eventId: this.eventId,
+                  handleContent: `工时登记<br/>日期:${this.form.workDate}<br/>工时:${this.form.workHour}h<br/>说明:${this.form.remark}`,
+                })
+              } catch (recordErr) {
+                console.error('写入过程记录失败:', recordErr)
+              }
               this.$message.success('登记成功')
               this.$emit('refresh')
               this.handleClose()

+ 0 - 7
src/views/devops/operation/components/WorkHourListDialog.vue

@@ -28,10 +28,6 @@
       </el-table-column>
     </el-table>
     <div v-if="!loading && list.length === 0" class="empty-hint">暂无工时登记记录</div>
-    <span slot="footer" class="dialog-footer">
-      <el-button size="small" @click="handleClose">关闭</el-button>
-      <el-button size="small" type="primary" @click="handleAdd">+ 登记工时</el-button>
-    </span>
   </el-dialog>
 </template>
 
@@ -87,9 +83,6 @@
       handleClose() {
         this.$emit('update:visible', false)
       },
-      handleAdd() {
-        this.$emit('add-work-hour')
-      },
     },
   }
 </script>

+ 113 - 25
src/views/devops/operationHistory/index.vue

@@ -97,9 +97,10 @@
               clearable
               placeholder="搜索项目名称/合同编号"
               prefix-icon="el-icon-search"
-              size="small" />
+              size="small"
+              @input="onProjectSearchDebounced" />
           </div>
-          <div class="project-list">
+          <div ref="projectList" class="project-list">
             <div
               :class="['project-item', 'project-item--all', { active: selectedContractId === '' }]"
               @click="selectProject('')">
@@ -109,7 +110,7 @@
               </div>
             </div>
             <div
-              v-for="project in filteredProjects"
+              v-for="project in projects"
               :key="project.contractId || project.id"
               :class="['project-card', { active: selectedContractId === project.contractId }]"
               @click="selectProject(project.contractId)">
@@ -141,6 +142,11 @@
               </div>
               <i v-if="selectedContractId === project.contractId" class="el-icon-check project-card-check" />
             </div>
+            <div v-if="projectLoading" class="project-list-loading">
+              <i class="el-icon-loading" />
+              加载中...
+            </div>
+            <div v-else-if="!projectHasMore && projects.length > 0" class="project-list-end">没有更多了</div>
           </div>
         </template>
       </div>
@@ -194,6 +200,14 @@
                   {{ selectDictLabel(eventStatusOptions, row.eventStatus) }}
                 </template>
               </el-table-column>
+              <el-table-column
+                align="center"
+                label="累计工时"
+                min-width="80"
+                prop="totalWorkHour"
+                show-overflow-tooltip>
+                <template #default="{ row }">{{ row.totalWorkHour || 0 }}h</template>
+              </el-table-column>
               <el-table-column
                 align="center"
                 label="关闭时间"
@@ -240,6 +254,7 @@
 <script>
   import to from 'await-to-js'
   import { mapGetters } from 'vuex'
+  import debounce from 'lodash/debounce'
   import deliveryProjectApi from '@/api/devops/deliveryProject'
   import operationEventApi from '@/api/operation/operationEvent'
   import dictApi from '@/api/system/dict'
@@ -268,6 +283,11 @@
         projects: [],
         selectedContractId: '',
         projectSearch: '',
+        projectPageNum: 1,
+        projectPageSize: 20,
+        projectTotal: 0,
+        projectLoading: false,
+        projectHasMore: true,
         sidebarCollapsed: false,
         showSidebarFilters: true,
         queryForm: {
@@ -291,19 +311,14 @@
       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))
-        })
-      },
+    },
+    created() {
+      this.onProjectSearchDebounced = debounce(() => {
+        this.projectPageNum = 1
+        this.projects = []
+        this.projectHasMore = true
+        this.fetchProjects()
+      }, 300)
     },
     activated() {
       if (this.hasLoaded) {
@@ -318,10 +333,29 @@
     async mounted() {
       await this.initializePage()
       this.scheduleTableLayout()
+      this._onProjectListScroll = debounce(() => {
+        this.handleProjectListScroll()
+      }, 200)
+      this.$nextTick(() => {
+        const el = this.$refs.projectList
+        if (el) {
+          el.addEventListener('scroll', this._onProjectListScroll)
+        }
+      })
     },
     beforeDestroy() {
       this.clearTableLayoutTasks()
       window.removeEventListener('resize', this.scheduleTableLayout)
+      if (this.onProjectSearchDebounced && this.onProjectSearchDebounced.cancel) {
+        this.onProjectSearchDebounced.cancel()
+      }
+      if (this._onProjectListScroll) {
+        this._onProjectListScroll.cancel()
+      }
+      const el = this.$refs.projectList
+      if (el && this._onProjectListScroll) {
+        el.removeEventListener('scroll', this._onProjectListScroll)
+      }
     },
     methods: {
       async initializePage() {
@@ -402,24 +436,39 @@
         }
       },
       async fetchProjects() {
+        if (this.projectLoading) return
+
+        this.projectLoading = true
         try {
           const params = {
-            pageNum: 1,
-            pageSize: 999,
+            pageNum: this.projectPageNum,
+            pageSize: this.projectPageSize,
             productLine: '10,20,30,40,50,60',
             sortField: 'contract_no',
             sortOrder: 'desc',
-            attribute9: '10',
             projectStatus: '50',
           }
 
+          const keyword = this.projectSearch.trim()
+          if (keyword) {
+            params.keyWords = keyword
+          }
+
           const res = await deliveryProjectApi.getList(params)
-          const projectList = (res.data && res.data.list ? res.data.list : [])
+          const list = (res.data && res.data.list ? res.data.list : [])
             .map(this.normalizeProject)
             .filter((project) => !!project.contractId)
+          const total = res.data?.total || 0
 
-          projectList.sort((a, b) => String(b.contractNo || '').localeCompare(String(a.contractNo || '')))
-          this.projects = projectList
+          if (this.projectPageNum === 1) {
+            this.projects = list
+          } else {
+            this.projects = this.projects.concat(list)
+          }
+
+          this.projectTotal = total
+          this.projectHasMore = this.projects.length < total
+          this.projectPageNum++
 
           if (
             this.selectedContractId &&
@@ -432,7 +481,27 @@
           }
         } catch (error) {
           console.error('获取项目列表失败:', error)
-          this.projects = []
+          if (this.projectPageNum === 1) {
+            this.projects = []
+          }
+        } finally {
+          this.projectLoading = false
+          // If content doesn't overflow container and more pages exist, load next
+          this.$nextTick(() => {
+            const el = this.$refs.projectList
+            if (el && el.scrollHeight <= el.clientHeight && this.projectHasMore && !this.projectLoading) {
+              this.fetchProjects()
+            }
+          })
+        }
+      },
+      handleProjectListScroll() {
+        const el = this.$refs.projectList
+        if (!el) return
+        const threshold = 80
+        const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= threshold
+        if (nearBottom && this.projectHasMore && !this.projectLoading) {
+          this.fetchProjects()
         }
       },
       async fetchData() {
@@ -506,8 +575,17 @@
         this.queryForm.pageNum = val
         this.fetchData()
       },
-      handleView(row) {
-        this.currentRow = row
+      async handleView(row) {
+        try {
+          const [err, res] = await to(operationEventApi.getDetail({ id: row.id }))
+          if (!err && res && res.code === 200 && res.data) {
+            this.currentRow = res.data
+          } else {
+            this.currentRow = row
+          }
+        } catch (_) {
+          this.currentRow = row
+        }
         this.detailVisible = true
       },
       getProductLineLabel(productLine) {
@@ -721,6 +799,16 @@
     padding-right: 2px;
   }
 
+  .project-list-loading,
+  .project-list-end {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 12px 0;
+    font-size: 13px;
+    color: #909399;
+  }
+
   .project-item {
     display: flex;
     align-items: center;

+ 109 - 12
src/views/devops/software/index.vue

@@ -160,9 +160,10 @@
               clearable
               placeholder="搜索项目/销售/交付"
               prefix-icon="el-icon-search"
-              size="small" />
+              size="small"
+              @input="onProjectSearchDebounced" />
           </template>
-          <div class="project-list">
+          <div ref="projectList" class="project-list">
             <div
               v-if="allProjectOption"
               :class="['project-item', 'project-item--all', { active: selectedProject === allProjectOption.id }]"
@@ -207,6 +208,11 @@
               </div>
               <i v-if="selectedProject === project.id" class="el-icon-check project-card-check" />
             </div>
+            <div v-if="projectLoading" class="project-list-loading">
+              <i class="el-icon-loading" />
+              加载中...
+            </div>
+            <div v-else-if="!projectHasMore && projects.length > 1" class="project-list-end">没有更多了</div>
           </div>
         </template>
       </div>
@@ -551,6 +557,7 @@
   import ProjectInfoDialog from '../components/ProjectInfoDialog'
   import { parseTime } from '@/utils'
   import { taskStatusTagTypes, priorityTagTypes, projectStatusTagTypes, getTagType } from '@/config/devopsTagTypes'
+  import debounce from 'lodash/debounce'
 
   const COLUMN_STORAGE_KEY = 'opms-devops-software-visible-columns'
   const TABLE_COLUMN_OPTIONS = [
@@ -617,6 +624,11 @@
         sidebarCollapsed: false,
         showSidebarFilters: true,
         projectSearch: '',
+        projectPageNum: 1,
+        projectPageSize: 20,
+        projectTotal: 0,
+        projectLoading: false,
+        projectHasMore: true,
         projectStatusFilter: 'delivering',
         selectedProject: '',
         projects: [{ id: '', name: '全部' }],
@@ -652,7 +664,6 @@
     },
     computed: {
       filteredProjects() {
-        const keyword = this.projectSearch.trim().toLowerCase()
         const statusFilter = this.projectStatusFilter
 
         return this.projects.filter((project) => {
@@ -677,11 +688,7 @@
             return false
           }
 
-          if (!keyword) return true
-
-          return [project.name, project.projectNo, project.salesOwner, project.deliveryOwner]
-            .filter(Boolean)
-            .some((field) => String(field).toLowerCase().includes(keyword))
+          return true
         })
       },
       allProjectOption() {
@@ -704,12 +711,43 @@
         }
       },
     },
+    created() {
+      this.onProjectSearchDebounced = debounce(() => {
+        this.projectPageNum = 1
+        this.projects = [{ id: '', name: '全部' }]
+        this.projectHasMore = true
+        this.fetchProjects()
+      }, 300)
+    },
     mounted() {
       this.initVisibleColumns()
       this.getOptions()
       this.remoteFetchOpsUsers('')
       this.fetchProjects()
       this.fetchData()
+      this.$nextTick(() => {
+        const el = this.$refs.projectList
+        if (!el) return
+        this._onProjectListScroll = debounce(() => {
+          if (!this.projectHasMore || this.projectLoading) return
+          const { scrollHeight, scrollTop, clientHeight } = el
+          if (scrollHeight - scrollTop - clientHeight <= 80) {
+            this.projectPageNum++
+            this.fetchProjects()
+          }
+        }, 200)
+        el.addEventListener('scroll', this._onProjectListScroll)
+      })
+    },
+    beforeDestroy() {
+      if (this._onProjectListScroll) {
+        this._onProjectListScroll.cancel()
+        const el = this.$refs.projectList
+        if (el) el.removeEventListener('scroll', this._onProjectListScroll)
+      }
+      if (this.onProjectSearchDebounced && this.onProjectSearchDebounced.cancel) {
+        this.onProjectSearchDebounced.cancel()
+      }
     },
     methods: {
       getOptions() {
@@ -787,12 +825,15 @@
       resetVisibleColumns() {
         this.applyVisibleColumns(this.getDefaultVisibleColumnKeys())
       },
-      // 获取项目列表
+      // 获取项目列表(支持滚动分页)
       async fetchProjects() {
+        if (this.projectLoading) return
+
+        this.projectLoading = true
         try {
           const params = {
-            pageNum: 1,
-            pageSize: 999,
+            pageNum: this.projectPageNum,
+            pageSize: this.projectPageSize,
             productLine: this.queryForm.productLine || '10,20,30',
             sortField: 'contract_no',
             sortOrder: 'desc',
@@ -808,6 +849,11 @@
             params.projectStatus = '10,20,30,40,50'
           }
 
+          const keyword = this.projectSearch.trim()
+          if (keyword) {
+            params.keyWords = keyword
+          }
+
           const res = await deliveryProjectApi.getListAll(params)
           const list = (res.data?.list || []).map((item) => ({
             id: String(item.id),
@@ -828,11 +874,44 @@
             deliveryOwner: item.deliveryUserName || item.delivery_user_name || '',
             status: String(item.projectStatus || item.project_status || ''),
           }))
+          const total = res.data?.total || 0
+
           // 按合同编号倒序排列(兜底)
           list.sort((a, b) => (b.contractNo || '').localeCompare(a.contractNo || ''))
-          this.projects = [{ id: '', name: '全部' }, ...list]
+
+          if (this.projectPageNum === 1) {
+            this.projects = [{ id: '', name: '全部' }, ...list]
+          } else {
+            this.projects = this.projects.concat(list)
+          }
+
+          this.projectTotal = total
+          this.projectHasMore = this.projects.length - 1 < total
+
+          // 如果选中的项目不在当前列表中,清除选中
+          if (this.selectedProject && !this.projects.some((project) => project.id === this.selectedProject)) {
+            this.selectedProject = ''
+            this.queryForm.projectId = ''
+            this.queryForm.pageNum = 1
+            this.fetchData()
+          }
         } catch (error) {
           console.error('获取项目列表失败', error)
+          if (this.projectPageNum === 1) {
+            this.projects = [{ id: '', name: '全部' }]
+          }
+        } finally {
+          this.projectLoading = false
+          // 内容未填满容器时自动加载下一页
+          this.$nextTick(() => {
+            const el = this.$refs.projectList
+            if (el && this.projectHasMore && !this.projectLoading) {
+              if (el.scrollHeight <= el.clientHeight) {
+                this.projectPageNum++
+                this.fetchProjects()
+              }
+            }
+          })
         }
       },
       // 获取任务列表
@@ -857,6 +936,8 @@
             completeTimeStart: this.queryForm.completeTimeRange?.[0] || '',
             completeTimeEnd: this.queryForm.completeTimeRange?.[1] || '',
             scheduleStatus: this.queryForm.scheduleStatus || '',
+            // 排序字段
+            sortFields: this.sortFields,
           }
           // 如果选了产品线,收集项目 ID
           if (this.queryForm.productLine) {
@@ -899,6 +980,9 @@
       // 产品线变更
       handleProductLineChange() {
         this.selectedProject = ''
+        this.projectPageNum = 1
+        this.projects = [{ id: '', name: '全部' }]
+        this.projectHasMore = true
         this.fetchProjects()
       },
       // 查询
@@ -989,6 +1073,9 @@
         }
         this.queryForm.projectId = this.selectedProject || ''
         this.queryForm.pageNum = 1
+        this.projectPageNum = 1
+        this.projects = [{ id: '', name: '全部' }]
+        this.projectHasMore = true
         this.fetchProjects()
         this.fetchData()
       },
@@ -1792,6 +1879,16 @@
     padding-right: 2px;
   }
 
+  .project-list-loading,
+  .project-list-end {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 12px 0;
+    font-size: 13px;
+    color: #909399;
+  }
+
   .project-item {
     display: flex;
     align-items: center;