Files
klp-oa/klp-ui/src/views/hrm/attendance/index.vue

528 lines
19 KiB
Vue
Raw Normal View History

2025-12-22 10:57:47 +08:00
<template>
<div class="hrm-page attendance-page">
<section class="panel-grid">
<el-card class="metal-panel flat" shadow="never">
<div class="card-toolbar">
2025-12-22 16:53:48 +08:00
<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>
2025-12-22 16:53:48 +08:00
</div>
2025-12-22 10:57:47 +08:00
</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" />
2025-12-22 16:53:48 +08:00
<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>
2025-12-22 10:57:47 +08:00
</el-table>
</el-card>
<el-card class="metal-panel flat" shadow="never">
<div class="card-toolbar">
2025-12-22 10:57:47 +08:00
<div class="actions-inline">
<el-date-picker
v-model="scheduleQuery.date"
type="date"
placeholder="日期"
size="mini"
2025-12-22 16:53:48 +08:00
style="width: 140px"
2025-12-22 10:57:47 +08:00
@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>
2025-12-22 10:57:47 +08:00
</div>
</div>
<el-table :data="scheduleList" v-loading="scheduleLoading" height="320" stripe>
2025-12-22 16:53:48 +08:00
<el-table-column label="员工" prop="empId" min-width="120">
<template slot-scope="scope">
{{ renderEmp(scope.row.empId) }}
</template>
</el-table-column>
2025-12-22 10:57:47 +08:00
<el-table-column label="日期" prop="workDate" min-width="120" />
2025-12-22 16:53:48 +08:00
<el-table-column label="班次" prop="shiftId" min-width="140">
<template slot-scope="scope">
{{ renderShift(scope.row.shiftId) }}
</template>
</el-table-column>
2025-12-22 10:57:47 +08:00
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
2025-12-22 16:53:48 +08:00
<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>
2025-12-22 10:57:47 +08:00
</el-table>
</el-card>
<el-card class="metal-panel flat wide" shadow="never">
<div class="card-toolbar">
2025-12-22 10:57:47 +08:00
<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>
2025-12-22 16:53:48 +08:00
<el-table-column label="员工" prop="empId" min-width="120">
<template slot-scope="scope">
{{ renderEmp(scope.row.empId) }}
</template>
</el-table-column>
2025-12-22 10:57:47 +08:00
<el-table-column label="来源" prop="source" min-width="100" />
<el-table-column label="定位/设备" prop="location" min-width="140" show-overflow-tooltip />
2025-12-22 16:53:48 +08:00
<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>
2025-12-22 10:57:47 +08:00
</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="overtimeMinutes" 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>
2025-12-22 16:53:48 +08:00
<!-- 班次弹窗 -->
<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>
2025-12-22 10:57:47 +08:00
</div>
</template>
<script>
2025-12-22 16:53:48 +08:00
import {
listEmployee,
listShift,
listSchedule,
listPunch,
listAttendCalc,
addShift,
updateShift,
delShift,
addSchedule,
updateSchedule,
delSchedule,
addPunch,
updatePunch,
delPunch
} from '@/api/hrm'
2025-12-22 10:57:47 +08:00
export default {
name: 'HrmAttendance',
data() {
return {
2025-12-22 16:53:48 +08:00
employeeOptions: [],
2025-12-22 10:57:47 +08:00
shiftList: [],
shiftLoading: false,
scheduleList: [],
scheduleLoading: false,
scheduleQuery: { date: '' },
punchList: [],
punchLoading: false,
punchQuery: { range: [] },
attendList: [],
2025-12-22 16:53:48 +08:00
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
2025-12-22 10:57:47 +08:00
}
},
created() {
2025-12-22 16:53:48 +08:00
this.loadEmployees()
2025-12-22 10:57:47 +08:00
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())}`
},
2025-12-22 16:53:48 +08:00
loadEmployees() {
listEmployee({ pageNum: 1, pageSize: 500 })
.then(res => {
this.employeeOptions = res.rows || []
})
},
2025-12-22 10:57:47 +08:00
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
})
2025-12-22 16:53:48 +08:00
},
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()
})
})
2025-12-22 10:57:47 +08:00
}
}
}
</script>
<style lang="scss" scoped>
.attendance-page {
padding: 0;
background: transparent;
2025-12-22 10:57:47 +08:00
}
.panel-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
align-items: start;
2025-12-22 10:57:47 +08:00
}
.metal-panel {
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.metal-panel :deep(.el-card__body) {
padding: 0;
background: transparent;
2025-12-22 10:57:47 +08:00
}
.card-toolbar {
2025-12-22 10:57:47 +08:00
display: flex;
justify-content: flex-end;
padding: 4px 0 8px;
2025-12-22 10:57:47 +08:00
}
.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;
}
2025-12-22 10:57:47 +08:00
.dual-tables {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.table-half {
border: none;
border-radius: 0;
padding: 0;
background: transparent;
2025-12-22 10:57:47 +08:00
}
.table-title {
font-weight: 600;
margin-bottom: 6px;
color: #1f2937;
2025-12-22 10:57:47 +08:00
}
.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;
}
2025-12-22 10:57:47 +08:00
.dual-tables {
grid-template-columns: 1fr;
}
}
</style>