update
This commit is contained in:
Generated
+38
@@ -9,7 +9,9 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.4.2",
|
||||
"@capacitor/barcode-scanner": "^3.1.0",
|
||||
"@capacitor/core": "^8.4.2",
|
||||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@capacitor/ios": "^8.4.2",
|
||||
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
@@ -102,6 +104,18 @@
|
||||
"@capacitor/core": "^8.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/barcode-scanner": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/barcode-scanner/-/barcode-scanner-3.1.0.tgz",
|
||||
"integrity": "sha512-uE4njvsQGVfhjChg5ZU8ayYX9i2LM9Dg9/TWXG/L1s+pRVR5JG6PxsC3dCuKa9F6OVCYrKcEmdSi3E6KT+fMbg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"html5-qrcode": "2.3.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/cli": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-8.4.2.tgz",
|
||||
@@ -144,6 +158,18 @@
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/geolocation": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-8.2.0.tgz",
|
||||
"integrity": "sha512-N29QcoIPmme0xSxRkm7+3hjoHp6mBAOarxecvtCCZKyOBeKiJsFUq981cezg2XWBa6fhCXJMCCjQPngKK/dIag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@capacitor/synapse": "^1.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/ios": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.4.2.tgz",
|
||||
@@ -153,6 +179,12 @@
|
||||
"@capacitor/core": "^8.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/synapse": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
|
||||
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@ckeditor/ckeditor5-adapter-ckfinder": {
|
||||
"version": "48.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@ckeditor/ckeditor5-adapter-ckfinder/-/ckeditor5-adapter-ckfinder-48.3.1.tgz",
|
||||
@@ -3405,6 +3437,12 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html5-qrcode": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
|
||||
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
|
||||
+4
-1
@@ -8,7 +8,8 @@
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"build:capacitor": "vue-tsc -b && vite build --mode capacitor",
|
||||
"preview": "vite preview",
|
||||
"cap:sync": "npx cap sync",
|
||||
"cap:configure": "node scripts/configure-capacitor.mjs",
|
||||
"cap:sync": "npx cap sync && npm run cap:configure",
|
||||
"cap:open:android": "npx cap open android",
|
||||
"cap:open:ios": "npx cap open ios",
|
||||
"cap:run:android": "npx cap run android",
|
||||
@@ -16,7 +17,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^8.4.2",
|
||||
"@capacitor/barcode-scanner": "^3.1.0",
|
||||
"@capacitor/core": "^8.4.2",
|
||||
"@capacitor/geolocation": "^8.2.0",
|
||||
"@capacitor/ios": "^8.4.2",
|
||||
"@ckeditor/ckeditor5-vue": "^8.2.0",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
function updateFile(path, update) {
|
||||
if (!existsSync(path)) return false
|
||||
const current = readFileSync(path, 'utf8')
|
||||
const next = update(current)
|
||||
if (next !== current) writeFileSync(path, next, 'utf8')
|
||||
return true
|
||||
}
|
||||
|
||||
function configureAndroid() {
|
||||
const variablesPath = resolve(webRoot, 'android', 'variables.gradle')
|
||||
const manifestPath = resolve(
|
||||
webRoot,
|
||||
'android',
|
||||
'app',
|
||||
'src',
|
||||
'main',
|
||||
'AndroidManifest.xml',
|
||||
)
|
||||
if (!existsSync(variablesPath) || !existsSync(manifestPath)) return false
|
||||
|
||||
updateFile(variablesPath, content => {
|
||||
if (!/minSdkVersion\s*=\s*\d+/.test(content)) {
|
||||
throw new Error('未在 android/variables.gradle 中找到 minSdkVersion。')
|
||||
}
|
||||
return content.replace(/minSdkVersion\s*=\s*\d+/, 'minSdkVersion = 26')
|
||||
})
|
||||
|
||||
updateFile(manifestPath, content => {
|
||||
const declarations = [
|
||||
'<uses-permission android:name="android.permission.CAMERA" />',
|
||||
'<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />',
|
||||
'<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />',
|
||||
'<uses-feature android:name="android.hardware.camera" android:required="false" />',
|
||||
'<uses-feature android:name="android.hardware.location.gps" android:required="false" />',
|
||||
]
|
||||
const missing = declarations.filter(declaration => !content.includes(declaration))
|
||||
if (!missing.length) return content
|
||||
return content.replace(
|
||||
'</manifest>',
|
||||
` ${missing.join('\n ')}\n</manifest>`,
|
||||
)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
function configureIos() {
|
||||
const infoPlistPath = resolve(webRoot, 'ios', 'App', 'App', 'Info.plist')
|
||||
if (!existsSync(infoPlistPath)) return false
|
||||
|
||||
updateFile(infoPlistPath, content => {
|
||||
const descriptions = [
|
||||
['NSCameraUsageDescription', '用于扫描教师展示的课堂签到二维码。'],
|
||||
['NSLocationWhenInUseUsageDescription', '用于在课堂签到时校验当前位置。'],
|
||||
['NSLocationAlwaysAndWhenInUseUsageDescription', '用于在课堂签到时校验当前位置。'],
|
||||
]
|
||||
const missing = descriptions.filter(([key]) => !content.includes(`<key>${key}</key>`))
|
||||
if (!missing.length) return content
|
||||
const entries = missing
|
||||
.map(([key, value]) => `\t<key>${key}</key>\n\t<string>${value}</string>`)
|
||||
.join('\n')
|
||||
return content.replace('</dict>', `${entries}\n</dict>`)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const configuredPlatforms = [
|
||||
configureAndroid() ? 'Android' : null,
|
||||
configureIos() ? 'iOS' : null,
|
||||
].filter(Boolean)
|
||||
|
||||
if (!configuredPlatforms.length) {
|
||||
throw new Error('尚未生成 Capacitor 原生工程,请先运行 npx cap add android 或 ios。')
|
||||
}
|
||||
|
||||
console.log(`已配置 ${configuredPlatforms.join('、')} 的扫码和定位权限。`)
|
||||
@@ -1,16 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import {
|
||||
CapacitorBarcodeScanner,
|
||||
CapacitorBarcodeScannerCameraDirection,
|
||||
CapacitorBarcodeScannerScanOrientation,
|
||||
CapacitorBarcodeScannerTypeHint,
|
||||
} from '@capacitor/barcode-scanner'
|
||||
import { Geolocation } from '@capacitor/geolocation'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Check, Clock, Location, Refresh, Warning } from '@element-plus/icons-vue'
|
||||
import { Camera, Check, Clock, Location, Refresh, Warning } from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isNativeApp = Capacitor.isNativePlatform()
|
||||
const attendanceDeviceId = getOrCreateAttendanceDeviceId()
|
||||
const attendanceDevicePlatform = Capacitor.getPlatform()
|
||||
const records = ref<any[]>([])
|
||||
const openActivities = ref<any[]>([])
|
||||
const scannedActivity = ref<any>(null)
|
||||
const loading = ref(false)
|
||||
const scanLoading = ref(false)
|
||||
const nativeScanLoading = ref(false)
|
||||
const checkInTargetId = ref('')
|
||||
const now = ref(Date.now())
|
||||
const appealDialog = ref(false)
|
||||
@@ -26,6 +38,24 @@ const statusColors: Record<string, 'success' | 'danger' | 'warning' | 'info' | u
|
||||
const appealStatusLabels: Record<string, string> = {
|
||||
None: '', Pending: '申诉中', Approved: '已通过', Rejected: '已驳回',
|
||||
}
|
||||
|
||||
function getOrCreateAttendanceDeviceId() {
|
||||
const storageKey = 'jiaowu_attendance_device_id'
|
||||
const existing = localStorage.getItem(storageKey)
|
||||
if (existing) return existing
|
||||
const created = typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now().toString(36)}-${crypto.getRandomValues(new Uint32Array(4)).join('-')}`
|
||||
localStorage.setItem(storageKey, created)
|
||||
return created
|
||||
}
|
||||
|
||||
function deviceAuditPayload() {
|
||||
return {
|
||||
deviceId: attendanceDeviceId,
|
||||
devicePlatform: attendanceDevicePlatform,
|
||||
}
|
||||
}
|
||||
const courseGroups = computed(() => {
|
||||
const groups = new Map<string, any>()
|
||||
records.value.forEach((record: any) => {
|
||||
@@ -104,6 +134,64 @@ async function loadScannedActivity(token: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function extractCheckInToken(value: string) {
|
||||
const scannedValue = value.trim()
|
||||
const isToken = (token: string) => /^[a-z\d._-]{20,64}$/i.test(token)
|
||||
if (isToken(scannedValue)) return scannedValue
|
||||
|
||||
try {
|
||||
const scannedUrl = new URL(scannedValue)
|
||||
const token = scannedUrl.searchParams.get('token')
|
||||
if (token && isToken(token)) return token
|
||||
|
||||
const hashQuery = scannedUrl.hash.includes('?')
|
||||
? scannedUrl.hash.slice(scannedUrl.hash.indexOf('?'))
|
||||
: ''
|
||||
const hashToken = new URLSearchParams(hashQuery).get('token')
|
||||
if (hashToken && isToken(hashToken)) return hashToken
|
||||
} catch {
|
||||
// 不是网址时继续按签到参数格式解析。
|
||||
}
|
||||
|
||||
const queryToken = /(?:[?&]token=)([^&#]+)/i.exec(scannedValue)?.[1]
|
||||
if (!queryToken) return ''
|
||||
try {
|
||||
const decodedToken = decodeURIComponent(queryToken)
|
||||
return isToken(decodedToken) ? decodedToken : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function scanCheckInCode() {
|
||||
nativeScanLoading.value = true
|
||||
try {
|
||||
const result = await CapacitorBarcodeScanner.scanBarcode({
|
||||
hint: CapacitorBarcodeScannerTypeHint.QR_CODE,
|
||||
scanInstructions: '请扫描教师展示的课堂签到二维码',
|
||||
cameraDirection: CapacitorBarcodeScannerCameraDirection.BACK,
|
||||
scanOrientation: CapacitorBarcodeScannerScanOrientation.ADAPTIVE,
|
||||
cancelButtonAccessibilityLabel: '取消扫码',
|
||||
torchButtonOnAccessibilityLabel: '关闭手电筒',
|
||||
torchButtonOffAccessibilityLabel: '打开手电筒',
|
||||
})
|
||||
const token = extractCheckInToken(result.ScanResult)
|
||||
if (!token) {
|
||||
ElMessage.warning('这不是有效的课堂签到二维码。')
|
||||
return
|
||||
}
|
||||
await router.replace({ path: route.path, query: { token } })
|
||||
await loadScannedActivity(token)
|
||||
} catch (error: any) {
|
||||
const message = String(error?.message ?? error ?? '')
|
||||
if (!/cancel/i.test(message)) {
|
||||
ElMessage.error('无法启动扫码,请在系统设置中允许本应用使用相机。')
|
||||
}
|
||||
} finally {
|
||||
nativeScanLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function serverUtcTime(value: string | null | undefined) {
|
||||
if (!value) return Number.NaN
|
||||
const normalized = /(?:Z|[+-]\d{2}:\d{2})$/i.test(value) ? value : `${value}Z`
|
||||
@@ -131,7 +219,10 @@ async function confirmQrCheckIn() {
|
||||
if (!token || !scannedActivity.value) return
|
||||
checkInTargetId.value = scannedActivity.value.sheetId
|
||||
try {
|
||||
const { data } = await http.post('/attendance/check-in', { token })
|
||||
const { data } = await http.post('/attendance/check-in', {
|
||||
token,
|
||||
...deviceAuditPayload(),
|
||||
})
|
||||
ElMessage.success(data.alreadyCheckedIn ? '你已完成本次签到' : '签到成功')
|
||||
await router.replace({ path: route.path, query: {} })
|
||||
await load()
|
||||
@@ -143,6 +234,20 @@ async function confirmQrCheckIn() {
|
||||
}
|
||||
|
||||
async function captureLocation() {
|
||||
if (isNativeApp) {
|
||||
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 })
|
||||
}
|
||||
return await Geolocation.getCurrentPosition({
|
||||
enableHighAccuracy: true,
|
||||
timeout: 12000,
|
||||
maximumAge: 0,
|
||||
})
|
||||
}
|
||||
if (!navigator.geolocation) throw new Error('unsupported')
|
||||
return await new Promise<GeolocationPosition>((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
@@ -162,6 +267,7 @@ async function locationCheckIn(activity: any) {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracyMeters: position.coords.accuracy,
|
||||
...deviceAuditPayload(),
|
||||
})
|
||||
ElMessage.success(data.alreadyCheckedIn
|
||||
? '你已完成本次签到'
|
||||
@@ -172,7 +278,9 @@ async function locationCheckIn(activity: any) {
|
||||
} catch (error: any) {
|
||||
if (!error?.response) {
|
||||
const message = error?.code === 1
|
||||
? '定位权限被拒绝,请在浏览器中允许本网站获取位置。'
|
||||
? isNativeApp
|
||||
? '定位权限被拒绝,请在系统设置中允许本应用获取精确位置。'
|
||||
: '定位权限被拒绝,请在浏览器中允许本网站获取位置。'
|
||||
: error?.code === 3
|
||||
? '获取位置超时,请移到信号较好的位置后重试。'
|
||||
: '无法获取当前位置,请检查手机定位服务后重试。'
|
||||
@@ -244,7 +352,16 @@ onUnmounted(() => {
|
||||
<h2>我的考勤</h2>
|
||||
<p>完成课堂扫码或定位签到,并查看已提交的考勤记录。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
<div class="intro-actions">
|
||||
<el-button
|
||||
v-if="isNativeApp"
|
||||
type="primary"
|
||||
:icon="Camera"
|
||||
:loading="nativeScanLoading"
|
||||
@click="scanCheckInCode"
|
||||
>扫码签到</el-button>
|
||||
<el-button :icon="Refresh" @click="load">刷新</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -402,6 +519,11 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.intro-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.check-in-board {
|
||||
border: 1px solid #cdd9e5;
|
||||
background: #f6f9fc;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -167,11 +167,21 @@ const weekEntries = computed(() =>
|
||||
const dayEntries = computed(() =>
|
||||
weekEntries.value.filter((entry: any) => entry.dayOfWeek === selectedDay.value),
|
||||
)
|
||||
const sortedExamEntries = computed(() =>
|
||||
examEntries.value.slice().sort((a: any, b: any) =>
|
||||
String(a.examDate).localeCompare(String(b.examDate)) ||
|
||||
a.startPeriod - b.startPeriod ||
|
||||
String(a.courseCode).localeCompare(String(b.courseCode)),
|
||||
),
|
||||
)
|
||||
const visibleGridEntries = computed(() =>
|
||||
viewMode.value === 'overview'
|
||||
? timedEntries.value
|
||||
? (timetable.value?.entries ?? [])
|
||||
: weekEntries.value,
|
||||
)
|
||||
const hasOverviewCourseEntries = computed(() =>
|
||||
(timetable.value?.entries ?? []).length > 0,
|
||||
)
|
||||
const selectedWeekLabel = computed(() => weekLabel(selectedWeek.value))
|
||||
const selectedDayDate = computed(() => {
|
||||
if (!termMonday.value) return ''
|
||||
@@ -910,7 +920,7 @@ onMounted(async () => {
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="(viewMode === 'overview' || viewMode === 'week') && hasTimedEntries"
|
||||
v-if="(viewMode === 'week' && hasTimedEntries) || (viewMode === 'overview' && hasOverviewCourseEntries)"
|
||||
class="timetable-view-section"
|
||||
>
|
||||
<div class="view-context">
|
||||
@@ -918,11 +928,11 @@ onMounted(async () => {
|
||||
<strong>{{ viewMode === 'overview' ? '全学期总览' : selectedWeekLabel }}</strong>
|
||||
<span>
|
||||
{{ viewMode === 'overview'
|
||||
? '固定课程与已发布考试按节次合并显示'
|
||||
? '固定课程按节次展示,考试安排按日期列于下方'
|
||||
: `本周共 ${weekEntries.length} 项课程、考试安排` }}
|
||||
</span>
|
||||
</div>
|
||||
<small v-if="viewMode === 'overview'">考试卡片显示具体日期,课程卡片保留周次信息</small>
|
||||
<small v-if="viewMode === 'overview'">课程卡片保留起止周与单双周信息</small>
|
||||
<small v-else-if="!weekEntries.length">本周没有课程或考试安排</small>
|
||||
</div>
|
||||
<div class="timetable-scroll">
|
||||
@@ -1012,9 +1022,38 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
<el-empty
|
||||
v-else-if="timetable && !loading && !timetable.flexibleCourses?.length"
|
||||
v-else-if="timetable && !loading && !hasTimedEntries && !timetable.flexibleCourses?.length"
|
||||
:description="timetable.plan ? '该课表暂时没有课程或考试安排' : '所选学期尚未发布课表或考试安排'"
|
||||
/>
|
||||
|
||||
<section v-if="viewMode === 'overview' && sortedExamEntries.length" class="exam-overview">
|
||||
<header>
|
||||
<div>
|
||||
<span>EXAM AGENDA</span>
|
||||
<strong>已发布考试安排</strong>
|
||||
</div>
|
||||
<small>共 {{ sortedExamEntries.length }} 场 · 按考试日期排序</small>
|
||||
</header>
|
||||
<div class="exam-overview-list">
|
||||
<article v-for="entry in sortedExamEntries" :key="entryKey(entry)">
|
||||
<div class="exam-date">
|
||||
<strong>{{ formatExamDate(entry.examDate) }}</strong>
|
||||
<span>{{ weekdays[entry.dayOfWeek] }}</span>
|
||||
<small>第 {{ entry.startWeek }} 周</small>
|
||||
</div>
|
||||
<div class="exam-summary">
|
||||
<span>{{ entry.courseCode }} · {{ entry.examPlanName || '已发布考试' }}</span>
|
||||
<strong>{{ entry.courseName }}</strong>
|
||||
<p>{{ location(entry) }}</p>
|
||||
<small>
|
||||
第 {{ entry.startWeek }} 周 ·
|
||||
第 {{ entry.startPeriod }}—{{ entry.startPeriod + entry.periodCount - 1 }} 节
|
||||
</small>
|
||||
<small>{{ entry.teacherNames.join('、') || '监考教师待定' }}</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1189,6 +1228,23 @@ onMounted(async () => {
|
||||
.course-block.exam-block strong, .day-course-block.exam-block strong { color: #78391e; }
|
||||
.course-block.exam-block small, .day-course-block.exam-block small { color: #8b5b43; }
|
||||
.entry-kind { display: inline-block; margin-right: 5px; padding: 1px 5px; border-radius: 2px; background: #b65b32; color: #fff; font-size: 10px !important; line-height: 1.5; vertical-align: 1px; }
|
||||
.exam-overview { margin-top: 20px; border: 1px solid #e2d6cf; background: #fffaf7; }
|
||||
.exam-overview > header { padding: 14px 16px; display: flex; align-items: center; justify-content: space-between; gap: 16px; border-bottom: 1px solid #eaded7; background: #fff5ef; }
|
||||
.exam-overview > header > div { display: flex; align-items: baseline; gap: 10px; }
|
||||
.exam-overview > header span { color: #a34f2b; font: 700 10px/1.4 Consolas, monospace; letter-spacing: .08em; }
|
||||
.exam-overview > header strong { color: #633824; font-size: 15px; }
|
||||
.exam-overview > header small { color: #8e6f60; }
|
||||
.exam-overview-list { padding: 14px; display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
|
||||
.exam-overview-list article { min-width: 0; display: grid; grid-template-columns: 88px minmax(0, 1fr); border: 1px solid #eadbd2; border-left: 4px solid #b65b32; background: #fff; }
|
||||
.exam-date { padding: 13px 10px; display: grid; align-content: start; gap: 4px; border-right: 1px solid #efe3dc; background: #fff7f2; text-align: center; }
|
||||
.exam-date strong { color: #8c4022; font-size: 14px; }
|
||||
.exam-date span { color: #76594b; font-size: 12px; }
|
||||
.exam-date small { color: #9a7a6b; font-size: 11px; }
|
||||
.exam-summary { min-width: 0; padding: 12px 14px; display: grid; gap: 5px; }
|
||||
.exam-summary > span { overflow: hidden; color: #a0502d; font: 700 10px/1.4 Consolas, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.exam-summary > strong { color: #533225; font-size: 15px; }
|
||||
.exam-summary p { margin: 0; color: #6e584e; font-size: 12px; }
|
||||
.exam-summary small { color: #8a7469; font-size: 11px; line-height: 1.5; }
|
||||
.day-view { border: 1px solid #dce4eb; }
|
||||
.day-view > header { padding: 14px 16px; display: flex; align-items: baseline; justify-content: space-between; background: #173e72; color: #fff; }
|
||||
.day-view > header > div { display: grid; gap: 3px; }
|
||||
@@ -1225,6 +1281,9 @@ onMounted(async () => {
|
||||
.calendar-actions .el-button, .calendar-security-actions .el-button { width: 100%; margin-left: 0; }
|
||||
.timetable-sheet { padding: 12px; }
|
||||
.view-context, .day-view > header { align-items: flex-start; flex-direction: column; gap: 5px; }
|
||||
.exam-overview > header { align-items: flex-start; flex-direction: column; }
|
||||
.exam-overview-list { grid-template-columns: 1fr; }
|
||||
.exam-overview-list article { grid-template-columns: 78px minmax(0, 1fr); }
|
||||
.day-grid { grid-template-columns: 90px minmax(0, 1fr); }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user