Procházet zdrojové kódy

添加项目管理模块并对部分页面布局进行优化

程健 před 2 týdny
rodič
revize
1d216560ca

+ 1 - 2
jsconfig.json

@@ -3,9 +3,8 @@
     "target": "es6",
     "module": "es6",
     "allowSyntheticDefaultImports": true,
-    "baseUrl": "./",
     "paths": {
-      "@/*": ["src/*"]
+      "@/*": ["./src/*"]
     }
   },
   "exclude": ["node_modules"]

+ 1 - 2
package.json

@@ -83,8 +83,7 @@
   "license": "Mozilla Public License Version 2.0",
   "lint-staged": {
     "*.{js,jsx,vue}": [
-      "vue-cli-service lint",
-      "git add"
+      "vue-cli-service lint"
     ]
   },
   "participants": [

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

@@ -0,0 +1,48 @@
+import request from '@/utils/request'
+
+const baseUrl = '/api/v1/deliveryProject'
+
+export default {
+  // 获取列表
+  getList(params) {
+    return request({
+      url: `${baseUrl}/list`,
+      method: 'get',
+      params,
+    })
+  },
+
+  // 获取详情
+  getById(id) {
+    return request({
+      url: `${baseUrl}/${id}`,
+      method: 'get',
+    })
+  },
+
+  // 创建
+  create(data) {
+    return request({
+      url: baseUrl,
+      method: 'post',
+      data,
+    })
+  },
+
+  // 更新
+  update(id, data) {
+    return request({
+      url: `${baseUrl}/${id}`,
+      method: 'put',
+      data,
+    })
+  },
+
+  // 删除
+  delete(id) {
+    return request({
+      url: `${baseUrl}/${id}`,
+      method: 'delete',
+    })
+  },
+}

+ 31 - 4
src/views/collection/index.vue

@@ -19,10 +19,15 @@
               @keyup.enter.native="queryData" />
           </el-form-item>
           <el-form-item prop="custId">
-            <el-input v-model="queryForm.custName" clearable placeholder="客户名称" @keyup.enter.native="queryData" />
+            <el-input
+              v-model="queryForm.custName"
+              class="customer-input"
+              clearable
+              placeholder="客户名称"
+              @keyup.enter.native="queryData" />
           </el-form-item>
           <el-form-item prop="custId">
-            <el-select v-model="queryForm.approStatus" clearable placeholder="审核状态">
+            <el-select v-model="queryForm.approStatus" class="status-select" clearable placeholder="审核状态">
               <el-option v-for="item in approStatusOption" :key="item.id" :label="item.label" :value="item.id" />
             </el-select>
           </el-form-item>
@@ -34,12 +39,22 @@
               @keyup.enter.native="queryData" />
           </el-form-item>
           <el-form-item prop="custProvince">
-            <el-select v-model="queryForm.custProvince" clearable placeholder="所在省" value-key="id">
+            <el-select
+              v-model="queryForm.custProvince"
+              class="compact-select"
+              clearable
+              placeholder="所在省"
+              value-key="id">
               <el-option v-for="item in provinceOptions" :key="item.id" :label="item.distName" :value="item" />
             </el-select>
           </el-form-item>
           <el-form-item prop="custCity">
-            <el-select v-model="queryForm.custCity" clearable placeholder="所在市" value-key="id">
+            <el-select
+              v-model="queryForm.custCity"
+              class="compact-select"
+              clearable
+              placeholder="所在市"
+              value-key="id">
               <el-option
                 v-for="item in queryForm.custProvince ? queryForm.custProvince.children : []"
                 :key="item.id"
@@ -391,4 +406,16 @@
     background: #fff;
     transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border 0s, color 0.1s, font-size 0s;
   }
+
+  .customer-input {
+    width: 240px;
+  }
+
+  .status-select {
+    width: 110px;
+  }
+
+  .compact-select {
+    width: 90px;
+  }
 </style>

