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

2404 lines
72 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
<view class="page">
<view class="header">
<view class="class-selector" @click="openClassPicker">
<text class="picker-label">班级:{{ currentClassName }}</text>
</view>
<view class="status">
<text>状态:{{ trainingStateText }}</text>
<text class="status-separator"></text>
<text>实时:{{ realtimeStatusText }}</text>
<text v-if="lastRealtimeEventText !== '--'" class="status-hint">最近:{{ lastRealtimeEventText }}</text>
</view>
</view>
<view v-if="showClassPicker" class="picker-overlay" @click="closeClassPicker">
<view class="picker-panel" @click.stop="noop">
<picker-view :value="classPickerValue" indicator-style="height: 48px" @change="onClassPickerChange">
<picker-view-column>
<view v-for="(name, idx) in classNames" :key="idx" class="picker-item">{{ name }}</view>
</picker-view-column>
</picker-view>
<view class="picker-actions">
<button size="mini" @click="closeClassPicker">取消</button>
<button size="mini" type="primary" @click="confirmClassPicker">确定</button>
</view>
</view>
</view>
<view class="actions">
<button type="primary" :disabled="isStarting || isInProgress" @click="onStart">开始训练</button>
<button type="warn" :disabled="!isInProgress || isStopping" @click="onStop">结束训练</button>
<button :disabled="isInProgress || isStarting" @click="refreshRoster">刷新名单</button>
<button type="default" @click="toggleThresholdSettings">阈值设置</button>
<view class="mock-toggle">
<switch :checked="useMock" @change="onToggleMock" />
<text class="mock-toggle-label">本地模拟</text>
</view>
</view>
<view v-if="showThresholdSettings" class="threshold-settings">
<view class="settings-header">
<text class="settings-title">阈值设置</text>
<button size="mini" @click="toggleThresholdSettings">关闭</button>
</view>
<view class="settings-content">
<view class="threshold-item">
<text class="metric-name">心率 (bpm)</text>
<view class="sliders">
<view class="slider-group">
<text>警告: {{ getThresholdValue('hr', 'warning') }}</text>
<slider :value="getThresholdValue('hr', 'warning')" :min="60" :max="180"
@change="updateThreshold('hr', 'warning', $event as UniSliderChangeEvent)" />
</view>
<view class="slider-group">
<text>危险: {{ getThresholdValue('hr', 'danger') }}</text>
<slider :value="getThresholdValue('hr', 'danger')" :min="100" :max="200"
@change="updateThreshold('hr', 'danger', $event as UniSliderChangeEvent)" />
</view>
</view>
</view>
<view class="threshold-item">
<text class="metric-name">电量 (%)</text>
<view class="sliders">
<view class="slider-group">
<text>警告: {{ getThresholdValue('battery', 'warning') }}</text>
<slider :value="getThresholdValue('battery', 'warning')" :min="5" :max="50"
@change="updateThreshold('battery', 'warning', $event as UniSliderChangeEvent)" />
</view>
<view class="slider-group">
<text>危险: {{ getThresholdValue('battery', 'danger') }}</text>
<slider :value="getThresholdValue('battery', 'danger')" :min="1" :max="20"
@change="updateThreshold('battery', 'danger', $event as UniSliderChangeEvent)" />
</view>
</view>
</view>
<view class="threshold-item">
<text class="metric-name">血氧 (%)</text>
<view class="sliders">
<view class="slider-group">
<text>警告: {{ getThresholdValue('spo2', 'warning') }}</text>
<slider :value="getThresholdValue('spo2', 'warning')" :min="90" :max="100"
@change="updateThreshold('spo2', 'warning', $event as UniSliderChangeEvent)" />
</view>
<view class="slider-group">
<text>危险: {{ getThresholdValue('spo2', 'danger') }}</text>
<slider :value="getThresholdValue('spo2', 'danger')" :min="85" :max="95"
@change="updateThreshold('spo2', 'danger', $event as UniSliderChangeEvent)" />
</view>
</view>
</view>
<view class="threshold-item">
<text class="metric-name">体温 (°C)</text>
<view class="sliders">
<view class="slider-group">
<text>警告: {{ getThresholdValue('temp', 'warning') }}</text>
<slider :value="getThresholdValue('temp', 'warning')" :min="36.0" :max="38.0" :step="0.1"
@change="updateThreshold('temp', 'warning', $event as UniSliderChangeEvent)" />
</view>
<view class="slider-group">
<text>危险: {{ getThresholdValue('temp', 'danger') }}</text>
<slider :value="getThresholdValue('temp', 'danger')" :min="37.0" :max="40.0" :step="0.1"
@change="updateThreshold('temp', 'danger', $event as UniSliderChangeEvent)" />
</view>
</view>
</view>
</view>
</view>
<view v-if="isStarting || isAwaitingAck" class="ack-panel">
<progress :percent="ackPercent" show-info></progress>
<view class="ack-actions">
<text>等待设备应答:{{ ackOkCount }}/{{ roster.length }}</text>
<button size="mini" :disabled="isInProgress || !hasPendingAck" @click="retryPending">重试未应答</button>
<button size="mini" :disabled="isInProgress" @click="markPendingAsIssue">标记异常</button>
</view>
</view>
<scroll-view class="grid" direction="vertical">
<view class="grid-content">
<view v-for="item in filteredRoster" :key="item.studentId" class="student-card"
:class="getCardClasses(item)" @click="openStudent(item)">
<view class="card-header">
<view class="status-indicators">
<view v-if="!item.online" class="status-dot offline"></view>
<view v-else-if="item.alert" class="status-dot alert"></view>
<view v-else class="status-dot normal"></view>
<text class="student-time">{{ item.lastEventAt != null ? formatTime(item.lastEventAt) : '--' }}</text>
</view>
<text class="student-name">{{ item.name }}</text>
</view>
<view class="card-body">
<view class="metric-row">
<view class="metric" :class="getMetricClass('hr', item.hr)" :aria-label="getMetricLabel('hr')" :title="getMetricLabel('hr')">
<text class="metric-icon" aria-hidden="true">{{ getMetricIcon('hr') }}</text>
<text class="metric-value">{{ displayValue(item.hr) }}</text>
</view>
<view class="metric" :class="getMetricClass('battery', item.battery)" :aria-label="getMetricLabel('battery')" :title="getMetricLabel('battery')">
<text class="metric-icon" aria-hidden="true">{{ getMetricIcon('battery') }}</text>
<text class="metric-value">{{ displayPercent(item.battery) }}</text>
</view>
</view>
<view class="metric-row">
<view class="metric" :class="getMetricClass('spo2', item.spo2)" :aria-label="getMetricLabel('spo2')" :title="getMetricLabel('spo2')">
<text class="metric-icon" aria-hidden="true">{{ getMetricIcon('spo2') }}</text>
<text class="metric-value">{{ displayValue(item.spo2) }}</text>
</view>
<view class="metric" :class="getMetricClass('temp', item.temp)" :aria-label="getMetricLabel('temp')" :title="getMetricLabel('temp')">
<text class="metric-icon" aria-hidden="true">{{ getMetricIcon('temp') }}</text>
<text class="metric-value">{{ displayValue(item.temp) }}</text>
</view>
</view>
<view class="metric-row">
<view class="metric" :aria-label="getMetricLabel('steps')" :title="getMetricLabel('steps')">
<text class="metric-icon" aria-hidden="true">{{ getMetricIcon('steps') }}</text>
<text class="metric-value">{{ displayValue(item.steps) }}</text>
</view>
<!-- <view class="metric" :aria-label="getMetricLabel('device')" :title="getMetricLabel('device')">
<text class="metric-icon" aria-hidden="true">{{ getMetricIcon('device') }}</text>
<text class="metric-value device">{{ displayDevice(item.deviceId) }}</text>
</view> -->
</view>
</view>
<view v-if="item.alert" class="alert-indicator">
<text class="alert-text">⚠️ 异常</text>
</view>
</view>
</view>
</scroll-view>
<view class="footer">
<text>异常:{{ abnormalCount }} 人,未应答:{{ pendingAckCount }} 台</text>
<button size="mini" @click="onToggleFilter">筛选:{{ filterModeText }}</button>
</view>
<view class="student-detail-overlay" v-if="showStudentDetail" @click="closeStudentDetail">
<view class="student-detail-panel" @click.stop>
<view class="detail-header">
<text class="detail-title">{{ selectedStudent != null ? selectedStudent.name : '学生详情' }}</text>
<view class="detail-subtitle" v-if="studentDetailLastTimestamp > 0">
<text>最后更新时间:{{ formatTime(studentDetailLastTimestamp) }}</text>
</view>
<button class="detail-close" @click="closeStudentDetail">×</button>
</view>
<view class="detail-body">
<view v-if="studentDetailLoading" class="detail-loading">正在加载...</view>
<view v-else-if="studentDetailError !== ''" class="detail-error">{{ studentDetailError }}</view>
<view v-else>
<view v-if="studentHistoryPoints.length == 0" class="detail-empty">暂无历史记录</view>
<view v-else class="detail-main">
<view class="detail-summary">
<view class="summary-item" v-for="metric in studentDetailMetricOrder" :key="metric">
<text
class="summary-value">{{ formatDetailMetricValue(metric, getStudentDetailMetric(metric)) }}</text>
<text class="summary-label">{{ getMetricLabel(metric) }}</text>
</view>
</view>
<view class="detail-tabs">
<button class="detail-tab" v-for="metric in studentDetailMetricOrder" :key="metric"
:class="{ active: studentDetailActiveMetric == metric }"
@click="switchDetailMetric(metric)">
{{ getMetricLabel(metric) }}
</button>
</view>
<ak-charts :option="studentDetailChartOption" :canvas-id="'student-detail-chart'"
class="detail-chart" />
<scroll-view class="detail-history" direction="vertical">
<view class="detail-row" v-for="(point, index) in studentHistoryPoints" :key="index">
<text class="detail-time">{{ point.label }}</text>
<text class="detail-metrics">{{ summarizePoint(point) }}</text>
</view>
</scroll-view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
// 说明:此页面为 UTS 友好写法,避免在模板中使用复杂表达式
import supa, { supaReady } from '@/components/supadb/aksupainstance.uts'
import AkCharts from '@/uni_modules/ak-charts/components/ak-charts.uvue'
import TrainingRealtimeService, { type TrainingRealtimeSubscription, type TrainingStreamEvent } from '@/utils/trainingRealtimeService.uts'
type ClassInfo = { id : string, name : string }
enum TrainingState { Idle, Starting, InProgress, Stopping }
enum FilterMode { All, OnlyAbnormal, OnlyOffline, LowBattery }
export type StudentCardValue = {
studentId : string
name : string
deviceId : string | null
online : boolean
battery : number | null
hr : number | null
steps : number | null
spo2 : number | null
temp : number | null
alert : boolean
hasAck : boolean
lastEventAt : number | null
}
type StudentMetricKey = 'hr' | 'spo2' | 'steps' | 'battery' | 'temp'
type StudentHistoryPoint = {
timestamp : number
label : string
hr : number | null
spo2 : number | null
steps : number | null
battery : number | null
temp : number | null
}
type StudentMetricMap = {
hr : number | null
spo2 : number | null
steps : number | null
battery : number | null
temp : number | null
}
export type ThresholdsStands = {
warning : number
danger : number
}
export type Thresholds = {
hr : ThresholdsStands
battery : ThresholdsStands
spo2 : ThresholdsStands
temp : ThresholdsStands
}
type MockTimers = {
ack : number | null
live : number | null
timeout : number | null
}
const MOCK_SESSION_DURATION_MS = 20000
export default {
components: {
AkCharts
},
data() {
return {
cellwidth: '100rpx',
classes: [] as ClassInfo[],
isClassLoading: false,
classNames: [] as string[],
selectedClassIndex: 0,
showClassPicker: false,
classPickerValue: [0] as Array<number>,
trainingState: TrainingState.Idle as TrainingState,
trainingId: '' as string,
roster: [] as StudentCardValue[],
isRosterLoading: false,
isSessionLoading: false,
// realtime subscription
trainingRealtimeSub: null as TrainingRealtimeSubscription | null,
realtimeStatus: 'idle' as string,
lastRealtimeEventAt: 0,
filterMode: FilterMode.All as FilterMode,
// flags
isStarting: false,
isStopping: false,
// mock toggle
useMock: false,
lastRealtimeErrorAt: 0,
// 可调阈值配置
thresholds: ({
hr: { warning: 120, danger: 160 } as ThresholdsStands,
battery: { warning: 20, danger: 10 } as ThresholdsStands,
spo2: { warning: 95, danger: 90 } as ThresholdsStands,
temp: { warning: 37.5, danger: 38.5 } as ThresholdsStands
}) as Thresholds,
// 阈值设置界面
showThresholdSettings: false,
mockTimers: ({
ack: null,
live: null,
timeout: null
}) as MockTimers,
showStudentDetail: false,
selectedStudent: null as StudentCardValue | null,
studentDetailLoading: false,
studentDetailError: '',
studentDetailMetricOrder: ['hr', 'spo2', 'steps', 'battery', 'temp'] as Array<StudentMetricKey>,
studentDetailActiveMetric: 'hr' as StudentMetricKey,
studentHistoryPoints: [] as Array<StudentHistoryPoint>,
studentDetailLatest: ({
hr: null,
spo2: null,
steps: null,
battery: null,
temp: null
}) as StudentMetricMap,
studentDetailChartOption: (() => {
const option = new UTSJSONObject()
option.set('type', 'line')
option.set('data', [] as number[])
option.set('labels', [] as string[])
option.set('color', '#4e73df')
return option
})(),
studentDetailLastTimestamp: 0
}
},
computed: {
currentClassId() : string {
if (this.classes.length == 0) return ''
const idx = this.selectedClassIndex
const item = this.classes[idx]
return item != null ? item.id : ''
},
currentClassName() : string {
if (this.classNames.length == 0) return '未选择'
const idx = this.selectedClassIndex
const name = this.classNames[idx]
return name != null ? name : '未选择'
},
isInProgress() : boolean {
return this.trainingState == TrainingState.InProgress
},
isAwaitingAck() : boolean {
return this.trainingState == TrainingState.Starting
},
trainingStateText() : string {
switch (this.trainingState) {
case TrainingState.Idle: return '未开始'
case TrainingState.Starting: return '等待应答'
case TrainingState.InProgress: return '进行中'
case TrainingState.Stopping: return '结束中'
}
return '未知'
},
ackOkCount() : number {
let cnt = 0
for (let i = 0; i < this.roster.length; i++) {
if (this.roster[i].hasAck) cnt++
}
return cnt
},
pendingAckCount() : number {
return this.roster.length - this.ackOkCount
},
hasPendingAck() : boolean { return this.pendingAckCount > 0 },
ackPercent() : number {
if (this.roster.length == 0) return 0
return Math.floor(this.ackOkCount * 100 / this.roster.length)
},
abnormalCount() : number {
let cnt = 0
for (let i = 0; i < this.filteredRoster.length; i++) {
if (this.filteredRoster[i].alert) cnt++
}
return cnt
},
filterModeText() : string {
switch (this.filterMode) {
case FilterMode.All: return '全部'
case FilterMode.OnlyAbnormal: return '异常'
case FilterMode.OnlyOffline: return '离线'
case FilterMode.LowBattery: return '低电'
}
return '全部'
},
filteredRoster() : StudentCardValue[] {
const out : StudentCardValue[] = []
const alerts : StudentCardValue[] = []
const normals : StudentCardValue[] = []
for (let i = 0; i < this.roster.length; i++) {
const it = this.roster[i]
let shouldInclude = false
if (this.filterMode == FilterMode.All) {
shouldInclude = true
} else if (this.filterMode == FilterMode.OnlyAbnormal && it.alert) {
shouldInclude = true
} else if (this.filterMode == FilterMode.OnlyOffline && !it.online) {
shouldInclude = true
} else if (this.filterMode == FilterMode.LowBattery && (it.battery !== null && it.battery < 20)) {
shouldInclude = true
}
if (shouldInclude) {
if (it.alert) {
alerts.push(it)
} else {
normals.push(it)
}
}
}
// 异常卡片排在前面
return alerts.concat(normals)
},
realtimeStatusText() : string {
switch (this.realtimeStatus) {
case 'connecting': return '连接中'
case 'connected': return '已连接'
case 'error': return '异常'
default: return '未连接'
}
},
lastRealtimeEventText() : string {
if (this.lastRealtimeEventAt <= 0) return '--'
return this.formatTime(this.lastRealtimeEventAt)
}
},
methods: {
noop() { },
generateUuid() : string {
const template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'
let uuid = ''
for (let i = 0; i < template.length; i++) {
const ch = template.charAt(i)
if (ch == 'x' || ch == 'y') {
const rand = Math.floor(Math.random() * 16)
if (ch == 'x') {
uuid += rand.toString(16)
} else {
uuid += ((rand & 0x3) | 0x8).toString(16)
}
} else {
uuid += ch
}
}
return uuid
},
formatTime(timestamp : number) : string {
if (timestamp <= 0) return '--'
const date = new Date(timestamp)
const pad = (value : number) : string => value < 10 ? `0${value}` : `${value}`
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
},
markRealtimeEvent(evt ?: TrainingStreamEvent | null) : number {
let timestamp = Date.now()
if (evt != null) {
const recorded = (evt.recorded_at ?? evt.ingested_at ?? '')
if (recorded != null && recorded !== '') {
const parsed = Date.parse(recorded)
if (!isNaN(parsed)) {
timestamp = parsed
}
}
}
this.lastRealtimeEventAt = timestamp
if (this.realtimeStatus !== 'connected') {
this.realtimeStatus = 'connected'
}
return timestamp
},
getCardClasses(item : StudentCardValue) : any {
return {
'offline': !item.online,
'alert': item.alert,
'priority': item.alert
}
},
displayValue(v : number | null) : string {
return v != null ? ('' + v) : '--'
},
displayPercent(v : number | null) : string {
return v != null ? (v + '%') : '--'
},
displayDevice(deviceId : string | null) : string {
return deviceId != null ? deviceId : '未绑定'
},
getMetricIcon(metric : string) : string {
switch (metric) {
case 'hr': return '❤️'
case 'battery': return '🔋'
case 'spo2': return 'O₂'
case 'temp': return '🌡️'
case 'steps': return '👟'
case 'device': return '⌚'
default: return ''
}
},
displayTime(timestamp : number | null) : string {
if (timestamp == null || timestamp <= 0) return '--'
return this.formatTime(timestamp)
},
getMetricClass(metric : string, value : number | null) : string {
if (value == null) return ''
let thresholds : ThresholdsStands | null = null
if (metric == 'hr') {
thresholds = this.thresholds.hr
} else if (metric == 'battery') {
thresholds = this.thresholds.battery
} else if (metric == 'spo2') {
thresholds = this.thresholds.spo2
} else if (metric == 'temp') {
thresholds = this.thresholds.temp
}
if (thresholds == null) return ''
if (metric == 'hr' || metric == 'temp') {
// Higher values are worse for hr and temp
if (value >= thresholds.danger) return 'metric-danger'
if (value >= thresholds.warning) return 'metric-warning'
} else if (metric == 'battery' || metric == 'spo2') {
// Lower values are worse for battery and spo2
if (value <= thresholds.danger) return 'metric-danger'
if (value <= thresholds.warning) return 'metric-warning'
}
return 'metric-normal'
},
onToggleFilter() { this.filterMode = this.nextFilterMode(this.filterMode) },
nextFilterMode(mode : FilterMode) : FilterMode {
const numeric = mode as number
const next = (numeric + 1) % 4
return next as FilterMode
},
toggleThresholdSettings() {
this.showThresholdSettings = !this.showThresholdSettings
},
onToggleMock(event : any) {
const detail = event != null ? (event as UTSJSONObject).get('detail') : null
const value = detail != null ? (detail as UTSJSONObject).get('value') : event
const enabled = value == true
if (this.useMock == enabled) return
this.useMock = enabled
this.mockFinishFlow(true)
if (this.useMock) {
if (this.trainingRealtimeSub != null) {
try { this.trainingRealtimeSub.dispose?.() } catch (_) { }
this.trainingRealtimeSub = null
this.realtimeStatus = 'idle'
}
this.trainingState = TrainingState.Idle
this.trainingId = ''
this.roster = this.generateMockRoster(24)
} else {
this.realtimeStatus = 'connecting'
this.lastRealtimeEventAt = 0
this.initializeServerMode()
}
},
generateMockRoster(count : number) : StudentCardValue[] {
const result : StudentCardValue[] = []
const total = count > 0 ? count : 24
for (let i = 1; i <= total; i++) {
result.push({
studentId: `mock_stu_${i}`,
name: `模拟学生${i}`,
deviceId: i % 6 == 0 ? null : `mock_dev_${i}`,
online: false,
battery: null,
hr: null,
steps: null,
spo2: null,
temp: null,
alert: false,
hasAck: false,
lastEventAt: null
})
}
return result
},
async initializeServerMode() {
try {
await this.loadTeacherClasses(false)
await this.refreshRoster()
await this.ensureActiveTrainingSession()
this.reconnectStream()
} catch (err) {
console.error('initializeServerMode failed', err)
}
},
async loadTeacherClasses(force : boolean) {
if (this.useMock && !force) {
return
}
if (!force && this.classes.length > 0) {
return
}
this.isClassLoading = true
try {
await supaReady
const session = supa.getSession()
const userId = session.user?.getString('id') ?? ''
if (userId == '') {
console.warn('无法获取教师用户ID使用默认班级列表')
this.classes = []
this.classNames = []
return
}
const res = await supa
.from('ak_teacher_roles')
.select('class_id, ak_classes(id, name)', {})
.eq('user_id', userId)
.execute()
if (res.status >= 200 && res.status < 300 && res.data != null) {
const list : ClassInfo[] = []
const raw = res.data as any
if (Array.isArray(raw)) {
for (let i = 0; i < raw.length; i++) {
const row = raw[i]
if (row == null) continue
let classId : string | null = null
let className : string | null = null
try { classId = (row as UTSJSONObject).get('class_id') as string } catch (_) { classId = null }
const clazz = (row as UTSJSONObject).get('ak_classes')
if (clazz != null) {
try { className = (clazz as UTSJSONObject).get('name') as string } catch (_) { className = null }
if (classId == null) {
try { classId = (clazz as UTSJSONObject).get('id') as string } catch (_) { classId = null }
}
}
if (classId != null && classId !== '') {
list.push({ id: classId, name: className != null ? className : classId })
}
}
}
if (list.length > 0) {
this.classes = list
this.classNames = list.map((item) => item.name)
if (this.selectedClassIndex >= list.length) {
this.selectedClassIndex = 0
}
return
}
}
console.warn('未找到教师班级数据,使用空列表')
this.classes = []
this.classNames = []
} catch (err) {
console.error('loadTeacherClasses error', err)
this.classes = []
this.classNames = []
} finally {
this.isClassLoading = false
}
},
clearMockTimers() {
if (this.mockTimers.ack != null) {
clearInterval(this.mockTimers.ack as number)
this.mockTimers.ack = null
}
if (this.mockTimers.live != null) {
clearInterval(this.mockTimers.live as number)
this.mockTimers.live = null
}
if (this.mockTimers.timeout != null) {
clearTimeout(this.mockTimers.timeout as number)
this.mockTimers.timeout = null
}
},
mockFinishFlow(manual : boolean) {
this.clearMockTimers()
this.isStarting = false
this.isStopping = false
if (manual !== false) {
this.trainingState = TrainingState.Idle
this.trainingId = ''
this.realtimeStatus = 'idle'
this.lastRealtimeEventAt = 0
return
}
this.trainingState = TrainingState.Idle
this.trainingId = ''
this.realtimeStatus = 'idle'
this.lastRealtimeEventAt = 0
try {
uni.showToast({ title: '模拟训练结束', icon: 'none' })
} catch (_) { }
},
getThresholdValue(metric : string, level : string) : number {
const metricObj = (this.thresholds as UTSJSONObject).get(metric) as ThresholdsStands | null
if (metricObj == null) return 0
const value = (metricObj as UTSJSONObject).get(level) as number | null
return value != null ? value : 0
},
updateThreshold(metric : string, level : string, event : UniSliderChangeEvent) {
const value = event.detail != null ? event.detail.value : event
const metricObj = (this.thresholds as UTSJSONObject).get(metric) as ThresholdsStands | null
if (metricObj != null) {
(metricObj as UTSJSONObject).set(level, value as number)
this.thresholds = this.thresholds
}
},
openClassPicker() {
this.classPickerValue = [this.selectedClassIndex]
this.showClassPicker = true
},
closeClassPicker() {
this.showClassPicker = false
},
onClassPickerChange(e : UniPickerViewChangeEvent) {
if (e == null) return
const detail = e.detail
if (detail == null) return
const value = detail.value as Array<number> | null
if (value == null) return
if (value.length > 0) {
this.classPickerValue = value
}
},
confirmClassPicker() {
const value = this.classPickerValue
let index = 0
if (value != null && value.length > 0) {
index = value[0]
}
this.applyClassSelection(index)
this.showClassPicker = false
},
applyClassSelection(index : number) {
if (index < 0) index = 0
if (index >= this.classes.length) {
if (this.classes.length > 0) {
index = this.classes.length - 1
} else {
index = 0
}
}
this.selectedClassIndex = index
this.refreshRoster()
this.reconnectStream()
},
async refreshRoster() {
if (this.useMock) {
this.roster = this.generateMockRoster(24)
return
}
const classId = this.currentClassId
if (classId == null || classId == '') {
this.roster = []
return
}
this.isRosterLoading = true
try {
await supaReady
const res = await supa
.from('ak_users')
.select('id, username, ak_devices(id, status)', {})
.eq('class_id', classId)
.eq('role', 'student')
.execute()
const rosterList : StudentCardValue[] = []
if (res.status >= 200 && res.status < 300 && res.data != null) {
const raw = res.data as any
if (Array.isArray(raw)) {
for (let i = 0; i < raw.length; i++) {
const row = raw[i]
if (row == null) continue
let studentId : string = ''
try { studentId = (row as UTSJSONObject).get('id') as string } catch (_) { studentId = '' }
if (studentId == null || studentId == '') continue
let name = ''
try { name = (row as UTSJSONObject).get('username') as string } catch (_) { name = '' }
if (name == null || name == '') name = '未命名学生'
let deviceId : string | null = null
try {
const devices = (row as UTSJSONObject).get('ak_devices') as any
if (Array.isArray(devices) && devices.length > 0) {
const deviceRow = devices.get(0) as UTSJSONObject | null
if (deviceRow != null) {
deviceId = deviceRow.getString('id')
}
}
} catch (_) { deviceId = null }
rosterList.push({
studentId: studentId,
name: name,
deviceId: deviceId,
online: false,
battery: null,
hr: null,
steps: null,
spo2: null,
temp: null,
alert: false,
hasAck: false,
lastEventAt: null
})
}
}
}
if (rosterList.length == 0) {
console.warn('roster empty for class', classId)
}
this.roster = rosterList
} catch (err) {
console.error('refreshRoster error', err)
this.roster = []
} finally {
this.isRosterLoading = false
}
},
async ensureActiveTrainingSession() {
if (this.useMock) return
const classId = this.currentClassId
if (classId == null || classId == '') {
this.trainingId = ''
this.trainingState = TrainingState.Idle
return
}
this.isSessionLoading = true
try {
await supaReady
const res = await supa
.from('training_stream_events')
.select('training_id, status, event_type, recorded_at', {})
.eq('class_id', classId)
.or('event_type.eq.state,event_type.eq.session')
.order('recorded_at', { ascending: false })
.limit(1)
.execute()
let trainingId : string | null = null
let statusText : string | null = null
let recordedAt : string | null = null
if (res.status >= 200 && res.status < 300 && res.data != null) {
const rows = res.data as any
if (Array.isArray(rows) && rows.length > 0) {
const row = rows[0] as UTSJSONObject | null
if (row != null) {
try { trainingId = row.getString('training_id') } catch (_) { trainingId = null }
try { statusText = row.getString('status') } catch (_) { statusText = null }
try { recordedAt = row.getString('recorded_at') } catch (_) { recordedAt = null }
}
}
}
if (trainingId == null || trainingId == '') {
this.trainingId = ''
this.trainingState = TrainingState.Idle
return
}
const statusLower = statusText != null ? statusText.toLowerCase() : ''
if (statusLower == 'stopped' || statusLower == 'ended' || statusLower == 'completed') {
this.trainingId = ''
this.trainingState = TrainingState.Idle
return
}
if (recordedAt != null && recordedAt !== '') {
try {
const ts = Date.parse(recordedAt)
if (!isNaN(ts)) {
const diff = Date.now() - ts
const sixHours = 6 * 60 * 60 * 1000
if (diff > sixHours) {
this.trainingId = ''
this.trainingState = TrainingState.Idle
return
}
}
} catch (_) { }
}
this.trainingId = trainingId
this.trainingState = this.mapStatusToState(statusLower)
await this.preloadTrainingSnapshots(trainingId)
this.reconnectStream()
} catch (err) {
console.error('ensureActiveTrainingSession error', err)
} finally {
this.isSessionLoading = false
}
},
mapStatusToState(statusLower : string) : TrainingState {
if (statusLower == 'starting' || statusLower == 'waiting') return TrainingState.Starting
if (statusLower == 'stopping') return TrainingState.Stopping
if (statusLower == 'stopped' || statusLower == 'ended' || statusLower == 'completed') return TrainingState.Idle
return TrainingState.InProgress
},
async preloadTrainingSnapshots(trainingId : string) {
try {
await this.fetchAckSnapshots(trainingId)
await this.fetchLatestMetricsSnapshots(trainingId)
} catch (err) {
console.error('preloadTrainingSnapshots error', err)
}
},
async fetchAckSnapshots(trainingId : string) {
if (this.useMock) return
const ready = await supaReady
if (!ready) {
throw new Error('Supabase 未就绪')
}
const res = await supa
.from('training_stream_events')
.select('training_id, class_id, student_id, device_id, ack, status, metrics, payload, ingest_source, ingest_note, recorded_at, ingested_at, event_type', {})
.eq('training_id', trainingId)
.or('event_type.eq.ack,event_type.eq.ack_pending,event_type.eq.ack_fail')
.order('recorded_at', { ascending: true })
.limit(200)
.execute()
if (res.status >= 200 && res.status < 300 && res.data != null) {
const rows = res.data
if (Array.isArray(rows)) {
for (let i = 0; i < rows.length; i++) {
const row = new UTSJSONObject(rows[i])
if (row == null) continue
const event = this.convertRowToStreamEvent(row)
if (event != null) {
this.handleRealtimeAck(event)
}
}
}
}
},
async fetchLatestMetricsSnapshots(trainingId : string) {
if (this.useMock) return
const ready = await supaReady
if (!ready) {
throw new Error('Supabase 未就绪')
}
const res = await supa
.from('training_stream_latest_metrics')
.select('training_id, class_id, student_id, device_id, metrics, payload, status, recorded_at, ingested_at', {})
.eq('training_id', trainingId)
.execute()
if (res.status >= 200 && res.status < 300 && res.data != null) {
const rows = res.data as any
if (Array.isArray(rows)) {
for (let i = 0; i < rows.length; i++) {
const row = rows[i]
if (row == null) continue
const event = this.convertRowToStreamEvent(row as UTSJSONObject)
if (event != null) {
event.event_type = 'metrics'
this.handleRealtimeMetrics(event)
}
}
}
}
},
convertRowToStreamEvent(row : UTSJSONObject) : TrainingStreamEvent | null {
if (row == null) return null
let trainingId : string = ''
try { trainingId = row.getString('training_id') ?? '' } catch (_) { trainingId = '' }
if (trainingId == '') return null
let eventType : string = ''
try { eventType = row.getString('event_type') ?? '' } catch (_) { eventType = '' }
if (eventType == null || eventType == '') eventType = 'metrics'
let classId : string | null = null
try { classId = row.getString('class_id') } catch (_) { classId = null }
let studentId : string | null = null
try { studentId = row['student_id'] as string } catch (_) { studentId = null }
let deviceId : string | null = null
try { deviceId = row['device_id'] as string } catch (_) { deviceId = null }
let ackVal : boolean | null = null
try {
const ackRaw = row['ack']
if (typeof ackRaw == 'boolean') ackVal = ackRaw
} catch (_) { ackVal = null }
let status : string | null = null
try { status = row['status'] as string } catch (_) { status = null }
let metrics : UTSJSONObject | null = null
try {
const rawMetrics = row['metrics']
metrics = rawMetrics != null ? new UTSJSONObject(rawMetrics) : null
} catch (_) { metrics = null }
let payload : UTSJSONObject | null = null
try {
const rawPayload = row['payload']
payload = rawPayload != null ? new UTSJSONObject(rawPayload) : null
} catch (_) { payload = null }
let ingestSource : string | null = null
try { ingestSource = row['ingest_source'] as string } catch (_) { ingestSource = null }
let ingestNote : string | null = null
try { ingestNote = row['ingest_note'] as string } catch (_) { ingestNote = null }
let recordedAt : string | null = null
try { recordedAt = row['recorded_at'] as string } catch (_) { recordedAt = null }
let ingestedAt : string | null = null
try { ingestedAt = row['ingested_at'] as string } catch (_) { ingestedAt = null }
return {
training_id: trainingId,
event_type: eventType,
class_id: classId,
student_id: studentId,
device_id: deviceId,
ack: ackVal,
status: status,
metrics: metrics,
payload: payload,
ingest_source: ingestSource,
ingest_note: ingestNote,
recorded_at: recordedAt,
ingested_at: ingestedAt
}
},
evaluateCardAlert(card : StudentCardValue) : boolean {
if (card == null) return false
const hrDanger = this.thresholds.hr.danger
const batteryDanger = this.thresholds.battery.danger
const spo2Danger = this.thresholds.spo2.danger
const tempDanger = this.thresholds.temp.danger
if (card.hr != null && card.hr >= hrDanger) return true
if (card.battery != null && card.battery <= batteryDanger) return true
if (card.spo2 != null && card.spo2 <= spo2Danger) return true
if (card.temp != null && card.temp >= tempDanger) return true
return false
},
async onStart() {
if (this.currentClassId == null || this.currentClassId == '') { uni.showToast({ title: '请选择班级', icon: 'none' }); return }
if (this.useMock) {
this.isStarting = true
this.trainingId = `mock_${Date.now()}`
this.trainingState = TrainingState.Starting
this.roster = this.generateMockRoster(24)
this.clearMockTimers()
// 初始 ack 状态
this.mockStartFlow(MOCK_SESSION_DURATION_MS)
return
}
this.isStarting = true
try {
await this.refreshRoster()
const res = await this.startTrainingRequest(this.currentClassId)
const resObj = res as UTSJSONObject
let trainingIdValue = ''
try { trainingIdValue = resObj.getString('training_id') ?? '' } catch (_) {
try {
const rawVal = resObj.get('training_id')
if (typeof rawVal == 'string') trainingIdValue = rawVal
} catch (_) {
const fallback = res['training_id']
if (typeof fallback == 'string') trainingIdValue = fallback
}
}
if (trainingIdValue == null || trainingIdValue == '') {
throw new Error('缺少训练会话ID')
}
this.trainingId = trainingIdValue
this.trainingState = TrainingState.Starting
this.markUnboundAsIssue()
this.reconnectStream()
} catch (e) {
console.error('start training failed', e)
const msg = (e instanceof Error && typeof e.message == 'string' && e.message !== '') ? e.message : '开始失败'
uni.showToast({ title: msg, icon: 'none' })
} finally {
this.isStarting = false
}
},
async onStop() {
if (this.trainingId == null || this.trainingId == '') return
if (this.useMock) {
this.mockFinishFlow(true)
return
}
this.isStopping = true
try {
await this.stopTrainingRequest(this.currentClassId, this.trainingId)
this.trainingState = TrainingState.Stopping
// 短暂延迟后归位
setTimeout(() => {
this.trainingState = TrainingState.Idle
this.trainingId = ''
}, 800)
} catch (e) {
console.error('stop training failed', e)
const msg = (e instanceof Error && typeof e.message == 'string' && e.message !== '') ? e.message : '结束失败'
uni.showToast({ title: msg, icon: 'none' })
} finally {
this.isStopping = false
}
},
retryPending() {
if (this.trainingId == null || this.trainingId == '') return
// 简化:对未应答且有设备号的再发一次
const targets : string[] = []
for (let i = 0; i < this.roster.length; i++) {
const it = this.roster[i]
if (!it.hasAck && it.deviceId != null) targets.push(it.deviceId)
}
if (targets.length == 0) return
// 调后端重试(此处略)
uni.showToast({ title: `已重试 ${targets.length} 台`, icon: 'none' })
},
markPendingAsIssue() {
for (let i = 0; i < this.roster.length; i++) {
const it = this.roster[i]
if (!it.hasAck) it.alert = true
}
},
markUnboundAsIssue() {
for (let i = 0; i < this.roster.length; i++) {
const it = this.roster[i]
if (it.deviceId == null) it.alert = true
}
},
openStudent(item : StudentCardValue) {
if (item == null) return
this.selectedStudent = item
this.showStudentDetail = true
this.studentDetailLoading = true
this.studentDetailError = ''
this.studentHistoryPoints = []
this.studentDetailActiveMetric = 'hr'
this.studentDetailChartOption = this.createLineChartOption([], [], this.getMetricColor('hr'))
this.studentDetailLatest.hr = item.hr
this.studentDetailLatest.spo2 = item.spo2
this.studentDetailLatest.steps = item.steps
this.studentDetailLatest.battery = item.battery
this.studentDetailLatest.temp = item.temp
this.studentDetailLastTimestamp = item.lastEventAt != null ? item.lastEventAt : 0
if (this.useMock) {
this.populateMockHistory(item)
this.studentDetailLoading = false
this.buildStudentDetailChart()
return
}
if (this.trainingId == null || this.trainingId == '') {
this.studentDetailLoading = false
this.studentDetailError = '当前没有进行中的训练'
return
}
this.loadStudentHistory(item).finally(() => {
this.studentDetailLoading = false
})
},
closeStudentDetail() {
this.showStudentDetail = false
this.selectedStudent = null
},
async loadStudentHistory(item : StudentCardValue) {
try {
const ready = await supaReady
if (!ready) {
throw new Error('Supabase 未就绪')
}
const res = await supa
.from('training_stream_events')
.select('recorded_at, metrics, payload, event_type, status', {})
.eq('training_id', this.trainingId)
.eq('student_id', item.studentId)
.or('event_type.eq.telemetry,event_type.eq.metrics')
.order('recorded_at', { ascending: true })
.limit(240)
.execute()
if (res.status >= 200 && res.status < 300 && res.data != null) {
const rows = res.data as any
if (Array.isArray(rows)) {
this.studentHistoryPoints = this.transformHistoryRows(rows)
this.updateStudentDetailLatest()
this.buildStudentDetailChart()
return
}
}
if (res.error != null && typeof res.error.message == 'string') {
throw new Error(res.error.message)
}
this.studentHistoryPoints = []
this.studentDetailError = '暂无历史记录'
} catch (err) {
console.error('loadStudentHistory error', err)
const msg = (err instanceof Error && typeof err.message == 'string') ? err.message : '无法加载学生历史记录'
this.studentDetailError = msg
this.studentHistoryPoints = []
}
},
transformHistoryRows(rows : any[]) : Array<StudentHistoryPoint> {
const list : Array<StudentHistoryPoint> = []
for (let i = 0; i < rows.length; i++) {
const row = rows.get(i) as UTSJSONObject | null
if (row == null) continue
const recordedAtRaw = row.getString('recorded_at')
let timestamp = Date.now()
if (recordedAtRaw != null && recordedAtRaw !== '') {
const parsed = Date.parse(recordedAtRaw)
if (!isNaN(parsed)) {
timestamp = parsed
}
}
const metricsObj = this.toJSONObject(row.get('metrics') as any)
const payloadObj = this.toJSONObject(row.get('payload') as any)
const source = metricsObj != null ? metricsObj : payloadObj
const point : StudentHistoryPoint = {
timestamp: timestamp,
label: this.formatTime(timestamp),
hr: this.readNumber(source, 'hr'),
spo2: this.readNumber(source, 'spo2'),
steps: this.readNumber(source, 'steps'),
battery: this.readNumber(source, 'battery'),
temp: this.readNumber(source, 'temp')
}
list.push(point)
}
return list
},
toJSONObject(value : any) : UTSJSONObject | null {
if (value == null) return null
try {
if (value instanceof UTSJSONObject) return value
return new UTSJSONObject(value)
} catch (_) {
return null
}
},
updateStudentDetailLatest() {
const marker : StudentMetricMap = {
hr: null,
spo2: null,
steps: null,
battery: null,
temp: null
}
let lastTimestamp = 0
for (let i = 0; i < this.studentHistoryPoints.length; i++) {
const point = this.studentHistoryPoints[i]
if (point.timestamp > lastTimestamp) {
lastTimestamp = point.timestamp
}
if (point.hr != null) marker.hr = point.hr
if (point.spo2 != null) marker.spo2 = point.spo2
if (point.steps != null) marker.steps = point.steps
if (point.battery != null) marker.battery = point.battery
if (point.temp != null) marker.temp = point.temp
}
this.studentDetailLatest.hr = marker.hr
this.studentDetailLatest.spo2 = marker.spo2
this.studentDetailLatest.steps = marker.steps
this.studentDetailLatest.battery = marker.battery
this.studentDetailLatest.temp = marker.temp
if (lastTimestamp > 0) {
this.studentDetailLastTimestamp = lastTimestamp
}
},
buildStudentDetailChart() {
const metric = this.studentDetailActiveMetric
const data : number[] = []
const labels : string[] = []
for (let i = 0; i < this.studentHistoryPoints.length; i++) {
const point = this.studentHistoryPoints[i]
const value = this.getPointMetric(point, metric)
if (value != null) {
data.push(value)
labels.push(point.label)
}
}
this.studentDetailChartOption = this.createLineChartOption(data, labels, this.getMetricColor(metric))
},
switchDetailMetric(metric : StudentMetricKey) {
if (metric == this.studentDetailActiveMetric) return
this.studentDetailActiveMetric = metric
this.buildStudentDetailChart()
},
getMetricLabel(metric : StudentMetricKey) : string {
switch (metric) {
case 'hr': return '心率'
case 'spo2': return '血氧'
case 'steps': return '步数'
case 'battery': return '电量'
case 'temp': return '体温'
}
return metric
},
getMetricUnit(metric : StudentMetricKey) : string {
switch (metric) {
case 'hr': return 'bpm'
case 'spo2': return '%'
case 'steps': return '步'
case 'battery': return '%'
case 'temp': return '°C'
}
return ''
},
formatDetailMetricValue(metric : StudentMetricKey, value : number | null) : string {
if (value == null) return '--'
const unit = this.getMetricUnit(metric)
if (metric == 'temp') {
return `${value.toFixed(1)}${unit}`
}
return unit !== '' ? `${value}${unit}` : `${value}`
},
getMetricColor(metric : StudentMetricKey) : string {
switch (metric) {
case 'hr': return '#ff6b6b'
case 'spo2': return '#45b7d1'
case 'steps': return '#4ecdc4'
case 'battery': return '#f6c23e'
case 'temp': return '#fd7e14'
}
return '#4e73df'
},
createLineChartOption(data : number[], labels : string[], color : string) : UTSJSONObject {
const option = new UTSJSONObject()
option.set('type', 'line')
option.set('data', data)
option.set('labels', labels)
option.set('color', color)
return option
},
summarizePoint(point : StudentHistoryPoint) : string {
const pieces : string[] = []
const pushIfExists = (metric : StudentMetricKey) => {
const value = this.getPointMetric(point, metric)
if (value != null) {
pieces.push(`${this.getMetricLabel(metric)} ${this.formatDetailMetricValue(metric, value)}`)
}
}
for (let i = 0; i < this.studentDetailMetricOrder.length; i++) {
pushIfExists(this.studentDetailMetricOrder[i])
}
return pieces.length > 0 ? pieces.join(' ') : '无有效指标'
},
populateMockHistory(item : StudentCardValue) {
const now = Date.now()
const list : Array<StudentHistoryPoint> = []
let lastHr = item.hr != null ? item.hr : 95
let lastSpo2 = item.spo2 != null ? item.spo2 : 96
let lastSteps = item.steps != null ? item.steps : 200
let lastBattery = item.battery != null ? item.battery : 70
let lastTemp = item.temp != null ? item.temp : 36.5
for (let i = 12; i >= 0; i--) {
const timestamp = now - i * 60 * 1000
lastHr = lastHr + Math.floor(Math.random() * 6 - 3)
lastSpo2 = lastSpo2 + Math.floor(Math.random() * 4 - 2)
lastSteps = lastSteps + Math.floor(Math.random() * 30)
lastBattery = Math.max(0, lastBattery - Math.floor(Math.random() * 2))
const tempDelta = (Math.random() * 0.2) - 0.1
lastTemp = Math.round((lastTemp + tempDelta) * 10) / 10
list.push({
timestamp: timestamp,
label: this.formatTime(timestamp),
hr: Math.max(60, lastHr),
spo2: Math.max(90, Math.min(100, lastSpo2)),
steps: Math.max(0, lastSteps),
battery: Math.max(0, Math.min(100, lastBattery)),
temp: Math.max(35, Math.min(40, Math.round(lastTemp * 10) / 10))
})
}
this.studentHistoryPoints = list
this.updateStudentDetailLatest()
},
getStudentDetailMetric(metric : StudentMetricKey) : number | null {
switch (metric) {
case 'hr': return this.studentDetailLatest.hr
case 'spo2': return this.studentDetailLatest.spo2
case 'steps': return this.studentDetailLatest.steps
case 'battery': return this.studentDetailLatest.battery
case 'temp': return this.studentDetailLatest.temp
default: return null
}
},
getPointMetric(point : StudentHistoryPoint, metric : StudentMetricKey) : number | null {
switch (metric) {
case 'hr': return this.studentDetailLatest.hr
case 'spo2': return this.studentDetailLatest.spo2
case 'steps': return this.studentDetailLatest.steps
case 'battery': return this.studentDetailLatest.battery
case 'temp': return this.studentDetailLatest.temp
default: return null
}
},
getPointMetric(point : StudentHistoryPoint, metric : StudentMetricKey) : number | null {
switch (metric) {
case 'hr': return point.hr
case 'spo2': return point.spo2
case 'steps': return point.steps
case 'battery': return point.battery
case 'temp': return point.temp
}
return null
},
findRosterIndex(evt : TrainingStreamEvent) : number {
if (evt == null) return -1
const deviceId = (evt.device_id ?? '').trim()
const studentId = (evt.student_id ?? '').trim()
for (let i = 0; i < this.roster.length; i++) {
const item = this.roster[i]
if (deviceId !== '' && item.deviceId == deviceId) return i
if (studentId !== '' && item.studentId == studentId) return i
}
return -1
},
readNumber(obj : UTSJSONObject | null, key : string) : number | null {
if (obj == null || key == null || key == '') return null
try {
const direct = obj[key] as number | string | null
if (typeof direct == 'number') return direct
if (typeof direct == 'string') {
const parsed = parseFloat(direct)
if (!isNaN(parsed)) return parsed
}
let viaGet : number | string | null = null
try {
viaGet = obj.get(key) as number | string
} catch (_) {
viaGet = null
}
if (typeof viaGet == 'number') return viaGet as number
if (typeof viaGet == 'string') {
const parsedVal = parseFloat(viaGet as string)
if (!isNaN(parsedVal)) return parsedVal
}
} catch (_) { }
return null
},
handleRealtimeAck(evt : TrainingStreamEvent) {
const timestamp = this.markRealtimeEvent(evt)
const idx = this.findRosterIndex(evt)
if (idx < 0) return
const card = this.roster[idx]
const ackStatus = evt.ack != null ? evt.ack : (evt.status == 'ok' || evt.status == 'success')
if (ackStatus) {
card.hasAck = true
card.online = true
} else {
card.alert = true
}
const payload = evt.metrics ?? evt.payload
if (payload != null) {
const batteryVal = this.readNumber(payload, 'battery')
if (batteryVal != null) card.battery = Math.floor(batteryVal)
const stepsVal = this.readNumber(payload, 'steps')
if (stepsVal != null) card.steps = Math.floor(stepsVal)
}
card.lastEventAt = timestamp
card.alert = !ackStatus || this.evaluateCardAlert(card)
this.roster[idx] = card
this.roster = this.roster.slice()
if (this.trainingState == TrainingState.Starting && this.pendingAckCount == 0) {
this.trainingState = TrainingState.InProgress
}
},
handleRealtimeMetrics(evt : TrainingStreamEvent) {
const timestamp = this.markRealtimeEvent(evt)
const idx = this.findRosterIndex(evt)
if (idx < 0) return
const card = this.roster[idx]
const payload = evt.metrics ?? evt.payload
if (payload != null) {
const hrVal = this.readNumber(payload, 'hr')
if (hrVal != null) card.hr = Math.round(hrVal)
const stepsVal = this.readNumber(payload, 'steps')
if (stepsVal != null) card.steps = Math.round(stepsVal)
const spo2Val = this.readNumber(payload, 'spo2')
if (spo2Val != null) card.spo2 = Math.round(spo2Val)
const tempVal = this.readNumber(payload, 'temp')
if (tempVal != null) {
const rounded = Math.round(tempVal * 10) / 10
card.temp = rounded
}
const batteryVal = this.readNumber(payload, 'battery')
if (batteryVal != null) card.battery = Math.max(0, Math.min(100, Math.round(batteryVal)))
}
card.online = true
card.hasAck = true
card.lastEventAt = timestamp
card.alert = this.evaluateCardAlert(card)
this.roster[idx] = card
this.roster = this.roster.slice()
},
handleRealtimeState(evt : TrainingStreamEvent) {
if (evt == null) return
const timestamp = this.markRealtimeEvent(evt)
const status = (evt.status ?? '').toLowerCase()
if (status == '') return
switch (status) {
case 'starting':
case 'waiting':
this.trainingState = TrainingState.Starting
break
case 'in_progress':
case 'running':
this.trainingState = TrainingState.InProgress
break
case 'stopping':
this.trainingState = TrainingState.Stopping
break
case 'stopped':
case 'ended':
case 'completed':
this.trainingState = TrainingState.Idle
if (evt.training_id == this.trainingId) {
this.trainingId = ''
if (this.trainingRealtimeSub != null) {
try { this.trainingRealtimeSub.dispose?.() } catch (_) { }
this.trainingRealtimeSub = null
this.realtimeStatus = 'idle'
}
if (this.lastRealtimeEventAt <= 0) {
this.lastRealtimeEventAt = timestamp
}
}
break
default:
break
}
},
handleRealtimeError(err : any) {
this.realtimeStatus = 'error'
console.error('training realtime error', err)
const now = Date.now()
if (now - this.lastRealtimeErrorAt > 5000) {
this.lastRealtimeErrorAt = now
try {
uni.showToast({ title: '训练实时通道异常', icon: 'none' })
} catch (_) { }
}
},
// --- 后端请求占位 ---
async startTrainingRequest(classId : string) : Promise<UTSJSONObject> {
if (this.useMock) {
return new Promise<UTSJSONObject>((resolve) => {
setTimeout(() => resolve(new UTSJSONObject({ training_id: `trn_${Date.now()}` })), 300)
})
}
await supaReady
const trainingId = this.generateUuid()
const nowIso = new Date().toISOString()
const totalStudents = this.roster.length
let expectedParticipants = 0
for (let i = 0; i < this.roster.length; i++) {
if (this.roster[i].deviceId != null && this.roster[i].deviceId !== '') {
expectedParticipants++
}
}
const basePayload = {
class_id: classId,
training_id: trainingId,
expected_participants: expectedParticipants,
student_count: totalStudents,
issued_by: 'teacher_app'
}
const insertRows : Array<UTSJSONObject> = []
insertRows.push({
training_id: trainingId,
event_type: 'session',
class_id: classId,
status: 'starting',
ack: null,
metrics: null,
payload: basePayload,
ingest_source: 'teacher_app',
ingest_note: 'manual_start',
recorded_at: nowIso,
ingested_at: nowIso
} as UTSJSONObject)
insertRows.push({
training_id: trainingId,
event_type: 'state',
class_id: classId,
status: 'starting',
ack: null,
metrics: null,
payload: basePayload,
ingest_source: 'teacher_app',
ingest_note: 'manual_start',
recorded_at: nowIso,
ingested_at: nowIso
} as UTSJSONObject)
const res = await supa
.from('training_stream_events')
.insert(insertRows)
.execute()
if (res.status < 200 || res.status >= 300) {
console.log(res)
const errMsg = (res.error != null && typeof res.error.message == 'string') ? res.error.message : '无法创建训练会话'
console.error('startTrainingRequest insert failed', res.error ?? res.status)
throw new Error(errMsg)
}
return new UTSJSONObject({ training_id: trainingId })
},
async stopTrainingRequest(classId : string, trainingId : string) : Promise<void> {
if (this.useMock) { return }
if (trainingId == null || trainingId == '') return
await supaReady
const nowIso = new Date().toISOString()
const payload = {
class_id: classId,
training_id: trainingId,
reason: 'teacher_manual_stop',
issued_by: 'teacher_app'
}
let ackCount = 0
let abnormalCount = 0
let onlineCount = 0
for (let i = 0; i < this.roster.length; i++) {
const card = this.roster[i]
if (card.hasAck) ackCount++
if (card.alert) abnormalCount++
if (card.online) onlineCount++
}
const summaryMetrics = {
ack_total: ackCount,
abnormal_total: abnormalCount,
online_total: onlineCount,
total_students: this.roster.length
}
const summaryPayload = Object.assign({}, payload, {
ack_total: ackCount,
abnormal_total: abnormalCount,
online_total: onlineCount,
total_students: this.roster.length,
stopped_at: nowIso
})
const rows : Array<UTSJSONObject> = []
rows.push({
training_id: trainingId,
event_type: 'state',
class_id: classId,
status: 'stopping',
ack: null,
metrics: null,
payload: payload,
ingest_source: 'teacher_app',
ingest_note: 'manual_stop',
recorded_at: nowIso,
ingested_at: nowIso
} as UTSJSONObject)
rows.push({
training_id: trainingId,
event_type: 'state',
class_id: classId,
status: 'stopped',
ack: null,
metrics: null,
payload: payload,
ingest_source: 'teacher_app',
ingest_note: 'manual_stop',
recorded_at: nowIso,
ingested_at: nowIso
} as UTSJSONObject)
rows.push({
training_id: trainingId,
event_type: 'summary',
class_id: classId,
status: 'completed',
ack: null,
metrics: summaryMetrics,
payload: summaryPayload,
ingest_source: 'teacher_app',
ingest_note: 'manual_stop',
recorded_at: nowIso,
ingested_at: nowIso
} as UTSJSONObject)
const res = await supa
.from('training_stream_events')
.insert(rows)
.execute()
if (res.status < 200 || res.status >= 300) {
const errMsg = (res.error != null && typeof res.error.message == 'string') ? res.error.message : '无法结束训练'
console.error('stopTrainingRequest insert failed', res.error ?? res.status)
throw new Error(errMsg)
}
},
// --- 实时通道WS ---
reconnectStream() {
if (this.trainingRealtimeSub != null) {
try { this.trainingRealtimeSub.dispose?.() } catch (_) { }
this.trainingRealtimeSub = null
this.realtimeStatus = 'idle'
}
if (this.useMock) {
this.realtimeStatus = 'idle'
return // mock 走本地定时器
}
if (this.trainingId == null || this.trainingId == '') {
this.realtimeStatus = 'idle'
return
}
this.realtimeStatus = 'connecting'
this.lastRealtimeEventAt = 0
TrainingRealtimeService.subscribeTrainingSession({
trainingId: this.trainingId,
classId: this.currentClassId
}, {
onAck: (evt) => { this.handleRealtimeAck(evt) },
onLiveMetrics: (evt) => { this.handleRealtimeMetrics(evt) },
onStateChange: (evt) => { this.handleRealtimeState(evt) },
onError: (err) => { this.handleRealtimeError(err) }
}).then((sub) => {
this.trainingRealtimeSub = sub
this.realtimeStatus = 'connected'
}).catch((err) => {
this.realtimeStatus = 'error'
this.handleRealtimeError(err as Error)
})
},
// --- MOCK: 模拟应答与遥测 ---
mockStartFlow(durationMs : number) {
this.clearMockTimers()
this.mockTimers.ack = setInterval(() => {
if (this.trainingState !== TrainingState.Starting) {
if (this.mockTimers.ack != null) {
clearInterval(this.mockTimers.ack as number)
this.mockTimers.ack = null
}
return
}
const pending : Array<number> = []
for (let i = 0; i < this.roster.length; i++) {
const item = this.roster[i]
if (!item.hasAck && item.deviceId != null) pending.push(i)
}
if (pending.length > 0) {
const targetIdx = pending[Math.floor(Math.random() * pending.length)]
const card = this.roster[targetIdx]
card.hasAck = true
card.online = true
card.battery = 60 + Math.floor(Math.random() * 35)
card.steps = 100 + Math.floor(Math.random() * 50)
card.hr = 85 + Math.floor(Math.random() * 25)
card.spo2 = 95 + Math.floor(Math.random() * 4)
const baseTemp = 36 + Math.round(Math.random() * 8) / 10
card.temp = Math.round(baseTemp * 10) / 10
this.roster[targetIdx] = card
this.roster = this.roster.slice()
}
if (this.pendingAckCount == 0) {
if (this.mockTimers.ack != null) {
clearInterval(this.mockTimers.ack as number)
this.mockTimers.ack = null
}
this.trainingState = TrainingState.InProgress
this.isStarting = false
}
for (let i = 0; i < this.roster.length; i++) {
const item = this.roster[i]
if (!item.hasAck && item.deviceId != null) pending.push(i)
}
if (pending.length > 0) {
const targetIdx = pending[Math.floor(Math.random() * pending.length)]
const card = this.roster[targetIdx]
card.hasAck = true
card.online = true
card.battery = 60 + Math.floor(Math.random() * 35)
card.steps = 100 + Math.floor(Math.random() * 50)
card.hr = 85 + Math.floor(Math.random() * 25)
card.spo2 = 95 + Math.floor(Math.random() * 4)
const baseTemp = 36 + Math.round(Math.random() * 8) / 10
card.temp = Math.round(baseTemp * 10) / 10
this.roster[targetIdx] = card
this.roster = this.roster.slice()
}
if (this.pendingAckCount == 0) {
if (this.mockTimers.ack != null) {
clearInterval(this.mockTimers.ack as number)
this.mockTimers.ack = null
}
this.trainingState = TrainingState.InProgress
this.isStarting = false
}
}, 300) as number
const liveInterval = setInterval(() => {
if (this.trainingState !== TrainingState.InProgress) return
let changed = false
for (let i = 0; i < this.roster.length; i++) {
const card = this.roster[i]
if (!card.online || card.deviceId == null) continue
const hr = 90 + Math.floor(Math.random() * 50)
const spo2 = 93 + Math.floor(Math.random() * 6)
const temp = 36 + Math.round(Math.random() * 15) / 10
const stepsBase = card.steps != null ? card.steps : 0
const steps = stepsBase + Math.floor(Math.random() * 40)
const batteryBase = card.battery != null ? card.battery : 80
const battery = Math.max(0, batteryBase - Math.floor(Math.random() * 3))
card.hr = hr
card.spo2 = spo2
card.temp = Math.round(temp * 10) / 10
card.steps = steps
card.battery = battery
const hrDanger = this.thresholds.hr.danger
const batteryDanger = this.thresholds.battery.danger
const spo2Danger = this.thresholds.spo2.danger
const tempDanger = this.thresholds.temp.danger
card.alert = (hr > hrDanger) || (battery < batteryDanger) || (spo2 < spo2Danger) || (card.temp != null && card.temp > tempDanger)
this.roster[i] = card
changed = true
}
if (changed) {
this.roster = this.roster.slice()
}
}, 1000) as number
this.mockTimers.live = liveInterval
const timeoutHandle = setTimeout(() => {
this.mockFinishFlow(false)
}, durationMs) as number
this.mockTimers.timeout = timeoutHandle
}
},
onLoad() {
if (this.useMock) {
const cls : ClassInfo[] = [
{ id: 'c_0901', name: '初三(1)班' },
{ id: 'c_0902', name: '初三(2)班' }
]
this.classes = cls
this.classNames = cls.map((c) => c.name)
this.selectedClassIndex = 0
this.refreshRoster()
return
}
this.classes = []
this.classNames = []
this.selectedClassIndex = 0
this.initializeServerMode()
},
onUnload() {
if (this.trainingRealtimeSub != null) {
try { this.trainingRealtimeSub.dispose?.() } catch (_) { }
this.trainingRealtimeSub = null
}
this.realtimeStatus = 'idle'
this.lastRealtimeEventAt = 0
if (this.useMock) {
this.mockFinishFlow(true)
} else {
TrainingRealtimeService.closeRealtime()
}
}
}
</script>
<style>
.page {
display: flex;
flex-direction: column;
height: 100%;
}
.header {
padding: 12px;
background: #fff;
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
}
.class-selector {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
background: #fff;
}
.picker-label {
font-size: 16px;
}
.status {
color: #666;
display: flex;
flex-direction:row;
align-items: center;
gap: 6px;
}
.status-separator {
color: #ccc;
}
.status-hint {
font-size: 12px;
color: #999;
}
.picker-overlay {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.picker-panel {
width: 260px;
background: #fff;
border-radius: 10px;
padding: 12px;
display: flex;
flex-direction: column;
}
.picker-item {
height: 48px;
line-height: 48px;
text-align: center;
font-size: 16px;
color: #333;
}
.picker-actions {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 12px;
}
.actions {
display: flex;
padding: 8px 12px;
background: #fff;
border-top: 1px solid #eee;
border-bottom: 1px solid #eee;
flex-direction: row;
}
.ack-panel {
padding: 10px 12px;
background: #fff;
border-bottom: 1px solid #eee;
}
.ack-actions {
margin-top: 8px;
display: flex;
flex-direction: row;
}
.grid {
flex: 1;
padding: 8px;
/* #ifdef WEB */
display: block;
/* #endif */
}
.grid-content {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
}
.grid-content>.student-card {
margin-bottom: 16rpx;
}
/* #ifdef WEB */
.grid-content {
flex-direction: row;
flex-wrap: wrap;
justify-content: flex-start;
align-items: stretch;
}
.grid-content>.student-card {
width: calc(33.333% - 24rpx);
margin-right: 12rpx;
margin-bottom: 24rpx;
}
.grid-content>.student-card:nth-child(3n) {
margin-right: 0;
}
/* #endif */
.threshold-settings {
background: #fff;
margin: 8px 12px;
border-radius: 8px;
padding: 12px;
border: 1px solid #eee;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.settings-title {
font-weight: bold;
font-size: 16px;
}
.settings-content {
display: flex;
flex-direction: column;
}
.threshold-item {
padding: 8px;
background: #f8f9fa;
border-radius: 6px;
margin-bottom: 8px;
}
.metric-name {
font-weight: bold;
margin-bottom: 8px;
display: block;
}
.sliders {
display: flex;
flex-direction: column;
}
.slider-group {
display: flex;
flex-direction: column;
margin-bottom: 8px;
}
.footer {
padding: 8px 12px;
background: #fff;
border-top: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
/* StudentCard styles */
.student-card {
background: #fff;
border-radius: 12px;
padding: 12px;
margin-bottom: 8px;
border: 2px solid #f0f0f0;
position: relative;
width: 140rpx;
height: 240rpx;
}
.student-card.offline {
opacity: 0.6;
border-color: #ccc;
}
.student-card.alert {
border-color: #ff5252;
background: #fff5f5;
}
.card-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.student-time {
font-size: 10px;
color: #333;
}
.student-name {
font-weight: bold;
font-size: 16px;
color: #333;
}
.status-indicators {
display: flex;
align-items: center;
flex-direction: column;
}
.status-dot {
width: 8px;
height: 8px;
margin-left: 4px;
}
.status-dot.offline {
background-color: #ccc;
}
.status-dot.alert {
background-color: #ff5252;
}
.status-dot.normal {
background-color: #4caf50;
}
.card-body {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
align-items: stretch;
padding: 8rpx;
}
.metric-row {
display: flex;
justify-content: space-between;
min-width: 0;
/* margin-bottom: 4rpx; */
}
.metric {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
/* padding: 6rpx 10rpx; */
border-radius: 8rpx;
background: #f8f9fa;
margin: 2rpx 0;
}
.metric-warning {
background: #fff3cd;
border: 1px solid #ffc107;
}
.metric-danger {
background: #f8d7da;
border: 1px solid #dc3545;
}
.metric-normal {
background: #d4edda;
border: 1px solid #28a745;
}
.metric-icon {
font-size: 22rpx;
margin-right: 10rpx;
flex-shrink: 0;
line-height: 1;
}
.metric-value {
font-size: 24rpx;
font-weight: bold;
color: #333;
text-align: right;
}
.metric-value.device {
font-size: 22rpx;
font-weight: normal;
color: #888;
}
.alert-indicator {
position: absolute;
top: -8px;
right: 12px;
background: #ff5252;
padding: 2px 6px;
border-radius: 10px;
}
.alert-text {
color: white;
font-size: 10px;
font-weight: bold;
}
.student-detail-overlay {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 24px;
box-sizing: border-box;
}
.student-detail-panel {
width: 90%;
max-width: 720px;
max-height: 90%;
background: #ffffff;
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.detail-header {
display: flex;
align-items: center;
flex-direction: row;
justify-content: space-between;
padding: 16px 20px;
background: #f5f7fb;
border-bottom: 1px solid #e5e7eb;
}
.detail-title {
font-size: 18px;
font-weight: bold;
color: #1f2937;
}
.detail-subtitle {
flex: 1;
margin-left: 16px;
color: #6b7280;
font-size: 14px;
}
.detail-close {
background: transparent;
border: none;
font-size: 22px;
color: #6b7280;
}
.detail-body {
flex: 1;
display: flex;
flex-direction: column;
padding: 12px 20px 20px;
box-sizing: border-box;
min-height: 0;
}
.detail-loading,
.detail-error,
.detail-empty {
padding: 24px 12px;
text-align: center;
color: #6b7280;
}
.detail-error {
color: #dc2626;
}
.detail-summary {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 8px;
margin-bottom: 8px;
}
.detail-main {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.summary-item {
flex: 0 0 auto;
display: flex;
flex-direction: row;
align-items: baseline;
padding: 6px;
padding: 4px 10px;
border-radius: 999px;
background: #eef2ff;
border: 1px solid #dbeafe;
}
.summary-label {
color: #4b5563;
font-size: 12px;
}
.summary-value {
font-size: 16px;
font-weight: 600;
color: #1d4ed8;
}
.detail-tabs {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.detail-tab {
background: #f3f4f6;
border: none;
border-radius: 999px;
padding: 8px 16px;
font-size: 14px;
color: #4b5563;
}
.detail-tab.active {
background: #2563eb;
color: #fff;
}
.detail-chart {
width: 100%;
height: 340px;
margin-bottom: 16px;
}
.detail-history {
height: 500rpx;
border-top: 1px solid #e5e7eb;
padding-top: 12px;
min-height: 0;
}
.detail-row {
display: flex;
flex-direction: column;
padding: 10px 8px;
border-bottom: 1px solid #f3f4f6;
gap: 4px;
}
.detail-time {
font-size: 14px;
color: #6b7280;
}
.detail-metrics {
font-size: 14px;
color: #111827;
}
</style>