|
|
@@ -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>
|