+ 31 - 4
src/views/collection/plan.vue

@@ -19,10 +19,15 @@
               @keyup.enter.native="queryData" />
           </el-form-item>
           <el-form-item prop="custId">
-            <el-input v-model="queryForm.custName" clearable placeholder="客户名称" @keyup.enter.native="queryData" />
+            <el-input
+              v-model="queryForm.custName"
+              class="customer-input"
+              clearable
+              placeholder="客户名称"
+              @keyup.enter.native="queryData" />
           </el-form-item>
           <el-form-item prop="contractStatus">
-            <el-select v-model="queryForm.contractStatus" clearable placeholder="回款状态">
+            <el-select v-model="queryForm.contractStatus" class="status-select" clearable placeholder="回款状态">
               <el-option v-for="item in approStatusOption" :key="item.id" :label="item.label" :value="item.id" />
             </el-select>
           </el-form-item>
@@ -34,12 +39,22 @@
               @keyup.enter.native="queryData" />
           </el-form-item>
           <el-form-item prop="custProvince">
-            <el-select v-model="queryForm.custProvince" clearable placeholder="所在省" value-key="id">
+            <el-select
+              v-model="queryForm.custProvince"
+              class="compact-select"
+              clearable
+              placeholder="所在省"
+              value-key="id">
               <el-option v-for="item in provinceOptions" :key="item.id" :label="item.distName" :value="item" />
             </el-select>
           </el-form-item>
           <el-form-item prop="custCity">
-            <el-select v-model="queryForm.custCity" clearable placeholder="所在市" value-key="id">
+            <el-select
+              v-model="queryForm.custCity"
+              class="compact-select"
+              clearable
+              placeholder="所在市"
+              value-key="id">
               <el-option
                 v-for="item in queryForm.custProvince ? queryForm.custProvince.children : []"
                 :key="item.id"
@@ -393,4 +408,16 @@
     background: #fff;
     transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border 0s, color 0.1s, font-size 0s;
   }
+
+  .customer-input {
+    width: 240px;
+  }
+
+  .status-select {
+    width: 110px;
+  }
+
+  .compact-select {
+    width: 90px;
+  }
 </style>

+ 33 - 2
src/views/contract/index.vue

@@ -257,6 +257,7 @@
       </el-table-column>
     </el-table>
     <el-pagination
+      ref="paginationRef"
       background
       :current-page="queryForm.pageNum"
       :layout="layout"
