Files
Academic-Affairs-System/web/src/views/TeacherAttendanceView.vue
T
2026-07-28 15:41:28 +08:00

1733 lines
57 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 { Capacitor } from '@capacitor/core'
import { Geolocation } from '@capacitor/geolocation'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
Check,
Clock,
Download,
Grid,
Location,
Plus,
Refresh,
Search,
Upload,
Warning,
} 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'
import { CanvasRenderer } from 'echarts/renderers'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel'
import { academicTermLabel, academicTermOptionClass, defaultAcademicTermId } from '../utils/academicTerms'
echarts.use([
LineChart,
PieChart,
GridComponent,
LegendComponent,
TooltipComponent,
CanvasRenderer,
])
const loading = ref(false)
const detailLoading = ref(false)
const statisticsLoading = ref(false)
const terms = ref<any[]>([])
const tasks = ref<any[]>([])
const sheets = ref<any[]>([])
const selectedTask = ref<any>(null)
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 qrChallengeExpiresAt = ref(0)
const isNativeApp = Capacitor.isNativePlatform()
const now = ref(Date.now())
const fileInput = ref<HTMLInputElement>()
const termId = ref<string>()
const activeMode = ref<'rollcall' | 'statistics'>('rollcall')
const studentKeyword = ref('')
const classFilter = ref('')
const attentionFilter = ref('')
const statusChartEl = ref<HTMLElement>()
const trendChartEl = ref<HTMLElement>()
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
let qrRefreshTimer: number | undefined
const statusOptions = [
{ value: 'Present', label: '出勤' },
{ value: 'Absent', label: '缺勤' },
{ value: 'Late', label: '迟到' },
{ value: 'Leave', label: '请假' },
{ value: 'Excused', label: '免修' },
]
const numericStatusNames: Record<number, string> = {
1: 'Present', 2: 'Absent', 3: 'Late', 4: 'Leave', 5: 'Excused',
}
const statusLabels: Record<string, string> = Object.fromEntries(
statusOptions.map(item => [item.value, item.label]),
)
const classOptions = computed(() => {
const names = (statistics.value?.students ?? []).map((item: any) => item.className)
return [...new Set<string>(names)].sort((a, b) => a.localeCompare(b, 'zh-CN'))
})
const filteredStudents = computed(() => {
const keyword = studentKeyword.value.trim().toLocaleLowerCase()
return (statistics.value?.students ?? []).filter((student: any) => {
if (keyword) {
const text = `${student.studentNumber} ${student.studentName}`.toLocaleLowerCase()
if (!text.includes(keyword)) return false
}
if (classFilter.value && student.className !== classFilter.value) return false
if (attentionFilter.value === 'abnormal' &&
student.absentCount === 0 && student.lateCount === 0) return false
if (attentionFilter.value === 'below90' &&
(student.attendanceRate === null || student.attendanceRate >= 90)) return false
return true
})
})
async function loadTasks() {
loading.value = true
try {
tasks.value = (await http.get('/attendance/my-tasks', {
params: { academicTermId: termId.value || undefined },
})).data
const preferred = tasks.value.find((item: any) => item.id === selectedTask.value?.id)
?? tasks.value[0]
if (preferred) await selectTask(preferred)
else {
selectedTask.value = null
sheets.value = []
statistics.value = null
}
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function selectTask(task: any) {
selectedTask.value = task
selectedSheet.value = null
sheetDetail.value = null
statistics.value = null
studentKeyword.value = ''
classFilter.value = ''
attentionFilter.value = ''
try {
sheets.value = (await http.get('/attendance/sheets', {
params: { teachingTaskId: task.id },
})).data
if (activeMode.value === 'statistics') await loadStatistics()
} catch (error) {
sheets.value = []
ElMessage.error(apiErrorMessage(error))
}
}
async function selectSheet(sheet: any) {
selectedSheet.value = sheet
detailLoading.value = true
try {
sheetDetail.value = (await http.get(`/attendance/sheets/${sheet.id}`)).data
} catch (error) {
sheetDetail.value = null
ElMessage.error(apiErrorMessage(error))
} finally {
detailLoading.value = false
}
}
async function loadStatistics() {
if (!selectedTask.value) return
statisticsLoading.value = true
try {
statistics.value = (
await http.get(`/attendance/tasks/${selectedTask.value.id}/statistics`)
).data
await nextTick()
renderStatisticsCharts()
} catch (error) {
statistics.value = null
ElMessage.error(apiErrorMessage(error))
} finally {
statisticsLoading.value = false
}
}
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 (isNativeApp) {
teacherLocationLoading.value = true
try {
let permission = await Geolocation.checkPermissions()
if (permission.location !== 'granted') {
permission = await Geolocation.requestPermissions({ permissions: ['location'] })
}
if (permission.location !== 'granted') {
throw Object.assign(new Error('permission denied'), { code: 1 })
}
const position = await Geolocation.getCurrentPosition({
enableHighAccuracy: true,
timeout: 12000,
maximumAge: 0,
})
applyTeacherLocation(position)
return true
} catch (error: any) {
const message = error?.code === 1
? '定位权限被拒绝,请在系统设置中允许本应用获取精确位置。'
: '暂时无法获取位置,请检查手机定位服务后重试。'
ElMessage.error(message)
return false
} finally {
teacherLocationLoading.value = false
}
}
if (!navigator.geolocation) {
ElMessage.error('当前电脑没有可用定位,请改用教师手机 App 发起定位签到。')
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,
})
})
applyTeacherLocation(position)
return true
} catch (error: any) {
const message = error?.code === 1
? '定位权限被拒绝,请在浏览器地址栏中允许本网站使用位置信息。'
: error?.code === 3
? '获取位置超时,请移到信号较好的位置后重试。'
: '暂时无法获取位置,请检查系统定位服务。'
ElMessage.error(message)
return false
} finally {
teacherLocationLoading.value = false
}
}
function applyTeacherLocation(position: {
coords: { latitude: number; longitude: number; accuracy: number }
}) {
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('已获取教师手机当前位置')
}
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 {
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(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 refreshQrCode(showError: boolean) {
const sheet = sheetDetail.value?.sheet
if (!sheet || !isCheckInOpen(sheet)) return false
try {
const { data } = await http.get(`/attendance/sheets/${sheet.id}/qr-challenge`)
const publicBase = import.meta.env.VITE_PUBLIC_BASE_URL ?? window.location.origin
qrCheckInUrl.value =
`${publicBase}/my-attendance?token=${encodeURIComponent(data.token)}`
qrDataUrl.value = await QRCode.toDataURL(qrCheckInUrl.value, {
width: 360,
margin: 2,
errorCorrectionLevel: 'M',
color: { dark: '#172b4d', light: '#ffffff' },
})
qrChallengeExpiresAt.value = serverUtcTime(data.expiresAt)
return true
} catch (error) {
if (showError) ElMessage.error(apiErrorMessage(error))
return false
}
}
async function showQrCode() {
stopQrRefresh()
if (!await refreshQrCode(true)) return
qrDialog.value = true
qrRefreshTimer = window.setInterval(async () => {
if (!qrDialog.value || !isCheckInOpen(sheetDetail.value?.sheet)) return
await refreshQrCode(false)
}, 10000)
}
function stopQrRefresh() {
if (qrRefreshTimer) {
window.clearInterval(qrRefreshTimer)
qrRefreshTimer = undefined
}
}
function qrChallengeRemainingLabel() {
const seconds = Math.max(0, Math.ceil((qrChallengeExpiresAt.value - now.value) / 1000))
return `${seconds} 秒内有效`
}
const riskLabels: Record<string, string> = {
SharedDevice: '多人共用设备',
HighFrequency: '高频尝试',
RepeatedFailures: '多次失败',
MissingDeviceId: '缺少设备标识',
}
function devicePlatformLabel(value: string | null | undefined) {
if (value === 'android') return 'Android App'
if (value === 'ios') return 'iOS App'
if (value === 'web') return '浏览器'
return value || '未知设备'
}
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
stopQrRefresh()
await selectTask(selectedTask.value)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
async function saveRecords() {
if (!sheetDetail.value) return
try {
await http.put(`/attendance/sheets/${sheetDetail.value.sheet.id}/records`, {
records: sheetDetail.value.sheet.records.map((record: any) => ({
studentId: record.studentId,
status: record.status,
notes: record.notes,
})),
})
ElMessage.success('考勤记录已保存')
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function submitSheet() {
try {
await http.post(`/attendance/sheets/${sheetDetail.value.sheet.id}/submit`)
ElMessage.success('考勤表已提交')
await selectTask(selectedTask.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function removeSheet(sheet: any) {
try {
await ElMessageBox.confirm(`确定删除考勤表“${sheet.name}”吗?`, '删除考勤表', {
type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消',
})
await http.delete(`/attendance/sheets/${sheet.id}`)
ElMessage.success('考勤表已删除')
await selectTask(selectedTask.value)
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(apiErrorMessage(error))
}
}
function chooseImportFile() {
fileInput.value?.click()
}
async function handleImport(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || !sheetDetail.value) return
try {
const { data } = await importExcel(
`/attendance/sheets/${sheetDetail.value.sheet.id}/import`,
file,
)
ElMessage.success(`已从 Excel 更新 ${data.updated} 条考勤记录`)
await selectSheet(selectedSheet.value)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function exportSheet() {
if (!selectedSheet.value) return
try {
await downloadApiFile(
`/attendance/sheets/${selectedSheet.value.id}/export.xlsx`,
`考勤表-${selectedSheet.value.name}.xlsx`,
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function exportStatistics() {
if (!selectedTask.value) return
try {
await downloadApiFile(
`/attendance/tasks/${selectedTask.value.id}/statistics.xlsx`,
`课程考勤统计-${selectedTask.value.taskNumber}.xlsx`,
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function normalizeStatus(status: string | number) {
return typeof status === 'number' ? numericStatusNames[status] : status
}
function statusLabel(status: string | number) {
return statusLabels[normalizeStatus(status)] ?? '未知'
}
function isSubmitted(status: string | number) {
return normalizeStatus(status) === 'Submitted' || status === 2
}
function isDraft(status: string | number) {
return normalizeStatus(status) === 'Draft' || status === 1
}
function batchStatus(status: string) {
if (!sheetDetail.value?.sheet?.records) return
sheetDetail.value.sheet.records.forEach((record: any) => {
record.status = status
})
}
function countStatus(status: string) {
return sheetDetail.value?.sheet?.records
?.filter((record: any) => normalizeStatus(record.status) === status).length ?? 0
}
function distributionClass(status: string | number) {
return `status-${normalizeStatus(status).toLocaleLowerCase()}`
}
function formatRate(value: number | null | undefined) {
return value === null || value === undefined ? '暂无' : `${value.toFixed(1)}%`
}
function rateClass(value: number | null) {
if (value === null) return 'no-rate'
if (value < 80) return 'low-rate'
if (value < 90) return 'mid-rate'
return 'good-rate'
}
function statusShare(count: number) {
const total = statistics.value?.summary?.recordCount ?? 0
return total > 0 ? `${count * 100 / total}%` : '0%'
}
function formatChartDate(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
day: 'numeric',
}).format(new Date(value))
}
function disposeCharts() {
chartInstances.splice(0).forEach(chart => chart.dispose())
}
function renderStatisticsCharts() {
disposeCharts()
if (!statistics.value || statistics.value.summary.submittedSheetCount === 0) return
const palette = ['#2d8975', '#b34e48', '#c78724', '#79579a', '#718096']
if (statusChartEl.value) {
const chart = echarts.init(statusChartEl.value)
chart.setOption({
color: palette,
tooltip: {
trigger: 'item',
formatter: '{b}<br/>{c} 条 · {d}%',
},
legend: {
bottom: 0,
itemWidth: 10,
itemHeight: 10,
textStyle: { color: '#5f6b7a', fontSize: 11 },
},
series: [{
name: '考勤状态',
type: 'pie',
radius: ['54%', '74%'],
center: ['50%', '44%'],
avoidLabelOverlap: true,
label: {
formatter: '{b}\n{d}%',
color: '#344054',
fontSize: 11,
lineHeight: 16,
},
labelLine: { length: 10, length2: 8 },
data: statistics.value.statusDistribution.map((item: any) => ({
name: item.label,
value: item.count,
})),
}],
})
chartInstances.push(chart)
}
if (trendChartEl.value) {
const chart = echarts.init(trendChartEl.value)
chart.setOption({
color: ['#176b87'],
tooltip: {
trigger: 'axis',
valueFormatter: (value: any) => value === null ? '暂无' : `${value}%`,
},
grid: { left: 42, right: 18, top: 18, bottom: 48 },
xAxis: {
type: 'category',
boundaryGap: false,
data: statistics.value.sessions.map((item: any) => formatChartDate(item.attendanceDate)),
axisLine: { lineStyle: { color: '#d8dee7' } },
axisLabel: { color: '#667085', fontSize: 10 },
},
yAxis: {
type: 'value',
min: 0,
max: 100,
interval: 25,
axisLabel: { formatter: '{value}%', color: '#667085', fontSize: 10 },
splitLine: { lineStyle: { color: '#edf0f4' } },
},
series: [{
name: '出勤率',
type: 'line',
smooth: 0.25,
symbolSize: 7,
data: statistics.value.sessions.map((item: any) => item.attendanceRate),
lineStyle: { width: 3 },
areaStyle: { color: 'rgba(23, 107, 135, 0.10)' },
}],
})
chartInstances.push(chart)
}
}
function resizeCharts() {
chartInstances.forEach(chart => chart.resize())
}
watch(activeMode, async mode => {
if (mode === 'statistics' && selectedTask.value) {
await loadStatistics()
} else {
disposeCharts()
}
})
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 = defaultAcademicTermId(terms.value)
await loadTasks()
})
onUnmounted(() => {
window.removeEventListener('resize', resizeCharts)
if (clockTimer) window.clearInterval(clockTimer)
stopQrRefresh()
disposeCharts()
})
</script>
<template>
<div class="page-stack attendance-page">
<section class="page-intro">
<div>
<span class="section-kicker">ATTENDANCE</span>
<h2>教学点名</h2>
<p>完成课堂点名并持续查看本课程每位学生的出勤表现</p>
</div>
<el-button :icon="Refresh" @click="loadTasks">刷新</el-button>
</section>
<section class="attendance-toolbar">
<el-select v-model="termId" clearable placeholder="全部学期" @change="loadTasks">
<el-option v-for="term in terms" :key="term.id" :label="academicTermLabel(term)" :value="term.id" :class="academicTermOptionClass(term)" />
</el-select>
<span> {{ tasks.length }} 个教学班</span>
<el-radio-group v-model="activeMode" class="mode-switch" size="small">
<el-radio-button value="rollcall">点名记录</el-radio-button>
<el-radio-button value="statistics">课程统计</el-radio-button>
</el-radio-group>
</section>
<section
class="attendance-workspace"
:class="{ 'statistics-mode': activeMode === 'statistics' }"
v-loading="loading"
>
<aside class="attendance-task-list">
<button
v-for="task in tasks"
:key="task.id"
type="button"
:class="{ active: selectedTask?.id === task.id }"
@click="selectTask(task)"
>
<span>{{ task.courseCode }} · {{ task.taskNumber }}</span>
<b>{{ task.courseName }}</b>
<small>{{ task.termName }} · {{ task.studentCount }} 名学生 · {{ task.sheetCount }} 张考勤表</small>
</button>
<el-empty v-if="!tasks.length" description="没有可点名的教学班" />
</aside>
<template v-if="activeMode === 'rollcall'">
<main class="attendance-detail">
<template v-if="!selectedTask">
<el-empty description="请选择一个教学班" />
</template>
<template v-else>
<header class="attendance-subhead">
<div>
<strong>{{ selectedTask.courseName }}</strong>
<span>{{ selectedTask.taskNumber }} · {{ selectedTask.studentCount }} 名学生</span>
</div>
<el-button type="primary" :icon="Plus" @click="openCreate">新建考勤表</el-button>
</header>
<div class="attendance-sheet-list">
<button
v-for="sheet in sheets"
:key="sheet.id"
type="button"
:class="{ active: selectedSheet?.id === sheet.id }"
@click="selectSheet(sheet)"
>
<div>
<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>
<span class="leave" v-if="sheet.leaveCount">请假 {{ sheet.leaveCount }}</span>
</div>
<i>{{ isSubmitted(sheet.status) ? '已提交' : '草稿' }}</i>
<el-button
v-if="isDraft(sheet.status)"
link
type="danger"
size="small"
@click.stop="removeSheet(sheet)"
>删除</el-button>
</button>
<el-empty v-if="!sheets.length" description="尚未创建考勤表,点击右上方按钮新建" />
</div>
</template>
</main>
<aside v-if="sheetDetail" class="attendance-panel" v-loading="detailLoading">
<header>
<div>
<strong>{{ sheetDetail.sheet.name }}</strong>
<span>{{ sheetDetail.sheet.courseCode }} · {{ sheetDetail.sheet.taskNumber }}</span>
<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"
:icon="Upload"
@click="chooseImportFile"
>导入 Excel</el-button>
<input
ref="fileInput"
class="visually-hidden"
type="file"
accept=".xlsx"
@change="handleImport"
/>
<el-button size="small" :icon="Download" @click="exportSheet">导出 Excel</el-button>
<el-button
v-if="sheetDetail.canEdit"
type="primary"
size="small"
:icon="Check"
@click="saveRecords"
>保存</el-button>
<el-button
v-if="sheetDetail.canEdit"
type="success"
size="small"
@click="submitSheet"
>提交</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>
<p
v-if="sheetDetail.sheet.riskSummary?.riskStudentCount"
class="risk-summary"
>
<Warning />
{{ sheetDetail.sheet.riskSummary.riskStudentCount }} 人存在签到风险信号
其中共用设备 {{ sheetDetail.sheet.riskSummary.sharedDeviceStudentCount }}
</p>
</div>
<div v-if="sheetDetail.canEdit" class="batch-row">
<span>批量设置</span>
<el-button size="small" @click="batchStatus('Present')">全部出勤</el-button>
<el-button size="small" @click="batchStatus('Absent')">全部缺勤</el-button>
<el-button size="small" @click="batchStatus('Late')">全部迟到</el-button>
<small>出勤 {{ countStatus('Present') }} · 缺勤 {{ countStatus('Absent') }} · 迟到 {{ countStatus('Late') }} · 请假 {{ countStatus('Leave') }} · 免修 {{ countStatus('Excused') }}</small>
</div>
<el-table :data="sheetDetail.sheet.records" class="data-table" size="small">
<el-table-column label="学号" width="120">
<template #default="{ row }"><span class="registry-number">{{ row.studentNumber }}</span></template>
</el-table-column>
<el-table-column label="姓名" min-width="110">
<template #default="{ row }">
<span>{{ row.name }}</span>
<el-tag v-if="row.isExempt" size="small" type="success" class="student-flag">免修</el-tag>
<el-tag v-if="row.isDeferred" size="small" type="warning" class="student-flag">缓考</el-tag>
</template>
</el-table-column>
<el-table-column label="考勤" width="110">
<template #default="{ row }">
<el-select v-if="sheetDetail.canEdit" v-model="row.status" size="small">
<el-option
v-for="option in statusOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<span v-else>{{ statusLabel(row.status) }}</span>
</template>
</el-table-column>
<el-table-column
v-if="isOnlineMethod(sheetDetail.sheet.checkInMethod)"
label="自主签到"
min-width="250"
>
<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>
<small v-if="row.checkInAudit">
{{ devicePlatformLabel(row.checkInAudit.devicePlatform) }}
· 设备 {{ row.checkInAudit.deviceCode || '未识别' }}
</small>
<small v-if="row.checkInAudit?.ipAddress">
IP {{ row.checkInAudit.ipAddress }}
<template v-if="row.checkInAudit.sameIpStudentCount > 1">
· 本次同 IP {{ row.checkInAudit.sameIpStudentCount }}
</template>
</small>
<div v-if="row.riskFlags?.length" class="check-in-risks">
<el-tag
v-for="flag in row.riskFlags"
:key="flag"
size="small"
type="danger"
>{{ riskLabels[flag] || flag }}</el-tag>
</div>
<small v-if="row.failedAttemptCount">
共尝试 {{ row.attemptCount }} 失败 {{ row.failedAttemptCount }}
</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
v-if="sheetDetail.canEdit"
v-model="row.notes"
size="small"
maxlength="300"
/>
<span v-else>{{ row.notes || '—' }}</span>
</template>
</el-table-column>
</el-table>
</aside>
</template>
<main
v-else
class="attendance-statistics"
v-loading="statisticsLoading"
>
<el-empty v-if="!selectedTask" description="请选择一个教学班查看统计" />
<template v-else-if="statistics">
<header class="statistics-head">
<div>
<span>课程出勤档案</span>
<strong>{{ statistics.course.courseName }}</strong>
<small>{{ statistics.course.courseCode }} · {{ statistics.course.taskNumber }} · {{ statistics.course.termName }}</small>
</div>
<el-button :icon="Download" type="primary" plain @click="exportStatistics">
导出统计 Excel
</el-button>
</header>
<section class="summary-ledger">
<div class="rate-signal">
<span>课程总体出勤率</span>
<strong>{{ formatRate(statistics.summary.overallAttendanceRate) }}</strong>
<small>出勤与迟到计为到课</small>
</div>
<div class="summary-facts">
<div><b>{{ statistics.summary.studentCount }}</b><span>统计学生</span></div>
<div><b>{{ statistics.summary.submittedSheetCount }}</b><span>已提交点名</span></div>
<div><b>{{ statistics.summary.perfectAttendanceCount }}</b><span>全勤学生</span></div>
<div><b>{{ statistics.summary.abnormalStudentCount }}</b><span>有缺勤或迟到</span></div>
</div>
<div class="status-rail" aria-label="考勤状态占比">
<span
v-for="item in statistics.statusDistribution"
:key="item.status"
:class="distributionClass(item.status)"
:style="{ width: statusShare(item.count) }"
:title="`${item.label} ${item.count} 条`"
/>
</div>
<p>仅统计已提交点名免修不计入应到次数请假计入应到但不计到课</p>
</section>
<template v-if="statistics.summary.submittedSheetCount > 0">
<section class="statistics-charts">
<article>
<header>
<strong>考勤状态构成</strong>
<span>累计 {{ statistics.summary.recordCount }} 条记录</span>
</header>
<div ref="statusChartEl" class="statistics-chart" />
</article>
<article>
<header>
<strong>历次点名出勤率</strong>
<span>按点名日期顺序展示</span>
</header>
<div ref="trendChartEl" class="statistics-chart" />
</article>
</section>
</template>
<div v-else class="statistics-empty">
<strong>还没有可统计的数据</strong>
<span>提交第一张考勤表后这里会生成状态分布和出勤率趋势</span>
</div>
<section class="student-statistics">
<header>
<div>
<strong>学生出勤明细</strong>
<span>当前显示 {{ filteredStudents.length }} / {{ statistics.students.length }} 默认从低出勤率开始</span>
</div>
<div class="student-filters">
<el-input
v-model="studentKeyword"
:prefix-icon="Search"
clearable
placeholder="搜索学号或姓名"
/>
<el-select v-model="classFilter" clearable placeholder="全部行政班">
<el-option v-for="name in classOptions" :key="name" :label="name" :value="name" />
</el-select>
<el-select v-model="attentionFilter" clearable placeholder="全部学生">
<el-option label="有缺勤或迟到" value="abnormal" />
<el-option label="出勤率低于 90%" value="below90" />
</el-select>
</div>
</header>
<el-table :data="filteredStudents" size="small" max-height="520">
<el-table-column prop="studentNumber" label="学号" width="128" fixed>
<template #default="{ row }">
<span class="registry-number">{{ row.studentNumber }}</span>
</template>
</el-table-column>
<el-table-column prop="studentName" label="姓名" width="105" fixed />
<el-table-column prop="className" label="行政班" min-width="145" show-overflow-tooltip />
<el-table-column label="出勤率" min-width="180" sortable :sort-method="(a: any, b: any) => (a.attendanceRate ?? 101) - (b.attendanceRate ?? 101)">
<template #default="{ row }">
<div class="student-rate" :class="rateClass(row.attendanceRate)">
<div><span :style="{ width: `${row.attendanceRate ?? 0}%` }" /></div>
<b>{{ formatRate(row.attendanceRate) }}</b>
</div>
</template>
</el-table-column>
<el-table-column prop="presentCount" label="出勤" width="72" sortable />
<el-table-column prop="absentCount" label="缺勤" width="76" sortable>
<template #default="{ row }">
<el-tag v-if="row.absentCount" size="small" type="danger">{{ row.absentCount }}</el-tag>
<span v-else>0</span>
</template>
</el-table-column>
<el-table-column prop="lateCount" label="迟到" width="76" sortable>
<template #default="{ row }">
<el-tag v-if="row.lateCount" size="small" type="warning">{{ row.lateCount }}</el-tag>
<span v-else>0</span>
</template>
</el-table-column>
<el-table-column prop="leaveCount" label="请假" width="72" sortable />
<el-table-column prop="excusedCount" label="免修" width="72" sortable />
<el-table-column prop="requiredCount" label="应到" width="72" sortable />
</el-table>
</section>
</template>
</main>
</section>
<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" />
</el-form-item>
<el-form-item label="考勤日期" required>
<el-date-picker
v-model="createForm.attendanceDate"
value-format="YYYY-MM-DD"
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>
{{ isNativeApp
? '创建前需要允许 App 获取精确位置'
: '电脑无定位时,请改用教师手机 App 发起' }}
</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"
: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
@closed="stopQrRefresh"
>
<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)
? `动态二维码 · ${qrChallengeRemainingLabel()}`
: '本次签到已结束' }}
</div>
<p>二维码每 10 秒自动刷新,过期截图和旧链接不能签到</p>
</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>
</template>
<style scoped>
.attendance-toolbar {
min-height: 56px;
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
border: 1px solid var(--line);
background: #fbfcfd;
}
.attendance-toolbar .el-select { width: 260px; }
.attendance-toolbar > span { color: var(--muted); font-size: 12px; }
.mode-switch { margin-left: auto; }
.attendance-workspace {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 420px;
min-height: 500px;
border: 1px solid var(--line);
background: white;
}
.attendance-workspace.statistics-mode {
grid-template-columns: 260px minmax(0, 1fr);
}
.attendance-task-list {
border-right: 1px solid var(--line);
overflow-y: auto;
max-height: 720px;
}
.attendance-task-list button {
display: grid;
gap: 3px;
width: 100%;
padding: 12px 14px;
border: none;
border-bottom: 1px solid #edf0f4;
background: none;
cursor: pointer;
text-align: left;
transition: background .15s;
}
.attendance-task-list button:hover { background: #f5f7fa; }
.attendance-task-list button.active {
padding-left: 11px;
background: #e9f3f5;
border-left: 3px solid #176b87;
}
.attendance-task-list button > span {
color: var(--teal);
font: 700 10px/1.2 Consolas, monospace;
}
.attendance-task-list button > b { font-size: 13px; }
.attendance-task-list button > small { color: var(--muted); font-size: 10px; }
.attendance-detail {
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid var(--line);
}
.attendance-subhead {
padding: 14px 18px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--line);
background: #f8fafb;
}
.attendance-subhead strong { font-size: 15px; }
.attendance-subhead span {
color: var(--muted);
font-size: 11px;
display: block;
}
.attendance-sheet-list { flex: 1; overflow-y: auto; }
.attendance-sheet-list > button {
width: 100%;
padding: 12px 16px;
border: none;
border-bottom: 1px solid #edf0f4;
background: none;
cursor: pointer;
text-align: left;
display: grid;
gap: 6px;
transition: background .15s;
}
.attendance-sheet-list > button:hover { background: #f5f7fa; }
.attendance-sheet-list > button.active {
padding-left: 13px;
background: #eef5f0;
border-left: 3px solid #2d8975;
}
.attendance-sheet-list > button > div {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.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;
font-weight: 700;
font-style: normal;
}
.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; }
.attendance-panel { overflow-y: auto; max-height: 720px; }
.attendance-panel > header {
padding: 14px 16px;
border-bottom: 1px solid var(--line);
background: #f8fafb;
}
.attendance-panel > header strong { font-size: 15px; display: block; }
.attendance-panel > header span { color: var(--muted); font-size: 10px; }
.attendance-panel > header small {
color: var(--indigo);
font-size: 10px;
display: block;
}
.panel-actions {
display: flex;
gap: 6px;
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;
align-items: center;
gap: 6px;
border-bottom: 1px solid #edf0f4;
background: #fff;
}
.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; }
.check-in-risks {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 3px;
}
.check-in-risks :deep(.el-tag) { height: 19px; font-size: 9px; }
.risk-summary { color: #ffdbb0 !important; font-weight: 700; }
.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;
background: #f7f9fb;
}
.statistics-head {
min-height: 76px;
padding: 14px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--line);
background: white;
}
.statistics-head > div { display: grid; gap: 2px; }
.statistics-head span {
color: #176b87;
font-size: 10px;
font-weight: 800;
letter-spacing: .12em;
}
.statistics-head strong { color: #172b4d; font-size: 18px; }
.statistics-head small { color: var(--muted); font-size: 11px; }
.summary-ledger {
display: grid;
grid-template-columns: minmax(210px, .65fr) minmax(420px, 1.35fr);
border-bottom: 1px solid var(--line);
background: white;
}
.rate-signal {
padding: 22px 24px;
display: grid;
align-content: center;
border-right: 1px solid var(--line);
background: #17395e;
color: white;
}
.rate-signal span {
font-size: 11px;
font-weight: 700;
letter-spacing: .08em;
opacity: .78;
}
.rate-signal strong {
margin: 5px 0 1px;
font: 700 42px/1.05 "Arial Narrow", "Microsoft YaHei", sans-serif;
letter-spacing: -.04em;
}
.rate-signal small { font-size: 10px; opacity: .68; }
.summary-facts {
display: grid;
grid-template-columns: repeat(4, 1fr);
min-width: 0;
}
.summary-facts > div {
padding: 22px 15px 18px;
display: grid;
align-content: center;
gap: 4px;
border-right: 1px solid #edf0f4;
}
.summary-facts > div:last-child { border-right: none; }
.summary-facts b {
color: #172b4d;
font: 700 24px/1 "Arial Narrow", "Microsoft YaHei", sans-serif;
}
.summary-facts span { color: var(--muted); font-size: 10px; }
.status-rail {
grid-column: 2;
height: 6px;
display: flex;
overflow: hidden;
background: #edf0f4;
}
.status-rail span { min-width: 0; transition: width .25s ease; }
.status-present { background: #2d8975; }
.status-absent { background: #b34e48; }
.status-late { background: #c78724; }
.status-leave { background: #79579a; }
.status-excused { background: #718096; }
.summary-ledger > p {
grid-column: 1 / -1;
margin: 0;
padding: 8px 20px;
color: #667085;
font-size: 10px;
border-top: 1px solid #edf0f4;
}
.statistics-charts {
padding: 16px;
display: grid;
grid-template-columns: minmax(300px, .8fr) minmax(400px, 1.2fr);
gap: 14px;
}
.statistics-charts article {
min-width: 0;
border: 1px solid var(--line);
background: white;
}
.statistics-charts article > header {
padding: 12px 15px 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.statistics-charts article > header strong { color: #172b4d; font-size: 13px; }
.statistics-charts article > header span { color: var(--muted); font-size: 10px; }
.statistics-chart { width: 100%; height: 270px; }
.statistics-empty {
margin: 16px;
min-height: 132px;
display: grid;
place-content: center;
gap: 5px;
text-align: center;
border: 1px dashed #cbd3df;
background: white;
}
.statistics-empty strong { color: #344054; font-size: 14px; }
.statistics-empty span { color: var(--muted); font-size: 11px; }
.student-statistics {
margin: 0 16px 16px;
border: 1px solid var(--line);
background: white;
}
.student-statistics > header {
padding: 13px 15px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid var(--line);
}
.student-statistics > header > div:first-child { display: grid; gap: 2px; }
.student-statistics > header strong { color: #172b4d; font-size: 14px; }
.student-statistics > header span { color: var(--muted); font-size: 10px; }
.student-filters {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.student-filters .el-input { width: 190px; }
.student-filters .el-select { width: 150px; }
.student-rate {
display: grid;
grid-template-columns: minmax(70px, 1fr) 48px;
align-items: center;
gap: 8px;
}
.student-rate > div {
height: 5px;
overflow: hidden;
background: #edf0f4;
}
.student-rate > div > span {
display: block;
height: 100%;
background: #2d8975;
}
.student-rate b {
font-size: 11px;
font-variant-numeric: tabular-nums;
text-align: right;
}
.student-rate.low-rate b { color: #b34e48; }
.student-rate.low-rate > div > span { background: #b34e48; }
.student-rate.mid-rate b { color: #b06f17; }
.student-rate.mid-rate > div > span { background: #c78724; }
.student-rate.good-rate b { color: #247261; }
.student-rate.no-rate b { color: var(--muted); }
@media (prefers-reduced-motion: reduce) {
.attendance-task-list button,
.attendance-sheet-list > button,
.status-rail span {
transition: none;
}
}
@media (max-width: 1100px) {
.attendance-workspace,
.attendance-workspace.statistics-mode {
grid-template-columns: 1fr;
}
.attendance-task-list {
max-height: 200px;
border-right: none;
border-bottom: 1px solid var(--line);
}
.attendance-detail { border-right: none; }
.attendance-sheet-list { max-height: 240px; }
.attendance-panel { max-height: 440px; }
.summary-ledger { grid-template-columns: 220px minmax(0, 1fr); }
.statistics-charts { grid-template-columns: 1fr 1fr; }
.student-statistics > header { align-items: flex-start; flex-direction: column; }
.student-filters { width: 100%; justify-content: flex-start; }
}
@media (max-width: 720px) {
.attendance-toolbar { align-items: stretch; flex-wrap: wrap; }
.attendance-toolbar .el-select { width: 100%; }
.attendance-toolbar > span { align-self: center; }
.mode-switch { margin-left: auto; }
.statistics-head { align-items: flex-start; flex-direction: column; }
.summary-ledger { grid-template-columns: 1fr; }
.rate-signal { border-right: none; }
.summary-facts { grid-template-columns: repeat(2, 1fr); }
.summary-facts > div:nth-child(2) { border-right: none; }
.summary-facts > div:nth-child(-n + 2) { border-bottom: 1px solid #edf0f4; }
.status-rail { grid-column: 1; }
.statistics-charts { grid-template-columns: 1fr; padding: 12px; }
.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; }
}
</style>