소스 검색

feat: 工时统计页面、Excel导出、运维工时组件

- 新增工时统计报告页面(views/report/workHour),支持周/月维度切换
- 多级表头:日期-运维/研发子列,姓名-合计分组列
- Excel 导出功能(xlsx/SheetJS,含多级表头、自动求和)
- 新增工时录入弹窗组件(WorkHourDialog/WorkHourListDialog)
- 运维事件详情页增加工时 Tab
- 安装 xlsx 依赖
- 调整网关端口至 9188
程健 3 주 전
부모
커밋
904e978233

+ 3 - 3
.env.development

@@ -13,7 +13,7 @@ VUE_APP_TENANT=8b9ec443
 # websocket地址
 VUE_APP_WEBSOCKET_URL=ws://39.105.105.147:9188/ws
 # GateWay地址
-VUE_APP_MicroSrvProxy_API=http://192.168.0.221:9189/api/
+VUE_APP_MicroSrvProxy_API=http://192.168.0.221:9188/api/
 # 登录验证微服务名称
 VUE_APP_AdminPath=dashoo.opms.admin-0.0.1-cj
 # 业务接口微服务名称
@@ -23,5 +23,5 @@ VUE_APP_ParentPath=dashoo.opms.parent-0.0.2-cj
 VUE_APP_UPLOAD_WEED='/dir/'
 # 文件一步上传
 VUE_APP_UPLOAD_FILE_WEED=/weedfs/
-VUE_APP_RICHTEXT_UPLOAD_API=http://192.168.0.221:9189/weed_filer/
-VUE_APP_RICHTEXT_UPLOAD_TARGET=http://192.168.0.221:9189/weed_filer/
+VUE_APP_RICHTEXT_UPLOAD_API=http://192.168.0.221:9188/weed_filer/
+VUE_APP_RICHTEXT_UPLOAD_TARGET=http://192.168.0.221:9188/weed_filer/

+ 2 - 1
package.json

@@ -42,7 +42,8 @@
     "vuedraggable": "^2.24.3",
     "vuex": "^3.6.2",
     "vxe-table": "^3.7.7",
