1627 lines
53 KiB
Vue
1627 lines
53 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from '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'
|
||
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 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
|
||
|
||
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 (!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 {
|
||
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 showQrCode() {
|
||
const sheet = sheetDetail.value?.sheet
|
||
if (!sheet?.checkInToken) {
|
||
ElMessage.warning('未取得签到码,请刷新考勤表后重试。')
|
||
return
|
||
}
|
||
const publicBase = import.meta.env.VITE_PUBLIC_BASE_URL ?? window.location.origin
|
||
qrCheckInUrl.value =
|
||
`${publicBase}/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))
|
||
}
|
||
}
|
||
|
||
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)
|
||
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>
|
||
</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="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
|
||
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>创建前需要允许浏览器获取位置</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
|
||
>
|
||
<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>
|
||
</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; }
|
||
.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>
|