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