-    "watermark-dom": "^2.3.0"
+    "watermark-dom": "^2.3.0",
+    "xlsx": "^0.18.5"
   },
   "devDependencies": {
     "@vue/cli-plugin-babel": "^4.5.15",

+ 7 - 0
src/api/operation/operationEvent.js

@@ -34,6 +34,7 @@ export default {
       operateType: operateType,
       handleContent: query.handleContent || '',
       handleResult: query.handleResult || '',
+      ...(query.adjustWorkHour !== undefined ? { adjustWorkHour: query.adjustWorkHour } : {}),
     })
   },
   getRecordList(query) {
@@ -42,6 +43,12 @@ export default {
   addRecord(query) {
     return micro_request.postRequest(basePath, 'Operation', 'AddRecord', query)
   },
+  doAddWorkHour(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'AddWorkHour', query)
+  },
+  getWorkHourList(query) {
+    return micro_request.postRequest(basePath, 'Operation', 'GetWorkHourList', query)
+  },
   uploadAttachment(query) {
     return micro_request.postRequest(basePath, 'Operation', 'UploadAttachment', query)
   },

+ 9 - 0
src/api/report/work_hour.js

@@ -0,0 +1,9 @@
+import micro_request from '@/utils/micro_request'
+
+const basePath = process.env.VUE_APP_ParentPath
+
+export default {
+  getStat(params) {
+    return micro_request.postRequest(basePath, 'WorkHourStat', 'GetStat', params)
+  },
+}

+ 54 - 2
src/views/devops/operation/components/OperationDetail.vue

@@ -84,6 +84,22 @@
             <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>
 
       <!-- 主内容区 -->
@@ -229,6 +245,15 @@
             <el-option v-for="dict in handleResultOptions" :key="dict.key" :label="dict.value" :value="dict.key" />
           </el-select>
         </el-form-item>
+        <el-form-item label="调整工时">
+          <el-input-number
+            v-model="closeForm.adjustWorkHour"
+            :min="totalWorkHour"
+            :precision="1"
+            :step="0.5"
+            style="width: 100%" />
+          <span style="color: #909399; font-size: 12px">当前累计 {{ totalWorkHour }}h,只能增加不能减少</span>
+        </el-form-item>
         <el-form-item label="关闭原因">
           <el-input
             v-model="closeForm.handleContent"
@@ -243,6 +268,9 @@
         <el-button :loading="submitLoading" type="primary" @click="submitClose">确定</el-button>
       </div>
     </el-dialog>
+
+    <WorkHourListDialog :event-id="data.id" :visible.sync="showWorkHourList" @add-work-hour="onAddWorkHour" />
+    <WorkHourDialog :event-id="data.id" :visible.sync="showWorkHourDialog" @refresh="onWorkHourRefresh" />
   </div>
 </template>
 
@@ -252,10 +280,12 @@
   import { parseTime } from '@/utils'
   import { uploadRichtextImage, uploadFileToRichtextServer } from '@/utils/richtextUpload'
   import { openSafeUrl, sanitizeHtml } from '@/utils/safeHtml'
+  import WorkHourListDialog from './WorkHourListDialog'
+  import WorkHourDialog from './WorkHourDialog'
 
   export default {
     name: 'OperationDetail',
-    components: { Editor, Toolbar },
+    components: { Editor, Toolbar, WorkHourListDialog, WorkHourDialog },
     props: {
       visible: {
         type: Boolean,
@@ -342,8 +372,12 @@
         closeForm: {
           handleResult: '10',
           handleContent: '',
+          adjustWorkHour: null,
         },
         handleResultOptions: [],
+        totalWorkHour: 0,
+        showWorkHourList: false,
+        showWorkHourDialog: false,
         detailAction: '',
         eventStatusOptions: [],
         eventTypeOptions: [],
@@ -404,7 +438,7 @@
             this.priorityLevelOptions = priorityLevel.data.values || []
             this.handleResultOptions = handleResult.data.values || []
           })
-          .catch((err) => console.log(err))
+          .catch(() => {})
       },
       onEditorCreated(editor) {
         this.editor = editor
@@ -421,6 +455,7 @@
         this.showProperty = false
         this.activeTab = 'record'
         this.detailAction = this.action || ''
+        this.totalWorkHour = (this.data && this.data.totalWorkHour) || 0
         if (this.mode === 'receive') {
           this.isReceiveMode = true
           this.isProcessMode = false
@@ -555,6 +590,7 @@
       async handleComplete() {
         this.closeForm.handleResult = '10'
         this.closeForm.handleContent = ''
+        this.closeForm.adjustWorkHour = this.totalWorkHour
         this.closeDialogVisible = true
       },
       async submitClose() {
@@ -569,6 +605,7 @@
             eventStatus: '80',
             handleContent: this.closeForm.handleContent.trim(),
             handleResult: this.closeForm.handleResult,
+            ...(this.closeForm.adjustWorkHour !== undefined ? { adjustWorkHour: this.closeForm.adjustWorkHour } : {}),
           })
           if (res.code === 200) {
             this.$message.success('事件已关闭')
@@ -790,6 +827,21 @@
         if (!name) return '?'
         return name.charAt(0)
       },
+      onAddWorkHour() {
+        this.showWorkHourDialog = true
+      },
+      async onWorkHourRefresh() {
+        try {
+          const res = await operationEventApi.getDetail({ id: this.data.id })
+          if (res.code === 200 && res.data) {
+            this.totalWorkHour = res.data.totalWorkHour || 0
+          }
+        } catch (err) {
+          // silently fail, totalWorkHour may be stale
+        }
+        this.fetchRecordList()
+        this.$emit('refresh')
+      },
     },
   }
 </script>

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

