Files
klp-oa/klp-ui/src/views/hrm/attendance/index.vue
2025-12-30 13:47:53 +08:00

527 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="hrm-page attendance-page">
<section class="panel-grid">
<el-card class="metal-panel flat" shadow="never">
<div class="card-toolbar">
<div class="actions-inline">
<el-button size="mini" type="primary" plain icon="el-icon-plus" @click="openShiftDialog()">新增</el-button>
<el-button size="mini" plain icon="el-icon-refresh" @click="loadShift">刷新</el-button>
</div>
</div>
<el-table :data="shiftList" v-loading="shiftLoading" height="320" stripe>
<el-table-column label="名称" prop="shiftName" min-width="140" />
<el-table-column label="时间段" min-width="180">
<template slot-scope="scope">{{ scope.row.startTime }} - {{ scope.row.endTime }}</template>
</el-table-column>
<el-table-column label="夜班" prop="nightShift" min-width="80">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.nightShift ? 'warning' : 'info'">
{{ scope.row.nightShift ? '是' : '否' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="休息" prop="restMinutes" min-width="100" />
<el-table-column label="操作" width="140" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click.stop="openShiftDialog(scope.row)">编辑</el-button>
<el-button size="mini" type="text" @click.stop="delShift(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="metal-panel flat" shadow="never">
<div class="card-toolbar">
<div class="actions-inline">
<el-date-picker
v-model="scheduleQuery.date"
type="date"
placeholder="日期"
size="mini"
style="width: 140px"
@change="loadSchedule"
/>
<el-button size="mini" type="primary" plain @click="loadSchedule">查询</el-button>
<el-button size="mini" plain icon="el-icon-plus" @click="openScheduleDialog()">新增</el-button>
</div>
</div>
<el-table :data="scheduleList" v-loading="scheduleLoading" height="320" stripe>
<el-table-column label="员工" prop="empId" min-width="120">
<template slot-scope="scope">
{{ renderEmp(scope.row.empId) }}
</template>
</el-table-column>
<el-table-column label="日期" prop="workDate" min-width="120" />
<el-table-column label="班次" prop="shiftId" min-width="140">
<template slot-scope="scope">
{{ renderShift(scope.row.shiftId) }}
</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="140" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click.stop="openScheduleDialog(scope.row)">编辑</el-button>
<el-button size="mini" type="text" @click.stop="delSchedule(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="metal-panel flat wide" shadow="never">
<div class="card-toolbar">
<div class="actions-inline">
<el-date-picker
v-model="punchQuery.range"
type="daterange"
start-placeholder="开始"
end-placeholder="结束"
size="mini"
value-format="yyyy-MM-dd"
@change="loadPunchAndAttend"
/>
</div>
</div>
<div class="dual-tables">
<div class="table-half">
<div class="table-title">打卡记录</div>
<el-table :data="punchList" v-loading="punchLoading" height="230" stripe>
<el-table-column label="时间" prop="punchTime" min-width="150">
<template slot-scope="scope">{{ formatDate(scope.row.punchTime) }}</template>
</el-table-column>
<el-table-column label="员工" prop="empId" min-width="120">
<template slot-scope="scope">
{{ renderEmp(scope.row.empId) }}
</template>
</el-table-column>
<el-table-column label="来源" prop="source" min-width="100" />
<el-table-column label="定位/设备" prop="location" min-width="140" show-overflow-tooltip />
<el-table-column label="操作" width="120" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click.stop="openPunchDialog(scope.row)">编辑</el-button>
<el-button size="mini" type="text" @click.stop="delPunch(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="table-half">
<div class="table-title">考勤结果</div>
<el-table :data="attendList" v-loading="attendLoading" height="230" stripe>
<el-table-column label="日期" prop="workDate" min-width="120" />
<el-table-column label="出勤分钟" prop="attendMinutes" min-width="110" />
<el-table-column label="请假分钟" prop="leaveMinutes" min-width="110" />
<el-table-column label="异常" prop="exceptionMsg" min-width="140" show-overflow-tooltip>
<template slot-scope="scope">
<span :class="['exception-tag', scope.row.exceptionMsg ? 'is-error' : '']">
{{ scope.row.exceptionMsg || '正常' }}
</span>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-card>
</section>
<!-- 班次弹窗 -->
<el-dialog :visible.sync="shiftDialogVisible" title="班次" width="420px">
<el-form ref="shiftForm" :model="shiftForm" :rules="shiftRules" label-width="90px" size="small">
<el-form-item label="名称" prop="shiftName">
<el-input v-model="shiftForm.shiftName" placeholder="班次名称" />
</el-form-item>
<el-form-item label="开始时间" prop="startTime">
<el-time-picker v-model="shiftForm.startTime" placeholder="开始时间" style="width: 100%" value-format="HH:mm" />
</el-form-item>
<el-form-item label="结束时间" prop="endTime">
<el-time-picker v-model="shiftForm.endTime" placeholder="结束时间" style="width: 100%" value-format="HH:mm" />
</el-form-item>
<el-form-item label="夜班" prop="nightShift">
<el-switch v-model="shiftForm.nightShift" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="休息分钟" prop="restMinutes">
<el-input-number v-model="shiftForm.restMinutes" :min="0" :max="600" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="shiftForm.remark" type="textarea" :rows="2" placeholder="备注" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="shiftDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="shiftSubmitting" @click="submitShift">保存</el-button>
</div>
</el-dialog>
<!-- 排班弹窗 -->
<el-dialog :visible.sync="scheduleDialogVisible" title="排班" width="420px">
<el-form ref="scheduleForm" :model="scheduleForm" :rules="scheduleRules" label-width="90px" size="small">
<el-form-item label="日期" prop="workDate">
<el-date-picker v-model="scheduleForm.workDate" type="date" placeholder="选择日期" style="width: 100%" value-format="yyyy-MM-dd" />
</el-form-item>
<el-form-item label="员工" prop="empId">
<el-select v-model="scheduleForm.empId" placeholder="选择员工" filterable style="width: 100%">
<el-option v-for="emp in employeeOptions" :key="emp.empId" :label="`${emp.empName} (${emp.empCode || emp.empId})`" :value="emp.empId" />
</el-select>
</el-form-item>
<el-form-item label="班次" prop="shiftId">
<el-select v-model="scheduleForm.shiftId" placeholder="选择班次" filterable style="width: 100%">
<el-option v-for="shift in shiftList" :key="shift.shiftId" :label="`${shift.shiftName} ${shift.startTime || ''}-${shift.endTime || ''}`" :value="shift.shiftId" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="scheduleForm.remark" type="textarea" :rows="2" placeholder="备注" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="scheduleDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="scheduleSubmitting" @click="submitSchedule">保存</el-button>
</div>
</el-dialog>
<!-- 打卡弹窗 -->
<el-dialog :visible.sync="punchDialogVisible" title="打卡" width="420px">
<el-form ref="punchForm" :model="punchForm" :rules="punchRules" label-width="90px" size="small">
<el-form-item label="员工" prop="empId">
<el-select v-model="punchForm.empId" placeholder="选择员工" filterable style="width: 100%">
<el-option v-for="emp in employeeOptions" :key="emp.empId" :label="`${emp.empName} (${emp.empCode || emp.empId})`" :value="emp.empId" />
</el-select>
</el-form-item>
<el-form-item label="时间" prop="punchTime">
<el-date-picker
v-model="punchForm.punchTime"
type="datetime"
placeholder="选择时间"
style="width: 100%"
value-format="yyyy-MM-dd HH:mm:ss"
/>
</el-form-item>
<el-form-item label="来源" prop="source">
<el-select v-model="punchForm.source" placeholder="选择来源" style="width: 100%">
<el-option label="手工" value="manual" />
<el-option label="设备" value="device" />
<el-option label="移动" value="mobile" />
</el-select>
</el-form-item>
<el-form-item label="定位/设备" prop="location">
<el-input v-model="punchForm.location" placeholder="如门禁A / GPS坐标" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="punchForm.remark" type="textarea" :rows="2" placeholder="备注" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="punchDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="punchSubmitting" @click="submitPunch">保存</el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import {
listEmployee,
listShift,
listSchedule,
listPunch,
listAttendCalc,
addShift,
updateShift,
delShift,
addSchedule,
updateSchedule,
delSchedule,
addPunch,
updatePunch,
delPunch
} from '@/api/hrm'
export default {
name: 'HrmAttendance',
data() {
return {
employeeOptions: [],
shiftList: [],
shiftLoading: false,
scheduleList: [],
scheduleLoading: false,
scheduleQuery: { date: '' },
punchList: [],
punchLoading: false,
punchQuery: { range: [] },
attendList: [],
attendLoading: false,
shiftDialogVisible: false,
shiftForm: {},
shiftRules: {
shiftName: [{ required: true, message: '请输入名称', trigger: 'blur' }],
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }]
},
shiftSubmitting: false,
scheduleDialogVisible: false,
scheduleForm: {},
scheduleRules: {
workDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
empId: [{ required: true, message: '请选择员工', trigger: 'change' }],
shiftId: [{ required: true, message: '请选择班次', trigger: 'change' }]
},
scheduleSubmitting: false,
punchDialogVisible: false,
punchForm: {},
punchRules: {
empId: [{ required: true, message: '请选择员工', trigger: 'change' }],
punchTime: [{ required: true, message: '请选择时间', trigger: 'change' }],
source: [{ required: true, message: '请选择来源', trigger: 'change' }],
location: [{ required: true, message: '请输入定位/设备', trigger: 'blur' }]
},
punchSubmitting: false
}
},
created() {
this.loadEmployees()
this.loadShift()
this.loadSchedule()
this.loadPunchAndAttend()
},
methods: {
formatDate(val) {
if (!val) return ''
const date = new Date(val)
const pad = n => (n < 10 ? `0${n}` : n)
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
},
loadEmployees() {
listEmployee({ pageNum: 1, pageSize: 500 })
.then(res => {
this.employeeOptions = res.rows || []
})
},
loadShift() {
this.shiftLoading = true
listShift({ pageNum: 1, pageSize: 200 })
.then(res => {
this.shiftList = res.rows || []
})
.finally(() => {
this.shiftLoading = false
})
},
loadSchedule() {
this.scheduleLoading = true
listSchedule({ ...this.scheduleQuery, pageNum: 1, pageSize: 200 })
.then(res => {
this.scheduleList = res.rows || []
})
.finally(() => {
this.scheduleLoading = false
})
},
loadPunchAndAttend() {
this.punchLoading = true
this.attendLoading = true
const [startTime, endTime] = this.punchQuery.range || []
listPunch({ pageNum: 1, pageSize: 200, startTime, endTime })
.then(res => {
this.punchList = res.rows || []
})
.finally(() => {
this.punchLoading = false
})
listAttendCalc({ pageNum: 1, pageSize: 200 })
.then(res => {
this.attendList = res.rows || []
})
.finally(() => {
this.attendLoading = false
})
},
renderEmp(empId) {
const emp = this.employeeOptions.find(e => e.empId === empId)
if (!emp) return empId || ''
return `${emp.empName}${emp.empCode ? ` (${emp.empCode})` : ''}`
},
renderShift(shiftId) {
const shift = this.shiftList.find(s => s.shiftId === shiftId)
if (!shift) return shiftId || ''
return `${shift.shiftName}${shift.startTime ? ` ${shift.startTime}-${shift.endTime}` : ''}`
},
// 班次
openShiftDialog(row) {
this.shiftForm = row
? { ...row }
: { shiftName: '', startTime: '', endTime: '', nightShift: 0, restMinutes: 0, remark: '' }
this.shiftDialogVisible = true
this.$nextTick(() => this.$refs.shiftForm && this.$refs.shiftForm.clearValidate())
},
submitShift() {
this.$refs.shiftForm.validate(valid => {
if (!valid) return
this.shiftSubmitting = true
const api = this.shiftForm.shiftId ? updateShift : addShift
api(this.shiftForm)
.then(() => {
this.$message.success('已保存')
this.shiftDialogVisible = false
this.loadShift()
})
.finally(() => {
this.shiftSubmitting = false
})
})
},
delShift(row) {
this.$confirm('确认删除该班次吗?', '提示', { type: 'warning' }).then(() => {
delShift(row.shiftId).then(() => {
this.$message.success('已删除')
this.loadShift()
})
})
},
// 排班
openScheduleDialog(row) {
this.scheduleForm = row
? { ...row }
: { workDate: '', empId: '', shiftId: '', remark: '' }
this.scheduleDialogVisible = true
this.$nextTick(() => this.$refs.scheduleForm && this.$refs.scheduleForm.clearValidate())
},
submitSchedule() {
this.$refs.scheduleForm.validate(valid => {
if (!valid) return
this.scheduleSubmitting = true
const api = this.scheduleForm.scheduleId ? updateSchedule : addSchedule
api(this.scheduleForm)
.then(() => {
this.$message.success('已保存')
this.scheduleDialogVisible = false
this.loadSchedule()
})
.finally(() => {
this.scheduleSubmitting = false
})
})
},
delSchedule(row) {
this.$confirm('确认删除该排班吗?', '提示', { type: 'warning' }).then(() => {
delSchedule(row.scheduleId).then(() => {
this.$message.success('已删除')
this.loadSchedule()
})
})
},
// 打卡
openPunchDialog(row) {
this.punchForm = row
? { ...row }
: { empId: '', punchTime: '', source: 'manual', location: '', remark: '' }
this.punchDialogVisible = true
this.$nextTick(() => this.$refs.punchForm && this.$refs.punchForm.clearValidate())
},
submitPunch() {
this.$refs.punchForm.validate(valid => {
if (!valid) return
this.punchSubmitting = true
const api = this.punchForm.punchId ? updatePunch : addPunch
api(this.punchForm)
.then(() => {
this.$message.success('已保存')
this.punchDialogVisible = false
this.loadPunchAndAttend()
})
.finally(() => {
this.punchSubmitting = false
})
})
},
delPunch(row) {
this.$confirm('确认删除该打卡记录吗?', '提示', { type: 'warning' }).then(() => {
delPunch(row.punchId).then(() => {
this.$message.success('已删除')
this.loadPunchAndAttend()
})
})
}
}
}
</script>
<style lang="scss" scoped>
.attendance-page {
padding: 0;
background: transparent;
}
.panel-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-items: start;
}
.metal-panel {
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.metal-panel :deep(.el-card__body) {
padding: 0;
background: transparent;
}
.card-toolbar {
display: flex;
justify-content: flex-end;
padding: 4px 0 8px;
}
.actions-inline {
display: flex;
gap: 8px;
align-items: center;
}
.flat :deep(.el-card__body) {
padding: 0;
}
.flat {
padding: 0;
}
.wide {
grid-column: span 2;
}
.flat :deep(.el-table),
.flat :deep(.el-table__body-wrapper),
.flat :deep(.el-table__header-wrapper),
.flat :deep(.el-table__empty-block) {
background: transparent;
}
.dual-tables {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.table-half {
border: none;
border-radius: 0;
padding: 0;
background: transparent;
}
.table-title {
font-weight: 600;
margin-bottom: 6px;
color: #1f2937;
}
.exception-tag {
color: #409eff;
&.is-error {
color: #e76f51;
font-weight: 600;
}
}
@media (max-width: 1200px) {
.panel-grid {
grid-template-columns: 1fr;
}
.wide {
grid-column: span 1;
}
.dual-tables {
grid-template-columns: 1fr;
}
}
</style>