@@ -461,17 +462,44 @@
     },
     watch: {
       showColumns: function () {
-        this.$nextTick(() => this.$refs.table.doLayout())
+        this.$nextTick(() => {
+          this.updateTableHeight()
+          this.$refs.table.doLayout()
+        })
       },
     },
     activated() {
       this.queryData()
+      this.$nextTick(() => {
+        this.updateTableHeight()
+      })
     },
     mounted() {
       this.queryData()
       this.getOptions()
+      this.$nextTick(() => {
+        this.updateTableHeight()
+      })
+      window.addEventListener('resize', this.updateTableHeight)
+    },
+    beforeDestroy() {
+      window.removeEventListener('resize', this.updateTableHeight)
     },
     methods: {
+      updateTableHeight() {
+        const tableEl = this.$refs.table && this.$refs.table.$el
+        const paginationEl = this.$refs.paginationRef && this.$refs.paginationRef.$el
+
+        if (!tableEl || !paginationEl) return
+
+        const footerHeight = 60
+        const bufferSpace = 20
+        const tableTop = tableEl.getBoundingClientRect().top
+        const paginationHeight = paginationEl.getBoundingClientRect().height
+        const availableHeight = window.innerHeight - tableTop - footerHeight - paginationHeight - bufferSpace
+
+        this.height = Math.max(availableHeight, 240)
+      },
       getOptions() {
         Promise.all([api.getProvinceDetail(), this.getDicts('contract_type'), this.getDicts('sys_product_line')])
           .then(([province, contract, productLine]) => {
@@ -522,7 +550,10 @@
         this.list = res.data.list || []
         this.total = res.data.total
         this.listLoading = false
-        this.$nextTick(() => this.$refs.table.doLayout())
+        this.$nextTick(() => {
+          this.updateTableHeight()
+          this.$refs.table.doLayout()
+        })
       },
       async exportMaintenance() {
         const params = { ...this.queryForm }

+ 977 - 0
src/views/devops/deliveryProject/index.vue

@@ -0,0 +1,977 @@
+<template>
+  <div class="delivery-project-page">
+    <!-- 工具栏 -->
+    <div class="toolbar">
+      <el-button icon="el-icon-plus" size="small" type="primary" @click="handleAdd">新增</el-button>
+      <div class="toolbar-right">
+        <span class="sort-label">排序</span>
+        <el-select v-model="queryForm.sortBy" class="sort-select" size="small">
+          <el-option label="创建时间" value="createdTime" />
+          <el-option label="项目名称" value="projectName" />
+        </el-select>
+        <el-select v-model="queryForm.searchType" class="search-type-select" size="small">
+          <el-option label="项目名称" value="projectName" />
+          <el-option label="合同编号" value="contractNo" />
+          <el-option label="客户名称" value="custName" />
+        </el-select>
+        <el-input
+          v-model="queryForm.keyWords"
+          class="search-input"
+          clearable
+          placeholder=""
+          prefix-icon="el-icon-search"
+          size="small"
+          @keyup.enter.native="handleSearch" />
+        <el-button icon="el-icon-search" size="small" type="primary" @click="handleSearch">查询</el-button>
+        <el-button icon="el-icon-refresh-right" size="small" @click="handleReset">重置</el-button>
+        <el-button icon="el-icon-download" size="small" type="success" @click="handleExport">导出</el-button>
+      </div>
+    </div>
+
+    <!-- 主体内容 -->
+    <div class="main-content">
+      <!-- 左侧项目筛选 -->
+      <div :class="['project-sidebar', { collapsed: sidebarCollapsed }]">
+        <div class="sidebar-header">
+          <span v-if="!sidebarCollapsed" class="sidebar-title">项目列表</span>
+          <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 v-if="sidebarCollapsed" class="sidebar-collapsed-label">项目列表</div>
+        <template v-if="!sidebarCollapsed">
+          <el-input
+            v-model="projectSearch"
+            class="project-search"
+            clearable
+            placeholder="搜索"
+            prefix-icon="el-icon-search"
+            size="small" />
+          <div class="project-list">
+            <div
+              v-if="allProjectOption"
+              :class="['project-item', 'project-item--all', { active: selectedProject === allProjectOption.id }]"
+              @click="selectProject(allProjectOption.id)">
+              <div>
+                <div class="project-overview-label">{{ allProjectOption.name }}</div>
+                <div class="project-overview-desc">查看全部项目交付进度</div>
+              </div>
+              <div class="project-overview-count">
+                <span>{{ allProjectOption.count }}</span>
+                <span class="project-overview-count-label">项</span>
+              </div>
+            </div>
+            <div
+              v-for="project in projectCards"
+              :key="project.id"
+              :class="['project-card', { active: selectedProject === project.id }]"
+              @click="selectProject(project.id)">
+              <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-card-count">{{ project.count }}项</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="selectedProject === project.id" class="el-icon-check project-card-check" />
+            </div>
+          </div>
+        </template>
+      </div>
+
+      <!-- 右侧数据表格 -->
+      <div class="table-container">
+        <div class="table-scroll-wrapper">
+          <el-table
+            v-loading="loading"
+            border
+            class="delivery-project-table"
+            :data="tableData"
+            :fit="false"
+            height="100%"
+            stripe
+            style="width: 100%"
+            @row-click="handleRowClick">
+            <el-table-column align="center" type="index" width="50" />
+            <el-table-column label="事件标题" min-width="320" show-overflow-tooltip>
+              <template slot-scope="{ row }">
+                <el-popover
+                  placement="top-start"
+                  popper-class="delivery-event-desc-popover"
+                  trigger="click"
+                  width="360">
+                  <div class="event-desc-popover">
+                    <div class="event-desc-popover__title">事件描述</div>
+                    <div class="event-desc-popover__content">
+                      {{ row.deliveryEventDesc || row.delivery_event_desc || '暂无事件描述' }}
+                    </div>
+                  </div>
+                  <span slot="reference" class="event-title-trigger" @click.stop>
+                    {{ row.deliveryEventTitle || row.delivery_event_title || '-' }}
+                  </span>
+                </el-popover>
+              </template>
+            </el-table-column>
+            <el-table-column label="事件类型" width="120">
+              <template slot-scope="{ row }">
+                <el-tag size="small" type="info">
+                  {{ getDeliveryEventTypeLabel(row.deliveryEventType || row.delivery_event_type) }}
+                </el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="事件状态" width="100">
+              <template slot-scope="{ row }">
+                <el-tag
+                  size="small"
+                  :type="getDeliveryEventStatusType(row.deliveryEventStatus || row.delivery_event_status)">
+                  {{ getDeliveryEventStatusLabel(row.deliveryEventStatus || row.delivery_event_status) }}
+                </el-tag>
+              </template>
+            </el-table-column>
+            <el-table-column label="事件结果" width="100">
+              <template slot-scope="{ row }">
+                {{ getDeliveryEventResultLabel(row.deliveryEventResult || row.delivery_event_result) }}
+              </template>
+            </el-table-column>
+            <el-table-column label="负责人" show-overflow-tooltip width="110">
+              <template slot-scope="{ row }">
+                {{ row.opsUserName || row.ops_user_name || '-' }}
+              </template>
+            </el-table-column>
+            <el-table-column label="处理时间" width="160">
+              <template slot-scope="{ row }">
+                {{ formatEventTime(row.completeTime || row.complete_time) }}
+              </template>
+            </el-table-column>
+            <el-table-column label="处理说明" min-width="240" show-overflow-tooltip>
+              <template slot-scope="{ row }">
+                {{ row.completeDesc || row.complete_desc || '-' }}
+              </template>
+            </el-table-column>
+            <el-table-column label="是否现场" width="90">
+              <template slot-scope="{ row }">
+                {{ getOnSiteLabel(row.onSite || row.on_site) }}
+              </template>
+            </el-table-column>
+            <el-table-column label="反馈人" show-overflow-tooltip width="110">
+              <template slot-scope="{ row }">
+                {{ row.feedbackReporter || row.feedback_reporter || '-' }}
+              </template>
+            </el-table-column>
+            <el-table-column label="反馈时间" width="160">
+              <template slot-scope="{ row }">
+                {{ formatEventTime(row.feedbackDate || row.feedback_date) }}
+              </template>
+            </el-table-column>
+            <el-table-column label="反馈来源" width="100">
+              <template slot-scope="{ row }">
+                {{ getFeedbackSourceLabel(row.feedbackSource || row.feedback_source) }}
+              </template>
+            </el-table-column>
+            <el-table-column fixed="right" header-align="center" label="操作" width="150">
+              <template slot-scope="{ row }">
+                <el-button size="mini" type="text" @click.stop="handleEdit(row)">编辑</el-button>
+                <el-button size="mini" type="text" @click.stop="handleView(row)">详情</el-button>
+                <el-button
+                  v-if="canDelete(row.projectStatus)"
+                  danger
+                  size="mini"
+                  type="text"
+                  @click.stop="handleDelete(row)">
+                  删除
+                </el-button>
+              </template>
+            </el-table-column>
+          </el-table>
+        </div>
+
+        <!-- 分页 -->
+        <div class="pagination-wrapper">
+          <el-pagination
+            background
+            :current-page.sync="queryForm.pageNum"
+            layout="total, sizes, prev, pager, next, jumper"
+            :page-size.sync="queryForm.pageSize"
+            :page-sizes="[10, 20, 50, 100]"
+            :total="total"
+            @current-change="handleCurrentChange"
+            @size-change="handleSizeChange" />
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+  import deliveryProjectApi from '@/api/devops/deliveryProject'
+  import { parseTime } from '@/utils'
+
+  export default {
+    name: 'DeliveryProject',
+    data() {
+      return {
+        queryForm: {
+          keyWords: '',
+          searchType: 'projectName',
+          sortBy: 'createdTime',
+          pageNum: 1,
+          pageSize: 20,
+        },
+        tableData: [],
+        total: 0,
+        loading: false,
+        mockTableData: [
+          {
+            id: 100000001,
+            deliveryEventTitle: '测试项目交付计划沟通',
+            deliveryEventDesc: '围绕整体交付里程碑、客户配合事项和项目实施边界进行首次沟通确认。',
+            deliveryEventType: '20',
+            deliveryEventStatus: '20',
+            deliveryEventResult: '10',
+            opsUserName: '李强',
+            completeTime: '2026-04-24 15:30:00',
+            completeDesc: '已完成首次交付计划确认,待客户二次反馈。',
+            onSite: '20',
+            feedbackReporter: '王静',
+            feedbackDate: '2026-04-23 10:20:00',
+            feedbackSource: '20',
+          },
+          {
+            id: 100000002,
+            deliveryEventTitle: '区域医检平台升级项目系统培训',
+            deliveryEventDesc: '针对检验、报告、质控等核心模块安排集中培训,覆盖关键业务流程和常见问题。',
+            deliveryEventType: '30',
+            deliveryEventStatus: '10',
+            deliveryEventResult: '30',
+            opsUserName: '周楠',
+            completeTime: '',
+            completeDesc: '培训议程已确认,等待客户安排现场时间。',
+            onSite: '10',
+            feedbackReporter: '陈晨',
+            feedbackDate: '2026-04-25 09:00:00',
+            feedbackSource: '10',
+          },
+          {
+            id: 100000003,
+            deliveryEventTitle: '生物样本库平台BUG修复',
+            deliveryEventDesc: '修复样本入库页面在批量提交场景下出现的数据校验异常,并完成回归验证。',
+            deliveryEventType: '34',
+            deliveryEventStatus: '30',
+            deliveryEventResult: '10',
+            opsUserName: '赵敏',
+            completeTime: '2026-04-22 18:10:00',
+            completeDesc: '已修复样本入库页面校验异常,并完成回归测试。',
+            onSite: '20',
+            feedbackReporter: '刘畅',
+            feedbackDate: '2026-04-21 14:35:00',
+            feedbackSource: '10',
+          },
+          {
+            id: 100000004,
+            deliveryEventTitle: 'LIMS系统发版准备',
+            deliveryEventDesc: '整理发版清单、确认数据库变更脚本及灰度验证方案,准备周五晚间上线。',
+            deliveryEventType: '36',
+            deliveryEventStatus: '20',
+            deliveryEventResult: '',
+            opsUserName: '孙涛',
+            completeTime: '',
+            completeDesc: '正在整理发版清单,计划本周五晚间发版。',
+            onSite: '20',
+            feedbackReporter: '杨帆',
+            feedbackDate: '2026-04-25 11:15:00',
+            feedbackSource: '30',
+          },
+        ],
+        sidebarCollapsed: false,
+        projectSearch: '',
+        selectedProject: '',
+        projects: [
+          { id: '', name: '全部', count: 9 },
+          {
+            id: '1',
+            name: '测试项目',
+            count: 9,
+            contractNo: 'HT2026040101',
+            productLine: '20',
+            salesOwner: '王静',
+            deliveryOwner: '李强',
+          },
+          {
+            id: '2',
+            name: '区域医检平台升级项目',
+            count: 6,
+            contractNo: 'HT2026040102',
+            productLine: '10',
+            salesOwner: '陈晨',
+            deliveryOwner: '周楠',
+          },
+        ],
+      }
+    },
+    computed: {
+      filteredProjects() {
+        const keyword = this.projectSearch.trim().toLowerCase()
+
+        if (!keyword) return this.projects
+
+        return this.projects.filter((project) => {
+          if (project.id === '') return true
+
+          return [project.name, project.contractNo, project.salesOwner, project.deliveryOwner]
+            .filter(Boolean)
+            .some((field) => field.toLowerCase().includes(keyword))
+        })
+      },
+      allProjectOption() {
+        return this.filteredProjects.find((project) => project.id === '')
+      },
+      projectCards() {
+        return this.filteredProjects.filter((project) => project.id !== '')
+      },
+    },
+    created() {
+      this.fetchData()
+    },
+    methods: {
+      parseTime,
+
+      // 获取列表数据
+      async fetchData() {
+        this.loading = true
+        try {
+          const res = await deliveryProjectApi.getList(this.queryForm)
+          if (res.code === 200 && res.data) {
+            const list = res.data.list || []
+            this.tableData = list.length ? list : this.mockTableData
+            this.total = list.length ? res.data.total || 0 : this.mockTableData.length
+          }
+        } catch (error) {
+          console.error('获取数据失败:', error)
+          this.tableData = this.mockTableData
+          this.total = this.mockTableData.length
+          this.$message.error('获取数据失败,已展示样例数据')
+        } finally {
+          this.loading = false
+        }
+      },
+
+      // 搜索
+      handleSearch() {
+        this.queryForm.pageNum = 1
+        this.fetchData()
+      },
+
+      // 重置
+      handleReset() {
+        this.queryForm = {
+          keyWords: '',
+          searchType: 'projectName',
+          sortBy: 'createdTime',
+          pageNum: 1,
+          pageSize: 20,
+        }
+        this.fetchData()
+      },
+
+      // 新增
+      handleAdd() {
+        this.$message.info('新增功能开发中...')
+      },
+
+      // 导出
+      handleExport() {
+        this.$message.info('导出功能开发中...')
+      },
+
+      // 编辑
+      handleEdit() {
+        this.$message.info('编辑功能开发中...')
+      },
+
+      // 查看详情
+      handleView() {
+        this.$message.info('详情功能开发中...')
+      },
+
+      // 删除
+      handleDelete() {
+        this.$confirm('确认删除该记录?', '提示', {
+          confirmButtonText: '确定',
+          cancelButtonText: '取消',
+          type: 'warning',
+        })
+          .then(() => {
+            this.$message.success('删除成功')
+            this.fetchData()
+          })
+          .catch(() => {})
+      },
+
+      // 行点击
+      handleRowClick(row) {
+        this.handleView(row)
+      },
+
+      // 分页
+      handleSizeChange(size) {
+        this.queryForm.pageSize = size
+        this.fetchData()
+      },
+
+      handleCurrentChange(page) {
+        this.queryForm.pageNum = page
+        this.fetchData()
+      },
+
+      // 侧边栏
+      toggleSidebar() {
+        this.sidebarCollapsed = !this.sidebarCollapsed
+      },
+
+      selectProject(projectId) {
+        this.selectedProject = projectId
+        this.queryForm.projectId = projectId
+        this.queryForm.pageNum = 1
+        this.fetchData()
+      },
+
+      formatEventTime(time) {
+        return time ? parseTime(time, '{y}-{m}-{d} {h}:{i}') : '-'
+      },
+
+      getDeliveryEventTypeLabel(type) {
+        const map = {
+          10: '内部启动会',
+          15: '外部启动会',
+          20: '制定计划',
+          30: '系统培训',
+          31: '需求沟通',
+          32: '功能调整',
+          33: '二开需求',
+          34: 'BUG修复',
+          35: '功能测试',
+          36: '系统发版',
+          39: '软件部署',
+          40: '硬件发货',
+          41: '硬件安装',
+          50: '试运行',
+          60: '验收汇报',
+        }
+        return map[type] || type || '-'
+      },
+
+      getDeliveryEventStatusLabel(status) {
+        const map = {
+          10: '待处理',
+          20: '处理中',
+          30: '已关闭',
+        }
+        return map[status] || status || '-'
+      },
+
+      getDeliveryEventStatusType(status) {
+        const map = {
+          10: 'info',
+          20: 'warning',
+          30: 'success',
+        }
+        return map[status] || 'info'
+      },
+
+      getDeliveryEventResultLabel(result) {
+        const map = {
+          10: '已解决',
+          30: '未解决',
+        }
+        return map[result] || result || '-'
+      },
+
+      getFeedbackSourceLabel(source) {
+        const map = {
+          10: '客户',
+          20: '销售',
+          30: '交付',
+        }
+        return map[source] || source || '-'
+      },
+
+      getOnSiteLabel(onSite) {
+        const map = {
+          10: '是',
+          20: '否',
+        }
+        return map[onSite] || onSite || '-'
+      },
+
+      // 产品线标签
+      getProductLineLabel(productLine) {
+        const map = {
+          10: 'Biobank',
+          20: 'LIMS',
+          30: 'CellBank',
+          40: 'MCS',
+        }
+        return map[productLine] || productLine
+      },
+
+      // 状态标签
+      getStatusLabel(status) {
+        const map = {
+          10: '待交付',
+          20: '交付中',
+          30: '暂停',
+          40: '交付完成',
+          50: '验收',
+          90: '作废',
+        }
+        return map[status] || status
+      },
+
+      // 状态样式
+      getStatusType(status) {
+        const map = {
+          10: 'info',
+          20: 'primary',
+          30: 'warning',
+          40: 'success',
+          50: 'success',
+          90: 'danger',
+        }
+        return map[status] || 'info'
+      },
+
+      // 是否可以删除
+      canDelete(status) {
+        return ['10', '90'].includes(status)
+      },
+    },
+  }
+</script>
+
+<style lang="scss" scoped>
+  .delivery-project-page {
+    box-sizing: border-box;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+    padding: 4px;
+    height: calc(100vh - 122px);
+    background: #f5f7fa;
+    overflow: hidden;
+  }
+
+  .toolbar {
+    margin-bottom: 4px;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    flex-shrink: 0;
+    padding: 8px;
+    background: #fff;
+    border-radius: 6px;
+    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+  }
+
+  .toolbar-right {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+  }
+
+  .sort-label {
+    font-size: 14px;
+    color: #606266;
+  }
+
+  .table-container {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    min-width: 0;
+    min-height: 0;
+    overflow: hidden;
+    background: #fff;
+    border-radius: 4px;
+    padding: 6px 8px;
+    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
+  }
+
+  .table-scroll-wrapper {
+    display: flex;
+    flex: 1;
+    min-width: 0;
+    min-height: 0;
+    overflow: hidden;
+  }
+
+  .delivery-project-table {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .pagination-wrapper {
+    margin-top: auto;
+    padding-top: 4px;
+    display: flex;
+    justify-content: flex-end;
+  }
+
+  .pagination-wrapper ::v-deep .el-pagination {
+    padding: 0;
+    line-height: 32px;
+  }
+
+  .event-title-trigger {
+    display: inline-block;
+    max-width: 100%;
+    color: #409eff;
+    cursor: pointer;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+
+    &:hover {
+      color: #66b1ff;
+    }
+  }
+
+  .event-desc-popover {
+    &__title {
+      margin-bottom: 8px;
+      font-size: 14px;
+      font-weight: 600;
+      color: #303133;
+    }
+
+    &__content {
+      max-height: 180px;
+      font-size: 13px;
+      line-height: 1.6;
+      color: #606266;
+      white-space: pre-wrap;
+      word-break: break-word;
+      overflow-y: auto;
+    }
+  }
+
+  .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;
+    }
+  }
+  .sort-select,
+  .search-type-select {
+    width: 120px;
+  }
+
+  .search-input {
+    width: 200px;
+  }
+
+  .main-content {
+    display: flex;
+    flex: 1;
+    gap: 4px;
+    min-height: 0;
+    overflow: hidden;
+  }
+
+  .sidebar-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 6px;
+  }
+
+  .project-sidebar.collapsed .sidebar-header {
+    justify-content: center;
+    margin-bottom: 0;
+    width: 100%;
+  }
+
+  .project-sidebar.collapsed .project-search,
+  .project-sidebar.collapsed .project-list {
+    display: none;
+  }
+
+  .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;
+    }
+
+    i {
+      font-size: 12px;
+      line-height: 1;
+    }
+  }
+
+  .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-overview-count {
+    display: flex;
+    align-items: baseline;
+    gap: 2px;
+    color: #409eff;
+    font-weight: 600;
+  }
+
+  .project-overview-count-label {
+    font-size: 12px;
+    font-weight: 500;
+  }
+
+  .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);
+    }
+  }
+
+  .project-card-top {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 8px;
+    margin-bottom: 6px;
+  }
+
+  .project-card-tags {
+    display: flex;
+    align-items: center;
+    gap: 6px;
+    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;
+  }
+
+  .project-line-tag {
+    flex-shrink: 0;
+    padding: 2px 8px;
+    font-size: 11px;
+    color: #409eff;
+    background: #ecf5ff;
+    border-radius: 999px;
+    white-space: nowrap;
+  }
+
+  .project-card-count {
+    font-size: 12px;
+    font-weight: 600;
+    color: #409eff;
+    white-space: nowrap;
+  }
+
+  .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: 12px;
+    font-size: 14px;
+    color: #409eff;
+  }
+
+  .el-icon-check {
+    font-size: 12px;
+    color: #409eff;
+  }
+</style>

