修复考勤交互:对齐名册签到资格与参训状态展示

This commit is contained in:
wangzhibiao committed 2026-09-22 00:05:31 +08:00
1 parent 49dbde8836
commit e2c15e6216
2 files changed
+188 -27

No files matched your search

@@ -0,0 +1,146 @@
/**
* 课次考勤与点名签到生产策略模块 (Attendance Policy Helper)
* 供 checkin/index.vue 生产页面与 Node.js 单元测试统一复用。
*
* 核心原则:
* 1. 严格区分 NO_CHECKIN (null/未产生打卡事实) 与 ABSENT (status=0 显式缺勤);
* 2. 严格对齐参训学员生命周期签到守卫:
* - 0 待报到: 禁止签到 (必须先 confirmReport)
* - 1 已报到: 允许签到
* - 2 请假: 允许签到 (Preview Default: PREVIEW_DEFAULT_ALLOWED; PENDING_CUSTOMER_CONFIRMATION)
* - 3 放弃: 禁止签到
* - 4 已完成: 禁止签到
* 3. 真实处理后端错误,杜绝假成功。
*/
export const ENROLLMENT_STATUS_OPTIONS = [
{ value: 0, label: '待报到' },
{ value: 1, label: '已报到' },
{ value: 2, label: '请假' },
{ value: 3, label: '放弃' },
{ value: 4, label: '已完成' }
];
export const ATTENDANCE_STATUS_OPTIONS = [
{ value: 0, label: '缺勤' },
{ value: 1, label: '正常' },
{ value: 2, label: '迟到' },
{ value: 3, label: '早退' },
{ value: 4, label: '请假' }
];
/**
* 格式化考勤状态展示标签 (严格区分未签到与缺勤)
* @param {number|null|undefined} status
* @returns {{ text: string, type: string }}
*/
export function formatAttendanceTag(status) {
if (status === null || status === undefined) {
return { text: '未签到', type: 'info' };
}
switch (Number(status)) {
case 0:
return { text: '缺勤', type: 'danger' };
case 1:
return { text: '正常', type: 'success' };
case 2:
return { text: '迟到', type: 'warning' };
case 3:
return { text: '早退', type: 'warning' };
case 4:
return { text: '请假', type: 'primary' };
default:
return { text: '-', type: '' };
}
}
/**
* 格式化学员参训状态展示标签
* @param {number|null|undefined} status
* @returns {{ text: string, type: string }}
*/
export function formatEnrollmentStatusTag(status) {
if (status === null || status === undefined) {
return { text: '-', type: '' };
}
switch (Number(status)) {
case 0:
return { text: '待报到', type: 'info' };
case 1:
return { text: '已报到', type: 'success' };
case 2:
return { text: '请假', type: 'warning' };
case 3:
return { text: '放弃', type: 'danger' };
case 4:
return { text: '已完成', type: 'info' };
default:
return { text: '未知', type: '' };
}
}
/**
* 判定参训学员生命周期状态是否允许生成新的签到事实
* @param {number|null|undefined} status
* @returns {boolean}
*/
export function isEnrollmentEligibleForSignIn(status) {
if (status === null || status === undefined) {
return false;
}
const s = Number(status);
// status 1 (已报到在训) 或 status 2 (请假学员,Preview Default 允许)
return s === 1 || s === 2;
}
/**
* 计算课次考勤名册单行学员的操作按钮可用性
* @param {Object} row
* @returns {{ canSignIn: boolean, canSignOut: boolean, canAdjust: boolean }}
*/
export function computeRosterActionEligibility(row) {
if (!row) {
return { canSignIn: false, canSignOut: false, canAdjust: false };
}
const hasSignInFact = !!row.signInTime;
const eligibleStatus = isEnrollmentEligibleForSignIn(row.enrollmentStatus);
// 必须同时满足:无签到事实 + 参训生命周期合法 (非待报到、非已完成、非放弃) + 后端允许标记
const canSignIn = !hasSignInFact && eligibleStatus && row.canSignIn !== false;
// 签退:已签到但未签退
const canSignOut = hasSignInFact && !row.signOutTime && row.canSignOut !== false;
// 调整:已有打卡记录
const canAdjust = row.checkinId !== null && row.checkinId !== undefined && row.canAdjust !== false;
return { canSignIn, canSignOut, canAdjust };
}
/**
* 解析签到/打卡操作的后端错误反馈,确保真实回显业务错误,杜绝假成功
* @param {any} err - 捕获的后端异常对象或字符串
* @param {string} [defaultName] - 学员名称
* @returns {{ isSuccess: false, message: string }}
*/
export function resolveSignInFeedback(err, defaultStudentName = '') {
if (err === 'cancel' || err === 'close') {
return { isSuccess: false, message: '操作已取消' };
}
let msg = '';
if (typeof err === 'string') {
msg = err;
} else if (err && typeof err === 'object') {
msg = err.msg || err.message || (err.data && err.data.msg) || '签到操作失败';
} else {
msg = '签到操作失败';
}
// 绝对禁止包含“成功”字样如果其实是错误返回
return {
isSuccess: false,
message: defaultStudentName ? `【${defaultStudentName}】${msg}` : msg
};
}
+42 -27
View File
@@ -50,7 +50,7 @@
</el-form>
<el-alert
title="课次应到学员考勤名册:展示该课次全部应到参训人员;未签到学员可点击【签到】完成首次签到录入;已签到学员支持签退与留痕调整。"
title="课次应到学员考勤名册:展示该课次全部应到参训人员;未签到且已报到/在训学员可点击【签到】录入考勤事实;待报到或已结训学员禁止直接签到;已签到学员支持签退与留痕调整。"
type="info"
:closable="false"
show-icon
@@ -63,15 +63,18 @@
<el-table-column label="性别" prop="gender" align="center" width="70" />
<el-table-column label="所属机构/部门" prop="deptName" min-width="140" show-overflow-tooltip />
<el-table-column label="联系电话" prop="phoneMasked" min-width="120" />
<el-table-column label="考勤状态" align="center" width="110">
<el-table-column label="参训状态" align="center" width="95">
<template slot-scope="scope">
<el-tag v-if="scope.row.attendanceStatus === null || scope.row.attendanceStatus === undefined" type="info" size="small">未签到</el-tag>
<el-tag v-else-if="scope.row.attendanceStatus === 0" type="danger" size="small">缺勤</el-tag>
<el-tag v-else-if="scope.row.attendanceStatus === 1" type="success" size="small">正常</el-tag>
<el-tag v-else-if="scope.row.attendanceStatus === 2" type="warning" size="small">迟到</el-tag>
<el-tag v-else-if="scope.row.attendanceStatus === 3" type="warning" size="small">早退</el-tag>
<el-tag v-else-if="scope.row.attendanceStatus === 4" type="primary" size="small">请假</el-tag>
<span v-else>-</span>
<el-tag :type="formatEnrollmentStatus(scope.row.enrollmentStatus).type" size="small">
{{ formatEnrollmentStatus(scope.row.enrollmentStatus).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="考勤状态" align="center" width="105">
<template slot-scope="scope">
<el-tag :type="formatAttendance(scope.row.attendanceStatus).type" size="small">
{{ formatAttendance(scope.row.attendanceStatus).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="签到时间" prop="signInTime" width="160">
@@ -86,7 +89,7 @@
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180">
<template slot-scope="scope">
<el-button
v-if="scope.row.canSignIn"
v-if="computeActionEligibility(scope.row).canSignIn"
v-hasPermi="['train:attendance:checkin']"
size="mini"
type="text"
@@ -94,7 +97,7 @@
@click="handleRosterSignIn(scope.row)"
>签到</el-button>
<el-button
v-if="scope.row.canSignOut"
v-if="computeActionEligibility(scope.row).canSignOut"
v-hasPermi="['train:attendance:checkin']"
size="mini"
type="text"
@@ -102,7 +105,7 @@
@click="handleRosterSignOut(scope.row)"
>签退</el-button>
<el-button
v-if="scope.row.canAdjust"
v-if="computeActionEligibility(scope.row).canAdjust"
v-hasPermi="['train:attendance:edit']"
size="mini"
type="text"
@@ -163,12 +166,9 @@
</el-table-column>
<el-table-column label="考勤状态" align="center" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.attendanceStatus === 0" type="danger" size="small">缺勤</el-tag>
<el-tag v-else-if="scope.row.attendanceStatus === 1" type="success" size="small">正常</el-tag>
<el-tag v-else-if="scope.row.attendanceStatus === 2" type="warning" size="small">迟到</el-tag>
<el-tag v-else-if="scope.row.attendanceStatus === 3" type="warning" size="small">早退</el-tag>
<el-tag v-else-if="scope.row.attendanceStatus === 4" type="primary" size="small">请假</el-tag>
<span v-else>-</span>
<el-tag :type="formatAttendance(scope.row.attendanceStatus).type" size="small">
{{ formatAttendance(scope.row.attendanceStatus).text }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="签到时间" prop="signInTime" width="170" />
@@ -223,7 +223,7 @@
<span>{{ currentRecord.courseName || '-' }}</span>
</el-form-item>
<el-form-item label="当前状态">
<el-tag size="small">{{ labelOf(attendanceStatuses, currentRecord.attendanceStatus) }}</el-tag>
<el-tag size="small">{{ formatAttendance(currentRecord.attendanceStatus).text }}</el-tag>
</el-form-item>
<el-form-item label="目标状态" prop="attendanceStatus">
<el-select v-model="adjustForm.attendanceStatus" placeholder="请选择调整后的考勤状态" style="width: 100%;">
@@ -254,6 +254,12 @@ import { listCheckin } from '@/api/train/checkin'
import { adjustAttendance, signOutAttendance, signInAttendance, getLessonRoster } from '@/api/train/attendance'
import { listPlan } from '@/api/train/plan'
import { getPlanTimetable } from '@/api/train/timetable'
import {
formatAttendanceTag,
formatEnrollmentStatusTag,
computeRosterActionEligibility,
resolveSignInFeedback
} from './attendancePolicy'
export default {
name: 'Checkin',
@@ -303,6 +309,15 @@ export default {
this.loadPlans()
},
methods: {
formatAttendance(status) {
return formatAttendanceTag(status)
},
formatEnrollmentStatus(status) {
return formatEnrollmentStatusTag(status)
},
computeActionEligibility(row) {
return computeRosterActionEligibility(row)
},
getList() {
this.loading = true
listCheckin(this.queryParams).then(response => {
@@ -367,8 +382,8 @@ export default {
getLessonRoster(this.rosterQuery.lessonId).then(res => {
this.rosterList = res.data || []
}).catch(err => {
const msg = (err && (err.msg || err.message)) || '获取课次考勤名册失败'
this.$modal.msgError(msg)
const feedback = resolveSignInFeedback(err)
this.$modal.msgError(feedback.message || '获取课次考勤名册失败')
}).finally(() => {
this.rosterLoading = false
})
@@ -386,8 +401,8 @@ export default {
this.getList()
}).catch(err => {
if (err !== 'cancel') {
const msg = (err && (err.msg || err.message)) || '签到操作失败'
this.$modal.msgError(msg)
const feedback = resolveSignInFeedback(err, row.studentName)
this.$modal.msgError(feedback.message)
}
})
},
@@ -403,8 +418,8 @@ export default {
this.getList()
}).catch(err => {
if (err !== 'cancel') {
const msg = (err && (err.msg || err.message)) || '签退操作失败'
this.$modal.msgError(msg)
const feedback = resolveSignInFeedback(err, row.studentName)
this.$modal.msgError(feedback.message)
}
})
},
@@ -455,8 +470,8 @@ export default {
this.loadRoster()
}
}).catch(err => {
const msg = (err && (err.msg || err.message)) || '考勤调整失败'
this.$modal.msgError(msg)
const feedback = resolveSignInFeedback(err)
this.$modal.msgError(feedback.message || '考勤调整失败')
}).finally(() => {
this.adjustSubmitting = false
})