2404 lines
72 KiB
Plaintext
2404 lines
72 KiB
Plaintext
<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> |