新增报到机:实现终端扫码报到核验与受控课堂打卡模拟

This commit is contained in:
wangzhibiao committed 2026-10-09 20:04:52 +08:00
1 parent b1d5a999ca
commit cecf11790e
16 files changed
+2571 -7

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
import request from '@/utils/request'
// 报到机扫码查询学员报到信息
export function lookupTerminal(params) {
return request({
url: '/train/report-terminal/lookup',
method: 'get',
params: params
})
}
// 报到机一键确认报到
export function confirmTerminal(data) {
return request({
url: '/train/report-terminal/confirm',
method: 'post',
data: data
})
}
// 模拟设备课堂打卡 (签到/签退)
export function mockAttendance(data) {
return request({
url: '/train/report-terminal/mock-attendance',
method: 'post',
data: data
})
}
// 模拟刷卡(通过考勤模块端点)
export function mockSwipe(data) {
return request({
url: '/train/attendance/mock-swipe',
method: 'post',
data: data
})
}
+8 -1
View File
@@ -107,7 +107,7 @@ export const dynamicRoutes = [
component: Layout,
hidden: true,
redirect: '/train/auth',
permissions: ['train:access:query', 'system:auth:list', 'train:accommodation:list'],
permissions: ['train:access:query', 'system:auth:list', 'train:accommodation:list', 'train:report:confirm'],
children: [
{
path: 'auth',
@@ -122,6 +122,13 @@ export const dynamicRoutes = [
name: 'TrainAccommodation',
meta: { title: '住宿管理', icon: 'home' },
permissions: ['train:accommodation:list']
},
{
path: 'terminal',
component: () => import('@/views/train/terminal/index'),
name: 'TrainTerminal',
meta: { title: '模拟报到机', icon: 'scan' },
permissions: ['train:report:confirm']
}
]
},
+468
View File
@@ -0,0 +1,468 @@
<template>
<div class="app-container terminal-container">
<el-alert
title="【内部测试与设备联调专用】SYFJGYD学员管理系统 — 模拟报到机与受控打卡工作台"
type="info"
description="本页面模拟现场学员自助报到终端(扫码二维码/查询/核验/一键确认报到)及课堂打卡机。操作仅限已授权测试管理员使用。"
show-icon
:closable="false"
class="mb15"
/>
<el-tabs v-model="activeTab" type="border-card">
<!-- Tab 1: 模拟报到机 -->
<el-tab-pane label="模拟报到机 (Self Check-in Kiosk)" name="reportTerminal">
<!-- 扫码/查询区 -->
<el-card class="box-card mb15" shadow="never">
<div slot="header" class="clearfix">
<span style="font-weight: bold; font-size: 15px;">
<i class="el-icon-camera" style="margin-right: 6px; color: #409EFF;" />模拟扫码 / 学号查询
</span>
</div>
<el-form :inline="true" :model="searchForm" size="small" class="demo-form-inline" @submit.native.prevent>
<el-form-item label="学员学号 (studentId)">
<el-input
v-model="searchForm.studentId"
placeholder="请输入学号或扫码二维码"
clearable
style="width: 260px;"
prefix-icon="el-icon-user"
@keyup.enter.native="handleLookup"
/>
</el-form-item>
<el-form-item v-if="previewData && previewData.hasMultiplePlans" label="目标开班计划">
<el-select
v-model="searchForm.planId"
placeholder="请明确选择开班计划"
style="width: 320px;"
@change="handlePlanChange"
>
<el-option
v-for="p in previewData.candidatePlans"
:key="p.planId"
:label="`${p.planName} (${p.enrollmentStatusLabel})`"
:value="p.planId"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" :loading="lookupLoading" @click="handleLookup">
查询报到信息
</el-button>
<el-button icon="el-icon-refresh-left" @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 查询结果展示区 -->
<div v-if="previewData && previewData.studentId">
<!-- 状态提示横幅 -->
<el-alert
v-if="previewData.isReported"
type="success"
:closable="false"
show-icon
class="mb15"
>
<span slot="title" style="font-size: 15px; font-weight: bold;">
【已报到】学员已完成现场报到(报到时间:{{ parseTime(previewData.reportTime) || '已确认' }})
</span>
</el-alert>
<el-alert
v-else
type="warning"
:closable="false"
show-icon
class="mb15"
>
<span slot="title" style="font-size: 15px; font-weight: bold;">
【待报到】学员尚未报到,请核对学员基础信息、宿舍安排与课程表后点击下方“确认报到”
</span>
</el-alert>
<el-row :gutter="15">
<!-- 学员基础信息 -->
<el-col :span="12">
<el-card shadow="never" class="mb15">
<div slot="header" class="clearfix">
<span style="font-weight: bold;"><i class="el-icon-user-solid" style="color: #409EFF; margin-right: 5px;" />学员信息与培训班级</span>
</div>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="学员姓名">{{ previewData.studentName || '-' }}</el-descriptions-item>
<el-descriptions-item label="学员学号">{{ previewData.studentId }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ formatGender(previewData.gender) }}</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ previewData.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="选派单位" :span="2">{{ previewData.orgName || '-' }}</el-descriptions-item>
<el-descriptions-item label="培训班计划" :span="2">
<span style="font-weight: bold; color: #303133;">{{ previewData.planName || previewData.planId }}</span>
<span style="color: #909399; font-size: 12px; margin-left: 6px;">({{ previewData.planId }})</span>
</el-descriptions-item>
<el-descriptions-item label="参训状态">
<el-tag :type="statusTagType(previewData.enrollmentStatus)" size="small">
{{ previewData.enrollmentStatusLabel }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="报到状态">
<el-tag :type="previewData.isReported ? 'success' : 'info'" size="small">
{{ previewData.isReported ? '已报到' : '未报到' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<!-- 住宿安排 -->
<el-col :span="12">
<el-card shadow="never" class="mb15">
<div slot="header" class="clearfix">
<span style="font-weight: bold;"><i class="el-icon-office-building" style="color: #67C23A; margin-right: 5px;" />提前住宿分配安排</span>
</div>
<div v-if="previewData.hasAccommodation">
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="宿舍楼栋">{{ previewData.dormBuilding || '-' }}</el-descriptions-item>
<el-descriptions-item label="房间号">{{ previewData.dormRoom || '-' }}</el-descriptions-item>
<el-descriptions-item label="床位号">
<span style="font-weight: bold; color: #409EFF;">{{ previewData.bedNo || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="住宿状态">
<el-tag :type="previewData.accommodationStatus === 1 ? 'success' : 'info'" size="small">
{{ previewData.accommodationStatusLabel }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<p style="margin-top: 10px; font-size: 12px; color: #67C23A;">
<i class="el-icon-success" /> 管理员已提前分配宿舍与床位,报到后可直接前往入住。
</p>
</div>
<div v-else>
<el-empty description="当前尚未分配宿舍" :image-size="70">
<span style="font-size: 12px; color: #909399;">如需住宿,请管理员在【开班计划】参训人员名单中进行住宿安排</span>
</el-empty>
</div>
</el-card>
</el-col>
</el-row>
<!-- 对应计划课程表 -->
<el-card shadow="never" class="mb15">
<div slot="header" class="clearfix">
<span style="font-weight: bold;"><i class="el-icon-date" style="color: #E6A23C; margin-right: 5px;" />本期培训课程表安排</span>
</div>
<div v-if="flattenedLessons && flattenedLessons.length > 0">
<el-table :data="flattenedLessons" size="small" border stripe>
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column prop="date" label="上课日期" width="110" align="center" />
<el-table-column label="上课时间" width="140" align="center">
<template slot-scope="scope">
<span>{{ formatTimeRange(scope.row.startTime, scope.row.endTime) }}</span>
</template>
</el-table-column>
<el-table-column prop="courseName" label="课程名称" min-width="160" show-overflow-tooltip />
<el-table-column prop="teacherName" label="授课教师" width="110" align="center" />
<el-table-column label="上课教室/场地" min-width="140" align="center">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.venueType === 2 ? 'warning' : 'primary'">
{{ scope.row.venueType === 2 ? '实训场地' : '教室' }}
</el-tag>
<span style="margin-left: 6px;">{{ scope.row.venueName || scope.row.classRoomName || scope.row.siteName || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="100" show-overflow-tooltip />
</el-table>
</div>
<div v-else>
<p style="text-align: center; color: #909399; margin: 20px 0;">当前开班计划尚未编排课次</p>
</div>
</el-card>
<!-- 确认报到操作栏 -->
<el-card shadow="never" style="text-align: center; background-color: #F8F9FB;">
<div style="margin: 10px 0;">
<span v-if="previewData.isReported" style="margin-right: 15px; color: #67C23A; font-weight: bold;">
<i class="el-icon-check" /> 学员已完成报到
</span>
<span v-else style="margin-right: 15px; color: #E6A23C; font-weight: bold;">
<i class="el-icon-info" /> 请确认学员信息无误后办理报到
</span>
<el-button
type="success"
size="medium"
icon="el-icon-circle-check"
:loading="confirmLoading"
:disabled="previewData.isReported"
@click="handleConfirmReport"
>
{{ previewData.isReported ? '已完成报到' : '一键确认报到' }}
</el-button>
<el-button
v-if="previewData.isReported"
type="primary"
plain
size="medium"
icon="el-icon-refresh"
@click="handleLookup"
>
刷新最新状态
</el-button>
</div>
</el-card>
</div>
</el-tab-pane>
<!-- Tab 2: 模拟课堂打卡 -->
<el-tab-pane label="模拟课堂打卡 (Mock Class Attendance)" name="mockAttendance">
<el-card class="box-card mb15" shadow="never">
<div slot="header" class="clearfix">
<span style="font-weight: bold; font-size: 15px;">
<i class="el-icon-bell" style="margin-right: 6px; color: #E6A23C;" />模拟设备课堂考勤刷卡
</span>
</div>
<el-alert
title="考勤业务规则说明"
type="warning"
:closable="false"
description="1. 课堂打卡与入学报到严格区分:打卡不会改变入学报到状态,报到也不会自动生成课堂考勤记录。
2. 只有已报到的学员才能进行课堂打卡;未完成入学报到的学员打卡将被系统拒绝。
3. 此接口为受控 Mock,打卡记录带 MOCK 标识,便于测试溯源。"
show-icon
class="mb15"
/>
<el-form ref="mockForm" :model="mockForm" :rules="mockRules" label-width="120px" style="max-width: 600px;">
<el-form-item label="学员学号" prop="studentId">
<el-input v-model="mockForm.studentId" placeholder="请输入学员学号,如 S101" />
</el-form-item>
<el-form-item label="目标课次标识" prop="lessonId">
<el-input-number v-model="mockForm.lessonId" :min="1" placeholder="课次ID,如 102" style="width: 100%;" />
</el-form-item>
<el-form-item label="打卡动作" prop="actionType">
<el-radio-group v-model="mockForm.actionType">
<el-radio label="SIGN_IN">课堂签到 (Sign In)</el-radio>
<el-radio label="SIGN_OUT">课堂签退 (Sign Out)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="模拟设备编号">
<el-input v-model="mockForm.deviceSn" placeholder="MOCK-BOARD-01" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="mockLoading" @click="handleMockSubmit">提交模拟打卡</el-button>
<el-button @click="resetMockForm">重置表单</el-button>
</el-form-item>
</el-form>
<!-- 打卡结果回显 -->
<div v-if="mockResult" style="margin-top: 20px;">
<el-divider content-position="left">打卡响应结果</el-divider>
<el-alert
:title="mockResult.message || '打卡操作完成'"
type="success"
:closable="false"
show-icon
class="mb15"
/>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="打卡记录ID">{{ mockResult.checkinId }}</el-descriptions-item>
<el-descriptions-item label="学员姓名">{{ mockResult.studentName || mockResult.studentId }}</el-descriptions-item>
<el-descriptions-item label="培训班ID">{{ mockResult.planId }}</el-descriptions-item>
<el-descriptions-item label="课程名称">{{ mockResult.courseName || '-' }}</el-descriptions-item>
<el-descriptions-item label="动作类型">
<el-tag size="mini">{{ mockResult.actionType === 'SIGN_IN' ? '签到' : '签退' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="出勤状态">
<el-tag size="mini" type="success">{{ formatAttendanceStatus(mockResult.attendanceStatus) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="签到时间">{{ parseTime(mockResult.signInTime) || '-' }}</el-descriptions-item>
<el-descriptions-item label="签退时间">{{ parseTime(mockResult.signOutTime) || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script>
import { lookupTerminal, confirmTerminal, mockAttendance } from '@/api/train/terminal'
export default {
name: 'TrainTerminal',
data() {
return {
activeTab: 'reportTerminal',
searchForm: {
studentId: '',
planId: null
},
lookupLoading: false,
confirmLoading: false,
previewData: null,
// Mock 打卡表单
mockForm: {
studentId: '',
lessonId: null,
actionType: 'SIGN_IN',
deviceSn: 'MOCK-BOARD-01'
},
mockRules: {
studentId: [{ required: true, message: '请输入学员学号', trigger: 'blur' }],
lessonId: [{ required: true, message: '请输入目标课次ID', trigger: 'blur' }],
actionType: [{ required: true, message: '请选择打卡动作', trigger: 'change' }]
},
mockLoading: false,
mockResult: null
}
},
computed: {
flattenedLessons() {
if (!this.previewData || !this.previewData.timetable || !this.previewData.timetable.days) {
return []
}
const list = []
this.previewData.timetable.days.forEach(day => {
if (day.lessons && day.lessons.length > 0) {
day.lessons.forEach(lesson => {
list.push({
...lesson,
date: day.date
})
})
}
})
return list
}
},
created() {
// 检查是否有路由参数预填
if (this.$route.query.studentId) {
this.searchForm.studentId = this.$route.query.studentId
if (this.$route.query.planId) {
this.searchForm.planId = this.$route.query.planId
}
this.handleLookup()
}
},
methods: {
handleLookup() {
if (!this.searchForm.studentId || !this.searchForm.studentId.trim()) {
this.$modal.msgWarning('请输入学员学号进行查询')
return
}
this.lookupLoading = true
lookupTerminal({
studentId: this.searchForm.studentId.trim(),
planId: this.searchForm.planId || undefined
}).then(res => {
this.previewData = res.data || null
if (this.previewData && this.previewData.planId) {
this.searchForm.planId = this.previewData.planId
}
if (this.previewData && this.previewData.message) {
this.$modal.msgSuccess(this.previewData.message)
}
}).catch(err => {
const msg = (err && (err.msg || err.message)) || '查询失败'
this.$modal.msgError(msg)
}).finally(() => {
this.lookupLoading = false
})
},
handlePlanChange(val) {
this.searchForm.planId = val
this.handleLookup()
},
handleReset() {
this.searchForm = {
studentId: '',
planId: null
}
this.previewData = null
},
handleConfirmReport() {
if (!this.previewData) return
this.$confirm(`确认学员【${this.previewData.studentName || this.previewData.studentId}】办理报到?`, '报到确认', {
confirmButtonText: '确定报到',
cancelButtonText: '取消',
type: 'info'
}).then(() => {
this.confirmLoading = true
return confirmTerminal({
enrollmentId: this.previewData.enrollmentId,
studentId: this.previewData.studentId,
planId: this.previewData.planId,
reportDeviceSn: 'TERMINAL-MOCK-01',
verifyNote: '报到机自主确认'
})
}).then(res => {
this.$modal.msgSuccess('报到确认成功!')
this.previewData = res.data || this.previewData
}).catch(err => {
if (err !== 'cancel') {
const msg = (err && (err.msg || err.message)) || '报到确认失败'
this.$modal.msgError(msg)
}
}).finally(() => {
this.confirmLoading = false
})
},
handleMockSubmit() {
this.$refs.mockForm.validate(valid => {
if (!valid) return
this.mockLoading = true
mockAttendance(this.mockForm).then(res => {
this.mockResult = res.data || null
this.$modal.msgSuccess('模拟打卡成功')
}).catch(err => {
const msg = (err && (err.msg || err.message)) || '模拟打卡失败'
this.$modal.msgError(msg)
}).finally(() => {
this.mockLoading = false
})
})
},
resetMockForm() {
this.$refs.mockForm.resetFields()
this.mockResult = null
},
formatGender(val) {
if (val === 1 || val === '1' || val === '男') return '男'
if (val === 2 || val === '2' || val === '女') return '女'
return '未知'
},
statusTagType(val) {
const map = { 0: 'info', 1: 'success', 2: 'warning', 3: 'danger', 4: 'primary' }
return map[val] || 'info'
},
formatTimeRange(start, end) {
if (!start || !end) return '-'
const s = start.length > 16 ? start.substring(11, 16) : start
const e = end.length > 16 ? end.substring(11, 16) : end
return `${s} ~ ${e}`
},
formatAttendanceStatus(status) {
const map = { 1: '正常出勤', 2: '迟到', 3: '早退', 4: '缺勤', 5: '请假' }
return map[status] || '已打卡'
}
}
}
</script>
<style scoped>
.terminal-container {
padding: 20px;
}
.mb15 {
margin-bottom: 15px;
}
</style>