已完成,学生端考勤记录现在按课程展示。

每门课程会独立显示:
课程名称、代码、教学班和任课教师。
课程总体出勤率。
出勤、迟到、缺勤、请假、免修次数。
按日期排列的历次点名记录。
缺勤或迟到记录仍可单独申诉。
This commit is contained in:
2026-07-26 10:49:06 +08:00 Unverified
parent 72297aa23f
commit 3b73292d93
12 changed files with 2172 additions and 54 deletions
+532 -8
View File
@@ -1,6 +1,18 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Check, Download, Plus, Refresh, Search, Upload } from '@element-plus/icons-vue'
import {
Check,
Clock,
CopyDocument,
Download,
Grid,
Location,
Plus,
Refresh,
Search,
Upload,
} from '@element-plus/icons-vue'
import * as QRCode from 'qrcode'
import * as echarts from 'echarts/core'
import { LineChart, PieChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
@@ -28,6 +40,12 @@ const selectedSheet = ref<any>(null)
const sheetDetail = ref<any>(null)
const statistics = ref<any>(null)
const createDialog = ref(false)
const qrDialog = ref(false)
const createSubmitting = ref(false)
const teacherLocationLoading = ref(false)
const qrDataUrl = ref('')
const qrCheckInUrl = ref('')
const now = ref(Date.now())
const fileInput = ref<HTMLInputElement>()
const termId = ref<string>()
const activeMode = ref<'rollcall' | 'statistics'>('rollcall')
@@ -36,8 +54,18 @@ const classFilter = ref('')
const attentionFilter = ref('')
const statusChartEl = ref<HTMLElement>()
const trendChartEl = ref<HTMLElement>()
const createForm = ref({ name: '', attendanceDate: '' })
const createForm = ref({
name: '',
attendanceDate: '',
checkInMethod: 'Manual',
checkInDurationMinutes: 15,
targetLatitude: null as number | null,
targetLongitude: null as number | null,
locationRadiusMeters: 100,
locationAccuracyMeters: null as number | null,
})
const chartInstances: echarts.ECharts[] = []
let clockTimer: number | undefined
const statusOptions = [
{ value: 'Present', label: '出勤' },
@@ -148,26 +176,181 @@ function openCreate() {
createForm.value = {
name: '',
attendanceDate: new Date().toISOString().slice(0, 10),
checkInMethod: 'Manual',
checkInDurationMinutes: 15,
targetLatitude: null,
targetLongitude: null,
locationRadiusMeters: 100,
locationAccuracyMeters: null,
}
createDialog.value = true
}
async function captureTeacherLocation() {
if (!navigator.geolocation) {
ElMessage.error('当前浏览器不支持定位,请更换浏览器或使用扫码签到。')
return false
}
teacherLocationLoading.value = true
try {
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 12000,
maximumAge: 0,
})
})
createForm.value.targetLatitude = Number(position.coords.latitude.toFixed(7))
createForm.value.targetLongitude = Number(position.coords.longitude.toFixed(7))
createForm.value.locationAccuracyMeters = Math.round(position.coords.accuracy)
ElMessage.success('已获取当前签到点')
return true
} catch (error: any) {
const message = error?.code === 1
? '定位权限被拒绝,请在浏览器地址栏中允许本网站使用位置信息。'
: error?.code === 3
? '获取位置超时,请移到信号较好的位置后重试。'
: '暂时无法获取位置,请检查系统定位服务。'
ElMessage.error(message)
return false
} finally {
teacherLocationLoading.value = false
}
}
async function createSheet() {
if (!createForm.value.name.trim()) {
ElMessage.warning('请填写考勤表名称。')
return
}
if (createForm.value.checkInMethod === 'Location' &&
(createForm.value.targetLatitude === null ||
createForm.value.targetLongitude === null) &&
!await captureTeacherLocation()) return
createSubmitting.value = true
try {
await http.post('/attendance/sheets', {
const { data } = await http.post('/attendance/sheets', {
teachingTaskId: selectedTask.value.id,
name: createForm.value.name,
attendanceDate: new Date(createForm.value.attendanceDate).toISOString(),
checkInMethod: createForm.value.checkInMethod,
checkInDurationMinutes: createForm.value.checkInMethod === 'Manual'
? null
: createForm.value.checkInDurationMinutes,
targetLatitude: createForm.value.checkInMethod === 'Location'
? createForm.value.targetLatitude
: null,
targetLongitude: createForm.value.checkInMethod === 'Location'
? createForm.value.targetLongitude
: null,
locationRadiusMeters: createForm.value.checkInMethod === 'Location'
? createForm.value.locationRadiusMeters
: null,
})
createDialog.value = false
ElMessage.success('考勤表已建立')
ElMessage.success(createForm.value.checkInMethod === 'Manual'
? '考勤表已建立'
: '签到活动已发起')
await selectTask(selectedTask.value)
const createdSheet = sheets.value.find((sheet: any) => sheet.id === data.id)
if (createdSheet) {
await selectSheet(createdSheet)
if (data.checkInMethod === 'QrCode') await showQrCode()
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
createSubmitting.value = false
}
}
function isOnlineMethod(method: string | number) {
return method === 'QrCode' || method === 'Location' || method === 2 || method === 3
}
function methodLabel(method: string | number) {
if (method === 'QrCode' || method === 2) return '扫码签到'
if (method === 'Location' || method === 3) return '定位签到'
return '教师点名'
}
function isQrCode(method: string | number) {
return method === 'QrCode' || method === 2
}
function serverUtcTime(value: string | null | undefined) {
if (!value) return Number.NaN
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
return new Date(normalized).getTime()
}
function formatServerTime(value: string) {
return new Date(serverUtcTime(value)).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
})
}
function isCheckInOpen(sheet: any) {
if (!sheet || !isDraft(sheet.status) || !isOnlineMethod(sheet.checkInMethod)) return false
const start = serverUtcTime(sheet.checkInStartsAt)
const end = serverUtcTime(sheet.checkInEndsAt)
return start <= now.value && now.value < end
}
function remainingLabel(sheet: any) {
if (!sheet?.checkInEndsAt) return ''
const remaining = serverUtcTime(sheet.checkInEndsAt) - now.value
if (remaining <= 0) return '签到已结束'
const minutes = Math.floor(remaining / 60000)
const seconds = Math.floor((remaining % 60000) / 1000)
return `${minutes}${String(seconds).padStart(2, '0')}秒后结束`
}
async function showQrCode() {
const sheet = sheetDetail.value?.sheet
if (!sheet?.checkInToken) {
ElMessage.warning('未取得签到码,请刷新考勤表后重试。')
return
}
qrCheckInUrl.value =
`${window.location.origin}/my-attendance?token=${encodeURIComponent(sheet.checkInToken)}`
try {
qrDataUrl.value = await QRCode.toDataURL(qrCheckInUrl.value, {
width: 360,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#172b4d', light: '#ffffff' },
})
qrDialog.value = true
} catch {
ElMessage.error('签到二维码生成失败,请刷新页面后重试。')
}
}
async function copyCheckInLink() {
try {
await navigator.clipboard.writeText(qrCheckInUrl.value)
ElMessage.success('签到链接已复制')
} catch {
ElMessage.error('无法自动复制,请手动选择签到链接。')
}
}
async function closeCheckIn() {
if (!sheetDetail.value?.sheet) return
try {
await ElMessageBox.confirm(
'提前结束后,学生将不能再扫码或定位签到,仍可由教师调整名单。',
'提前结束签到',
{ type: 'warning', confirmButtonText: '结束签到', cancelButtonText: '继续签到' },
)
await http.post(`/attendance/sheets/${sheetDetail.value.sheet.id}/close-check-in`)
ElMessage.success('签到已结束')
qrDialog.value = false
await selectTask(selectedTask.value)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
@@ -407,6 +590,7 @@ watch(activeMode, async mode => {
onMounted(async () => {
window.addEventListener('resize', resizeCharts)
clockTimer = window.setInterval(() => { now.value = Date.now() }, 1000)
terms.value = (await http.get('/base-data/terms')).data
termId.value = terms.value.find((item: any) => item.isCurrent)?.id
await loadTasks()
@@ -414,6 +598,7 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('resize', resizeCharts)
if (clockTimer) window.clearInterval(clockTimer)
disposeCharts()
})
</script>
@@ -482,10 +667,20 @@ onUnmounted(() => {
@click="selectSheet(sheet)"
>
<div>
<b>{{ sheet.name }}</b>
<b>
{{ sheet.name }}
<span
v-if="isOnlineMethod(sheet.checkInMethod)"
class="method-mark"
>{{ methodLabel(sheet.checkInMethod) }}</span>
</b>
<span>{{ new Date(sheet.attendanceDate).toLocaleDateString('zh-CN') }}</span>
</div>
<div class="sheet-stats">
<span
v-if="isOnlineMethod(sheet.checkInMethod)"
class="checked-in"
>已签到 {{ sheet.checkedInCount }}</span>
<span class="present">出勤 {{ sheet.presentCount }}</span>
<span class="absent" v-if="sheet.absentCount">缺勤 {{ sheet.absentCount }}</span>
<span class="late" v-if="sheet.lateCount">迟到 {{ sheet.lateCount }}</span>
@@ -510,9 +705,26 @@ onUnmounted(() => {
<div>
<strong>{{ sheetDetail.sheet.name }}</strong>
<span>{{ sheetDetail.sheet.courseCode }} · {{ sheetDetail.sheet.taskNumber }}</span>
<small>{{ new Date(sheetDetail.sheet.attendanceDate).toLocaleDateString('zh-CN') }} · {{ isSubmitted(sheetDetail.sheet.status) ? '已提交' : '草稿' }}</small>
<small>
{{ new Date(sheetDetail.sheet.attendanceDate).toLocaleDateString('zh-CN') }}
· {{ methodLabel(sheetDetail.sheet.checkInMethod) }}
· {{ isSubmitted(sheetDetail.sheet.status) ? '已提交' : '草稿' }}
</small>
</div>
<div class="panel-actions">
<el-button
v-if="sheetDetail.canEdit && isQrCode(sheetDetail.sheet.checkInMethod)"
size="small"
:icon="Grid"
@click="showQrCode"
>显示签到码</el-button>
<el-button
v-if="sheetDetail.canEdit && isCheckInOpen(sheetDetail.sheet)"
size="small"
type="warning"
plain
@click="closeCheckIn"
>提前结束</el-button>
<el-button
v-if="sheetDetail.canEdit"
size="small"
@@ -542,6 +754,27 @@ onUnmounted(() => {
>提交</el-button>
</div>
</header>
<div
v-if="isOnlineMethod(sheetDetail.sheet.checkInMethod)"
class="check-in-console"
:class="{ open: isCheckInOpen(sheetDetail.sheet) }"
>
<div class="check-in-signal">
<span />
{{ isCheckInOpen(sheetDetail.sheet) ? '签到进行中' : '签到已结束' }}
</div>
<strong>
{{ sheetDetail.sheet.checkedInCount }}
<small>/ {{ sheetDetail.sheet.records.length }} 人已自主签到</small>
</strong>
<p>
<Clock />
{{ remainingLabel(sheetDetail.sheet) }}
<template v-if="sheetDetail.sheet.locationRadiusMeters">
· 签到点 {{ sheetDetail.sheet.locationRadiusMeters }} 米内有效
</template>
</p>
</div>
<div v-if="sheetDetail.canEdit" class="batch-row">
<span>批量设置</span>
<el-button size="small" @click="batchStatus('Present')">全部出勤</el-button>
@@ -573,6 +806,22 @@ onUnmounted(() => {
<span v-else>{{ statusLabel(row.status) }}</span>
</template>
</el-table-column>
<el-table-column
v-if="isOnlineMethod(sheetDetail.sheet.checkInMethod)"
label="自主签到"
min-width="138"
>
<template #default="{ row }">
<div v-if="row.checkInAt" class="record-check-in">
<b>{{ methodLabel(row.checkedInMethod) }}</b>
<span>{{ formatServerTime(row.checkInAt) }}</span>
<small v-if="row.checkInDistanceMeters !== null">
距签到点 {{ Math.round(row.checkInDistanceMeters) }}
</small>
</div>
<span v-else class="muted-cell">尚未签到</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120">
<template #default="{ row }">
<el-input
@@ -713,7 +962,7 @@ onUnmounted(() => {
</main>
</section>
<el-dialog v-model="createDialog" title="新建考勤" width="500px">
<el-dialog v-model="createDialog" title="发起课堂考勤" width="620px">
<el-form label-position="top">
<el-form-item label="考勤名称" required>
<el-input v-model="createForm.name" placeholder="第3周课堂点名" maxlength="120" />
@@ -725,10 +974,109 @@ onUnmounted(() => {
class="full-width"
/>
</el-form-item>
<el-form-item label="签到方式" required>
<div class="method-options">
<button
v-for="method in [
{ value: 'Manual', icon: Check, name: '教师点名', hint: '逐人确认,可随时修改' },
{ value: 'QrCode', icon: Grid, name: '扫码签到', hint: '投屏二维码,学生登录后签到' },
{ value: 'Location', icon: Location, name: '定位签到', hint: '在指定签到点范围内签到' },
]"
:key="method.value"
type="button"
:class="{ active: createForm.checkInMethod === method.value }"
@click="createForm.checkInMethod = method.value"
>
<el-icon><component :is="method.icon" /></el-icon>
<b>{{ method.name }}</b>
<span>{{ method.hint }}</span>
</button>
</div>
</el-form-item>
<div v-if="createForm.checkInMethod !== 'Manual'" class="online-settings">
<el-form-item label="签到时长" required>
<el-input-number
v-model="createForm.checkInDurationMinutes"
:min="1"
:max="180"
:step="5"
controls-position="right"
/>
<span class="field-unit">分钟</span>
</el-form-item>
<template v-if="createForm.checkInMethod === 'Location'">
<el-form-item label="有效范围" required>
<el-input-number
v-model="createForm.locationRadiusMeters"
:min="20"
:max="1000"
:step="10"
controls-position="right"
/>
<span class="field-unit">米</span>
</el-form-item>
<div class="location-anchor">
<div>
<Location />
<p>
<b>教师当前位置作为签到点</b>
<span v-if="createForm.targetLatitude !== null">
已定位,精度约 {{ createForm.locationAccuracyMeters }} 米
</span>
<span v-else>创建前需要允许浏览器获取位置</span>
</p>
</div>
<el-button
:loading="teacherLocationLoading"
@click="captureTeacherLocation"
>{{ createForm.targetLatitude === null ? '获取位置' : '重新定位' }}</el-button>
</div>
</template>
</div>
</el-form>
<template #footer>
<el-button @click="createDialog = false">取消</el-button>
<el-button type="primary" @click="createSheet">建立考勤表</el-button>
<el-button
type="primary"
:loading="createSubmitting"
@click="createSheet"
>{{ createForm.checkInMethod === 'Manual' ? '建立考勤表' : '立即发起签到' }}</el-button>
</template>
</el-dialog>
<el-dialog
v-model="qrDialog"
class="qr-dialog"
title="课堂扫码签到"
width="520px"
align-center
>
<div v-if="sheetDetail" class="qr-stage">
<div class="qr-course">
<span>{{ sheetDetail.sheet.courseCode }} · {{ sheetDetail.sheet.taskNumber }}</span>
<strong>{{ sheetDetail.sheet.name }}</strong>
<small>{{ sheetDetail.sheet.courseName }}</small>
</div>
<img :src="qrDataUrl" alt="课堂签到二维码" />
<div class="qr-status" :class="{ ended: !isCheckInOpen(sheetDetail.sheet) }">
<span />
{{ isCheckInOpen(sheetDetail.sheet) ? remainingLabel(sheetDetail.sheet) : '本次签到已结束' }}
</div>
<p>学生使用手机扫码,登录教务系统后完成签到</p>
<el-input v-model="qrCheckInUrl" readonly>
<template #append>
<el-button :icon="CopyDocument" @click="copyCheckInLink">复制链接</el-button>
</template>
</el-input>
</div>
<template #footer>
<el-button
v-if="sheetDetail && isCheckInOpen(sheetDetail.sheet)"
type="warning"
plain
@click="closeCheckIn"
>提前结束签到</el-button>
<el-button type="primary" @click="qrDialog = false">完成</el-button>
</template>
</el-dialog>
</div>
@@ -833,6 +1181,16 @@ onUnmounted(() => {
}
.attendance-sheet-list > button b { font-size: 14px; }
.attendance-sheet-list > button span { color: var(--muted); font-size: 11px; }
.method-mark {
margin-left: 5px;
padding: 2px 5px;
color: #176b87 !important;
font-size: 9px !important;
font-weight: 700;
vertical-align: middle;
border: 1px solid #b9d5dc;
background: #edf7f8;
}
.attendance-sheet-list > button i {
color: var(--indigo);
font-size: 10px;
@@ -842,6 +1200,7 @@ onUnmounted(() => {
.sheet-stats { display: flex; gap: 8px; flex-wrap: wrap; }
.sheet-stats span { font-size: 10px !important; font-weight: 700; }
.sheet-stats .present { color: #2d8975; }
.sheet-stats .checked-in { color: #176b87; }
.sheet-stats .absent { color: #b34e48; }
.sheet-stats .late { color: #c78724; }
.sheet-stats .leave { color: #79579a; }
@@ -864,6 +1223,56 @@ onUnmounted(() => {
margin-top: 8px;
flex-wrap: wrap;
}
.check-in-console {
padding: 13px 15px;
display: grid;
gap: 4px;
color: #64748b;
border-bottom: 1px solid #dce4ed;
background: #f5f7fa;
}
.check-in-console.open {
color: #d8f4ec;
border-color: #21506d;
background: #17395e;
}
.check-in-signal {
display: flex;
align-items: center;
gap: 7px;
font-size: 10px;
font-weight: 800;
letter-spacing: .08em;
}
.check-in-signal > span,
.qr-status > span {
width: 7px;
height: 7px;
border-radius: 50%;
background: #94a3b8;
}
.check-in-console.open .check-in-signal > span,
.qr-status:not(.ended) > span {
background: #4fe0b1;
box-shadow: 0 0 0 4px rgba(79, 224, 177, .14);
}
.check-in-console > strong {
color: #334155;
font: 700 30px/1.1 "Arial Narrow", "Microsoft YaHei", sans-serif;
}
.check-in-console.open > strong { color: white; }
.check-in-console > strong small {
font-size: 11px;
font-weight: 500;
}
.check-in-console > p {
margin: 0;
display: flex;
align-items: center;
gap: 5px;
font-size: 10px;
}
.check-in-console > p svg { width: 12px; }
.batch-row {
padding: 8px 14px;
display: flex;
@@ -875,8 +1284,119 @@ onUnmounted(() => {
.batch-row > span { font-size: 11px; color: var(--muted); }
.batch-row > small { margin-left: auto; font-size: 10px; color: var(--muted); }
.student-flag { margin-left: 4px; }
.record-check-in { display: grid; gap: 1px; line-height: 1.25; }
.record-check-in b { color: #176b87; font-size: 10px; }
.record-check-in span,
.record-check-in small,
.muted-cell { color: var(--muted); font-size: 10px; }
.full-width { width: 100%; }
.method-options {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.method-options > button {
min-width: 0;
padding: 13px 10px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
gap: 2px 7px;
color: #344054;
text-align: left;
border: 1px solid #d8dee7;
background: white;
cursor: pointer;
}
.method-options > button:hover { border-color: #87aeb9; }
.method-options > button.active {
color: #17395e;
border-color: #176b87;
box-shadow: inset 0 -3px #176b87;
background: #f1f8f9;
}
.method-options .el-icon {
grid-row: 1 / 3;
width: 28px;
height: 28px;
color: #176b87;
font-size: 19px;
background: #e7f2f4;
}
.method-options b { font-size: 12px; }
.method-options span {
overflow: hidden;
color: #7b8794;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.online-settings {
padding: 13px 15px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0 16px;
border: 1px solid #dce4ed;
background: #f7f9fb;
}
.online-settings :deep(.el-form-item) { margin-bottom: 10px; }
.field-unit { margin-left: 8px; color: var(--muted); font-size: 11px; }
.location-anchor {
grid-column: 1 / -1;
padding-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
border-top: 1px solid #dce4ed;
}
.location-anchor > div {
min-width: 0;
display: flex;
align-items: center;
gap: 9px;
}
.location-anchor svg {
width: 22px;
color: #176b87;
flex: 0 0 auto;
}
.location-anchor p { margin: 0; display: grid; }
.location-anchor b { color: #344054; font-size: 11px; }
.location-anchor span { color: var(--muted); font-size: 9px; }
.qr-stage {
display: grid;
justify-items: center;
gap: 11px;
text-align: center;
}
.qr-course { display: grid; gap: 2px; }
.qr-course span {
color: #176b87;
font: 700 10px/1.2 Consolas, monospace;
}
.qr-course strong { color: #172b4d; font-size: 20px; }
.qr-course small { color: var(--muted); }
.qr-stage > img {
width: min(340px, 78vw);
aspect-ratio: 1;
border: 10px solid white;
box-shadow: 0 0 0 1px #d8dee7, 0 12px 32px rgba(23, 43, 77, .12);
}
.qr-status {
display: flex;
align-items: center;
gap: 8px;
color: #247261;
font-size: 12px;
font-weight: 800;
}
.qr-status.ended { color: #64748b; }
.qr-stage > p { margin: 0; color: var(--muted); font-size: 11px; }
.qr-stage :deep(.el-input) { width: 100%; }
.attendance-statistics {
min-width: 0;
overflow: hidden;
@@ -1094,6 +1614,10 @@ onUnmounted(() => {
.student-statistics { margin: 0 12px 12px; }
.student-filters .el-input,
.student-filters .el-select { width: 100%; }
.method-options { grid-template-columns: 1fr; }
.method-options > button { grid-template-columns: 32px minmax(0, 1fr); }
.online-settings { grid-template-columns: 1fr; }
.location-anchor { align-items: flex-start; }
.batch-row { align-items: flex-start; flex-wrap: wrap; }
.batch-row > small { width: 100%; margin-left: 0; }
}