Files
Academic-Affairs-System/web/src/views/StudentAttendanceView.vue
T
biss 3b73292d93 已完成,学生端考勤记录现在按课程展示。
每门课程会独立显示:
课程名称、代码、教学班和任课教师。
课程总体出勤率。
出勤、迟到、缺勤、请假、免修次数。
按日期排列的历次点名记录。
缺勤或迟到记录仍可单独申诉。
2026-07-26 10:49:06 +08:00

663 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
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('')
const statusLabels: Record<string, string> = {
Present: '出勤', Absent: '缺勤', Late: '迟到', Leave: '请假', Excused: '免修',
}
const statusColors: Record<string, 'success' | 'danger' | 'warning' | 'info' | undefined> = {
Present: 'success', Absent: 'danger', Late: 'warning', Leave: 'info', Excused: undefined,
}
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 {
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 = ''
appealDialog.value = true
}
async function submitAppeal() {
if (!appealReason.value.trim()) { ElMessage.warning('请填写申诉原因'); return }
try {
await http.post('/attendance/records/appeal', {
attendanceSheetId: appealTarget.value.attendanceSheetId,
reason: appealReason.value,
})
appealDialog.value = false
ElMessage.success('申诉已提交')
await load()
} catch (e) { ElMessage.error(apiErrorMessage(e)) }
}
function canAppeal(record: any) {
return record.appealStatus === 'None' || record.appealStatus === 'Rejected'
}
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>
<div class="page-stack att-page">
<section class="page-intro">
<div>
<span class="section-kicker">ATTENDANCE RECORD</span>
<h2>我的考勤</h2>
<p>完成课堂扫码或定位签到并查看已提交的考勤记录</p>
</div>
<el-button :icon="Refresh" @click="load">刷新</el-button>
</section>
<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>
<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="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
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="!courseGroups.length" description="还没有已提交的课程考勤记录" />
</section>
<el-dialog v-model="appealDialog" title="考勤申诉" width="500px">
<el-form label-position="top">
<el-form-item label="申诉原因" required>
<el-input v-model="appealReason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请说明您对该考勤记录的异议原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="appealDialog = false">取消</el-button>
<el-button type="primary" @click="submitAppeal">提交申诉</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.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>