diff --git a/build_ui/src/views/train/checkin/attendancePolicy.js b/build_ui/src/views/train/checkin/attendancePolicy.js
new file mode 100644
index 000000000..a0f051dee
--- /dev/null
+++ b/build_ui/src/views/train/checkin/attendancePolicy.js
@@ -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
+ };
+}
diff --git a/build_ui/src/views/train/checkin/index.vue b/build_ui/src/views/train/checkin/index.vue
index 624a54f3e..8716b9872 100644
--- a/build_ui/src/views/train/checkin/index.vue
+++ b/build_ui/src/views/train/checkin/index.vue
@@ -50,7 +50,7 @@
-
+
- 未签到
- 缺勤
- 正常
- 迟到
- 早退
- 请假
- -
+
+ {{ formatEnrollmentStatus(scope.row.enrollmentStatus).text }}
+
+
+
+
+
+
+ {{ formatAttendance(scope.row.attendanceStatus).text }}
+
@@ -86,7 +89,7 @@
签到
签退
- 缺勤
- 正常
- 迟到
- 早退
- 请假
- -
+
+ {{ formatAttendance(scope.row.attendanceStatus).text }}
+
@@ -223,7 +223,7 @@
{{ currentRecord.courseName || '-' }}
- {{ labelOf(attendanceStatuses, currentRecord.attendanceStatus) }}
+ {{ formatAttendance(currentRecord.attendanceStatus).text }}
@@ -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
})