+ 6 - 5
src/views/devops/operation/index.vue

@@ -314,10 +314,11 @@
 
 <style lang="scss" scoped>
   .operation-container {
-    padding: 20px;
+    padding: 0;
     display: flex;
     flex-direction: column;
-    height: calc(100vh - 84px);
+    min-height: 0;
+    height: calc(100vh - 60px - 12px * 2 - 40px - 10px);
   }
 
   .toolbar {
@@ -342,11 +343,11 @@
   }
 
   .sort-select {
-    width: 120px;
+    width: 80px;
   }
 
   .search-type-select {
-    width: 100px;
+    width: 66px;
   }
 
   .search-input {
@@ -359,7 +360,7 @@
     overflow-x: hidden;
     padding: 0;
     flex: 1;
-    height: calc(100vh - 180px);
+    min-height: 0;
   }
 
   .kanban-column {

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

@@ -285,7 +285,7 @@
 
 <style scoped>
   .operation-history-container {
-    padding: 10px;
+    padding: 0;
     position: relative;
   }
   .export-btn-fixed {

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

@@ -88,6 +88,6 @@
 
 <style lang="scss" scoped>
   .detail {
-    padding: 30px;
+    padding: 0;
   }
 </style>

+ 1 - 1
src/views/report/followup/index.vue

@@ -87,6 +87,6 @@
 
 <style lang="scss" scoped>
   .detail {
-    padding: 30px;
+    padding: 0;
   }
 </style>

+ 1 - 1
src/views/report/proj/index.vue

@@ -65,6 +65,6 @@
 
 <style lang="scss" scoped>
   .detail {
-    padding: 30px;
+    padding: 0;
   }
 </style>

+ 1 - 1
src/views/report/punch/index.vue

@@ -87,6 +87,6 @@
 
 <style lang="scss" scoped>
   .detail {
-    padding: 30px;
+    padding: 0;
   }
 </style>