@@ -0,0 +1,151 @@
+<template>
+  <el-dialog
+    :close-on-click-modal="false"
+    :title="'工时登记'"
+    :visible="visible"
+    width="480px"
+    @close="handleClose"
+    @update:visible="$emit('update:visible', $event)">
+    <el-form ref="form" class="work-hour-dialog-form" label-width="110px" :model="form" :rules="rules">
+      <el-form-item label="工作日期" prop="workDate">
+        <el-date-picker
+          v-model="form.workDate"
+          format="yyyy-MM-dd"
+          :picker-options="pickerOptions"
+          placeholder="请选择日期"
+          style="width: 100%"
+          type="date"
+          value-format="yyyy-MM-dd" />
+      </el-form-item>
+
+      <el-form-item label="工时" prop="workHour">
+        <div class="work-hour-row">
+          <el-input-number
+            v-model="form.workHour"
+            controls-position="right"
+            :min="0.5"
+            :precision="1"
+            :step="0.5"
+            style="width: 140px" />
+          <el-button size="mini" @click="addWorkHour(0.5)">+0.5</el-button>
+          <el-button size="mini" @click="addWorkHour(2)">+2.0</el-button>
+        </div>
+      </el-form-item>
+
+      <el-form-item label="工作说明" prop="remark">
+        <el-input v-model="form.remark" placeholder="工作内容说明" :rows="3" type="textarea" />
+      </el-form-item>
+    </el-form>
+    <span slot="footer" class="dialog-footer">
+      <el-button @click="handleClose">取消</el-button>
+      <el-button type="primary" @click="handleSubmit">提交</el-button>
+    </span>
+  </el-dialog>
+</template>
+
+<script>
+  import operationEventApi from '@/api/operation/operationEvent'
+  import { parseTime } from '@/utils'
+
+  export default {
+    name: 'WorkHourDialog',
+    props: {
+      visible: {
+        type: Boolean,
+        required: true,
+      },
+      eventId: {
+        type: Number,
+        default: null,
+      },
+    },
+    data() {
+      return {
+        form: {
+          workDate: '',
+          workHour: 0,
+          remark: '',
+        },
+        rules: {
+          workDate: [{ required: true, message: '请选择工作日期', trigger: 'change' }],
+          workHour: [{ required: true, type: 'number', message: '请输入工时', trigger: 'blur' }],
+        },
+        pickerOptions: {
+          disabledDate(time) {
+            return time.getTime() > Date.now()
+          },
+        },
+      }
+    },
+    watch: {
+      visible(val) {
+        if (val) {
+          this.resetForm()
+        }
+      },
+    },
+    methods: {
+      resetForm() {
+        this.form = {
+          workDate: parseTime(new Date(), '{y}-{m}-{d}'),
+          workHour: 0,
+          remark: '',
+        }
+        this.$nextTick(() => {
+          this.$refs.form && this.$refs.form.clearValidate()
+        })
+      },
+      addWorkHour(hours) {
+        const current = Number(this.form.workHour) || 0
+        this.form.workHour = Math.round((current + hours) * 10) / 10
+      },
+      handleSubmit() {
+        this.$refs.form.validate(async (valid) => {
+          if (!valid) return
+          if (!this.eventId) {
+            this.$message.error('事件ID不能为空')
+            return
+          }
+          try {
+            const res = await operationEventApi.doAddWorkHour({
+              eventId: this.eventId,
+              workDate: this.form.workDate,
+              workHour: this.form.workHour,
+              remark: this.form.remark,
+            })
+            if (res.code === 200) {
+              this.$message.success('登记成功')
+              this.$emit('refresh')
+              this.handleClose()
+            } else {
+              this.$message.error(res.message || '登记失败')
+            }
+          } catch (err) {
+            console.error(err)
+            this.$message.error(err.message || '登记失败,请重试')
+          }
+        })
+      },
+      handleClose() {
+        this.$emit('update:visible', false)
+        this.resetForm()
+      },
+    },
+  }
+</script>
+
+<style lang="scss" scoped>
+  .work-hour-dialog-form {
+    padding: 0 20px;
+  }
+
+  .work-hour-row {
+    display: flex;
+    gap: 6px;
+    align-items: center;
+
+    .el-button--mini {
+      flex-shrink: 0;
+    }
+  }
+</style>

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

