Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

View File

@@ -0,0 +1,777 @@
<!-- 学生列表页面 -->
<template>
<scroll-view direction="vertical" class="students-container">
<!-- Header -->
<view class="header">
<text class="title">学生列表</text>
<text class="subtitle">本人权限下的学生信息</text>
</view>
<!-- 搜索栏 -->
<view class="search-section">
<view class="search-box">
<text class="search-icon">🔍</text>
<input :value="searchQuery" placeholder="搜索学生姓名" class="search-input" @input="onSearchInput" />
</view>
</view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-container">
<text class="loading-text">加载学生数据中...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="error" class="error-container">
<text class="error-text">{{ error }}</text>
<button class="retry-btn" @click="loadStudents">重试</button>
</view>
<!-- 学生列表 -->
<view v-else class="students-list">
<view v-if="filteredStudents.length === 0" class="empty-state">
<text class="empty-icon">👥</text>
<text class="empty-text">暂无学生数据</text>
</view>
<view v-for="student in filteredStudents" :key="student.id" class="student-card"
:class="{ 'abnormal-student': student.has_abnormal_vitals, 'not-arrived-student': student.inside_fence === false }" @click="viewStudentDetail(student.id)">
<view v-if="student.has_abnormal_vitals" class="abnormal-badge">
<text class="abnormal-icon">⚠️</text>
<text class="abnormal-text">{{ student.abnormal_count }}项异常</text>
</view>
<!-- 学生基本信息 -->
<view class="student-header">
<view class="avatar-container">
<image v-if="student.avatar" :src="student.avatar" class="student-avatar" mode="aspectFill" />
<view v-else class="student-avatar-placeholder">
<text class="avatar-text">{{ getInitials(student.name) }}</text>
</view>
</view>
<view class="student-info">
<text class="student-name">{{ student.name }}</text>
<text class="student-id">学号: {{ student.student_id ?? '未设置' }}</text>
</view>
</view>
<!-- 健康数据 -->
<view class="health-data">
<view class="health-item">
<text class="health-icon">🌡️</text>
<view class="health-info">
<text class="health-label">体温</text>
<text class="health-value" :class="getTemperatureStatus(student.latest_temperature)">
{{ student.latest_temperature != null ? (student.latest_temperature as number).toFixed(1) : '--' }}°C
</text>
<text class="health-time">{{ formatTime(student.temperature_time as string | null) }}</text>
</view>
</view>
<view class="health-item">
<text class="health-icon">❤️</text>
<view class="health-info">
<text class="health-label">心率</text>
<text class="health-value" :class="getHeartRateStatus(student.latest_heart_rate)">
{{ typeof student.latest_heart_rate === 'number' ? student.latest_heart_rate : '--' }}
bpm
</text>
<text class="health-time">{{ formatTime(student.heart_rate_time as string | null) }}</text>
</view>
</view>
<view class="health-item">
<text class="health-icon">🫁</text>
<view class="health-info">
<text class="health-label">血氧</text>
<text class="health-value" :class="getOxygenStatus(student.latest_oxygen_level)">
{{ typeof student.latest_oxygen_level === 'number' ? student.latest_oxygen_level : '--' }}%
</text>
<text
class="health-time">{{ formatTime(student.oxygen_level_time as string | null) }}</text>
</view>
</view>
<view class="health-item">
<text class="health-icon">👟</text>
<view class="health-info">
<text class="health-label">步数</text>
<text class="health-value normal">
{{ typeof student.latest_steps === 'number' ? student.latest_steps : '--' }} 步
</text>
<text class="health-time">{{ formatTime(student.steps_time as string | null) }}</text>
</view>
</view>
</view>
<!-- 到校 / 围栏状态 -->
<view class="arrival-status" :class="student.inside_fence == true ? 'arrived' : 'not-arrived'">
<text class="arrival-dot">●</text>
<text class="arrival-text">{{ getArrivalText(student) }}</text>
</view>
<!-- 箭头指示 -->
<view class="arrow-container">
<text class="arrow"></text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
import { getCurrentUserId, getCurrentUserClassId } from '@/utils/store.uts'
// 学生数据类型定义
type Student = {
id : string
name : string
student_id : string | null
avatar : string | null
latest_temperature : number | null
temperature_time : string | null
latest_heart_rate : number | null
heart_rate_time : string | null
latest_oxygen_level : number | null
oxygen_level_time : string | null
latest_steps : number | null
steps_time : string | null
created_at : string
// 新增异常标识
has_abnormal_vitals : boolean
abnormal_count : number
class_name ?: string // 班级名称
// 位置相关(新增)
lat ?: number | null
lng ?: number | null
location_time ?: string | null
inside_fence ?: boolean // 是否在围栏内(到校)
distance_m ?: number | null // 距离围栏中心米数
}
// 健康数据异常检测类型
type VitalAbnormalResult = {
abnormal : boolean;
count : number;
}
// 响应式数据
const students = ref<Array<Student>>([])
const loading = ref<boolean>(false)
const error = ref<string>('')
const searchQuery = ref<string>('')
// 围栏配置(到校判断)
const fenceCenterLat = ref<number>(0)
const fenceCenterLng = ref<number>(0)
const fenceRadiusM = ref<number>(120) // 米
const fenceLoaded = ref<boolean>(false)
// 计算过滤后的学生列表
const filteredStudents = computed<Array<Student>>(() => {
if (searchQuery.value.trim() == '') {
return students.value;
}
console.log(searchQuery.value)
return students.value.filter(student => {
if (typeof student.name == 'string') {
return student.name.toLocaleLowerCase().includes(searchQuery.value.toLocaleLowerCase());
}
return false;
});
})
// 判断生理指标是否异常
const checkVitalAbnormal = (
temp : number | null,
heartRate : number | null,
oxygen : number | null
) : VitalAbnormalResult => {
let abnormalCount = 0
// 体温异常判断 (正常范围: 36.0-37.5°C)
if (temp !== null && (temp < 36.0 || temp > 37.5)) {
abnormalCount++
}
// 心率异常判断 (正常范围: 60-100 bpm)
if (heartRate !== null && (heartRate < 60 || heartRate > 100)) {
abnormalCount++
}
// 血氧异常判断 (正常范围: ≥95%)
if (oxygen !== null && oxygen < 95) {
abnormalCount++
}
const result = {
abnormal: abnormalCount > 0,
count: abnormalCount
} as VitalAbnormalResult
return result
}
// 获取体温状态
const getTemperatureStatus = (temp : number | null) : string => {
if (temp === null) return 'unknown';
if (temp < 36.0 || temp > 37.5) return 'abnormal';
return 'normal'
}
// 获取心率状态
const getHeartRateStatus = (rate : number | null) : string => {
if (rate === null) return 'unknown';
if (rate < 60 || rate > 100) return 'abnormal';
return 'normal';
}
// 获取血氧状态
const getOxygenStatus = (level : number | null) : string => {
if (level === null) return 'unknown';
if (level < 95) return 'abnormal';
return 'normal';
} // 搜索输入处理
const onSearchInput = (e:UniInputEvent) => {
searchQuery.value = e.detail.value;
}
// 获取姓名首字母
const getInitials = (name : string) : string => {
if (name.trim() === '') return 'N';
const words = name.trim().split(' ');
if (words.length >= 2) {
return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase();
}
return name.charAt(0).toUpperCase();
}
// 格式化时间
const formatTime = (timeStr : string | null) : string => {
if (timeStr === null || timeStr === '') return '暂无数据'
try {
const date = new Date(timeStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMins < 60) {
return diffMins <= 0 ? '刚刚' : `${diffMins}分钟前`
} else if (diffHours < 24) {
return `${diffHours}小时前`
} else if (diffDays < 7) {
return `${diffDays}天前`
} else {
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
}
} catch (e) {
return '时间格式错误'
}
}
// 查看学生详情
const viewStudentDetail = (studentId : string) => {
uni.navigateTo({
url: `/pages/sport/teacher/student-detail?id=${studentId}`
})
} // 加载学生数据
const loadStudents = async () => {
loading.value = true;
error.value = '';
try {
const currentUser = getCurrentUserId();
if (currentUser === null || currentUser === '') {
error.value = '用户未登录';
return;
}
// 获取当前用户的class_id
const currentUserClassId = getCurrentUserClassId();
if (currentUserClassId === null || currentUserClassId === '') {
error.value = '用户未分配班级';
return;
}
console.log('开始加载学生数据...', '当前用户班级ID:', currentUserClassId);
// 直接获取同班级的学生ID
const studentsResponse = await supa
.from('ak_users')
.select('id, username,phone, avatar_url, class_id', {})
.eq('role', 'student')
.eq('class_id', currentUserClassId) // 使用当前用户的class_id
.execute()
if (studentsResponse.data == null || studentsResponse.status < 200 || studentsResponse.status >= 300) {
console.error('获取学生列表失败:', studentsResponse.status, studentsResponse.error)
students.value = []
return
}
const studentIds = (studentsResponse.data as Array<UTSJSONObject>).map(u => u['id'] as string)
if (studentIds.length === 0) {
console.warn('当前班级中没有学生')
students.value = []
return
}
console.log('找到同班级学生ID:', studentIds)
const response = await supa
.from('ak_users')
.select(`
id,
username,
email,
avatar_url,
class_id
`, {})
.eq('role', 'student')
.eq('class_id', currentUserClassId)
.limit(50)
.execute();
if (response.status >= 200 && response.status < 300 && response.data != null) {
const studentsData = response.data as Array<UTSJSONObject>;
// 处理学生基本信息,健康数据使用模拟值进行演示
const studentsWithHealth = studentsData.map((student) : Student => {
const studentId = student['id'] as string;
const studentName = (student['username'] != null && student['username'] !== '') ? student['username'] as string : '未命名';
const avatar = student['avatar_url'] as string | null;
// 生成模拟健康数据用于演示(实际项目中应该从真实的健康数据表获取)
const mockTemp = 36.0 + Math.random() * 2.0 // 36.0-38.0度
const mockHeartRate = 60 + Math.random() * 40 // 60-100 bpm
const mockOxygen = 95 + Math.random() * 5 // 95-100%
const mockSteps = Math.floor(Math.random() * 10000) // 0-10000步
const mockTime = new Date().toISOString()
// 判断是否有异常指标
const vitalCheck = checkVitalAbnormal(mockTemp, mockHeartRate, mockOxygen)
const baseStudent: Student = {
id: studentId,
name: studentName,
student_id: (student['email'] != null && student['email'] !== '') ? student['email'] as string : null,
avatar: avatar,
latest_temperature: mockTemp,
temperature_time: mockTime,
latest_heart_rate: mockHeartRate,
heart_rate_time: mockTime,
latest_oxygen_level: mockOxygen,
oxygen_level_time: mockTime,
latest_steps: mockSteps,
steps_time: mockTime,
created_at: '',
has_abnormal_vitals: vitalCheck.abnormal,
abnormal_count: vitalCheck.count,
lat: null,
lng: null,
location_time: null,
inside_fence: false,
distance_m: null
}
return baseStudent
})
// 按异常情况排序:异常的排在前面,异常数量多的排在更前面
studentsWithHealth.sort((a, b) => {
// 首先按是否有异常排序
if (a.has_abnormal_vitals && !b.has_abnormal_vitals) return -1
if (!a.has_abnormal_vitals && b.has_abnormal_vitals) return 1
// 如果都有异常,按异常数量排序
if (a.has_abnormal_vitals && b.has_abnormal_vitals) {
return b.abnormal_count - a.abnormal_count
}
// 都正常的话按姓名排序
const nameA = a.name != null ? a.name : '';
const nameB = b.name != null ? b.name : '';
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return 0;
})
students.value = studentsWithHealth
// 进一步加载位置数据并计算围栏
// 模拟位置数据
for (let i = 0; i < students.value.length; i++) {
const s = students.value[i]
// 随机生成一个在中心附近 300m 内的点;让部分学生超出围栏
const rand = Math.random()
const maxDist = 0.003 // 粗略对应几百米
const dLat = (Math.random() - 0.5) * maxDist
const dLng = (Math.random() - 0.5) * maxDist
s.lat = fenceCenterLat.value + dLat
s.lng = fenceCenterLng.value + dLng + (rand < 0.2 ? 0.01 : 0) // 20% 故意偏移较远,模拟未到校
s.location_time = new Date().toISOString()
// 计算距离
if (s.lat != null && s.lng != null) {
const R = 6371000.0
const toRad = (d: number): number => d * Math.PI / 180.0
const dLat = toRad(s.lat - fenceCenterLat.value)
const dLng = toRad(s.lng - fenceCenterLng.value)
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(fenceCenterLat.value)) * Math.cos(toRad(s.lat)) * Math.sin(dLng / 2) * Math.sin(dLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
s.distance_m = Math.round(R * c)
s.inside_fence = s.distance_m <= fenceRadiusM.value
} else {
s.inside_fence = false
}
}
console.log(`成功加载 ${students.value.length} 个同班级学生数据`)
console.log(`异常学生数量: ${studentsWithHealth.filter(s => s.has_abnormal_vitals).length}`)
} else {
error.value = '加载学生健康数据失败'
console.error('学生健康数据查询失败:', response.status, response.error)
}
} catch (e) {
error.value = '网络错误,请稍后重试'
console.error('加载学生数据异常:', e)
} finally {
loading.value = false
}
}
// 页面加载时获取数据
onMounted(() => {
loadStudents()
})
// ========= 位置 / 围栏逻辑 =========
// 加载围栏配置(示例:从假设表 school_fences 读取)
const loadFenceConfig = async (): Promise<void> => {
// 真实情况根据学校或班级ID查询中心点与半径
try {
// 这里做演示:如果尚未加载,就给一个固定坐标(示例坐标:上海市中心近似)
if (!fenceLoaded.value) {
fenceCenterLat.value = 31.2304
fenceCenterLng.value = 121.4737
fenceRadiusM.value = 150 // 半径 150m
fenceLoaded.value = true
}
} catch (e) {
console.error('加载围栏配置失败', e)
}
}
// 加载位置信息(假设有 device_locations 表student_id, lat, lng, recorded_at
const loadLocationsAndComputeFence = async (classId: string): Promise<void> => {
try {
// 示例:直接模拟位置数据(真实项目改为 supa 查询)
for (let i = 0; i < students.value.length; i++) {
const s = students.value[i]
// 随机生成一个在中心附近 300m 内的点;让部分学生超出围栏
const rand = Math.random()
const maxDist = 0.003 // 粗略对应几百米
const dLat = (Math.random() - 0.5) * maxDist
const dLng = (Math.random() - 0.5) * maxDist
s.lat = fenceCenterLat.value + dLat
s.lng = fenceCenterLng.value + dLng + (rand < 0.2 ? 0.01 : 0) // 20% 故意偏移较远,模拟未到校
s.location_time = new Date().toISOString()
// 计算距离
if (s.lat != null && s.lng != null) {
s.distance_m = computeDistanceMeters(fenceCenterLat.value, fenceCenterLng.value, s.lat, s.lng)
s.inside_fence = s.distance_m <= fenceRadiusM.value
} else {
s.inside_fence = false
}
}
} catch (e) {
console.error('加载位置数据失败', e)
}
}
// 计算两点间距离Haversine 简化)
const computeDistanceMeters = (lat1: number, lng1: number, lat2: number, lng2: number): number => {
const R = 6371000.0
const toRad = (d: number): number => d * Math.PI / 180.0
const dLat = toRad(lat2 - lat1)
const dLng = toRad(lng2 - lng1)
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
const dist = R * c
return Math.round(dist)
}
// 显示到校状态文字
const getArrivalText = (s: Student): string => {
if (s.inside_fence === true) {
return '已到校'
}
const distStr = s.distance_m != null ? (s.distance_m as number).toString() + 'm' : '未知距离'
return '未到校 • 距离围栏中心' + distStr
}
</script>
<style scoped>
.students-container {
flex: 1;
background-color: #f5f5f5;
}
.header {
padding: 20px;
background-color: #ffffff;
border-bottom: 1px solid #e5e5e5;
}
.title {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.subtitle {
font-size: 14px;
color: #666;
}
.search-section {
padding: 16px 20px;
background-color: #ffffff;
border-bottom: 1px solid #e5e5e5;
}
.search-box {
flex-direction: row;
align-items: center;
background-color: #f8f9fa;
border-radius: 8px;
padding: 12px 16px;
border: 1px solid #e9ecef;
}
.search-icon {
font-size: 16px;
margin-right: 8px;
color: #6c757d;
}
.search-input {
flex: 1;
font-size: 16px;
color: #333;
}
.loading-container,
.error-container,
.empty-state {
flex: 1;
justify-content: center;
align-items: center;
padding: 40px 20px;
}
.loading-text,
.error-text,
.empty-text {
font-size: 16px;
color: #666;
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.retry-btn {
margin-top: 16px;
padding: 12px 24px;
background-color: #007AFF;
color: #ffffff;
border-radius: 8px;
border: none;
font-size: 16px;
}
.students-list {
padding: 16px;
}
.student-card {
background-color: #ffffff;
border-radius: 12px;
margin-bottom: 16px;
padding: 16px;
flex-direction: column;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
}
.abnormal-student {
border-left: 4px solid #ff4757;
background-color: #fff5f5;
}
.not-arrived-student {
border-left: 4px solid #ffa502;
background-color: #fff8e6;
}
.abnormal-badge {
position: absolute;
top: 12px;
right: 12px;
flex-direction: row;
align-items: center;
background-color: #ff4757;
padding: 4px 8px;
border-radius: 12px;
}
.abnormal-icon {
font-size: 12px;
margin-right: 4px;
}
.abnormal-text {
font-size: 10px;
color: #ffffff;
font-weight: bold;
}
.student-header {
flex-direction: row;
align-items: center;
flex: 1;
}
.avatar-container {
margin-right: 12px;
}
.student-avatar {
width: 48px;
height: 48px;
border-radius: 24px;
}
.student-avatar-placeholder {
width: 48px;
height: 48px;
border-radius: 24px;
background-color: #007AFF;
justify-content: center;
align-items: center;
}
.avatar-text {
color: #ffffff;
font-size: 16px;
font-weight: bold;
}
.student-info {
flex: 1;
}
.student-name {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.student-id {
font-size: 14px;
color: #666;
}
.health-data {
flex: 2;
margin-left: 16px;
}
.health-item {
flex-direction: row;
align-items: center;
margin-bottom: 8px;
}
.health-icon {
font-size: 16px;
margin-right: 8px;
width: 20px;
}
.health-info {
flex: 1;
flex-direction: row;
align-items: center;
}
.health-label {
font-size: 12px;
color: #666;
width: 30px;
margin-right: 8px;
}
.health-value {
font-size: 14px;
font-weight: bold;
color: #333;
margin-right: 8px;
min-width: 60px;
}
.health-value.normal {
color: #27ae60;
}
.health-value.abnormal {
color: #e74c3c;
background-color: #ffebee;
padding: 2px 6px;
border-radius: 4px;
}
.health-value.unknown {
color: #95a5a6;
}
.health-time {
font-size: 11px;
color: #999;
}
.arrow-container {
margin-left: 16px;
}
.arrow {
font-size: 20px;
color: #ccc;
}
/* 到校状态样式 */
.arrival-status {
margin-top: 8px;
flex-direction: row;
align-items: center;
}
.arrival-dot {
font-size: 10px;
margin-right: 6px;
}
.arrival-status.arrived .arrival-dot {
color: #2ecc71;
}
.arrival-status.not-arrived .arrival-dot {
color: #e67e22;
}
.arrival-text {
font-size: 12px;
color: #555;
}
.arrival-status.not-arrived .arrival-text {
color: #d35400;
font-weight: bold;
}
</style>