update
This commit is contained in:
1 parent
a61dacff1a
commit
2dae303cf1
17 files changed
+6796
-90
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user