@@ -0,0 +1,104 @@
+<template>
+  <el-dialog :close-on-click-modal="false" title="工时记录" :visible="visible" width="640px" @close="handleClose">
+    <el-table v-loading="loading" border :data="list" size="small" style="width: 100%">
+      <el-table-column align="center" label="工作日期" prop="workDate" width="110">
+        <template slot-scope="{ row }">
+          {{ formatDate(row.workDate) }}
+        </template>
+      </el-table-column>
+      <el-table-column align="right" label="工时(h)" prop="workHour" width="100">
+        <template slot-scope="{ row }">
+          {{ row.workHour ? row.workHour + 'h' : '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column label="工作说明" min-width="160" prop="remark" show-overflow-tooltip>
+        <template slot-scope="{ row }">
+          {{ row.remark || '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column label="登记人" prop="createdName" width="100">
+        <template slot-scope="{ row }">
+          {{ row.createdName || '-' }}
+        </template>
+      </el-table-column>
+      <el-table-column align="center" label="登记时间" prop="createdTime" width="150">
+        <template slot-scope="{ row }">
+          {{ formatTime(row.createdTime) }}
+        </template>
+      </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>
+
+<script>
+  import operationEventApi from '@/api/operation/operationEvent'
+  import { parseTime } from '@/utils'
+
+  export default {
+    name: 'WorkHourListDialog',
+    props: {
+      eventId: {
+        type: Number,
+        default: null,
+      },
+      visible: {
+        type: Boolean,
+        default: false,
+      },
+    },
+    data() {
+      return {
+        list: [],
+        loading: false,
+      }
+    },
+    watch: {
+      visible(val) {
+        if (val && this.eventId) {
+          this.fetchList()
+        }
+      },
+    },
+    methods: {
+      async fetchList() {
+        this.loading = true
+        try {
+          const res = await operationEventApi.getWorkHourList({ eventId: this.eventId })
+          if (res.code === 200) {
+            this.list = res.data?.list || []
+          }
+        } catch (err) {
+          this.list = []
+        } finally {
+          this.loading = false
+        }
+      },
+      formatDate(time) {
+        return time ? parseTime(time, '{y}-{m}-{d}') : '-'
+      },
+      formatTime(time) {
+        return time ? parseTime(time, '{y}-{m}-{d} {h}:{i}') : '-'
+      },
+      handleClose() {
+        this.$emit('update:visible', false)
+      },
+      handleAdd() {
+        this.$emit('add-work-hour')
+      },
+    },
+  }
+</script>
+
+<style lang="scss" scoped>
+  .empty-hint {
+    text-align: center;
+    color: #909399;
+    font-size: 13px;
+    padding: 32px 0;
+  }
+</style>

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

@@ -146,8 +146,8 @@
         },
         kanbanColumns: [
           { status: '10', name: '待处理', count: 0, list: [], isDragOver: false },
-          { status: '20', name: '处理中(重点)', count: 0, list: [], isDragOver: false },
-          { status: '30', name: '处理中(普通)', count: 0, list: [], isDragOver: false },
+          { status: '20', name: '处理中', count: 0, list: [], isDragOver: false },
+          { status: '20', name: '处理中', count: 0, list: [], isDragOver: false },
           { status: '40', name: '转研发', count: 0, list: [], isDragOver: false },
           { status: '70', name: '挂起', count: 0, list: [], isDragOver: false },
         ],
@@ -194,10 +194,28 @@
         }
       },
       updateKanbanData(data) {
-        this.kanbanColumns.forEach((column) => {
-          const columnData = data[column.status] || { list: [], count: 0 }
-          column.list = columnData.list || []
-          column.count = columnData.count || 0
+        const COL_MAX = 5
+        const processingAll = (data['20'] && data['20'].list) || []
+
+        this.kanbanColumns.forEach((column, idx) => {
+          if (column.status === '20') {
+            const isFirst = this.kanbanColumns.findIndex((c) => c.status === '20') === idx
+            if (processingAll.length <= COL_MAX) {
+              column.list = isFirst ? processingAll : []
+              column.count = isFirst ? processingAll.length : 0
+            } else if (processingAll.length <= COL_MAX * 2) {
+              column.list = isFirst ? processingAll.slice(0, COL_MAX) : processingAll.slice(COL_MAX)
+              column.count = isFirst ? COL_MAX : processingAll.length - COL_MAX
+            } else {
+              const mid = Math.ceil(processingAll.length / 2)
+              column.list = isFirst ? processingAll.slice(0, mid) : processingAll.slice(mid)
+              column.count = isFirst ? mid : processingAll.length - mid
+            }
+          } else {
+            const columnData = data[column.status] || { list: [], count: 0 }
+            column.list = columnData.list || []
+            column.count = columnData.count || 0
+          }
         })
       },
       handleSearch() {

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

@@ -410,18 +410,13 @@
             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)
-            })
+            .filter((project) => !!project.contractId)
 
           projectList.sort((a, b) => String(b.contractNo || '').localeCompare(String(a.contractNo || '')))
           this.projects = projectList

+ 395 - 0
src/views/report/workHour/index.vue

@@ -0,0 +1,395 @@
+<template>
+  <div class="work-hour-stat">
+    <el-card class="stat-card" shadow="never">
+      <!-- header -->
+      <div class="stat-header">
+        <div class="stat-title">
+          <span class="title-accent" />
+          <h2>人员工时统计</h2>
+          <el-tag v-if="header.length" effect="plain" size="mini" type="info">{{ header.length }} 天</el-tag>
+        </div>
+        <div class="stat-actions">
+          <el-button
+            :disabled="!tableData.length"
+            icon="el-icon-download"
+            size="mini"
+            type="primary"
+            @click="exportExcel">
+            导出 Excel
+          </el-button>
+        </div>
+      </div>
+
+      <!-- filters -->
+      <div class="stat-filters">
+        <el-button-group class="mode-group">
+          <el-button size="mini" :type="mode === 'week' ? 'primary' : 'default'" @click="selectMode('week')">
+            周
+          </el-button>
+          <el-button size="mini" :type="mode === 'month' ? 'primary' : 'default'" @click="selectMode('month')">
+            月
+          </el-button>
+        </el-button-group>
+
+        <el-date-picker
+          v-if="mode === 'week'"
+          v-model="pickerDate"
+          class="date-picker"
+          format="yyyy 第 WW 周"
+          type="week"
+          @change="fetchData" />
+        <el-date-picker
+          v-if="mode === 'month'"
+          v-model="pickerDate"
+          class="date-picker"
+          type="month"
+          value-format="yyyy-MM"
+          @change="fetchData" />
+
+        <el-select
+          v-model="selectedUserIds"
+          class="user-select"
+          clearable
+          filterable
+          :loading="userLoading"
+          multiple
+          placeholder="输入姓名筛选人员"
+          remote
+          :remote-method="searchUsers"
+          reserve-keyword
+          @change="fetchData">
+          <el-option v-for="user in userOptions" :key="user.id" :label="user.nickName" :value="user.id" />
+        </el-select>
+      </div>
+
+      <!-- table -->
+      <el-table
+        ref="table"
+        v-loading="loading"
+        border
+        class="stat-table"
+        :data="tableData"
+        header-row-class-name="stat-header-row"
+        :height="$baseTableHeight(1)"
+        show-summary
+        style="width: 100%"
+        :summary-method="getSummaries">
+        <el-table-column align="center" label="" width="100">
+          <el-table-column align="center" label="姓名" prop="userName" />
+        </el-table-column>
+        <el-table-column v-for="day in header" :key="day.date" align="center" :label="day.label">
+          <el-table-column align="center" label="运维" width="78">
+            <template #default="{ row }">
+              <span class="cell-op">{{ formatHour(row.dailyHours?.[day.date]?.opHour) }}</span>
+            </template>
+          </el-table-column>
+          <el-table-column align="center" label="研发" width="78">
+            <template #default="{ row }">
+              <span class="cell-rd">{{ formatHour(row.dailyHours?.[day.date]?.rdHour) }}</span>
+            </template>
+          </el-table-column>
+        </el-table-column>
+        <el-table-column align="center" label="合计" width="180">
+          <el-table-column align="center" label="运维合计" prop="totalOpHour" width="90" />
+          <el-table-column align="center" label="研发合计" prop="totalRdHour" width="90" />
+        </el-table-column>
+      </el-table>
+    </el-card>
+  </div>
+</template>
+
+<script>
+  import * as XLSX from 'xlsx'
+  import workHourApi from '@/api/report/work_hour'
+  import micro_request from '@/utils/micro_request'
+  import { parseTime } from '@/utils'
+
+  export default {
+    name: 'WorkHourStat',
+    data() {
+      return {
+        mode: 'week',
+        pickerDate: '',
+        loading: false,
+        header: [],
+        tableData: [],
+        selectedUserIds: [],
+        userOptions: [],
+        userLoading: false,
+      }
+    },
+    mounted() {
+      const now = new Date()
+      const dow = now.getDay()
+      const offset = dow === 0 ? 6 : dow + 6
+      const lastMonday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - offset)
+      this.pickerDate = parseTime(lastMonday, '{y}-{m}-{d}')
+      this.fetchData()
+    },
+    methods: {
+      formatHour(val) {
+        const num = Number(val) || 0
+        return num % 1 === 0 ? num.toString() : num.toFixed(1)
+      },
+
+      selectMode(mode) {
+        this.mode = mode
+        this.fetchData()
+      },
+
+      calcDateRange() {
+        if (!this.pickerDate) {
+          this.pickerDate = parseTime(new Date(), '{y}-{m}-{d}')
+        }
+        let startDate, endDate
+
+        if (this.mode === 'week') {
+          const today = new Date(this.pickerDate)
+          const day = today.getDay()
+          startDate = parseTime(new Date(today.getFullYear(), today.getMonth(), today.getDate() - day), '{y}-{m}-{d}')
+          endDate = parseTime(new Date(today.getFullYear(), today.getMonth(), today.getDate() - day + 6), '{y}-{m}-{d}')
+        } else if (this.mode === 'month') {
+          const [y, m] = this.pickerDate.split('-')
+          const lastDay = new Date(Number(y), Number(m), 0).getDate()
+          startDate = `${y}-${m}-01`
+          endDate = `${y}-${m}-${String(lastDay).padStart(2, '0')}`
+        }
+
+        return { startDate, endDate }
+      },
+
+      async fetchData() {
+        this.loading = true
+        try {
+          const { startDate, endDate } = this.calcDateRange()
+          const { data } = await workHourApi.getStat({
+            mode: this.mode,
+            startDate,
+            endDate,
+            userIds: this.selectedUserIds,
+          })
+          this.header = data.header || []
+          this.tableData = data.persons || []
+        } catch (e) {
+          console.error('获取工时统计失败', e)
+        } finally {
+          this.loading = false
+        }
+      },
+
+      async searchUsers(query) {
+        if (!query) {
+          this.userOptions = []
+          return
+        }
+        this.userLoading = true
+        try {
+          const { data } = await micro_request.postRequest(process.env.VUE_APP_AdminPath, 'User', 'GetList', {
+            keyWords: query,
+            pageNum: 1,
+            pageSize: 50,
+          })
+          this.userOptions = (data && data.list) || []
+        } catch (e) {
+          console.error(e)
+        } finally {
+          this.userLoading = false
+        }
+      },
+
+      getSummaries({ columns, data }) {
+        const sums = []
+        columns.forEach((col, index) => {
+          if (index === 0) {
+            sums[index] = '合计'
+            return
+          }
+          const prop = col.property
+          if (!prop) {
+            sums[index] = ''
+            return
+          }
+          if (prop === 'totalOpHour' || prop === 'totalRdHour') {
+            sums[index] = data.reduce((sum, row) => sum + (row[prop] || 0), 0)
+          } else {
+            sums[index] = ''
+          }
+        })
+        return sums
+      },
+
+      exportExcel() {
+        if (!this.tableData.length) return
+
+        const days = this.header || []
+        const persons = this.tableData || []
+
+        // -- header rows --
+        // Row 1: day labels spanning 2 cols each
+        const row1 = ['姓名']
+        days.forEach((d) => {
+          row1.push(d.label, null) // will be merged
+        })
+        row1.push('运维合计', '研发合计')
+
+        // Row 2: sub-labels
+        const row2 = ['姓名']
+        days.forEach(() => {
+          row2.push('运维', '研发')
+        })
+        row2.push('', '')
+
+        // -- data rows --
+        const dataRows = persons.map((p) => {
+          const r = [p.userName || '']
+          days.forEach((d) => {
+            r.push(p.dailyHours?.[d.date]?.opHour || 0)
+            r.push(p.dailyHours?.[d.date]?.rdHour || 0)
+          })
+          r.push(p.totalOpHour || 0)
+          r.push(p.totalRdHour || 0)
+          return r
+        })
+
+        // -- summary row --
+        const totalRow = ['合计']
+        days.forEach((d) => {
+          const sumOp = persons.reduce((s, p) => s + (p.dailyHours?.[d.date]?.opHour || 0), 0)
+          const sumRd = persons.reduce((s, p) => s + (p.dailyHours?.[d.date]?.rdHour || 0), 0)
+          totalRow.push(sumOp, sumRd)
+        })
+        totalRow.push(
+          persons.reduce((s, p) => s + (p.totalOpHour || 0), 0),
+          persons.reduce((s, p) => s + (p.totalRdHour || 0), 0)
+        )
+
+        // -- build worksheet --
+        const wsData = [row1, row2, ...dataRows, totalRow]
+        const ws = XLSX.utils.aoa_to_sheet(wsData)
+
+        // column widths
+        ws['!cols'] = [{ wch: 10 }, ...days.map(() => ({ wch: 8 })), { wch: 10 }, { wch: 10 }]
+
+        // merge day header cells: row 1, each day spans 2 cols
+        const merges = days.map((d, i) => ({
+          s: { r: 0, c: 1 + i * 2 },
+          e: { r: 0, c: 2 + i * 2 },
+        }))
+        ws['!merges'] = merges
+
+        // number format for all hour columns
+        for (let R = 2; R < wsData.length; R++) {
+          for (let C = 1; C < wsData[R].length; C++) {
+            const addr = XLSX.utils.encode_cell({ r: R, c: C })
+            if (ws[addr]) {
+              ws[addr].z = '0.0'
+            }
+          }
+        }
+
+        // -- workbook --
+        const wb = XLSX.utils.book_new()
+        XLSX.utils.book_append_sheet(wb, ws, '工时统计')
+
+        const wbout = XLSX.write(wb, { bookType: 'xlsx', type: 'array' })
+        const blob = new Blob([wbout], { type: 'application/octet-stream' })
+        const link = document.createElement('a')
+        link.href = URL.createObjectURL(blob)
+        link.download = `工时统计_${this.calcDateRange().startDate}_${this.calcDateRange().endDate}.xlsx`
+        document.body.appendChild(link)
+        link.click()
+        document.body.removeChild(link)
+        URL.revokeObjectURL(link.href)
+      },
+    },
+  }
+</script>
+
+<style lang="scss" scoped>
+  .work-hour-stat {
+    .stat-card {
+      border-radius: 8px;
+      border: 1px solid #e8ecf1;
+
+      :deep(.el-card__body) {
+        padding: 16px 20px;
+      }
+    }
+
+    .stat-header {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+      margin-bottom: 12px;
+
+      .stat-title {
+        display: flex;
+        align-items: center;
+        gap: 12px;
+
+        .title-accent {
+          display: block;
+          width: 4px;
+          height: 22px;
+          border-radius: 2px;
+          background: linear-gradient(180deg, #409eff, #2d7ad9);
+          flex-shrink: 0;
+        }
+
+        h2 {
+          margin: 0;
+          font-size: 18px;
+          font-weight: 600;
+          color: #1d2129;
+          line-height: 1.4;
+        }
+      }
+    }
+
+    .stat-filters {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      margin-bottom: 12px;
+      flex-wrap: wrap;
+
+      .date-picker {
+        width: 160px;
+      }
+
+      .user-select {
+        width: 280px;
+      }
+    }
+
+    .stat-table {
+      :deep(.el-table__header-wrapper) {
+        .stat-header-row th {
+          background: #f5f7fa;
+          color: #1d2129;
+          font-weight: 600;
+        }
+      }
+
+      :deep(.el-table__body-wrapper) {
+        .cell-op {
+          color: #409eff;
+          font-weight: 500;
+        }
+
+        .cell-rd {
+          color: #67c23a;
+          font-weight: 500;
+        }
+      }
+
+      :deep(.el-table__footer-wrapper) {
+        td {
+          font-weight: 700;
+          color: #1d2129;
+          background: #fafafa;
+        }
+      }
+    }
+  }
+</style>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 126 - 126
yarn.lock


이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.