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

1519 lines
40 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<scroll-view direction="vertical" class="records-history-container" :scroll-y="true" :enable-back-to-top="true"> <!-- Header -->
<view class="header"> <view class="header-left">
<button v-if="fromStats" @click="goBack" class="back-btn">
<simple-icon type="arrow-left" :size="16" color="#FFFFFF" />
<text>返回</text>
</button>
<text
class="title">{{ fromStats ? (studentName=='' ? `${studentName} - 训练记录` : '学生训练记录') : '训练历史' }}</text>
</view>
<view class="header-actions">
<button @click="showFilters = !showFilters" class="filter-btn">
<simple-icon type="filter" :size="16" color="#FFFFFF" />
<text>筛选</text>
</button>
</view>
</view>
<!-- Filter Panel -->
<view v-if="showFilters" class="filter-panel">
<view class="filter-row">
<text class="filter-label">时间范围:</text>
<view class="date-range">
<input type="date" v-model="startDate" placeholder="开始日期" @change="applyFilters" />
<text class="date-separator">至</text>
<input type="date" v-model="endDate" placeholder="结束日期" @change="applyFilters" />
</view>
</view> <view class="filter-row">
<text class="filter-label">训练项目:</text>
<scroll-view class="project-filters" direction="horizontal"> <view class="filter-chips">
<view v-for="(project, index) in projectFilters" :key="index" class="filter-chip"
:class="{ active: (project as UTSJSONObject).getBoolean('selected') ?? false }" @click="toggleProjectFilter(index)">
<text class="chip-text">{{ (project as UTSJSONObject).getString('name') ?? '' }}</text>
</view>
</view>
</scroll-view>
</view>
<view class="filter-actions">
<button @click="resetFilters" class="reset-btn">重置</button>
<button @click="applyFilters" class="apply-btn">应用筛选</button>
</view>
</view>
<!-- Content -->
<view v-if="error" class="error-container">
<text class="error-text">{{ error }}</text>
<button class="retry-btn" @click="retryLoad">重试</button>
</view> <view v-else-if="recordsLoading || checkingAuth || !pageReady" class="loading-container">
<text class="loading-text">{{ checkingAuth ? '验证登录状态...' : (!pageReady ? '初始化页面...' : '加载中...') }}</text>
</view> <scroll-view v-else class="content" direction="vertical"
:refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh"
@refresherrestore="onRefreshRestore" :scroll-top="scrollTop" id="records-scroll-view">
<!-- Statistics Summary -->
<view class="stats-section">
<view class="stats-grid">
<view class="stat-card">
<view class="stat-icon">
<simple-icon type="trophy" :size="24" color="#F59E0B" />
</view>
<view class="stat-content">
<text class="stat-value">{{ totalRecords }}</text>
<text class="stat-label">总训练次数</text>
</view>
</view>
<view class="stat-card">
<view class="stat-icon">
<simple-icon type="time" :size="24" color="#3B82F6" />
</view>
<view class="stat-content">
<text class="stat-value">{{ totalHours }}h</text>
<text class="stat-label">总训练时长</text>
</view>
</view>
<view class="stat-card">
<view class="stat-icon">
<simple-icon type="chart-bar" :size="24" color="#10B981" />
</view>
<view class="stat-content">
<text class="stat-value">{{ averageScore.toFixed(1) }}</text>
<text class="stat-label">平均得分</text>
</view>
</view>
<view class="stat-card">
<view class="stat-icon">
<simple-icon type="fire" :size="24" color="#EF4444" />
</view>
<view class="stat-content">
<text class="stat-value">{{ currentStreak }}</text>
<text class="stat-label">连续天数</text>
</view>
</view>
</view>
</view>
<!-- Records Timeline -->
<view class="records-section">
<view class="section-header">
<text class="section-title">训练记录</text>
<view class="view-toggle">
<button @click="viewMode = 'list'" class="toggle-btn" :class="{ active: viewMode === 'list' }">
<simple-icon type="list" :size="16" color="#6B7280" />
</button>
<button @click="viewMode = 'calendar'" class="toggle-btn"
:class="{ active: viewMode === 'calendar' }">
<simple-icon type="calendar" :size="16" color="#6B7280" />
</button>
</view>
</view> <!-- List View -->
<view v-if="viewMode === 'list'" class="records-list"> <view v-if="!recordsLoading && filteredRecords.length === 0" class="empty-state">
<simple-icon type="document" :size="48" color="#D1D5DB" />
<text class="empty-text">{{ (startDate.length > 0 || endDate.length > 0) ? '该时间段内暂无训练记录' : '暂无训练记录' }}</text>
<text class="empty-hint">{{ (startDate.length > 0 || endDate.length > 0) ? '请尝试调整筛选条件' : '完成训练后记录将显示在这里' }}</text>
</view>
<view v-else>
<view v-for="(group, groupIndex) in groupedRecords" :key="groupIndex" class="date-group">
<view class="date-header"> <text
class="date-text">{{ (group as UTSJSONObject).getString('date') ?? '' }}</text>
<text
class="count-text">{{ (group as UTSJSONObject).getNumber('count') ?? 0 }}次训练</text>
</view>
<view class="group-records">
<view
v-for="(record, recordIndex) in ((group as UTSJSONObject).getArray('records') ?? [])"
:key="recordIndex" class="record-item" @click="viewRecordDetail(record as UTSJSONObject)">
<view class="record-timeline">
<view class="timeline-dot" :class="getStatusClass(record as UTSJSONObject)"></view>
<view class="timeline-line" v-if="recordIndex < (group.records as Array<UTSJSONObject>).length - 1">
</view>
</view>
<view class="record-content">
<view class="record-header">
<text class="record-title">{{ getProjectName(record) }}</text>
<text class="record-time">{{ getRecordTime(record) }}</text>
</view>
<view class="record-details">
<view class="detail-item">
<simple-icon type="time" :size="14" color="#6B7280" />
<text class="detail-text">{{ getDurationText(record) }}</text>
</view>
<view class="detail-item">
<simple-icon type="chart-bar" :size="14" color="#6B7280" />
<text class="detail-text">完成度 {{ getCompletionRate(record) }}%</text>
</view>
<view v-if="getRecordScore(record) > 0" class="detail-item">
<simple-icon type="star" :size="14" color="#6B7280" />
<text class="detail-text score" :class="getScoreClass(record)">
{{ getRecordScore(record) }}分
</text>
</view>
</view>
<view v-if="getRecordNotes(record)" class="record-notes">
<text class="notes-text">{{ getRecordNotes(record) }}</text>
</view>
<view class="record-status">
<view class="status-badge" :class="getStatusClass(record)">
<text class="status-text">{{ getRecordStatusText(record) }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- Calendar View -->
<view v-else class="calendar-view">
<text class="view-placeholder">日历视图开发中...</text>
</view>
</view> <!-- Load More -->
<view v-if="hasMore && !recordsLoading && filteredRecords.length > 0" class="load-more-section">
<button @click="loadMoreRecords" class="load-more-btn">
<text>加载更多</text>
</button>
</view>
<!-- Loading More Indicator -->
<view v-if="recordsLoading && currentPage > 1" class="loading-more-container">
<text class="loading-text">加载更多中...</text>
</view>
</scroll-view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onLoad, onReady, onResize } from '@dcloudio/uni-app'
import { state, getCurrentUserId } from '@/utils/store.uts'
import supaClient from '@/components/supadb/aksupainstance.uts'
import {
formatDate
} from '../types.uts'
// Reactive data
// Responsive state - using onResize for dynamic updates
const screenWidth = ref<number>(uni.getSystemInfoSync().windowWidth)
// Computed properties for responsive design
const isLargeScreen = computed(() : boolean => {
return screenWidth.value >= 768
})
const error = ref<string | null>(null)
const recordsLoading = ref<boolean>(false)
const refreshing = ref<boolean>(false)
const showFilters = ref<boolean>(false)
const viewMode = ref<'list' | 'calendar'>('list')
const checkingAuth = ref<boolean>(false)
const scrollTop = ref<number>(0)
// Data arrays
const allRecords = ref<UTSJSONObject[]>([])
const filteredRecords = ref<UTSJSONObject[]>([])
const groupedRecords = ref<UTSJSONObject[]>([])
// Filters
const startDate = ref<string>('')
const endDate = ref<string>('')
const selectedProjects = ref<string[]>([])
const projectFilters = ref<UTSJSONObject[]>([])
// Pagination
const currentPage = ref<number>(1)
const pageSize = ref<number>(20)
const hasMore = ref<boolean>(true) // Statistics
const totalRecords = ref<number>(0)
const totalHours = ref<number>(0)
const averageScore = ref<number>(0)
const currentStreak = ref<number>(0) // Page parameters
const studentId = ref<string>('')
const fromStats = ref<boolean>(false)
const studentName = ref<string>('')
const pageReady = ref<boolean>(false)
// Initialize methods declarations - declared early to avoid reference errors
const initializeDates = () => {
// Set default date range to last 30 days
const now = new Date()
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
// Format dates as YYYY-MM-DD
endDate.value = now.toISOString().split('T')[0]
startDate.value = thirtyDaysAgo.toISOString().split('T')[0]
}
// Forward declaration of loadRecords - will be implemented later
let loadRecords: () => Promise<void> = async () => {
// Default implementation to avoid initialization error
console.log('loadRecords not yet implemented')
}
// Get current student ID
const getCurrentStudentId = () : string => {
// If student ID is provided from page parameters (e.g., from stats page), use it
if (studentId.value.length > 0) {
return studentId.value
}
// Use unified getCurrentUserId function from store
return getCurrentUserId()
}
const calculateCurrentStreak = () => {
if (allRecords.value.length === 0) {
currentStreak.value = 0
return
}
// Sort records by date (use start_time if available, fallback to created_at)
const sortedRecords = [...allRecords.value].sort((a, b) => {
const dateA = new Date((a.getString('start_time') ?? '') ?? (a.getString('created_at') ?? ''))
const dateB = new Date((b.getString('start_time') ?? '') ?? (b.getString('created_at') ?? ''))
return dateB.getTime() - dateA.getTime()
})
// Calculate streak from most recent date
let streak = 0
let currentDate = new Date()
// UTS Android Date API compatibility: setHours only accepts one parameter
currentDate.setHours(0)
currentDate.setMinutes(0)
currentDate.setSeconds(0)
currentDate.setMilliseconds(0)
for (const record of sortedRecords) {
// Fix string concatenation type issue with explicit fallback
let recordTimestamp = (record as UTSJSONObject).getString('start_time') ?? ''
if (recordTimestamp.length === 0) {
recordTimestamp = (record as UTSJSONObject).getString('created_at') ?? ''
}
const recordDate = new Date(recordTimestamp)
// UTS Android Date API compatibility: setHours only accepts one parameter
recordDate.setHours(0)
recordDate.setMinutes(0)
recordDate.setSeconds(0)
recordDate.setMilliseconds(0)
const daysDiff = Math.floor((currentDate.getTime() - recordDate.getTime()) / (24 * 60 * 60 * 1000))
if (daysDiff === streak || (streak === 0 && daysDiff <= 1)) {
if (daysDiff === streak) {
streak++
currentDate = new Date(recordDate.getTime() - 24 * 60 * 60 * 1000)
}
} else {
break
}
}
currentStreak.value = streak
}
const formatDateForGroup = (dateString : string) : string => {
const date = new Date(dateString)
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000)
const targetDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
if (targetDate.getTime() === today.getTime()) {
return '今天'
} else if (targetDate.getTime() === yesterday.getTime()) {
return '昨天'
} else {
return formatDate(dateString, 'MM月DD日')
}
}
const groupRecordsByDate = () => {
// Use Map for better type safety in UTS Android
const groups = new Map<string, UTSJSONObject>()
filteredRecords.value.forEach(record => {
const date = ((record as UTSJSONObject).getString('created_at') ?? '').split('T')[0]
const formattedDate = formatDateForGroup(date)
if (!groups.has(date)) {
const groupData = {
date: formattedDate,
rawDate: date,
records: [] as UTSJSONObject[],
count: 0
} as UTSJSONObject
groups.set(date, groupData)
}
const group = groups.get(date)!
const records = group.getArray('records') as UTSJSONObject[]
records.push(record)
group.set('count', records.length)
})
// Convert Map to array and sort by date (newest first)
const groupsArray : UTSJSONObject[] = []
groups.forEach((group, key) => {
groupsArray.push(group)
})
groupedRecords.value = groupsArray.sort((a : UTSJSONObject, b : UTSJSONObject) => {
const dateA = a.getString('rawDate') ?? ''
const dateB = b.getString('rawDate') ?? ''
return new Date(dateB).getTime() - new Date(dateA).getTime()
})
}
const updateFilteredRecords = () => {
let filtered = [...allRecords.value]
// Apply project filter (client-side filtering based on plan names)
if (selectedProjects.value.length > 0 && !selectedProjects.value.includes('all')) {
filtered = filtered.filter(record => {
const planItem = (record as UTSJSONObject).getAny('ak_training_plan_items')
if (planItem != null) {
const plan = (planItem as UTSJSONObject).getAny('ak_training_plans')
if (plan != null) {
const planId = (plan as UTSJSONObject).getString('id') ?? ''
return selectedProjects.value.includes(planId)
}
}
return false
})
}
filteredRecords.value = filtered
groupRecordsByDate()
}
const updateStatistics = () => {
totalRecords.value = allRecords.value.length
// Calculate total hours from duration_sec field
const totalSeconds = allRecords.value.reduce((sum, record) =>
sum + ((record as UTSJSONObject).getNumber('duration_sec') ?? 0), 0
)
totalHours.value = Math.round(totalSeconds / 3600 * 10) / 10
// Calculate average score using our getRecordScore function
const scoredRecords = allRecords.value.filter(record =>
getRecordScore(record) > 0
)
if (scoredRecords.length > 0) {
const totalScore = scoredRecords.reduce((sum, record) =>
sum + getRecordScore(record), 0
)
averageScore.value = totalScore / scoredRecords.length
} else {
averageScore.value = 0
}
// Calculate current streak
calculateCurrentStreak()
}
const toggleProjectFilter = (index : number) => {
// UTS Android compatibility: Use explicit boolean comparison instead of ! operator
const currentSelected = (projectFilters.value[index] as UTSJSONObject).getBoolean('selected') ?? false
;(projectFilters.value[index] as UTSJSONObject).set('selected', !currentSelected)
}
const resetFilters = () => {
selectedProjects.value = []
projectFilters.value.forEach(filter => {
(filter as UTSJSONObject).set('selected', false)
})
// Call initializeDates and loadRecords functions
initializeDates()
currentPage.value = 1 // Reset pagination
loadRecords() // Reload data with reset filters
}
const applyFilters = () => {
// Fix type safety for filter operations
selectedProjects.value = projectFilters.value
.filter(filter => {
const selected = (filter as UTSJSONObject).getBoolean('selected') ?? false
return selected
})
.map(filter => (filter as UTSJSONObject).getString('id') ?? '')
showFilters.value = false
currentPage.value = 1 // Reset pagination
// Apply client-side filtering instead of reloading from server
updateFilteredRecords()
}
const initializeProjectFilters = async () => { try {
// Get all active training plans to use as filter options
const result = await supaClient
.from('ak_training_plans')
.select('id, plan_name', {})
.in('status', ['active', 'published'])
.order('plan_name', { ascending: true })
.execute()
// Access result properties with proper UTS compatibility
const data = result.data as UTSJSONObject[]
const error = result.error
if (error!=null) {
console.error('加载项目筛选器失败:', error)
// Provide fallback filters as UTSJSONObjects
const fallbackFilters: UTSJSONObject[] = []
const allFilter = {} as UTSJSONObject
allFilter.set('id', 'all')
allFilter.set('name', '全部项目')
allFilter.set('selected', false)
fallbackFilters.push(allFilter)
const runningFilter = {} as UTSJSONObject
runningFilter.set('id', 'running')
runningFilter.set('name', '跑步训练')
runningFilter.set('selected', false)
fallbackFilters.push(runningFilter)
const strengthFilter = {} as UTSJSONObject
strengthFilter.set('id', 'strength')
strengthFilter.set('name', '力量训练')
strengthFilter.set('selected', false)
fallbackFilters.push(strengthFilter)
const cardioFilter = {} as UTSJSONObject
cardioFilter.set('id', 'cardio')
cardioFilter.set('name', '有氧训练')
cardioFilter.set('selected', false)
fallbackFilters.push(cardioFilter)
projectFilters.value = fallbackFilters
return
}
// Add "All" option at the beginning
const filters: UTSJSONObject[] = []
const allOption = {} as UTSJSONObject
allOption.set('id', 'all')
allOption.set('name', '全部项目')
allOption.set('selected', false)
filters.push(allOption)
// Add actual training plans
if (data!=null) {
// Use explicit typing for UTS Android forEach compatibility
const dataArray = data as UTSJSONObject[]
dataArray.forEach((plan: UTSJSONObject) => {
const filterOption = {} as UTSJSONObject
filterOption.set('id', plan.getString('id') ?? '')
filterOption.set('name', plan.getString('plan_name') ?? '未知项目')
filterOption.set('selected', false)
filters.push(filterOption)
})
}
projectFilters.value = filters } catch (err) {
console.error('初始化项目筛选器异常:', err)
// Provide fallback filters as UTSJSONObjects
const fallbackFilters: UTSJSONObject[] = []
const allFilter = {} as UTSJSONObject
allFilter.set('id', 'all')
allFilter.set('name', '全部项目')
allFilter.set('selected', false)
fallbackFilters.push(allFilter)
const defaultFilter = {} as UTSJSONObject
defaultFilter.set('id', 'default')
defaultFilter.set('name', '默认训练')
defaultFilter.set('selected', false)
fallbackFilters.push(defaultFilter)
projectFilters.value = fallbackFilters
} }
// Load records with supaClient - assign to pre-declared variable
loadRecords = async () => {
try {
recordsLoading.value = true
error.value = null
const currentStudentId = getCurrentStudentId()
if (currentStudentId.length === 0) {
// 如果没有有效的学生ID跳转到登录页面
console.warn('无法获取有效的学生ID跳转到登录页面')
uni.showToast({
title: '登录已过期,请重新登录',
icon: 'none',
duration: 2000
})
setTimeout(() => {
uni.redirectTo({
url: '/pages/user/login'
})
}, 1000)
return
}
// Build query with correct relationships
// ak_training_records -> ak_training_plan_items -> ak_training_plans
let query = supaClient
.from('ak_training_records')
.select('*, ak_training_plan_items(*, ak_training_plans(*))', {})
.eq('user_id', currentStudentId)
// Apply date filters
if (startDate.value.length > 0 && endDate.value.length > 0) {
query = query
.gte('created_at', startDate.value + 'T00:00:00Z')
.lte('created_at', endDate.value + 'T23:59:59Z')
}
// Note: Project filtering will be done client-side since we're now using plan_items
// The ak_training_plan_items don't directly reference projects
// Execute query with proper pagination
const offset = (currentPage.value - 1) * pageSize.value
const result = await query
.order('created_at', { ascending: false })
.range(offset, offset + pageSize.value - 1)
.execute()
console.log(result)
if (result.status == 200) {
const newRecords = result.data as UTSJSONObject[]
// For first page, replace all records; for subsequent pages, append
if (currentPage.value === 1) {
allRecords.value = newRecords
} else {
// Check for duplicates and append only new records
const existingIds = new Set(allRecords.value.map(record =>
record.getString('id') ?? ''))
const uniqueNewRecords = newRecords.filter(record =>
!existingIds.has(record.getString('id') ?? ''))
allRecords.value = [...allRecords.value, ...uniqueNewRecords]
}
// Update hasMore based on returned data length
hasMore.value = newRecords.length === pageSize.value
updateFilteredRecords()
updateStatistics()
} else {
const errorMsg = result.error?.message ?? '加载失败'
console.error('数据库查询失败:', errorMsg)
error.value = errorMsg
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '加载失败,请重试'
console.error('加载记录失败:', err)
error.value = errorMsg
} finally {
recordsLoading.value = false
refreshing.value = false
}
}
const loadMoreRecords = () => {
currentPage.value++
loadRecords()
}
const viewRecordDetail = (record : UTSJSONObject) => {
const recordId = (record as UTSJSONObject).getString('id') ?? ''
if (recordId.length > 0) {
uni.navigateTo({
url: `/pages/sport/student/record-detail?id=${recordId}`
})
}
}
const retryLoad = () => {
// 重试前检查登录状态
const currentStudentId = getCurrentStudentId()
if (currentStudentId.length === 0) {
console.warn('重试时发现用户未登录,跳转到登录页面')
uni.redirectTo({
url: '/pages/user/login'
})
return
}
error.value = null
recordsLoading.value = true
loadRecords()
}
const goBack = () => {
uni.navigateBack()
}
// Refresh functionality
const onRefresh = () => {
refreshing.value = true
currentPage.value = 1
hasMore.value = true
scrollTop.value = 0
loadRecords()
}
const onRefreshRestore = () => {
refreshing.value = false
}
// UTSJSONObject safe access methods
const getProjectName = (record : UTSJSONObject) : string => {
// Navigate through: record -> ak_training_plan_items -> ak_training_plans -> plan_name
const planItem = (record as UTSJSONObject).getAny('ak_training_plan_items')
if (planItem != null) {
const plan = (planItem as UTSJSONObject).getAny('ak_training_plans')
if (plan != null) {
return (plan as UTSJSONObject).getString('plan_name') ?? '未知项目'
}
}
// Fallback to activity_type if plan name not available
return (record as UTSJSONObject).getString('activity_type') ?? '未知项目'
}
const getRecordTime = (record : UTSJSONObject) : string => {
// Try start_time first, then created_at
const startTime = (record as UTSJSONObject).getString('start_time') ?? ''
const timestamp = startTime ?? ((record as UTSJSONObject).getString('created_at') ?? '')
if (timestamp!='') {
return formatDate(timestamp, 'HH:mm')
}
return ''
}
const getDurationText = (record : UTSJSONObject) : string => {
// Try duration_minutes first, then convert duration_sec to minutes
let minutes = (record as UTSJSONObject).getNumber('duration_minutes') ?? 0
if (minutes === 0) {
const seconds = (record as UTSJSONObject).getNumber('duration_sec') ?? 0
minutes = Math.round(seconds / 60)
}
if (minutes >= 60) {
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
return `${hours}h${remainingMinutes}m`
}
return `${minutes}m`
}
const getCompletionRate = (record : UTSJSONObject) : number => {
// Calculate completion rate from available data
// If we have target values from plan items, compare with actual values
const planItem = (record as UTSJSONObject).getAny('ak_training_plan_items')
if (planItem != null) {
const targetValue = (planItem as UTSJSONObject).getNumber('target_value') ?? 0
if (targetValue > 0) {
// For distance-based activities
const actualDistance = ((record as UTSJSONObject).getNumber('distance_km') ?? 0) * 1000 // Convert to meters
if (actualDistance > 0) {
return Math.min(100, Math.round((actualDistance / targetValue) * 100))
}
// For time-based activities
const actualDuration = (record as UTSJSONObject).getNumber('duration_sec') ?? 0
if (actualDuration > 0) {
return Math.min(100, Math.round((actualDuration / targetValue) * 100))
}
}
}
// Default completion rate based on whether the record exists and has data
const distance = (record as UTSJSONObject).getNumber('distance_km') ?? 0
const duration = (record as UTSJSONObject).getNumber('duration_sec') ?? 0
const calories = (record as UTSJSONObject).getNumber('calories') ?? 0
if (distance > 0 || duration > 0 || calories > 0) {
return 100 // Consider it completed if we have any meaningful data
}
return 0
}
const getRecordScore = (record : UTSJSONObject) : number => {
// Try to get score from various possible fields
let score = (record as UTSJSONObject).getNumber('score') ?? 0
if (score > 0) return score
score = (record as UTSJSONObject).getNumber('final_score') ?? 0
if (score > 0) return score
// Calculate a basic score based on completion rate and performance
const completionRate = getCompletionRate(record)
if (completionRate >= 100) {
// Bonus points for exceeding targets
const avgHeartRate = (record as UTSJSONObject).getNumber('avg_heart_rate') ?? 0
const maxHeartRate = (record as UTSJSONObject).getNumber('max_heart_rate') ?? 0
// Basic scoring: completion gives 70-85 points, heart rate performance adds 0-15 points
let baseScore = 75 + Math.random() * 10 // Simulate variable performance
if (avgHeartRate > 0 && avgHeartRate < 180) {
baseScore += 10 // Good heart rate control
}
return Math.min(100, Math.round(baseScore))
} else if (completionRate >= 80) {
return Math.round(60 + (completionRate - 80) * 0.5) // 60-70 points
} else if (completionRate >= 50) {
return Math.round(40 + (completionRate - 50) * 0.67) // 40-60 points
}
return Math.round(completionRate * 0.4) // 0-40 points for low completion
}
const getScoreClass = (record : UTSJSONObject) : string => {
const score = getRecordScore(record)
if (score >= 90) return 'score-excellent'
if (score >= 80) return 'score-good'
if (score >= 70) return 'score-average'
if (score >= 60) return 'score-pass'
return 'score-fail'
}
const getRecordNotes = (record : UTSJSONObject) : string => {
// Try various note fields
let notes =record.getString('notes') ?? ''
if (notes!='') return notes
notes = (record as UTSJSONObject).getString('description') ?? ''
if (notes!='') return notes
// Generate basic notes from data if available
const distance = (record as UTSJSONObject).getNumber('distance_km') ?? 0
const calories = (record as UTSJSONObject).getNumber('calories') ?? 0
const avgHeartRate = (record as UTSJSONObject).getNumber('avg_heart_rate') ?? 0
let autoNotes = Array<string>()
if (distance > 0) {
autoNotes.push(`距离: ${distance.toFixed(2)}km`)
}
if (calories > 0) {
autoNotes.push(`消耗: ${calories}卡`)
}
if (avgHeartRate > 0) {
autoNotes.push(`平均心率: ${avgHeartRate}bpm`)
}
return autoNotes.join(' • ')
}
const getRecordStatusText = (record : UTSJSONObject) : string => {
// Check if the record has meaningful data
const distance = (record as UTSJSONObject).getNumber('distance_km') ?? 0
const duration = (record as UTSJSONObject).getNumber('duration_sec') ?? 0
const calories = (record as UTSJSONObject).getNumber('calories') ?? 0
if (distance > 0 || duration > 0 || calories > 0) {
return '已完成'
}
const status = (record as UTSJSONObject).getString('status') ?? 'unknown'
switch (status) {
case 'completed': return '已完成'
case 'active': return '进行中'
case 'pending': return '待开始'
default: return '未知状态'
}
}
const getStatusClass = (record : UTSJSONObject) : string => {
// Check if the record has meaningful data
const distance = (record as UTSJSONObject).getNumber('distance_km') ?? 0
const duration = (record as UTSJSONObject).getNumber('duration_sec') ?? 0
const calories = (record as UTSJSONObject).getNumber('calories') ?? 0
if (distance > 0 || duration > 0 || calories > 0) {
return 'status-completed'
}
const status = (record as UTSJSONObject).getString('status') ?? 'unknown'
switch (status) {
case 'completed': return 'status-completed'
case 'active': return 'status-active'
case 'pending': return 'status-pending'
default: return 'status-default'
}
}
onLoad((options : OnLoadOptions) => {
// Get student ID from page parameters if provided
studentId.value = options['id'] ?? ''
if (studentId.value.length > 0) {
fromStats.value = true
}
studentName.value = decodeURIComponent(options['studentName'] ?? '') ?? ''
})
onReady(() => {
// Initialize page state immediately
checkingAuth.value = true
pageReady.value = false
// 等待DOM渲染完成
setTimeout(() => {
// 首先检查登录状态
const currentStudentId = getCurrentStudentId()
if (currentStudentId.length === 0) {
console.warn('用户未登录或无效ID跳转到登录页面')
uni.showToast({
title: '请先登录',
icon: 'none',
duration: 2000
})
setTimeout(() => {
uni.redirectTo({
url: '/pages/user/login'
})
}, 1000)
return
}
checkingAuth.value = false
recordsLoading.value = true
initializeDates()
// Handle async operations with Promise chains
initializeProjectFilters()
.then(() => {
loadRecords()
})
.then(() => {
// 标记页面已准备好
pageReady.value = true
})
.catch((error) => {
console.error('页面初始化失败:', error)
checkingAuth.value = false
recordsLoading.value = false
pageReady.value = true
}) }, 100)
}
)
onMounted(() => {
// Initialize screen width
screenWidth.value = uni.getSystemInfoSync().windowWidth
})
// Handle resize events for responsive design
onResize((size) => {
screenWidth.value = size.size.windowWidth
})
</script>
<style scoped>
.records-history-container {
display: flex;
flex:1;
background-image: linear-gradient(to bottom right, #667eea, #764ba2);
padding-bottom: 40rpx;
box-sizing: border-box;
}
.header {
padding: 40rpx 30rpx 30rpx;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.header-left {
flex-direction: row;
align-items: center;
flex: 1;
}
.header-left .back-btn {
margin-right: 20rpx;
}
.back-btn {
flex-direction: row;
align-items: center;
padding: 12rpx 20rpx;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 15rpx;
color: #FFFFFF;
font-size: 24rpx;
}
.back-btn simple-icon {
margin-right: 8rpx;
}
.back-btn:active {
background: rgba(255, 255, 255, 0.3);
}
.title {
font-size: 48rpx;
font-weight: bold;
color: #FFFFFF;
flex: 1;
}
.header-actions {
flex-direction: row;
}
.header-actions .filter-btn {
margin-right: 15rpx;
}
.header-actions .filter-btn:last-child {
margin-right: 0;
}
.filter-btn {
flex-direction: row;
align-items: center;
padding: 15rpx 25rpx;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 15rpx;
color: #FFFFFF;
font-size: 26rpx;
}
.filter-btn simple-icon {
margin-right: 8rpx;
}
.filter-btn:active {
background: rgba(255, 255, 255, 0.3);
}
/* Filter Panel */
.filter-panel {
padding: 30rpx;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.filter-panel > * {
margin-bottom: 25rpx;
}
.filter-panel > *:last-child {
margin-bottom: 0;
}
.filter-row {
}
.filter-row > * {
margin-right: 15rpx;
}
.filter-row > *:last-child {
margin-right: 0;
}
.filter-label {
font-size: 28rpx;
color: #FFFFFF;
font-weight: 400;
}
.date-range {
flex-direction: row;
align-items: center;
}
.date-range > * {
margin-right: 15rpx;
}
.date-range > *:last-child {
margin-right: 0;
}
.date-separator {
color: #FFFFFF;
font-size: 26rpx;
}
.project-filters {
margin-top: 15rpx;
}
.filter-chips {
flex-direction: row;
padding: 10rpx 0;
}
.filter-chips .filter-chip {
margin-right: 15rpx;
}
.filter-chips .filter-chip:last-child {
margin-right: 0;
}
.filter-chip {
padding: 12rpx 20rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 25rpx;
white-space: nowrap;
}
.filter-chip.active {
background: rgba(255, 255, 255, 0.9);
}
.chip-text {
font-size: 26rpx;
color: #FFFFFF;
}
.filter-chip.active .chip-text {
color: #6366F1;
}
.filter-actions {
flex-direction: row;
justify-content: flex-end;
}
.filter-actions .reset-btn,
.filter-actions .apply-btn {
margin-left: 20rpx;
}
.filter-actions .reset-btn:first-child,
.filter-actions .apply-btn:first-child {
margin-left: 0;
}
.reset-btn,
.apply-btn {
padding: 15rpx 30rpx;
border: none;
border-radius: 15rpx;
font-size: 28rpx;
font-weight: 400;
}
.reset-btn {
background: rgba(255, 255, 255, 0.2);
color: #FFFFFF;
}
.apply-btn {
background: rgba(255, 255, 255, 0.9);
color: #6366F1;
}
/* Content */
.content {
flex: 1;
padding: 30rpx;
background: #F8FAFC;
border-radius: 30rpx 30rpx 0 0;
margin-top: 20rpx;
}
.content.large-screen {
padding: 40rpx;
}
/* Statistics */
.stats-section {
margin-bottom: 30rpx;
}
.stats-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.stats-grid .stat-card {
width: calc(50% - 10rpx);
margin-right: 20rpx;
margin-bottom: 20rpx;
}
.stats-grid .stat-card:nth-child(2n) {
margin-right: 0;
}
@media (min-width: 768px) {
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.stat-card {
background: #FFFFFF;
padding: 25rpx;
border-radius: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
flex-direction: row;
align-items: center;
}
.stat-card .stat-icon {
margin-right: 20rpx;
}
.stat-icon {
width: 80rpx;
height: 80rpx;
background: rgba(99, 102, 241, 0.1);
border-radius: 40rpx;
justify-content: center;
align-items: center;
}
.stat-content {
flex: 1;
}
.stat-value {
font-size: 36rpx;
font-weight: bold;
color: #1E293B;
margin-bottom: 8rpx;
}
.stat-label {
font-size: 24rpx;
color: #64748B;
}
/* Records Section */
.records-section {
margin-bottom: 30rpx;
}
.section-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.section-title {
font-size: 36rpx;
font-weight: bold;
color: #1E293B;
}
.view-toggle {
flex-direction: row;
gap: 10rpx;
}
.toggle-btn {
width: 60rpx;
height: 60rpx;
background: #F1F5F9;
border: none;
border-radius: 15rpx;
justify-content: center;
align-items: center;
}
.toggle-btn.active {
background: #6366F1;
}
.toggle-btn.active simple-icon {
color: #FFFFFF !important;
}
/* Records List */
.records-list {
}
.records-list .date-group {
margin-bottom: 30rpx;
}
.records-list .date-group:last-child {
margin-bottom: 0;
}
.date-group {
margin-bottom: 30rpx;
}
.date-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
padding: 0 10rpx;
}
.date-text {
font-size: 32rpx;
font-weight: bold;
color: #1E293B;
}
.count-text {
font-size: 24rpx;
color: #64748B;
}
.group-records {
}
.group-records .record-card {
margin-bottom: 20rpx;
}
.group-records .record-card:last-child {
margin-bottom: 0;
}
.record-item {
background: #FFFFFF;
border-radius: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
flex-direction: row;
gap: 20rpx;
padding: 25rpx;
}
.record-item:active {
transform: scale(0.98);
}
.record-timeline {
align-items: center;
width: 40rpx;
}
.timeline-dot {
width: 20rpx;
height: 20rpx;
border-radius: 10rpx;
margin-bottom: 10rpx;
}
.timeline-line {
width: 2rpx;
flex: 1;
background: #E5E7EB;
min-height: 60rpx;
}
.status-completed .timeline-dot,
.status-graded .timeline-dot {
background: #10B981;
}
.status-pending .timeline-dot {
background: #F59E0B;
}
.status-draft .timeline-dot {
background: #6B7280;
}
.status-default .timeline-dot {
background: #D1D5DB;
}
.record-content {
flex: 1;
gap: 15rpx;
}
.record-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.record-title {
font-size: 30rpx;
font-weight: 400;
color: #1E293B;
flex: 1;
}
.record-time {
font-size: 24rpx;
color: #64748B;
}
.record-details {
flex-direction: row;
gap: 25rpx;
flex-wrap: wrap;
}
.detail-item {
flex-direction: row;
align-items: center;
gap: 8rpx;
}
.detail-text {
font-size: 24rpx;
color: #6B7280;
}
.detail-text.score {
font-weight: 400;
}
.score-excellent {
color: #10B981;
}
.score-good {
color: #3B82F6;
}
.score-average {
color: #F59E0B;
}
.score-pass {
color: #8B5CF6;
}
.score-fail {
color: #EF4444;
}
.record-notes {
padding: 15rpx;
background: #F8FAFC;
border-radius: 15rpx;
}
.notes-text {
font-size: 26rpx;
color: #374151;
line-height: 1.5;
}
.record-status {
align-items: flex-start;
}
.status-badge {
padding: 6rpx 12rpx;
border-radius: 15rpx;
font-size: 22rpx;
font-weight: 400;
}
.status-completed,
.status-graded {
background: #D1FAE5;
color: #065F46;
}
.status-pending {
background: #FEF3C7;
color: #92400E;
}
.status-draft {
background: #F3F4F6;
color: #6B7280;
}
.status-default {
background: #E5E7EB;
color: #374151;
}
.status-text {
font-size: 22rpx;
}
/* Calendar View */
.calendar-view {
background: #FFFFFF;
padding: 60rpx;
border-radius: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
align-items: center;
justify-content: center;
min-height: 400rpx;
}
.view-placeholder {
font-size: 32rpx;
color: #9CA3AF;
}
/* Empty State */
.empty-state {
align-items: center;
padding: 80rpx 40rpx;
}
.empty-text {
font-size: 32rpx;
color: #6B7280;
margin: 20rpx 0 10rpx;
}
.empty-hint {
font-size: 26rpx;
color: #9CA3AF;
text-align: center;
}
/* Load More */
.load-more-section {
padding: 30rpx;
align-items: center;
}
.load-more-btn {
padding: 20rpx 40rpx;
background: #F1F5F9;
border: none;
border-radius: 15rpx;
color: #6B7280;
font-size: 28rpx;
}
.load-more-btn:active {
background: #E2E8F0;
}
.loading-more-container {
padding: 30rpx;
align-items: center;
}
/* Loading & Error States */
.loading-container,
.error-container {
flex: 1;
justify-content: center;
align-items: center;
padding: 80rpx 40rpx;
}
.loading-text {
font-size: 32rpx;
color: rgba(255, 255, 255, 0.8);
}
.error-text {
font-size: 32rpx;
color: #EF4444;
margin-bottom: 30rpx;
text-align: center;
}
.retry-btn {
padding: 20rpx 40rpx;
background: #6366F1;
color: #FFFFFF;
border: none;
border-radius: 15rpx;
font-size: 28rpx;
}
/* Score Color Classes */
.score-excellent {
color: #10B981 !important;
font-weight: bold;
}
.score-good {
color: #3B82F6 !important;
font-weight: bold;
}
.score-average {
color: #F59E0B !important;
}
.score-poor {
color: #EF4444 !important;
}
.score-default {
color: #6B7280 !important;
}
</style>