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

每门课程会独立显示:
课程名称、代码、教学班和任课教师。
课程总体出勤率。
出勤、迟到、缺勤、请假、免修次数。
按日期排列的历次点名记录。
缺勤或迟到记录仍可单独申诉。
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
+579 -36
View File
@@ -1,10 +1,18 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Refresh, Warning } from '@element-plus/icons-vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Check, Clock, Location, Refresh, Warning } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
const route = useRoute()
const router = useRouter()
const records = ref<any[]>([])
const openActivities = ref<any[]>([])
const scannedActivity = ref<any>(null)
const loading = ref(false)
const scanLoading = ref(false)
const checkInTargetId = ref('')
const now = ref(Date.now())
const appealDialog = ref(false)
const appealTarget = ref<any>(null)
const appealReason = ref('')
@@ -18,14 +26,165 @@ const statusColors: Record<string, 'success' | 'danger' | 'warning' | 'info' | u
const appealStatusLabels: Record<string, string> = {
None: '', Pending: '申诉中', Approved: '已通过', Rejected: '已驳回',
}
const courseGroups = computed(() => {
const groups = new Map<string, any>()
records.value.forEach((record: any) => {
const key = record.teachingTaskId ?? `${record.courseCode}-${record.taskNumber}`
let group = groups.get(key)
if (!group) {
group = {
key,
courseCode: record.courseCode,
courseName: record.courseName,
taskNumber: record.taskNumber,
teacherNames: [...record.teacherNames],
records: [],
presentCount: 0,
absentCount: 0,
lateCount: 0,
leaveCount: 0,
excusedCount: 0,
requiredCount: 0,
attendedCount: 0,
attendanceRate: null,
latestAt: 0,
}
groups.set(key, group)
}
group.records.push(record)
group.latestAt = Math.max(group.latestAt, new Date(record.attendanceDate).getTime())
const countKey = `${record.status.charAt(0).toLowerCase()}${record.status.slice(1)}Count`
if (countKey in group) group[countKey] += 1
if (record.status !== 'Excused') group.requiredCount += 1
if (record.status === 'Present' || record.status === 'Late') group.attendedCount += 1
})
return [...groups.values()]
.map(group => ({
...group,
attendanceRate: group.requiredCount > 0
? Math.round(group.attendedCount * 1000 / group.requiredCount) / 10
: null,
}))
.sort((a, b) => b.latestAt - a.latestAt)
})
let clockTimer: number | undefined
async function load() {
loading.value = true
try { records.value = (await http.get('/attendance/my-records')).data }
try {
const [recordResponse, activityResponse] = await Promise.all([
http.get('/attendance/my-records'),
http.get('/attendance/open-check-ins'),
])
records.value = recordResponse.data
openActivities.value = activityResponse.data
const token = typeof route.query.token === 'string' ? route.query.token : ''
if (token) await loadScannedActivity(token)
else scannedActivity.value = null
}
catch (e) { ElMessage.error(apiErrorMessage(e)) }
finally { loading.value = false }
}
async function loadScannedActivity(token: string) {
scanLoading.value = true
try {
scannedActivity.value = (
await http.get('/attendance/check-in-info', { params: { token } })
).data
} catch (error: any) {
scannedActivity.value = null
if (error?.response?.status === 404) {
ElMessage.error('签到码无效,或你不在本次课程名单中。')
} else {
ElMessage.error(apiErrorMessage(error))
}
} finally {
scanLoading.value = false
}
}
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 remainingLabel(activity: any) {
if (!activity?.checkInEndsAt) return ''
const remaining = serverUtcTime(activity.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 confirmQrCheckIn() {
const token = typeof route.query.token === 'string' ? route.query.token : ''
if (!token || !scannedActivity.value) return
checkInTargetId.value = scannedActivity.value.sheetId
try {
const { data } = await http.post('/attendance/check-in', { token })
ElMessage.success(data.alreadyCheckedIn ? '你已完成本次签到' : '签到成功')
await router.replace({ path: route.path, query: {} })
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
checkInTargetId.value = ''
}
}
async function captureLocation() {
if (!navigator.geolocation) throw new Error('unsupported')
return await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 12000,
maximumAge: 0,
})
})
}
async function locationCheckIn(activity: any) {
checkInTargetId.value = activity.sheetId
try {
const position = await captureLocation()
const { data } = await http.post('/attendance/check-in', {
attendanceSheetId: activity.sheetId,
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracyMeters: position.coords.accuracy,
})
ElMessage.success(data.alreadyCheckedIn
? '你已完成本次签到'
: `签到成功${data.checkInDistanceMeters === null
? ''
: `,距签到点约 ${Math.round(data.checkInDistanceMeters)}`}`)
await load()
} catch (error: any) {
if (!error?.response) {
const message = error?.code === 1
? '定位权限被拒绝,请在浏览器中允许本网站获取位置。'
: error?.code === 3
? '获取位置超时,请移到信号较好的位置后重试。'
: '无法获取当前位置,请检查手机定位服务后重试。'
ElMessage.error(message)
} else {
ElMessage.error(apiErrorMessage(error))
}
} finally {
checkInTargetId.value = ''
}
}
function openAppeal(record: any) {
appealTarget.value = record
appealReason.value = ''
@@ -49,7 +208,32 @@ function canAppeal(record: any) {
return record.appealStatus === 'None' || record.appealStatus === 'Rejected'
}
onMounted(load)
function formatRate(value: number | null) {
return value === null ? '暂无' : `${value.toFixed(1)}%`
}
function rateTone(value: number | null) {
if (value === null) return 'neutral'
if (value < 80) return 'danger'
if (value < 90) return 'warning'
return 'good'
}
function recordDate(value: string) {
const date = new Date(value)
return {
month: `${date.getMonth() + 1}`,
day: String(date.getDate()).padStart(2, '0'),
}
}
onMounted(() => {
clockTimer = window.setInterval(() => { now.value = Date.now() }, 1000)
load()
})
onUnmounted(() => {
if (clockTimer) window.clearInterval(clockTimer)
})
</script>
<template>
@@ -58,39 +242,149 @@ onMounted(load)
<div>
<span class="section-kicker">ATTENDANCE RECORD</span>
<h2>我的考勤</h2>
<p>查看所有已提交的考勤记录对记录有异议可以提交申诉辅导员将进行审核</p>
<p>完成课堂扫码或定位签到并查看已提交的考勤记录</p>
</div>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</section>
<section v-loading="loading" class="att-list">
<article v-for="r in records" :key="`${r.attendanceSheetId}`" class="att-card">
<div class="att-info">
<span>{{ r.courseCode }} · {{ r.taskNumber }}</span>
<h3>{{ r.courseName }}</h3>
<p>{{ r.sheetName }} · {{ new Date(r.attendanceDate).toLocaleDateString('zh-CN') }} · {{ r.teacherNames.join('、') }}</p>
<section
v-if="route.query.token || openActivities.length"
v-loading="loading || scanLoading"
class="check-in-board"
>
<header>
<div>
<span>LIVE CHECK-IN</span>
<strong>待签到</strong>
</div>
<div class="att-status">
<el-tag :type="statusColors[r.status]" size="small">{{ statusLabels[r.status] }}</el-tag>
<span v-if="r.notes" class="att-note">{{ r.notes }}</span>
<small>签到由服务器校验课程名单有效时间和位置范围</small>
</header>
<article v-if="scannedActivity" class="scan-ticket">
<div class="ticket-mark"><el-icon><Check /></el-icon></div>
<div class="ticket-course">
<span>{{ scannedActivity.courseCode }} · {{ scannedActivity.taskNumber }}</span>
<strong>{{ scannedActivity.courseName }}</strong>
<p>{{ scannedActivity.sheetName }} · 扫码签到</p>
</div>
<div class="att-appeal">
<template v-if="r.appealStatus !== 'None'">
<el-tag size="small" :type="r.appealStatus === 'Pending' ? 'warning' : r.appealStatus === 'Approved' ? 'success' : 'danger'">
{{ appealStatusLabels[r.appealStatus] }}
</el-tag>
<span v-if="r.appealReviewComment" class="att-note">{{ r.appealReviewComment }}</span>
</template>
<div class="ticket-time">
<Clock />
<b>{{ remainingLabel(scannedActivity) }}</b>
<small v-if="scannedActivity.checkInAt">
已于 {{ formatServerTime(scannedActivity.checkInAt) }} 签到
</small>
<small v-else>请确认课程信息后完成签到</small>
</div>
<el-button
type="primary"
size="large"
:disabled="!scannedActivity.isOpen || Boolean(scannedActivity.checkInAt)"
:loading="checkInTargetId === scannedActivity.sheetId"
@click="confirmQrCheckIn"
>{{ scannedActivity.checkInAt ? '已签到' : scannedActivity.isOpen ? '确认签到' : '签到已结束' }}</el-button>
</article>
<div v-if="openActivities.length" class="location-list">
<article v-for="activity in openActivities" :key="activity.sheetId">
<div class="location-pin"><Location /></div>
<div>
<span>{{ activity.courseCode }} · {{ activity.taskNumber }}</span>
<strong>{{ activity.courseName }}</strong>
<p>{{ activity.sheetName }} · {{ activity.locationRadiusMeters }} 米范围内</p>
</div>
<div class="location-clock">
<b>{{ remainingLabel(activity) }}</b>
<small v-if="activity.checkInAt">
已于 {{ formatServerTime(activity.checkInAt) }} 签到
</small>
<small v-else>将获取一次当前位置用于本次签到</small>
</div>
<el-button
v-if="canAppeal(r) && (r.status === 'Absent' || r.status === 'Late')"
size="small"
type="warning"
:icon="Warning"
@click="openAppeal(r)"
>申诉</el-button>
type="primary"
plain
:disabled="Boolean(activity.checkInAt)"
:loading="checkInTargetId === activity.sheetId"
@click="locationCheckIn(activity)"
>{{ activity.checkInAt ? '已签到' : '定位并签到' }}</el-button>
</article>
</div>
</section>
<section class="record-head">
<div>
<strong>课程考勤档案</strong>
<span>按课程汇总出勤率仅统计教师已经提交的考勤结果</span>
</div>
<span>{{ courseGroups.length }} 门课程 · {{ records.length }} 次点名</span>
</section>
<section v-loading="loading" class="course-archive">
<article v-for="course in courseGroups" :key="course.key" class="course-record">
<header>
<div class="course-identity">
<span>{{ course.courseCode }} · {{ course.taskNumber }}</span>
<h3>{{ course.courseName }}</h3>
<small>{{ course.teacherNames.join('、') || '任课教师未登记' }}</small>
</div>
<div class="course-rate" :class="rateTone(course.attendanceRate)">
<span>课程出勤率</span>
<strong>{{ formatRate(course.attendanceRate) }}</strong>
<small>{{ course.attendedCount }} / {{ course.requiredCount }} 次到课</small>
</div>
</header>
<div class="course-summary">
<div><b>{{ course.records.length }}</b><span>点名次数</span></div>
<div class="present"><b>{{ course.presentCount }}</b><span>出勤</span></div>
<div class="late"><b>{{ course.lateCount }}</b><span>迟到</span></div>
<div class="absent"><b>{{ course.absentCount }}</b><span>缺勤</span></div>
<div><b>{{ course.leaveCount }}</b><span>请假</span></div>
<div><b>{{ course.excusedCount }}</b><span>免修</span></div>
</div>
<div class="session-list">
<div
v-for="r in course.records"
:key="r.attendanceSheetId"
class="session-row"
>
<div class="session-date" aria-hidden="true">
<span>{{ recordDate(r.attendanceDate).month }}</span>
<strong>{{ recordDate(r.attendanceDate).day }}</strong>
</div>
<div class="session-info">
<strong>{{ r.sheetName }}</strong>
<span v-if="r.notes">{{ r.notes }}</span>
<span v-else>本次点名没有备注</span>
</div>
<div class="session-result">
<el-tag :type="statusColors[r.status]" size="small">
{{ statusLabels[r.status] }}
</el-tag>
<template v-if="r.appealStatus !== 'None'">
<el-tag
size="small"
:type="r.appealStatus === 'Pending' ? 'warning' : r.appealStatus === 'Approved' ? 'success' : 'danger'"
>
{{ appealStatusLabels[r.appealStatus] }}
</el-tag>
<span v-if="r.appealReviewComment" class="review-comment">
{{ r.appealReviewComment }}
</span>
</template>
</div>
<el-button
v-if="canAppeal(r) && (r.status === 'Absent' || r.status === 'Late')"
size="small"
type="warning"
plain
:icon="Warning"
@click="openAppeal(r)"
>申诉</el-button>
</div>
</div>
</article>
<el-empty v-if="!records.length" description="暂无考勤记录" />
<el-empty v-if="!courseGroups.length" description="还没有已提交的课程考勤记录" />
</section>
<el-dialog v-model="appealDialog" title="考勤申诉" width="500px">
@@ -108,12 +402,261 @@ onMounted(load)
</template>
<style scoped>
.att-list { display: grid; gap: 10px; }
.att-card { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; background: #fff; border: 1px solid #e4e7ed; border-radius: 8px; flex-wrap: wrap; }
.att-info span { font-size: 11px; color: var(--muted); }
.att-info h3 { font-size: 14px; margin: 2px 0; }
.att-info p { font-size: 12px; color: var(--muted); margin: 0; }
.att-status { display: flex; align-items: center; gap: 8px; }
.att-appeal { display: flex; align-items: center; gap: 8px; }
.att-note { font-size: 11px; color: var(--muted); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.check-in-board {
border: 1px solid #cdd9e5;
background: #f6f9fc;
}
.check-in-board > header {
min-height: 58px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
color: white;
background: #17395e;
}
.check-in-board > header > div { display: grid; }
.check-in-board > header span {
color: #6de0c0;
font-size: 9px;
font-weight: 800;
letter-spacing: .14em;
}
.check-in-board > header strong { font-size: 17px; }
.check-in-board > header small { font-size: 10px; opacity: .72; }
.scan-ticket {
margin: 14px;
padding: 16px;
display: grid;
grid-template-columns: 44px minmax(180px, 1fr) minmax(150px, .7fr) auto;
align-items: center;
gap: 14px;
border-left: 4px solid #2d8975;
background: white;
box-shadow: 0 4px 14px rgba(23, 43, 77, .07);
}
.ticket-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
color: #247261;
font-size: 23px;
background: #e8f5ef;
}
.ticket-course,
.ticket-time { display: grid; gap: 2px; }
.ticket-course span,
.location-list article > div:nth-child(2) > span {
color: #176b87;
font: 700 10px/1.2 Consolas, monospace;
}
.ticket-course strong,
.location-list article strong { color: #172b4d; font-size: 15px; }
.ticket-course p,
.location-list article p { margin: 0; color: var(--muted); font-size: 10px; }
.ticket-time { grid-template-columns: 16px minmax(0, 1fr); }
.ticket-time svg { width: 14px; color: #176b87; }
.ticket-time b { color: #344054; font-size: 12px; }
.ticket-time small { grid-column: 2; color: var(--muted); font-size: 9px; }
.location-list { border-top: 1px solid #dce4ed; }
.location-list article {
padding: 13px 16px;
display: grid;
grid-template-columns: 38px minmax(180px, 1fr) minmax(160px, .7fr) auto;
align-items: center;
gap: 12px;
border-bottom: 1px solid #e3e9ef;
background: white;
}
.location-list article:last-child { border-bottom: none; }
.location-pin {
width: 34px;
height: 34px;
display: grid;
place-items: center;
color: #176b87;
background: #e9f3f5;
}
.location-pin svg { width: 19px; }
.location-list article > div:nth-child(2) { display: grid; gap: 2px; }
.location-clock { display: grid; gap: 2px; }
.location-clock b { color: #247261; font-size: 11px; }
.location-clock small { color: var(--muted); font-size: 9px; }
.record-head {
padding: 11px 15px;
display: flex;
align-items: center;
justify-content: space-between;
border: 1px solid var(--line);
background: #fbfcfd;
}
.record-head > div { display: grid; gap: 1px; }
.record-head strong { color: #172b4d; font-size: 14px; }
.record-head span { color: var(--muted); font-size: 10px; }
.course-archive { display: grid; gap: 14px; }
.course-record {
overflow: hidden;
border: 1px solid #d8e0e9;
background: white;
}
.course-record > header {
min-height: 104px;
display: grid;
grid-template-columns: minmax(0, 1fr) 176px;
border-bottom: 1px solid #dce4ed;
}
.course-identity {
padding: 18px 20px;
display: grid;
align-content: center;
gap: 2px;
}
.course-identity > span {
color: #176b87;
font: 700 10px/1.2 Consolas, monospace;
letter-spacing: .03em;
}
.course-identity h3 {
margin: 2px 0;
color: #172b4d;
font-size: 20px;
line-height: 1.25;
}
.course-identity small { color: var(--muted); font-size: 10px; }
.course-rate {
padding: 14px 18px;
display: grid;
align-content: center;
color: white;
background: #17395e;
}
.course-rate > span {
font-size: 9px;
font-weight: 800;
letter-spacing: .08em;
opacity: .75;
}
.course-rate strong {
margin: 3px 0;
font: 700 30px/1 "Arial Narrow", "Microsoft YaHei", sans-serif;
}
.course-rate small { font-size: 9px; opacity: .72; }
.course-rate.good { background: #245f57; }
.course-rate.warning { background: #8b5a18; }
.course-rate.danger { background: #8d403d; }
.course-rate.neutral { background: #526276; }
.course-summary {
display: grid;
grid-template-columns: repeat(6, 1fr);
border-bottom: 1px solid #e3e9ef;
background: #f8fafc;
}
.course-summary > div {
min-width: 0;
padding: 10px 12px;
display: grid;
gap: 2px;
border-right: 1px solid #e3e9ef;
}
.course-summary > div:last-child { border-right: none; }
.course-summary b {
color: #344054;
font: 700 18px/1.1 "Arial Narrow", "Microsoft YaHei", sans-serif;
}
.course-summary span { color: var(--muted); font-size: 9px; }
.course-summary .present b { color: #247261; }
.course-summary .late b { color: #a66716; }
.course-summary .absent b { color: #a9433e; }
.session-list { display: grid; }
.session-row {
min-height: 68px;
padding: 9px 14px;
display: grid;
grid-template-columns: 46px minmax(160px, 1fr) minmax(120px, auto) auto;
align-items: center;
gap: 12px;
border-bottom: 1px solid #edf0f4;
}
.session-row:last-child { border-bottom: none; }
.session-date {
width: 42px;
height: 46px;
display: grid;
align-content: center;
justify-items: center;
color: #17395e;
border: 1px solid #cbd7e3;
background: #f5f8fb;
}
.session-date span { font-size: 8px; font-weight: 700; }
.session-date strong {
font: 700 19px/1 "Arial Narrow", sans-serif;
font-variant-numeric: tabular-nums;
}
.session-info { min-width: 0; display: grid; gap: 3px; }
.session-info strong {
overflow: hidden;
color: #344054;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-info span,
.review-comment {
overflow: hidden;
color: var(--muted);
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-result {
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
.review-comment { max-width: 150px; }
@media (max-width: 720px) {
.check-in-board > header { align-items: flex-start; flex-direction: column; }
.scan-ticket,
.location-list article {
grid-template-columns: 38px minmax(0, 1fr);
}
.ticket-time,
.location-clock,
.scan-ticket > .el-button,
.location-list article > .el-button {
grid-column: 1 / -1;
}
.scan-ticket > .el-button,
.location-list article > .el-button { width: 100%; }
.ticket-time { grid-template-columns: 16px minmax(0, 1fr); }
.record-head { align-items: flex-start; flex-direction: column; gap: 5px; }
.record-head > span { text-align: left; }
.course-record > header { grid-template-columns: minmax(0, 1fr) 118px; }
.course-identity { padding: 15px 13px; }
.course-identity h3 { font-size: 17px; }
.course-rate { padding: 12px; }
.course-rate strong { font-size: 22px; }
.course-summary { grid-template-columns: repeat(3, 1fr); }
.course-summary > div:nth-child(3) { border-right: none; }
.course-summary > div:nth-child(-n + 3) { border-bottom: 1px solid #e3e9ef; }
.session-row {
grid-template-columns: 42px minmax(0, 1fr) auto;
gap: 9px;
}
.session-result {
grid-column: 2 / -1;
justify-content: flex-start;
}
.session-row > .el-button {
grid-column: 2 / -1;
width: 100%;
}
}
</style>
+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; }
}