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

1052 lines
23 KiB
Plaintext

<template>
<scroll-view direction="vertical" class="records-container" :enable-back-to-top="true">
<!-- Training Records Data -->
<supadb ref="recordsRef" collection="ak_training_records" :filter="recordsFilter" :orderby="recordsOrderBy"
@process-data="handleRecordsData" @error="handleError">
</supadb>
<!-- Header -->
<view class="header">
<text class="title">训练记录</text>
<view class="header-actions">
<button @click="exportRecords" class="export-btn" :disabled="recordsLoading">
<simple-icon type="download" :size="16" color="#FFFFFF" />
<text>导出</text>
</button>
</view>
</view>
<!-- Filter Bar -->
<view class="filter-bar">
<scroll-view class="filter-scroll" scroll-x="true">
<view class="filter-chips">
<view v-for="(filter, index) in statusFilters" :key="index" class="filter-chip"
:class="{ active: filter.value === activeStatusFilter }" @click="setStatusFilter(filter.value)">
<text class="chip-text">{{ filter.label }}</text>
<text v-if="filter.count > 0" class="chip-count">{{ filter.count }}</text>
</view>
</view>
</scroll-view>
<view class="filter-controls">
<view class="search-box">
<simple-icon type="search" :size="16" color="#9CA3AF" />
<input v-model="searchQuery" placeholder="搜索学员姓名" class="search-input" @input="onSearchInput" />
</view>
<view class="date-filters">
<input type="date" v-model="startDate" placeholder="开始日期" @change="onDateFilterChange" />
<input type="date" v-model="endDate" placeholder="结束日期" @change="onDateFilterChange" />
</view>
</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" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
<scroll-view v-else class="content" :class="{ 'large-screen': isLargeScreen }" scroll-y="true">
<!-- Statistics Cards -->
<view class="stats-section">
<view class="stats-grid">
<view class="stat-card">
<text class="stat-value">{{ totalRecords }}</text>
<text class="stat-label">总记录数</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ completedRecords }}</text>
<text class="stat-label">已完成</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ averageScore.toFixed(1) }}</text>
<text class="stat-label">平均分</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ activeStudents }}</text>
<text class="stat-label">活跃学员</text>
</view>
</view>
</view>
<!-- Records List -->
<view class="records-section">
<view class="section-header">
<text class="section-title">训练记录</text>
<view class="sort-controls">
<button @click="toggleSortOrder" class="sort-btn">
<text class="sort-text">{{ getSortText() }}</text>
<simple-icon :type="sortOrder === 'desc' ? 'arrow-down' : 'arrow-up'" :size="14"
color="#6B7280" />
</button>
</view>
</view>
<view v-if="filteredRecords.length === 0" class="empty-state">
<simple-icon type="document" :size="48" color="#D1D5DB" />
<text class="empty-text">暂无训练记录</text>
<text class="empty-hint">学员完成训练后记录将显示在这里</text>
</view>
<view v-else class="records-list">
<view v-for="(record, index) in filteredRecords" :key="index" class="record-card"
@click="viewRecordDetail(record)">
<view class="record-header">
<view class="student-info">
<view class="student-avatar">
<text class="avatar-text">{{ getStudentInitial(record) }}</text>
</view>
<view class="student-details">
<text class="student-name">{{ getStudentName(record) }}</text>
<text class="record-time">{{ getRecordTime(record) }}</text>
</view>
</view>
<view class="record-status">
<view class="status-badge" :class="getStatusClass(record)">
<text class="status-text">{{ getRecordStatus(record) }}</text>
</view>
</view>
</view>
<view class="record-content">
<view class="project-info">
<text class="project-name">{{ getProjectName(record) }}</text>
<text class="project-category">{{ getProjectCategory(record) }}</text>
</view>
<view class="record-metrics">
<view class="metric-item">
<text class="metric-label">训练时长</text>
<text class="metric-value">{{ getDurationText(record) }}</text>
</view>
<view class="metric-item">
<text class="metric-label">完成度</text>
<text class="metric-value">{{ getCompletionRate(record) }}%</text>
</view>
<view v-if="getRecordScore(record) > 0" class="metric-item">
<text class="metric-label">得分</text>
<text class="metric-value score" :class="getScoreClass(record)">
{{ getRecordScore(record) }}
</text>
</view>
</view>
</view>
<view v-if="getRecordNotes(record)" class="record-notes">
<text class="notes-text">{{ getRecordNotes(record) }}</text>
</view>
<view class="record-actions">
<button @click.stop="reviewRecord(record)" class="action-btn review-btn">
<simple-icon type="eye" :size="14" color="#6366F1" />
<text>查看详情</text>
</button>
<button v-if="canGrade(record)" @click.stop="gradeRecord(record)"
class="action-btn grade-btn">
<simple-icon type="edit" :size="14" color="#10B981" />
<text>评分</text>
</button>
</view>
</view>
</view>
</view>
<!-- Load More -->
<view v-if="hasMore && !recordsLoading" class="load-more-section">
<button @click="loadMoreRecords" class="load-more-btn">
<text>加载更多</text>
</button>
</view>
</scroll-view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { onResize } from '@dcloudio/uni-app'
import { formatDate } from '../types'
// 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
})
// Refs
const recordsRef = ref<SupadbComponentPublicInstance | null>(null)
const error = ref<string | null>(null)
const recordsLoading = ref(false)
const searchTimeout = ref<number | null>(null)
// Data arrays
const allRecords = ref<UTSJSONObject[]>([])
const filteredRecords = ref<UTSJSONObject[]>([])
// Filters
const activeStatusFilter = ref('all')
const searchQuery = ref('')
const startDate = ref('')
const endDate = ref('')
// Sorting
const sortField = ref('created_at')
const sortOrder = ref<'asc' | 'desc'>('desc')
// Pagination
const currentPage = ref(1)
const pageSize = ref(20)
const hasMore = ref(true)
// Status filters with counts
type StatusFilter = {
label : string
value : string
count : number
}
const statusFilters = ref<StatusFilter[]>([
{ label: '全部', value: 'all', count: 0 },
{ label: '已完成', value: 'completed', count: 0 },
{ label: '进行中', value: 'in_progress', count: 0 },
{ label: '待评分', value: 'pending_review', count: 0 },
{ label: '已评分', value: 'graded', count: 0 }
])
// Computed data
const totalRecords = ref(0)
const completedRecords = ref(0)
const averageScore = ref(0)
const activeStudents = ref(0)
// Local helper functions using direct UTSJSONObject methods
const getStudentName = (record : UTSJSONObject) : string => {
return record.getString('student_name') ?? '未知学员'
}
const getStudentInitial = (record : UTSJSONObject) : string => {
const name = getStudentName(record)
return name.charAt(0).toUpperCase()
}
const getRecordTime = (record : UTSJSONObject) : string => {
const timestamp = record.getString('created_at') ?? ''
if (timestamp.length > 0) {
return formatDate(timestamp, 'MM-DD HH:mm')
}
return ''
}
const getRecordStatus = (record : UTSJSONObject) : string => {
return record.getString('status') ?? 'unknown'
}
const getStatusClass = (record : UTSJSONObject) : string => {
const status = getRecordStatus(record)
switch (status) {
case 'completed': return 'status-completed'
case 'in_progress': return 'status-progress'
case 'pending_review': return 'status-pending'
case 'graded': return 'status-graded'
default: return 'status-default'
}
}
const getProjectName = (record : UTSJSONObject) : string => {
return record.getString('project_name') ?? '未知项目'
}
const getProjectCategory = (record : UTSJSONObject) : string => {
return record.getString('project_category') ?? '其他'
}
const getDurationText = (record : UTSJSONObject) : string => {
const minutes = record.getNumber('duration_minutes') ?? 0
if (minutes >= 60) {
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
return `${hours}小时${remainingMinutes}分钟`
}
return `${minutes}分钟`
}
const getCompletionRate = (record : UTSJSONObject) : number => {
return record.getNumber('completion_rate') ?? 0
}
const getRecordScore = (record : UTSJSONObject) : number => {
return record.getNumber('score') ?? 0
}
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 => {
return record.getString('notes') ?? ''
}
const canGrade = (record : UTSJSONObject) : boolean => {
const status = getRecordStatus(record)
return status === 'completed' || status === 'pending_review'
} // Computed properties
const recordsFilter = computed(() => {
const filter = {} as UTSJSONObject
if (activeStatusFilter.value !== 'all') {
filter['status'] = activeStatusFilter.value
}
if (startDate.value.length > 0 && endDate.value.length > 0) {
filter['created_at'] = {
gte: startDate.value + 'T00:00:00Z',
lte: endDate.value + 'T23:59:59Z'
} as UTSJSONObject
}
console.log(filter)
return filter
})
const recordsOrderBy = computed(() => {
return sortField.value + '.' + sortOrder.value
})
// Methods
const initializeDates = () => {
const now = new Date()
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
endDate.value = now.toISOString().split('T')[0]
startDate.value = thirtyDaysAgo.toISOString().split('T')[0]
}
const updateFilteredRecords = () => {
let filtered = [...allRecords.value]
// Apply search filter
if (searchQuery.value.trim().length > 0) {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(record => {
const studentName = getStudentName(record).toLowerCase()
return studentName.includes(query)
})
}
filteredRecords.value = filtered
}
const updateStatistics = () => {
totalRecords.value = allRecords.value.length
completedRecords.value = allRecords.value.filter(record =>
getRecordStatus(record) === 'completed'
).length
// Calculate average score
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
}
// Count active students
const uniqueStudents = new Set(allRecords.value.map(record =>
record.getString('student_id') ?? ''
))
activeStudents.value = uniqueStudents.size
}
const updateStatusFilterCounts = () => {
statusFilters.value.forEach((filter : StatusFilter) => {
if (filter.value === 'all') {
filter.count = allRecords.value.length as number
} else {
filter.count = allRecords.value.filter(record =>
getRecordStatus(record) === filter.value
).length as number
}
})
}
const setStatusFilter = (status : string) => {
activeStatusFilter.value = status
updateFilteredRecords()
nextTick(() => {
const ref = recordsRef.value
if (ref != null) {
const refresh = ref.refresh
if (refresh != null) {
refresh()
}
}
})
}
const onSearchInput = (event : InputEvent) => {
// Debounce search
if (searchTimeout.value != null) {
clearTimeout(searchTimeout.value as number)
}
searchTimeout.value = setTimeout(() => {
updateFilteredRecords()
}, 300)
}
const onDateFilterChange = () => {
console.log('onDateFilterChange')
}
const toggleSortOrder = () => {
sortOrder.value = sortOrder.value === 'desc' ? 'asc' : 'desc'
}
const getSortText = () : string => {
return sortOrder.value === 'desc' ? '最新在前' : '最早在前'
}
const loadMoreRecords = () => {
currentPage.value++
// This would typically load more data from the API
}
const exportRecords = () => {
uni.showToast({
title: '导出功能开发中',
icon: 'none'
})
}
const viewRecordDetail = (record : UTSJSONObject) => {
const recordId = record.getString('id') ?? ''
if (recordId.length > 0) {
uni.navigateTo({
url: `/pages/sport/teacher/record-detail?id=${recordId}`
})
}
}
const reviewRecord = (record : UTSJSONObject) => {
viewRecordDetail(record)
}
const gradeRecord = (record : UTSJSONObject) => {
const recordId = record.getString('id') ?? ''
if (recordId.length > 0) {
uni.navigateTo({
url: `/pages/sport/teacher/grade-record?id=${recordId}`
})
}
}
const handleError = (errorData : any) => {
console.error('Records error:', errorData)
error.value = '数据加载失败,请重试'
}
const retryLoad = () => {
error.value = null
// Trigger data reload
} // UTSJSONObject safe access methods
const handleRecordsData = (data : UTSJSONObject) => {
recordsLoading.value = false
const recordsData = data.getArray('data')
if (recordsData != null) {
allRecords.value = recordsData as UTSJSONObject[]
} else {
allRecords.value = []
}
updateFilteredRecords()
updateStatistics()
updateStatusFilterCounts()
} // Lifecycle hooks
onMounted(() => {
initializeDates()
// 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-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;
}
.title {
font-size: 48rpx;
font-weight: bold;
color: #FFFFFF;
}
.header-actions {
flex-direction: row;
}
.header-actions .export-btn {
margin-right: 15rpx;
}
.header-actions .export-btn:last-child {
margin-right: 0;
}
.export-btn {
display: flex;
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;
}
.export-btn text {
margin-left: 8rpx;
}
.export-btn:active {
background: rgba(255, 255, 255, 0.3);
}
.export-btn:disabled {
opacity: 0.5;
}
/* Filter Bar */
.filter-bar {
padding: 20rpx 30rpx;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.filter-scroll {
margin-bottom: 20rpx;
}
.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 {
flex-direction: row;
align-items: center;
padding: 12rpx 20rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 25rpx;
white-space: nowrap;
}
.filter-chip .chip-text {
margin-right: 8rpx;
}
.filter-chip.active {
background: rgba(255, 255, 255, 0.9);
}
.chip-text {
font-size: 26rpx;
color: #FFFFFF;
}
.filter-chip.active .chip-text {
color: #6366F1;
}
.chip-count {
font-size: 22rpx;
color: #FFFFFF;
background: rgba(255, 255, 255, 0.3);
padding: 4rpx 8rpx;
border-radius: 10rpx;
min-width: 30rpx;
text-align: center;
}
.filter-chip.active .chip-count {
color: #6366F1;
background: rgba(99, 102, 241, 0.2);
}
.filter-controls {}
.filter-controls>* {
margin-bottom: 15rpx;
}
.filter-controls>*:last-child {
margin-bottom: 0;
}
.search-box {
flex-direction: row;
align-items: center;
padding: 15rpx 20rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 15rpx;
}
.search-box simple-icon {
margin-right: 15rpx;
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #FFFFFF;
background: transparent;
border: none;
}
.search-input::placeholder {
color: rgba(255, 255, 255, 0.7);
}
.date-filters {
flex-direction: row;
justify-content: space-between;
}
/* 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: 46%;
flex: 0 0 46%;
margin-right: 20rpx;
margin-bottom: 20rpx;
}
.stats-grid .stat-card:nth-child(2n) {
margin-right: 20rpx;
}
.stats-grid .stat-card:nth-child(4n) {
margin-right: 0;
}
.stat-card {
background: #FFFFFF;
padding: 30rpx;
border-radius: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
align-items: center;
}
.stat-value {
font-size: 42rpx;
font-weight: bold;
color: #6366F1;
margin-bottom: 10rpx;
}
.stat-label {
font-size: 26rpx;
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;
}
.sort-controls {
flex-direction: row;
}
.sort-controls .sort-btn {
margin-right: 15rpx;
}
.sort-controls .sort-btn:last-child {
margin-right: 0;
}
.sort-btn {
display: flex;
flex-direction: row;
align-items: center;
padding: 12rpx 20rpx;
background: #F1F5F9;
border: none;
border-radius: 15rpx;
font-size: 26rpx;
}
.sort-btn simple-icon {
margin-left: 8rpx;
}
.sort-text {
color: #6B7280;
}
/* Records List */
.records-list {}
.records-list .record-card {
margin-bottom: 20rpx;
}
.records-list .record-card:last-child {
margin-bottom: 0;
}
.record-card {
background: #FFFFFF;
padding: 30rpx;
border-radius: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
}
.record-card:active {
transform: scale(0.98);
}
.record-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.student-info {
flex-direction: row;
align-items: center;
flex: 1;
}
.student-info .student-avatar {
margin-right: 15rpx;
}
.student-avatar {
width: 80rpx;
height: 80rpx;
background-image: linear-gradient(to bottom right, #6366F1, #8B5CF6);
border-radius: 40rpx;
justify-content: center;
align-items: center;
}
.avatar-text {
font-size: 28rpx;
font-weight: bold;
color: #FFFFFF;
}
.student-details {
flex: 1;
}
.student-name {
font-size: 30rpx;
font-weight: 400;
color: #1E293B;
margin-bottom: 8rpx;
}
.record-time {
font-size: 24rpx;
color: #64748B;
}
.record-status {
align-items: flex-end;
}
.status-badge {
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 22rpx;
font-weight: 400;
}
.status-completed {
background: #D1FAE5;
color: #065F46;
}
.status-progress {
background: #DBEAFE;
color: #1E40AF;
}
.status-pending {
background: #FEF3C7;
color: #92400E;
}
.status-graded {
background: #EDE9FE;
color: #6B21A8;
}
.status-default {
background: #F3F4F6;
color: #6B7280;
}
.status-text {
font-size: 22rpx;
}
.record-content {
margin-bottom: 20rpx;
}
.project-info {
margin-bottom: 20rpx;
}
.project-name {
font-size: 28rpx;
font-weight: 400;
color: #1E293B;
margin-bottom: 8rpx;
}
.project-category {
font-size: 24rpx;
color: #64748B;
}
.record-metrics {
flex-direction: row;
flex-wrap: wrap;
}
.record-metrics .metric-item {
margin-right: 30rpx;
}
.metric-item {
align-items: center;
}
.metric-label {
font-size: 22rpx;
color: #6B7280;
margin-bottom: 8rpx;
}
.metric-value {
font-size: 26rpx;
font-weight: 400;
color: #1E293B;
}
.metric-value.score {
font-weight: bold;
}
.score-excellent {
color: #10B981;
}
.score-good {
color: #3B82F6;
}
.score-average {
color: #F59E0B;
}
.score-pass {
color: #8B5CF6;
}
.score-fail {
color: #EF4444;
}
.record-notes {
margin-bottom: 20rpx;
padding: 20rpx;
background: #F8FAFC;
border-radius: 15rpx;
}
.notes-text {
font-size: 26rpx;
color: #374151;
line-height: 1.6;
}
.record-actions {
flex-direction: row;
justify-content: flex-end;
}
.record-actions .action-btn {
margin-left: 15rpx;
}
.record-actions .action-btn:first-child {
margin-left: 0;
}
.action-btn {
flex-direction: row;
align-items: center;
padding: 12rpx 20rpx;
border: none;
border-radius: 15rpx;
font-size: 24rpx;
}
.action-btn simple-icon {
margin-right: 8rpx;
}
.review-btn {
background: #EDE9FE;
color: #6366F1;
}
.grade-btn {
background: #D1FAE5;
color: #10B981;
}
.action-btn:active {
opacity: 0.7;
}
/* 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 & 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;
}
</style>