update
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
<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,
|
||||
CopyDocument,
|
||||
Download,
|
||||
Grid,
|
||||
Location,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
Refresh,
|
||||
Search,
|
||||
Upload,
|
||||
Warning,
|
||||
} from '@element-plus/icons-vue'
|
||||
import * as QRCode from 'qrcode'
|
||||
import * as echarts from 'echarts/core'
|
||||
@@ -46,6 +48,8 @@ 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>()
|
||||
@@ -67,6 +71,7 @@ const createForm = ref({
|
||||
})
|
||||
const chartInstances: echarts.ECharts[] = []
|
||||
let clockTimer: number | undefined
|
||||
let qrRefreshTimer: number | undefined
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'Present', label: '出勤' },
|
||||
@@ -188,8 +193,35 @@ function openCreate() {
|
||||
}
|
||||
|
||||
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('当前浏览器不支持定位,请更换浏览器或使用扫码签到。')
|
||||
ElMessage.error('当前电脑没有可用定位,请改用教师手机 App 发起定位签到。')
|
||||
return false
|
||||
}
|
||||
teacherLocationLoading.value = true
|
||||
@@ -201,10 +233,7 @@ async function captureTeacherLocation() {
|
||||
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('已获取当前签到点')
|
||||
applyTeacherLocation(position)
|
||||
return true
|
||||
} catch (error: any) {
|
||||
const message = error?.code === 1
|
||||
@@ -219,6 +248,15 @@ async function captureTeacherLocation() {
|
||||
}
|
||||
}
|
||||
|
||||
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('请填写考勤表名称。')
|
||||
@@ -308,37 +346,64 @@ function remainingLabel(sheet: any) {
|
||||
return `${minutes}分${String(seconds).padStart(2, '0')}秒后结束`
|
||||
}
|
||||
|
||||
async function showQrCode() {
|
||||
async function refreshQrCode(showError: boolean) {
|
||||
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)}`
|
||||
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' },
|
||||
})
|
||||
qrDialog.value = true
|
||||
} catch {
|
||||
ElMessage.error('签到二维码生成失败,请刷新页面后重试。')
|
||||
qrChallengeExpiresAt.value = serverUtcTime(data.expiresAt)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (showError) ElMessage.error(apiErrorMessage(error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCheckInLink() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(qrCheckInUrl.value)
|
||||
ElMessage.success('签到链接已复制')
|
||||
} catch {
|
||||
ElMessage.error('无法自动复制,请手动选择签到链接。')
|
||||
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 {
|
||||
@@ -350,6 +415,7 @@ async function closeCheckIn() {
|
||||
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))
|
||||
@@ -601,6 +667,7 @@ onMounted(async () => {
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', resizeCharts)
|
||||
if (clockTimer) window.clearInterval(clockTimer)
|
||||
stopQrRefresh()
|
||||
disposeCharts()
|
||||
})
|
||||
</script>
|
||||
@@ -776,6 +843,14 @@ onUnmounted(() => {
|
||||
· 签到点 {{ 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>
|
||||
@@ -811,7 +886,7 @@ onUnmounted(() => {
|
||||
<el-table-column
|
||||
v-if="isOnlineMethod(sheetDetail.sheet.checkInMethod)"
|
||||
label="自主签到"
|
||||
min-width="138"
|
||||
min-width="250"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.checkInAt" class="record-check-in">
|
||||
@@ -820,6 +895,27 @@ onUnmounted(() => {
|
||||
<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>
|
||||
@@ -1025,7 +1121,11 @@ onUnmounted(() => {
|
||||
<span v-if="createForm.targetLatitude !== null">
|
||||
已定位,精度约 {{ createForm.locationAccuracyMeters }} 米
|
||||
</span>
|
||||
<span v-else>创建前需要允许浏览器获取位置</span>
|
||||
<span v-else>
|
||||
{{ isNativeApp
|
||||
? '创建前需要允许 App 获取精确位置'
|
||||
: '电脑无定位时,请改用教师手机 App 发起' }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<el-button
|
||||
@@ -1052,6 +1152,7 @@ onUnmounted(() => {
|
||||
title="课堂扫码签到"
|
||||
width="520px"
|
||||
align-center
|
||||
@closed="stopQrRefresh"
|
||||
>
|
||||
<div v-if="sheetDetail" class="qr-stage">
|
||||
<div class="qr-course">
|
||||
@@ -1062,14 +1163,11 @@ onUnmounted(() => {
|
||||
<img :src="qrDataUrl" alt="课堂签到二维码" />
|
||||
<div class="qr-status" :class="{ ended: !isCheckInOpen(sheetDetail.sheet) }">
|
||||
<span />
|
||||
{{ isCheckInOpen(sheetDetail.sheet) ? remainingLabel(sheetDetail.sheet) : '本次签到已结束' }}
|
||||
{{ isCheckInOpen(sheetDetail.sheet)
|
||||
? `动态二维码 · ${qrChallengeRemainingLabel()}`
|
||||
: '本次签到已结束' }}
|
||||
</div>
|
||||
<p>学生使用手机扫码,登录教务系统后完成签到</p>
|
||||
<el-input v-model="qrCheckInUrl" readonly>
|
||||
<template #append>
|
||||
<el-button :icon="CopyDocument" @click="copyCheckInLink">复制链接</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<p>二维码每 10 秒自动刷新,过期截图和旧链接不能签到</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button
|
||||
@@ -1291,6 +1389,14 @@ onUnmounted(() => {
|
||||
.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 {
|
||||
|
||||
Reference in New Issue
Block a user