Files
akmon/pages/sport/teacher/students.uvue
2026-01-20 08:04:15 +08:00

777 lines
21 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 学生列表页面 -->
<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>