Initial commit of akmon project
This commit is contained in:
1156
pages/sport/student/achievements.uvue
Normal file
1156
pages/sport/student/achievements.uvue
Normal file
File diff suppressed because it is too large
Load Diff
680
pages/sport/student/assignment-detail.uvue
Normal file
680
pages/sport/student/assignment-detail.uvue
Normal file
@@ -0,0 +1,680 @@
|
||||
<template>
|
||||
<scroll-view direction="vertical" class="assignment-detail">
|
||||
<!-- Header Card -->
|
||||
<view class="header-card"> <view class="assignment-header">
|
||||
<text class="assignment-title">{{ assignment.getString("title") }}</text>
|
||||
<view class="status-badge" :class="`status-${getAssignmentStatusWrapper(assignment)}`">
|
||||
<text class="status-text">{{ formatAssignmentStatusWrapper(getAssignmentStatusWrapper(assignment)) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="assignment-description">{{ getAssignmentDescriptionWrapper(assignment) }}</text>
|
||||
<view class="meta-info">
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">训练项目:</text>
|
||||
<text class="meta-value">{{ getProjectNameWrapper(assignment) }}</text>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">截止时间:</text>
|
||||
<text class="meta-value">{{ formatDateWrapper(getAssignmentDeadlineWrapper(assignment)) }}</text>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">目标分数:</text>
|
||||
<text class="meta-value">{{ getAssignmentTargetScoreWrapper(assignment) }}分</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Progress Card -->
|
||||
<view class="progress-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">完成进度</text>
|
||||
</view>
|
||||
<view class="progress-content">
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="`width: ${getProgressPercentage()}%`"></view>
|
||||
</view>
|
||||
<text class="progress-text">{{ getProgressPercentage() }}%</text>
|
||||
</view>
|
||||
<view class="progress-stats">
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ getCompletedCount() }}</text>
|
||||
<text class="stat-label">已完成</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ getRemainingCount() }}</text>
|
||||
<text class="stat-label">剩余</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-value">{{ getTargetCount() }}</text>
|
||||
<text class="stat-label">目标</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Requirements Card -->
|
||||
<view class="requirements-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">训练要求</text>
|
||||
</view> <view class="requirements-list">
|
||||
<view class="requirement-item" v-for="(requirement, index) in getRequirements()" :key="index">
|
||||
<view class="requirement-icon">
|
||||
<text class="icon">{{ getRequirementIcon(requirement) }}</text>
|
||||
</view>
|
||||
<view class="requirement-content">
|
||||
<text class="requirement-title">{{ getRequirementTitle(requirement) }}</text>
|
||||
<text class="requirement-desc">{{ getRequirementDescription(requirement) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Scoring Criteria Card -->
|
||||
<view class="scoring-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">评分标准</text>
|
||||
</view> <view class="scoring-list">
|
||||
<view class="scoring-item" v-for="(criteria, index) in getScoringCriteria()" :key="index">
|
||||
<view class="score-range">
|
||||
<text class="score-text">{{ getScoringRange(criteria) }}</text>
|
||||
</view>
|
||||
<view class="criteria-content">
|
||||
<text class="criteria-title">{{ getScoringTitle(criteria) }}</text>
|
||||
<text class="criteria-desc">{{ getScoringDescription(criteria) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<view class="action-buttons">
|
||||
<button
|
||||
v-if="canStartTraining()"
|
||||
class="action-btn primary-btn"
|
||||
@click="startTraining"
|
||||
>
|
||||
开始训练
|
||||
</button>
|
||||
<button
|
||||
v-if="canContinueTraining()"
|
||||
class="action-btn primary-btn"
|
||||
@click="continueTraining"
|
||||
>
|
||||
继续训练
|
||||
</button>
|
||||
<button
|
||||
v-if="canViewRecords()"
|
||||
class="action-btn secondary-btn"
|
||||
@click="viewRecords"
|
||||
>
|
||||
查看记录
|
||||
</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts"> import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { onLoad, onResize } from '@dcloudio/uni-app'
|
||||
import { state, getCurrentUserId } from '@/utils/store.uts'
|
||||
import { getAssignmentId, getAssignmentDisplayName, getAssignmentDescription,
|
||||
getAssignmentStatus, getAssignmentDeadline, getAssignmentTargetScore, getProjectName,
|
||||
formatDate, formatAssignmentStatus } from '../types.uts' // Reactive data
|
||||
const userId = ref('')
|
||||
const assignment = ref<UTSJSONObject>({})
|
||||
const assignmentId = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
// Template-accessible wrapper functions for imported functions
|
||||
const getAssignmentDisplayNameWrapper = (assignmentData: UTSJSONObject): string => {
|
||||
return getAssignmentDisplayName(assignmentData)
|
||||
}
|
||||
|
||||
const getAssignmentStatusWrapper = (assignmentData: UTSJSONObject): string => {
|
||||
return getAssignmentStatus(assignmentData)
|
||||
}
|
||||
|
||||
const getAssignmentDescriptionWrapper = (assignmentData: UTSJSONObject): string => {
|
||||
return getAssignmentDescription(assignmentData)
|
||||
}
|
||||
|
||||
const getProjectNameWrapper = (assignmentData: UTSJSONObject): string => {
|
||||
return getProjectName(assignmentData)
|
||||
}
|
||||
|
||||
const getAssignmentDeadlineWrapper = (assignmentData: UTSJSONObject): string => {
|
||||
return getAssignmentDeadline(assignmentData)
|
||||
}
|
||||
|
||||
const getAssignmentTargetScoreWrapper = (assignmentData: UTSJSONObject): number => {
|
||||
return getAssignmentTargetScore(assignmentData)
|
||||
}
|
||||
|
||||
const formatDateWrapper = (date: string): string => {
|
||||
return formatDate(date)
|
||||
}
|
||||
const formatAssignmentStatusWrapper = (status: string): string => {
|
||||
return formatAssignmentStatus(status)
|
||||
}
|
||||
|
||||
// Wrapper functions for requirement and scoring criteria property access
|
||||
const getRequirementIcon = (requirement: any): string => {
|
||||
const req = requirement as UTSJSONObject
|
||||
return req.getString('icon') ?? ''
|
||||
}
|
||||
|
||||
const getRequirementTitle = (requirement: any): string => {
|
||||
const req = requirement as UTSJSONObject
|
||||
return req.getString('title') ?? ''
|
||||
}
|
||||
|
||||
const getRequirementDescription = (requirement: any): string => {
|
||||
const req = requirement as UTSJSONObject
|
||||
return req.getString('description') ?? ''
|
||||
}
|
||||
|
||||
const getScoringRange = (criteria: any): string => {
|
||||
const crit = criteria as UTSJSONObject
|
||||
return crit.getString('range') ?? ''
|
||||
}
|
||||
|
||||
const getScoringTitle = (criteria: any): string => {
|
||||
const crit = criteria as UTSJSONObject
|
||||
return crit.getString('title') ?? ''
|
||||
}
|
||||
|
||||
const getScoringDescription = (criteria: any): string => {
|
||||
const crit = criteria as UTSJSONObject
|
||||
return crit.getString('description') ?? ''
|
||||
}
|
||||
|
||||
// Helper functions - defined before they are used
|
||||
function getCompletedCount(): number {
|
||||
return assignment.value.getNumber('completed_count') ?? 0
|
||||
}
|
||||
|
||||
function getTargetCount(): number {
|
||||
return assignment.value.getNumber('target_count') ?? 0
|
||||
}
|
||||
|
||||
function getProgressPercentage(): number {
|
||||
const completed = getCompletedCount()
|
||||
const target = getTargetCount()
|
||||
if (target <= 0) return 0
|
||||
return Math.min(Math.round((completed / target) * 100), 100)
|
||||
}
|
||||
|
||||
function getRemainingCount(): number {
|
||||
return Math.max(getTargetCount() - getCompletedCount(), 0)
|
||||
}
|
||||
|
||||
function getRequirements(): Array<any> {
|
||||
const requirements = assignment.value.getAny('requirements') ?? ([] as Array<any>)
|
||||
if (requirements instanceof Array) {
|
||||
return requirements
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function getScoringCriteria(): Array<any> {
|
||||
const criteriaData = assignment.value.getAny('scoring_criteria') ?? ([] as Array<any>)
|
||||
|
||||
// Handle new JSON format: {criteria: [{min_score, max_score, description}], ...}
|
||||
if (criteriaData != null && typeof criteriaData === 'object' && !(criteriaData instanceof Array)) {
|
||||
const criteria = (criteriaData as UTSJSONObject).getAny('criteria') ?? ([] as Array<any>)
|
||||
if (criteria instanceof Array) {
|
||||
return criteria.map((item: any) => {
|
||||
const itemObj = item as UTSJSONObject
|
||||
const minScore = itemObj.getNumber('min_score') ?? 0
|
||||
const maxScore = itemObj.getNumber('max_score') ?? 100
|
||||
const description = itemObj.getString('description') ?? ''
|
||||
return {
|
||||
range: `${minScore}-${maxScore}分`,
|
||||
title: minScore >= 90 ? '优秀' : minScore >= 80 ? '良好' : minScore >= 70 ? '及格' : '不及格',
|
||||
description: description
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle legacy array format
|
||||
if (criteriaData instanceof Array) {
|
||||
return criteriaData
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
function canStartTraining(): boolean {
|
||||
const status = getAssignmentStatusWrapper(assignment.value)
|
||||
return status === 'pending' || status === 'not_started'
|
||||
}
|
||||
|
||||
function canContinueTraining(): boolean {
|
||||
const status = getAssignmentStatusWrapper(assignment.value)
|
||||
return status === 'in_progress'
|
||||
}
|
||||
|
||||
function canViewRecords(): boolean {
|
||||
return getCompletedCount() > 0
|
||||
}
|
||||
|
||||
function startTraining() {
|
||||
uni.navigateTo({
|
||||
url: `/pages/sport/student/training-record?assignmentId=${assignmentId.value}`
|
||||
})
|
||||
}
|
||||
|
||||
function continueTraining() {
|
||||
uni.navigateTo({
|
||||
url: `/pages/sport/student/training-record?assignmentId=${assignmentId.value}`
|
||||
})
|
||||
}
|
||||
|
||||
function viewRecords() {
|
||||
uni.navigateTo({
|
||||
url: `/pages/sport/student/records?assignmentId=${assignmentId.value}`
|
||||
})
|
||||
}
|
||||
// Methods
|
||||
function loadAssignmentDetail() {
|
||||
loading.value = true
|
||||
|
||||
// Mock data - replace with actual API call
|
||||
setTimeout(() => {
|
||||
assignment.value = {
|
||||
"id": assignmentId.value,
|
||||
"title": "跳远技术训练",
|
||||
"description": "完成跳远基础技术动作训练,重点练习助跑、起跳和落地技术",
|
||||
"project_name": "跳远训练",
|
||||
"status": "in_progress",
|
||||
"deadline": "2024-01-20T23:59:59",
|
||||
"target_score": 85,
|
||||
"target_count": 10,
|
||||
"completed_count": 6,
|
||||
"requirements": [
|
||||
{
|
||||
"icon": "",
|
||||
"title": "助跑距离",
|
||||
"description": "助跑距离控制在12-16步"
|
||||
},
|
||||
{
|
||||
"icon": "",
|
||||
"title": "起跳技术",
|
||||
"description": "单脚起跳,起跳点准确"
|
||||
},
|
||||
{
|
||||
"icon": "",
|
||||
"title": "落地姿势",
|
||||
"description": "双脚并拢前伸落地"
|
||||
}
|
||||
],
|
||||
"scoring_criteria": [
|
||||
{
|
||||
"range": "90-100分",
|
||||
"title": "优秀",
|
||||
"description": "动作标准,技术熟练,成绩优异"
|
||||
},
|
||||
{
|
||||
"range": "80-89分",
|
||||
"title": "良好",
|
||||
"description": "动作较标准,技术较熟练"
|
||||
},
|
||||
{
|
||||
"range": "70-79分",
|
||||
"title": "及格",
|
||||
"description": "动作基本标准,需要继续练习"
|
||||
},
|
||||
{
|
||||
"range": "60-69分",
|
||||
"title": "不及格",
|
||||
"description": "动作不标准,需要重点改进"
|
||||
}
|
||||
]
|
||||
} as UTSJSONObject
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
} // Lifecycle
|
||||
onLoad((options: OnLoadOptions) => {
|
||||
userId.value = options['userId'] ?? ''
|
||||
if (userId.value.length === 0) {
|
||||
userId.value = getCurrentUserId()
|
||||
}
|
||||
|
||||
assignmentId.value = options['id'] ?? ''
|
||||
loadAssignmentDetail()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Initialize screen width
|
||||
screenWidth.value = uni.getSystemInfoSync().windowWidth
|
||||
})
|
||||
|
||||
// Handle resize events for responsive design
|
||||
onResize((size) => {
|
||||
screenWidth.value = size.size.windowWidth
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.assignment-detail {
|
||||
display: flex;
|
||||
flex:1;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
/* Header Card */
|
||||
.header-card {
|
||||
background-image: linear-gradient(to bottom right, #667eea, #764ba2);
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.assignment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.assignment-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background-color: rgba(255, 193, 7, 0.2);
|
||||
border: 1px solid rgba(255, 193, 7, 0.5);
|
||||
}
|
||||
|
||||
.status-in_progress {
|
||||
background-color: rgba(0, 123, 255, 0.2);
|
||||
border: 1px solid rgba(0, 123, 255, 0.5);
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background-color: rgba(40, 167, 69, 0.2);
|
||||
border: 1px solid rgba(40, 167, 69, 0.5);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 24rpx;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.assignment-description {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20rpx;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.meta-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.meta-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 26rpx;
|
||||
opacity: 0.8;
|
||||
width: 140rpx;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 26rpx;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Progress Card */
|
||||
.progress-card {
|
||||
background-color: white;
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 25rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.progress-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 25rpx;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 12rpx;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 6rpx;
|
||||
overflow: hidden;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-image: linear-gradient(to bottom, #667eea, #764ba2);
|
||||
border-radius: 6rpx;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
min-width: 80rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.progress-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Requirements Card */
|
||||
.requirements-card {
|
||||
background-color: white;
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.requirements-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.requirement-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 20rpx;
|
||||
background-color: #f8f9ff;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.requirement-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.requirement-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 30rpx;
|
||||
background-color: #667eea;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 20rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.requirement-content {
|
||||
flex: 1;
|
||||
}
|
||||
.requirement-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.requirement-desc {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Scoring Card */
|
||||
.scoring-card {
|
||||
background-color: white;
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.scoring-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scoring-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
background-color: #fafafa;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.scoring-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.score-range {
|
||||
width: 150rpx;
|
||||
text-align: center;
|
||||
margin-right: 20rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.score-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
padding: 8rpx 12rpx;
|
||||
background-color: rgba(102, 126, 234, 0.1);
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.criteria-content {
|
||||
flex: 1;
|
||||
}
|
||||
.criteria-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.criteria-desc {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
/* Action Buttons */
|
||||
.action-buttons {
|
||||
padding: 20rpx 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.action-btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background-image: linear-gradient(to bottom right, #667eea, #764ba2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background-color: white;
|
||||
color: #667eea;
|
||||
border: 2rpx solid #667eea;
|
||||
}
|
||||
|
||||
.primary-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.secondary-btn:active {
|
||||
transform: scale(0.98);
|
||||
background-color: #f8f9ff;
|
||||
}
|
||||
</style>
|
||||
686
pages/sport/student/assignments.uvue
Normal file
686
pages/sport/student/assignments.uvue
Normal file
@@ -0,0 +1,686 @@
|
||||
<template>
|
||||
<scroll-view direction="vertical" class="assignments-page" :scroll-y="true" :enable-back-to-top="true">
|
||||
<!-- 顶部统计卡片 -->
|
||||
<view class="stats-container">
|
||||
<view class="stat-card">
|
||||
<view class="stat-number">{{ totalAssignments }}</view>
|
||||
<view class="stat-label">总作业</view>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<view class="stat-number">{{ completedAssignments }}</view>
|
||||
<view class="stat-label">已完成</view>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<view class="stat-number">{{ pendingAssignments }}</view>
|
||||
<view class="stat-label">待完成</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 筛选器 -->
|
||||
<view class="filter-container">
|
||||
<scroll-view class="filter-scroll" direction="horizontal" :show-scrollbar="false">
|
||||
<view class="filter-item" :class="{ active: selectedStatus === 'all' }" @click="filterByStatus('all')">
|
||||
全部
|
||||
</view>
|
||||
<view class="filter-item" :class="{ active: selectedStatus === 'pending' }"
|
||||
@click="filterByStatus('pending')">
|
||||
待完成
|
||||
</view>
|
||||
<view class="filter-item" :class="{ active: selectedStatus === 'completed' }"
|
||||
@click="filterByStatus('completed')">
|
||||
已完成
|
||||
</view>
|
||||
<view class="filter-item" :class="{ active: selectedStatus === 'overdue' }"
|
||||
@click="filterByStatus('overdue')">
|
||||
已逾期
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
<!-- 作业列表 -->
|
||||
<scroll-view class="assignments-list" scroll-y="true" :refresher-enabled="true"
|
||||
:refresher-triggered="isRefreshing" @refresherrefresh="refreshAssignments">
|
||||
<view class="assignment-item" v-for="assignment in filteredAssignments"
|
||||
:key="getAssignmentIdLocal(assignment)" @click="viewAssignmentDetail(assignment)">
|
||||
<view class="assignment-header">
|
||||
<view class="assignment-title">{{ getAssignmentTitleLocal(assignment) }}</view>
|
||||
<view class="assignment-status" :class="getAssignmentStatusLocal(assignment)">
|
||||
{{ getAssignmentStatusText(assignment) }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="assignment-meta">
|
||||
<view class="meta-item">
|
||||
<text class="meta-icon"></text>
|
||||
<text class="meta-text">{{ getProjectNameLocal(assignment) }}</text>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<text class="meta-icon">⏰</text>
|
||||
<text class="meta-text">{{ formatDueDate(assignment) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="assignment-description">
|
||||
{{ getAssignmentDescriptionLocal(assignment) }}
|
||||
</view>
|
||||
|
||||
<view class="assignment-progress" v-if="getAssignmentProgress(assignment) > 0">
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: getAssignmentProgress(assignment) + '%' }"></view>
|
||||
</view>
|
||||
<text class="progress-text">{{ getAssignmentProgress(assignment) }}% 完成</text>
|
||||
</view>
|
||||
|
||||
<view class="assignment-actions">
|
||||
<button class="action-btn primary" @click.stop="startTraining(assignment)"
|
||||
v-if="getAssignmentStatusLocal(assignment) === 'pending'">
|
||||
开始训练
|
||||
</button>
|
||||
<button class="action-btn secondary" @click.stop="viewDetails(assignment)">
|
||||
查看详情
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view class="empty-state" v-if="filteredAssignments.length === 0 && !isLoading">
|
||||
<text class="empty-icon"></text>
|
||||
<text class="empty-title">暂无作业</text>
|
||||
<text class="empty-description">{{ getEmptyStateText() }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<view class="loading-state" v-if="isLoading">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 浮动操作按钮 -->
|
||||
<view class="fab-container">
|
||||
<view class="fab" @click="quickFilter">
|
||||
<text class="fab-icon"></text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import { onLoad, onResize } from '@dcloudio/uni-app'
|
||||
import {
|
||||
|
||||
getAssignmentId, getAssignmentTitle, getAssignmentDescription,
|
||||
getProjectName, formatDateTime, getAssignmentStatus
|
||||
|
||||
} from '../types.uts'
|
||||
import { getCurrentUserId } from '@/utils/store.uts'
|
||||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||||
// 响应式数据
|
||||
const assignments = ref<UTSJSONObject[]>([])
|
||||
const filteredAssignments = ref<UTSJSONObject[]>([])
|
||||
const selectedStatus = ref<string>('all')
|
||||
const isLoading = ref<boolean>(false)
|
||||
const isRefreshing = ref<boolean>(false)
|
||||
const totalAssignments = ref<number>(0)
|
||||
const completedAssignments = ref<number>(0)
|
||||
const pendingAssignments = ref<number>(0)
|
||||
const assignmentSubscription = ref<any | null>(null)
|
||||
const studentId = ref<string>('')
|
||||
const userId = ref('')
|
||||
|
||||
// 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 updateStatistics = () => {
|
||||
totalAssignments.value = assignments.value.length
|
||||
completedAssignments.value = assignments.value.filter(assignment =>
|
||||
getAssignmentStatus(assignment) === 'completed').length
|
||||
pendingAssignments.value = assignments.value.filter(assignment =>
|
||||
getAssignmentStatus(assignment) === 'pending').length
|
||||
}
|
||||
|
||||
// 应用筛选
|
||||
const applyFilter = () => {
|
||||
if (selectedStatus.value === 'all') {
|
||||
filteredAssignments.value = assignments.value
|
||||
} else {
|
||||
filteredAssignments.value = assignments.value.filter(assignment =>
|
||||
getAssignmentStatus(assignment) === selectedStatus.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载作业列表
|
||||
const loadAssignments = async () => {
|
||||
try {
|
||||
isLoading.value = true // 直接从 ak_assignments 表查询,按学生ID筛选
|
||||
const result = await supaClient
|
||||
.from('ak_assignments')
|
||||
.select('*', {})
|
||||
.eq('student_id', studentId.value)
|
||||
.order('created_at', { ascending: false })
|
||||
.execute()
|
||||
|
||||
if (result.error == null) {
|
||||
assignments.value = result.data as UTSJSONObject[]
|
||||
updateStatistics()
|
||||
applyFilter()
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '加载失败:' + (result.error?.message ?? '未知错误'),
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载作业失败:', error)
|
||||
uni.showToast({
|
||||
title: '加载作业失败',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 示例:实时查询作业状态变化
|
||||
const watchAssignmentUpdates = async () => {
|
||||
try {
|
||||
uni.showToast({
|
||||
title: "订阅功能尚未开放",
|
||||
duration: 3000
|
||||
})
|
||||
// 使用 supaClient 的实时订阅功能
|
||||
// const subscription = supaClient
|
||||
// .from('ak_assignments')
|
||||
// .on('UPDATE', (payload) => {
|
||||
// console.log('作业更新:', payload)
|
||||
// // 实时更新本地数据
|
||||
// updateLocalAssignment(payload.new)
|
||||
// })
|
||||
// .subscribe()
|
||||
|
||||
// // 保存订阅引用以便后续取消
|
||||
// assignmentSubscription.value = subscription
|
||||
} catch (error) {
|
||||
console.error('订阅作业更新失败:', error)
|
||||
}
|
||||
} // 生命周期
|
||||
onLoad((options : OnLoadOptions) => {
|
||||
// 从页面参数获取学生ID,如果没有则从store中获取
|
||||
studentId.value = options['studentId'] ?? ''
|
||||
userId.value = options['id'] ?? getCurrentUserId()
|
||||
if (studentId.value === '') {
|
||||
studentId.value = getCurrentUserId()
|
||||
}
|
||||
console.log('onLoad - studentId:', studentId.value)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadAssignments()
|
||||
watchAssignmentUpdates()
|
||||
// Initialize screen width
|
||||
screenWidth.value = uni.getSystemInfoSync().windowWidth
|
||||
})
|
||||
|
||||
// Handle resize events for responsive design
|
||||
onResize((size) => {
|
||||
screenWidth.value = size.size.windowWidth
|
||||
})// 安全获取作业信息
|
||||
const getAssignmentIdLocal = (assignment : UTSJSONObject) : string => {
|
||||
return getAssignmentId(assignment)
|
||||
}
|
||||
|
||||
const getAssignmentTitleLocal = (assignment : UTSJSONObject) : string => {
|
||||
return getAssignmentTitle(assignment)
|
||||
}
|
||||
|
||||
const getAssignmentDescriptionLocal = (assignment : UTSJSONObject) : string => {
|
||||
return getAssignmentDescription(assignment)
|
||||
}
|
||||
|
||||
const getProjectNameLocal = (assignment : UTSJSONObject) : string => {
|
||||
return getProjectName(assignment)
|
||||
}
|
||||
|
||||
const getAssignmentStatusLocal = (assignment : UTSJSONObject) : string => {
|
||||
return getAssignmentStatus(assignment)
|
||||
}
|
||||
const getAssignmentStatusText = (assignment : UTSJSONObject) : string => {
|
||||
const status = getAssignmentStatus(assignment)
|
||||
const statusMap : UTSJSONObject = {
|
||||
'pending': '待完成',
|
||||
'in_progress': '进行中',
|
||||
'completed': '已完成',
|
||||
'overdue': '已逾期'
|
||||
}
|
||||
const statusText = statusMap.getString(status)
|
||||
if (statusText != null && statusText !== '') {
|
||||
return statusText
|
||||
} else {
|
||||
return '未知状态'
|
||||
}
|
||||
}
|
||||
const getAssignmentProgress = (assignment : UTSJSONObject) : number => {
|
||||
return assignment.getNumber('progress') ?? 0
|
||||
}
|
||||
const formatDueDate = (assignment : UTSJSONObject) : string => {
|
||||
const dueDate = assignment.getString('due_date') ?? ''
|
||||
if (dueDate != null && dueDate !== '') {
|
||||
return '截止:' + formatDateTime(dueDate)
|
||||
}
|
||||
return '无截止时间'
|
||||
}
|
||||
|
||||
// 刷新作业
|
||||
const refreshAssignments = async () => {
|
||||
isRefreshing.value = true
|
||||
await loadAssignments()
|
||||
isRefreshing.value = false
|
||||
}
|
||||
|
||||
// 按状态筛选
|
||||
const filterByStatus = (status : string) => {
|
||||
selectedStatus.value = status
|
||||
applyFilter()
|
||||
}
|
||||
// 获取空状态文本
|
||||
const getEmptyStateText = () : string => {
|
||||
const textMap : UTSJSONObject = {
|
||||
'all': '暂时没有作业,等待老师分配新的训练任务',
|
||||
'pending': '没有待完成的作业',
|
||||
'completed': '还没有完成任何作业',
|
||||
'overdue': '没有逾期的作业'
|
||||
}
|
||||
const text = textMap.getString(selectedStatus.value)
|
||||
if (text != null && text !== '') {
|
||||
return text
|
||||
} else {
|
||||
return '暂无数据'
|
||||
}
|
||||
}
|
||||
// 查看作业详情
|
||||
const viewAssignmentDetail = (assignment : UTSJSONObject) => {
|
||||
const assignmentId = getAssignmentId(assignment)
|
||||
uni.navigateTo({
|
||||
url: `/pages/sport/student/assignment-detail?id=${assignmentId}`
|
||||
})
|
||||
}
|
||||
|
||||
// 开始训练
|
||||
const startTraining = (assignment : UTSJSONObject) => {
|
||||
const assignmentId = getAssignmentId(assignment)
|
||||
uni.navigateTo({
|
||||
url: `/pages/sport/student/training-record?assignmentId=${assignmentId}`
|
||||
})
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const viewDetails = (assignment : UTSJSONObject) => {
|
||||
viewAssignmentDetail(assignment)
|
||||
}
|
||||
|
||||
// 快速筛选
|
||||
const quickFilter = () => {
|
||||
uni.showActionSheet({
|
||||
itemList: ['全部作业', '待完成', '已完成', '已逾期'],
|
||||
success: (res) => {
|
||||
const statusMap = ['all', 'pending', 'completed', 'overdue']
|
||||
filterByStatus(statusMap[res.tapIndex!])
|
||||
}
|
||||
})
|
||||
} // 获取当前学生ID(优先从页面参数,然后从store中获取)
|
||||
const getCurrentStudentId = () : string => {
|
||||
try {
|
||||
// 优先使用页面参数传入的学生ID
|
||||
if (studentId.value != null && studentId.value !== '') {
|
||||
return studentId.value
|
||||
}
|
||||
|
||||
// 其次从store获取当前用户ID
|
||||
const userId = getCurrentUserId()
|
||||
if (userId != null && userId !== '') {
|
||||
return userId
|
||||
}
|
||||
|
||||
// 备用方案:从本地存储获取
|
||||
return uni.getStorageSync('current_student_id') || 'demo_student_id'
|
||||
} catch (error) {
|
||||
console.error('获取学生ID失败:', error)
|
||||
return 'demo_student_id'
|
||||
}
|
||||
}// 示例:直接使用 supaClient 创建训练记录
|
||||
const createTrainingRecord = async (assignmentId : string, recordData : UTSJSONObject) => {
|
||||
try {
|
||||
|
||||
const result = await supaClient
|
||||
.from('ak_training_records')
|
||||
.insert({
|
||||
assignment_id: assignmentId,
|
||||
student_id: getCurrentStudentId(),
|
||||
...recordData,
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
.single()
|
||||
.execute()
|
||||
|
||||
if (result.error == null) {
|
||||
uni.showToast({
|
||||
title: '训练记录保存成功',
|
||||
icon: 'success'
|
||||
})
|
||||
return result.data
|
||||
} else {
|
||||
throw new Error(result.error?.message ?? '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建训练记录失败:', error)
|
||||
uni.showToast({
|
||||
title: '保存训练记录失败',
|
||||
icon: 'none'
|
||||
})
|
||||
throw error
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// 更新本地作业数据
|
||||
const updateLocalAssignment = (updatedAssignment : UTSJSONObject) => {
|
||||
const assignmentId = getAssignmentId(updatedAssignment)
|
||||
const index = assignments.value.findIndex(assignment =>
|
||||
getAssignmentId(assignment) === assignmentId)
|
||||
|
||||
if (index !== -1) {
|
||||
assignments.value[index] = updatedAssignment
|
||||
updateStatistics()
|
||||
applyFilter()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.assignments-page {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
background-image: linear-gradient(to bottom right, #667eea, #764ba2);
|
||||
height: 100vh;
|
||||
padding: 20rpx;
|
||||
padding-bottom: 40rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 统计卡片 */
|
||||
.stats-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx 20rpx;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(10rpx);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
margin-right: 15rpx;
|
||||
}
|
||||
|
||||
.stat-card:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 24rpx;
|
||||
color: #7f8c8d;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
|
||||
/* 筛选器 */
|
||||
.filter-container {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
flex-direction: row;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: inline-block;
|
||||
padding: 15rpx 30rpx;
|
||||
margin-right: 15rpx;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
border-radius: 25rpx;
|
||||
font-size: 28rpx;
|
||||
transition: background-color 0.3s ease, color 0.3s ease, transform 0.3s ease;
|
||||
backdrop-filter: blur(10rpx);
|
||||
}
|
||||
|
||||
.filter-item.active {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #2c3e50;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 作业列表 */
|
||||
.assignments-list {
|
||||
height: 70vh;
|
||||
}
|
||||
|
||||
.assignment-item {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 30rpx;
|
||||
backdrop-filter: blur(10rpx);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
transition: transform 0.3s ease, background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.assignment-item:active {
|
||||
transform: scale(0.98);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.assignment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.assignment-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
flex: 1;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.assignment-status {
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.assignment-status.pending {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.assignment-status.completed {
|
||||
background: #d1edff;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.assignment-status.overdue {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.assignment-meta {
|
||||
display: flex;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.meta-item:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
font-size: 24rpx;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.meta-text {
|
||||
font-size: 26rpx;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.assignment-description {
|
||||
font-size: 28rpx;
|
||||
color: #34495e;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.assignment-progress {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8rpx;
|
||||
background: #ecf0f1;
|
||||
border-radius: 4rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-image: linear-gradient(to bottom, #667eea, #764ba2);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 24rpx;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.assignment-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 20rpx;
|
||||
border-radius: 15rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
transition: transform 0.3s ease;
|
||||
margin-right: 15rpx;
|
||||
}
|
||||
|
||||
.action-btn:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background-image: linear-gradient(to top right, #667eea, #764ba2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
background: #ecf0f1;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 100rpx 40rpx;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 120rpx;
|
||||
margin-bottom: 30rpx;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 28rpx;
|
||||
opacity: 0.8;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 加载状态 */
|
||||
.loading-state {
|
||||
text-align: center;
|
||||
padding: 50rpx;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 28rpx;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* 浮动按钮 */
|
||||
.fab-container {
|
||||
position: fixed;
|
||||
bottom: 40rpx;
|
||||
right: 40rpx;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.fab {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
background-image: linear-gradient(to top right, #667eea, #764ba2);
|
||||
border-radius: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 20rpx rgba(102, 126, 234, 0.4);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.fab:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.fab-icon {
|
||||
1328
pages/sport/student/dashboard.uvue
Normal file
1328
pages/sport/student/dashboard.uvue
Normal file
File diff suppressed because it is too large
Load Diff
1037
pages/sport/student/device-management.uvue
Normal file
1037
pages/sport/student/device-management.uvue
Normal file
File diff suppressed because it is too large
Load Diff
1292
pages/sport/student/favorite-exercises.uvue
Normal file
1292
pages/sport/student/favorite-exercises.uvue
Normal file
File diff suppressed because it is too large
Load Diff
980
pages/sport/student/goal-settings.uvue
Normal file
980
pages/sport/student/goal-settings.uvue
Normal file
@@ -0,0 +1,980 @@
|
||||
<template>
|
||||
<scroll-view direction="vertical" class="goal-settings-page">
|
||||
<!-- Header -->
|
||||
<view class="header">
|
||||
<view class="header-left">
|
||||
<button @click="goBack" class="back-btn">
|
||||
<simple-icon type="arrow-left" :size="16" />
|
||||
<text>返回</text>
|
||||
</button>
|
||||
<text class="title">训练目标</text>
|
||||
</view>
|
||||
<view class="header-actions">
|
||||
<button @click="addGoal" class="add-btn">
|
||||
<simple-icon type="plus" :size="16" />
|
||||
<text>添加</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Loading State -->
|
||||
<view v-if="loading" class="loading-container">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<!-- Content -->
|
||||
<scroll-view v-else class="content" scroll-y="true" :style="{ height: contentHeight + 'px' }">
|
||||
<!-- Current Goals -->
|
||||
<view class="goals-section">
|
||||
<view class="section-title">当前目标</view>
|
||||
<view v-if="goals.length === 0" class="empty-state">
|
||||
<simple-icon type="target" :size="48" color="#BDC3C7" />
|
||||
<text class="empty-text">还没有设置训练目标</text>
|
||||
<text class="empty-desc">设置目标让训练更有动力</text>
|
||||
<button @click="addGoal" class="add-goal-btn">设置第一个目标</button>
|
||||
</view>
|
||||
<view v-else class="goals-list">
|
||||
<view v-for="(goal,index) in goals" :key="goal.id" class="goal-item" @click="editGoal(goal)">
|
||||
<view class="goal-header">
|
||||
<view class="goal-icon">
|
||||
<text class="goal-emoji">{{ goal["goal_type"] }}</text>
|
||||
</view>
|
||||
<view class="goal-info">
|
||||
<text class="goal-name">{{ goal["goal_type"] }}</text>
|
||||
<text class="goal-desc">{{ goal.getString("description") }}</text>
|
||||
</view>
|
||||
<view class="goal-status" :class="getGoalStatusClass(goal.getString('status')??'')">
|
||||
<text class="status-text">{{ getGoalStatusText(goal.getString('status')??"") }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="goal-progress">
|
||||
<view class="progress-info">
|
||||
<text class="progress-text">
|
||||
{{ goal.current_value ?? 0 }} / {{ goal.target_value }} {{ goal.unit ?? '' }}
|
||||
</text>
|
||||
<text class="progress-percent">{{ getProgressPercent(goal) }}%</text>
|
||||
</view>
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: getProgressPercent(goal) + '%' }"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="goal-meta">
|
||||
<text class="goal-date">目标日期: {{ getGoalTargetDate(goal) }}</text>
|
||||
<text class="goal-priority">优先级: {{ getGoalPriorityText(goal) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Goal Templates -->
|
||||
<view class="templates-section">
|
||||
<view class="section-title">目标模板</view>
|
||||
<view class="templates-grid">
|
||||
<view v-for="template in goalTemplates" :key="template.type"
|
||||
class="template-item" @click="createFromTemplate(template)">
|
||||
<view class="template-icon">
|
||||
<text class="template-emoji">{{ template.icon }}</text>
|
||||
</view>
|
||||
<text class="template-name">{{ template.name }}</text>
|
||||
<text class="template-desc">{{ template.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Statistics -->
|
||||
<view class="stats-section">
|
||||
<view class="section-title">目标统计</view>
|
||||
<view class="stats-grid">
|
||||
<view class="stat-item">
|
||||
<text class="stat-number">{{ completedGoals }}</text>
|
||||
<text class="stat-label">已完成</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-number">{{ activeGoals }}</text>
|
||||
<text class="stat-label">进行中</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-number">{{ getAverageProgress() }}%</text>
|
||||
<text class="stat-label">平均进度</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- Add/Edit Goal Modal -->
|
||||
<view v-if="showGoalModal" class="modal-overlay" @click="closeGoalModal">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">{{ editingGoal != null ? '编辑目标' : '添加目标' }}</text>
|
||||
<button @click="closeGoalModal" class="modal-close" type="button">
|
||||
<simple-icon type="x" :size="20" color="#666" />
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="modal-body">
|
||||
<view class="form-group">
|
||||
<text class="form-label">目标类型</text>
|
||||
<view @click="showGoalTypePicker = true" class="picker-input">
|
||||
<text>{{ goalTypeOptions[goalTypeIndex] ?? '请选择目标类型' }}</text>
|
||||
<simple-icon type="chevron-down" :size="16" />
|
||||
</view>
|
||||
<view v-if="showGoalTypePicker" class="picker-view-modal">
|
||||
<picker-view :value="[goalTypeIndex]" :indicator-style="'height: 40px;'" @change="onGoalTypePickerChange">
|
||||
<picker-view-column>
|
||||
<view v-for="(item, idx) in goalTypeOptions" :key="idx" class="picker-view-item">{{ item }}</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
<view class="picker-view-actions">
|
||||
<button @click="showGoalTypePicker = false">取消</button>
|
||||
<button @click="confirmGoalTypePicker">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">目标数值</text>
|
||||
<input :value="goalForm.target_value" type="number"
|
||||
class="form-input" placeholder="请输入目标数值" />
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">单位</text>
|
||||
<input :value="goalForm.unit" type="text"
|
||||
class="form-input" placeholder="如: kg, 次, 分钟" />
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">目标日期</text>
|
||||
<input :value="goalForm.target_date" type="date" class="form-input" placeholder="请选择目标日期" />
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">优先级</text>
|
||||
<view @click="showPriorityPicker = true" class="picker-input">
|
||||
<text>{{ priorityOptions[priorityIndex] ?? '请选择优先级' }}</text>
|
||||
<simple-icon type="chevron-down" :size="16" color="#999" />
|
||||
</view>
|
||||
<view v-if="showPriorityPicker" class="picker-view-modal">
|
||||
<picker-view :value="[priorityIndex]" :indicator-style="'height: 40px;'" @change="onPriorityPickerChange">
|
||||
<picker-view-column>
|
||||
<view v-for="(item, idx) in priorityOptions" :key="idx" class="picker-view-item">{{ item }}</view>
|
||||
</picker-view-column>
|
||||
</picker-view>
|
||||
<view class="picker-view-actions">
|
||||
<button @click="showPriorityPicker = false">取消</button>
|
||||
<button @click="confirmPriorityPicker">确定</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="form-group">
|
||||
<text class="form-label">描述</text>
|
||||
<textarea :value="goalForm.description" class="form-textarea"
|
||||
placeholder="描述你的目标..." maxlength="200"></textarea>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal-footer">
|
||||
<button @click="closeGoalModal" class="cancel-btn">取消</button>
|
||||
<button @click="saveGoal" class="save-btn" :disabled="!isFormValid">保存</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { onLoad,onResize } from '@dcloudio/uni-app'
|
||||
import { formatDate } from '../types.uts'
|
||||
import { getCurrentUserId } from '@/utils/store.uts'
|
||||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
const userId = ref('')
|
||||
|
||||
// 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 loading = ref(true)
|
||||
const goals = ref<Array<UTSJSONObject>>([])
|
||||
const showGoalModal = ref(false)
|
||||
const editingGoal = ref<UTSJSONObject | null>(null)
|
||||
const contentHeight = ref(0)
|
||||
|
||||
// 表单数据
|
||||
const goalForm = ref<UTSJSONObject>({
|
||||
goal_type: '',
|
||||
target_value: '',
|
||||
unit: '',
|
||||
target_date: '',
|
||||
priority: 1,
|
||||
description: ''
|
||||
})
|
||||
|
||||
// 选择器数据
|
||||
const goalTypeIndex = ref(0)
|
||||
const priorityIndex = ref(0)
|
||||
|
||||
// Add missing picker-view state refs for Android compatibility
|
||||
const tempGoalTypeIndex = ref(0)
|
||||
const tempPriorityIndex = ref(0)
|
||||
const showGoalTypePicker = ref(false)
|
||||
const showPriorityPicker = ref(false)
|
||||
|
||||
const goalTypeOptions = ['减肥', '增肌', '耐力提升', '柔韧性', '力量增强', '技能提升']
|
||||
const goalTypes = ['weight_loss', 'muscle_gain', 'endurance', 'flexibility', 'strength', 'skill']
|
||||
const priorityOptions = ['低', '一般', '中等', '较高', '最高']
|
||||
|
||||
// 目标模板
|
||||
const goalTemplates = ref<Array<UTSJSONObject>>([
|
||||
{
|
||||
type: 'weight_loss',
|
||||
name: '减重目标',
|
||||
description: '设定理想体重目标',
|
||||
icon: '⚖️',
|
||||
defaultValue: 5,
|
||||
unit: 'kg'
|
||||
},
|
||||
{
|
||||
type: 'muscle_gain',
|
||||
name: '增肌目标',
|
||||
description: '增加肌肉量',
|
||||
icon: '',
|
||||
defaultValue: 3,
|
||||
unit: 'kg'
|
||||
},
|
||||
{
|
||||
type: 'endurance',
|
||||
name: '耐力提升',
|
||||
description: '提高有氧耐力',
|
||||
icon: '',
|
||||
defaultValue: 30,
|
||||
unit: '分钟'
|
||||
},
|
||||
{
|
||||
type: 'strength',
|
||||
name: '力量增强',
|
||||
description: '提升最大力量',
|
||||
icon: '️',
|
||||
defaultValue: 20,
|
||||
unit: 'kg'
|
||||
}
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const completedGoals = computed(() => {
|
||||
return goals.value.filter(goal => goal.get('status') === 'completed').length
|
||||
})
|
||||
|
||||
const activeGoals = computed(() => {
|
||||
return goals.value.filter(goal => goal.get('status') === 'active').length
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return (goalForm.value['goal_type'] as string) !== '' &&
|
||||
(goalForm.value['target_value'] as string) !== '' &&
|
||||
(goalForm.value['unit'] as string) !== '' &&
|
||||
(goalForm.value['target_date'] as string) !== ''
|
||||
})
|
||||
|
||||
// 计算内容高度
|
||||
const calculateContentHeight = () => {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
const windowHeight = systemInfo.windowHeight
|
||||
const headerHeight = 60
|
||||
contentHeight.value = windowHeight - headerHeight
|
||||
}
|
||||
|
||||
// 加载目标数据
|
||||
const loadGoals = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
if ((userId.value as string) === '') {
|
||||
uni.showToast({
|
||||
title: '请先登录',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
const result = await supaClient
|
||||
.from('ak_user_training_goals')
|
||||
.select('*', {})
|
||||
.eq('user_id', userId.value)
|
||||
.order('created_at', { ascending: false })
|
||||
.execute()
|
||||
if (result.error == null && result.data != null) {
|
||||
goals.value = result.data as UTSJSONObject[]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载目标失败:', error)
|
||||
uni.showToast({
|
||||
title: '加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
calculateContentHeight()
|
||||
loadGoals()
|
||||
})
|
||||
|
||||
onLoad((options: OnLoadOptions) => {
|
||||
userId.value = options['id'] ?? getCurrentUserId()
|
||||
loadGoals()
|
||||
})
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
function resetForm() {
|
||||
goalForm.value = {
|
||||
goal_type: '',
|
||||
target_value: '',
|
||||
unit: '',
|
||||
target_date: '',
|
||||
priority: 1,
|
||||
description: ''
|
||||
}
|
||||
goalTypeIndex.value = 0
|
||||
priorityIndex.value = 0
|
||||
}
|
||||
|
||||
// 填充表单
|
||||
function populateForm(goal: UTSJSONObject) {
|
||||
goalForm.value = {
|
||||
goal_type: goal.get('goal_type') as string,
|
||||
target_value: goal.get('target_value') as string,
|
||||
unit: goal.get('unit') as string,
|
||||
target_date: goal.get('target_date') as string,
|
||||
priority: goal.get('priority') != null ? goal.get('priority') as number : 1,
|
||||
description: goal.get('description') as string
|
||||
}
|
||||
const form = goalForm.value
|
||||
const typeIndex = goalTypes.indexOf(form['goal_type'])
|
||||
goalTypeIndex.value = typeIndex >= 0 ? typeIndex : 0
|
||||
const priorityNum = parseInt(form['priority'] != null ? form['priority'].toString() : '1')
|
||||
priorityIndex.value = isNaN(priorityNum) ? 0 : priorityNum - 1
|
||||
}
|
||||
|
||||
// 添加目标
|
||||
const addGoal = () => {
|
||||
editingGoal.value = null
|
||||
resetForm()
|
||||
showGoalModal.value = true
|
||||
}
|
||||
|
||||
// 编辑目标
|
||||
const editGoal = (goal: UTSJSONObject) => {
|
||||
editingGoal.value = goal
|
||||
populateForm(goal)
|
||||
showGoalModal.value = true
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const closeGoalModal = () => {
|
||||
showGoalModal.value = false
|
||||
editingGoal.value = null
|
||||
}
|
||||
|
||||
// 保存目标
|
||||
const saveGoal = async () => {
|
||||
try {
|
||||
const goalData = {
|
||||
user_id: userId.value,
|
||||
goal_type: goalTypes[goalTypeIndex.value],
|
||||
target_value: parseFloat(goalForm.value['target_value'] != null ? goalForm.value['target_value'].toString() : '0'),
|
||||
unit: goalForm.value['unit'],
|
||||
target_date: goalForm.value['target_date'],
|
||||
priority: priorityIndex.value + 1,
|
||||
description: goalForm.value['description'],
|
||||
status: 'active'
|
||||
}
|
||||
|
||||
let result: any
|
||||
const currentEditingGoal = editingGoal.value
|
||||
if (currentEditingGoal != null) {
|
||||
// 更新
|
||||
result = await supaClient
|
||||
.from('ak_user_training_goals')
|
||||
.update(goalData)
|
||||
.eq('id', (currentEditingGoal.get('id') ?? '').toString())
|
||||
.execute()
|
||||
} else {
|
||||
// 创建
|
||||
result = await supaClient
|
||||
.from('ak_user_training_goals')
|
||||
.insert(goalData)
|
||||
.execute()
|
||||
}
|
||||
|
||||
if (result.error != null) {
|
||||
throw new Error(result.error?.message ?? '未知错误')
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
title: editingGoal.value != null ? '更新成功' : '创建成功',
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
closeGoalModal()
|
||||
loadGoals()
|
||||
} catch (error) {
|
||||
console.error('保存目标失败:', error)
|
||||
uni.showToast({
|
||||
title: '保存失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 从模板创建目标
|
||||
const createFromTemplate = (template: UTSJSONObject) => {
|
||||
editingGoal.value = null
|
||||
resetForm()
|
||||
// Use bracket notation for UTS compatibility
|
||||
goalForm.value['goal_type'] = template.get('type') as string
|
||||
goalForm.value['target_value'] = template.get('defaultValue') as string
|
||||
goalForm.value['unit'] = template.get('unit') as string
|
||||
const typeIndex = goalTypes.indexOf(goalForm.value['goal_type'])
|
||||
goalTypeIndex.value = typeIndex >= 0 ? typeIndex : 0
|
||||
|
||||
// 设置默认目标日期为3个月后
|
||||
const targetDate = new Date()
|
||||
targetDate.setMonth(targetDate.getMonth() + 3)
|
||||
goalForm.value['target_date'] = targetDate.toISOString().split('T')[0]
|
||||
|
||||
showGoalModal.value = true
|
||||
}
|
||||
|
||||
// 事件处理
|
||||
const onGoalTypePickerChange = (e: UniPickerViewChangeEvent) => {
|
||||
tempGoalTypeIndex.value = e.detail.value[0]
|
||||
|
||||
}
|
||||
const confirmGoalTypePicker = () => {
|
||||
goalTypeIndex.value = tempGoalTypeIndex.value
|
||||
goalForm.value['goal_type'] = goalTypes[goalTypeIndex.value]
|
||||
showGoalTypePicker.value = false
|
||||
}
|
||||
|
||||
const onPriorityPickerChange = (e: UniPickerViewChangeEvent) => {
|
||||
tempPriorityIndex.value = e.detail.value[0]
|
||||
|
||||
}
|
||||
const confirmPriorityPicker = () => {
|
||||
priorityIndex.value = tempPriorityIndex.value
|
||||
goalForm.value['priority'] = priorityIndex.value + 1
|
||||
showPriorityPicker.value = false
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
const getGoalIcon = (goalType: string): string => {
|
||||
const icons = {
|
||||
'weight_loss': '⚖️',
|
||||
'muscle_gain': '',
|
||||
'endurance': '',
|
||||
'flexibility': '',
|
||||
'strength': '️',
|
||||
'skill': ''
|
||||
}
|
||||
return icons[goalType] ?? ''
|
||||
}
|
||||
|
||||
const getGoalTypeName = (goalType: string): string => {
|
||||
const names = {
|
||||
'weight_loss': '减肥目标',
|
||||
'muscle_gain': '增肌目标',
|
||||
'endurance': '耐力提升',
|
||||
'flexibility': '柔韧性',
|
||||
'strength': '力量增强',
|
||||
'skill': '技能提升'
|
||||
}
|
||||
return names[goalType] ?? '未知目标'
|
||||
}
|
||||
|
||||
const getGoalStatusText = (status: string): string => {
|
||||
const statusTexts = {
|
||||
'active': '进行中',
|
||||
'paused': '已暂停',
|
||||
'completed': '已完成',
|
||||
'cancelled': '已取消'
|
||||
}
|
||||
const result =statusTexts[status]
|
||||
return result!=null ? result.toString() :'未知'
|
||||
}
|
||||
|
||||
const getGoalStatusClass = (status: string): string => {
|
||||
return `status-${status}`
|
||||
}
|
||||
|
||||
const getPriorityText = (priority: number): string => {
|
||||
const priorities = ['低', '一般', '中等', '较高', '最高']
|
||||
return priorities[priority - 1] ?? '一般'
|
||||
}
|
||||
|
||||
const getProgressPercent = (goal: UTSJSONObject): number => {
|
||||
const current = goal.getNumber('current_value') ?? 0
|
||||
const target = goal.getNumber('target_value') ?? 1
|
||||
return Math.min(Math.round((current / target) * 100), 100)
|
||||
}
|
||||
|
||||
const getAverageProgress = (): number => {
|
||||
if (goals.value.length === 0) return 0
|
||||
const totalProgress = goals.value.reduce((sum, goal) => sum + getProgressPercent(goal), 0)
|
||||
return Math.round(totalProgress / goals.value.length)
|
||||
}
|
||||
|
||||
// Add a helper to safely get description as string
|
||||
function getGoalDescription(desc: string): string {
|
||||
if (desc == null) return '暂无描述'
|
||||
if (typeof desc === 'string') {
|
||||
const trimmed = desc.trim()
|
||||
return trimmed !== '' ? trimmed : '暂无描述'
|
||||
}
|
||||
return '暂无描述'
|
||||
}
|
||||
|
||||
// Helper to safely get priority text from goal object
|
||||
function getGoalPriorityText(goal: UTSJSONObject): string {
|
||||
const raw = goal.get('priority')
|
||||
let num = 1
|
||||
if (typeof raw === 'number') {
|
||||
num = raw
|
||||
} else if (typeof raw === 'string') {
|
||||
const parsed = parseInt(raw)
|
||||
num = isNaN(parsed) ? 1 : parsed
|
||||
}
|
||||
return getPriorityText(num)
|
||||
}
|
||||
// Helper to safely get formatted date from goal object
|
||||
function getGoalTargetDate(goal: UTSJSONObject): string {
|
||||
const raw = goal.get('target_date')
|
||||
const dateStr = (raw != null) ? raw.toString() : ''
|
||||
// UTS: formatDate is always a function, call directly
|
||||
return formatDate(dateStr, 'YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// Lifecycle hooks
|
||||
onMounted(() => {
|
||||
screenWidth.value = uni.getSystemInfoSync().windowWidth
|
||||
userId.value = getCurrentUserId()
|
||||
loadGoals()
|
||||
})
|
||||
|
||||
onResize((size) => {
|
||||
screenWidth.value = size.size.windowWidth
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.goal-settings-page {
|
||||
flex: 1;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header { height: 60px;
|
||||
background-image: linear-gradient(to top right, #4CAF50, #45a049);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.back-btn, .add-btn {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 8px 12px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.back-btn text, .add-btn text {
|
||||
color: #FFFFFF;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.goals-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 12px;
|
||||
padding: 40px 20px;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin: 12px 0 4px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.add-goal-btn {
|
||||
background-color: #4CAF50;
|
||||
color: #FFFFFF;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 12px 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.goals-list {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.goal-item {
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.goal-header {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.goal-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 20px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.goal-emoji {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.goal-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.goal-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.goal-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.goal-status {
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background-color: #E8F5E8;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background-color: #E3F2FD;
|
||||
}
|
||||
|
||||
.status-paused {
|
||||
background-color: #FFF3E0;
|
||||
}
|
||||
|
||||
.status-cancelled {
|
||||
background-color: #FFEBEE;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.goal-progress {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.progress-percent {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: #4CAF50;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.goal-meta {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.goal-date, .goal-priority {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.templates-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.templates-grid {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-item {
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
width: calc(50% - 6px);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.template-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.template-emoji {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.template-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 12px;
|
||||
width: 80%;
|
||||
max-height: 80%;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-input, .form-textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
height: 80px;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.picker-input {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.cancel-btn, .save-btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background-color: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
background-color: #cccccc;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
151
pages/sport/student/location-test.uvue
Normal file
151
pages/sport/student/location-test.uvue
Normal file
@@ -0,0 +1,151 @@
|
||||
<!-- 手环位置功能测试页面 -->
|
||||
<template>
|
||||
<view class="test-page">
|
||||
<text class="test-title">手环位置功能测试</text>
|
||||
|
||||
<view class="test-section">
|
||||
<text class="section-title">基本功能测试</text>
|
||||
<button class="test-btn" @click="testGetCurrentLocation">测试获取当前位置</button>
|
||||
<button class="test-btn" @click="testGetBaseStations">测试获取基站列表</button>
|
||||
<button class="test-btn" @click="testGetFences">测试获取围栏列表</button>
|
||||
<button class="test-btn" @click="testGetLocationHistory">测试获取位置历史</button>
|
||||
<button class="test-btn" @click="testGetFenceEvents">测试获取围栏事件</button>
|
||||
</view>
|
||||
|
||||
<view class="test-results">
|
||||
<text class="results-title">测试结果:</text>
|
||||
<scroll-view class="results-scroll" scroll-y="true">
|
||||
<text class="result-text">{{ testResults }}</text>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref } from 'vue'
|
||||
import { LocationService } from '@/utils/locationService.uts'
|
||||
|
||||
const testResults = ref<string>('等待测试...\n')
|
||||
|
||||
const addResult = (text: string) => {
|
||||
const now = new Date()
|
||||
const h = now.getHours().toString().padStart(2, '0')
|
||||
const m = now.getMinutes().toString().padStart(2, '0')
|
||||
const s = now.getSeconds().toString().padStart(2, '0')
|
||||
const timestamp = `${h}:${m}:${s}`
|
||||
testResults.value += `[${timestamp}] ${text}\n`
|
||||
}
|
||||
|
||||
const testGetCurrentLocation = async () => {
|
||||
addResult('开始测试获取当前位置...')
|
||||
try {
|
||||
const result = await LocationService.getCurrentLocation('device_test')
|
||||
addResult(`获取位置结果: ${JSON.stringify(result, null, 2)}`)
|
||||
} catch (error) {
|
||||
addResult(`获取位置失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const testGetBaseStations = async () => {
|
||||
addResult('开始测试获取基站列表...')
|
||||
try {
|
||||
const result = await LocationService.getBaseStations()
|
||||
addResult(`获取基站结果: ${JSON.stringify(result, null, 2)}`)
|
||||
} catch (error) {
|
||||
addResult(`获取基站失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const testGetFences = async () => {
|
||||
addResult('开始测试获取围栏列表...')
|
||||
try {
|
||||
const result = await LocationService.getFences('device_test')
|
||||
addResult(`获取围栏结果: ${JSON.stringify(result, null, 2)}`)
|
||||
} catch (error) {
|
||||
addResult(`获取围栏失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const testGetLocationHistory = async () => {
|
||||
addResult('开始测试获取位置历史...')
|
||||
try {
|
||||
const endDate = new Date().toISOString()
|
||||
const startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const result = await LocationService.getLocationHistory('device_test', startDate, endDate)
|
||||
addResult(`获取历史结果: ${JSON.stringify(result, null, 2)}`)
|
||||
} catch (error) {
|
||||
addResult(`获取历史失败: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const testGetFenceEvents = async () => {
|
||||
addResult('开始测试获取围栏事件...')
|
||||
try {
|
||||
const result = await LocationService.getFenceEvents('device_test', 10)
|
||||
addResult(`获取事件结果: ${JSON.stringify(result, null, 2)}`)
|
||||
} catch (error) {
|
||||
addResult(`获取事件失败: ${error}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.test-page {
|
||||
padding: 32rpx;
|
||||
}
|
||||
|
||||
.test-title {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.test-btn {
|
||||
width: 100%;
|
||||
height: 80rpx;
|
||||
background: #007aff;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.test-results {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.results-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.results-scroll {
|
||||
height: 400rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
.result-text {
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
1103
pages/sport/student/location.uvue
Normal file
1103
pages/sport/student/location.uvue
Normal file
File diff suppressed because it is too large
Load Diff
1242
pages/sport/student/preferences-analytics.uvue
Normal file
1242
pages/sport/student/preferences-analytics.uvue
Normal file
File diff suppressed because it is too large
Load Diff
900
pages/sport/student/profile.uvue
Normal file
900
pages/sport/student/profile.uvue
Normal file
@@ -0,0 +1,900 @@
|
||||
<template>
|
||||
<scroll-view class="profile-page" direction="vertical">
|
||||
<!-- 头部背景 -->
|
||||
<view class="profile-header">
|
||||
<view class="header-bg"></view>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<view class="user-info">
|
||||
<view class="avatar-container">
|
||||
<image class="avatar" :src="userAvatar" mode="aspectFill" @click="changeAvatar"></image>
|
||||
<view class="avatar-edit">
|
||||
<text class="edit-icon"></text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="user-details">
|
||||
<text class="username">{{ getUserName() }}</text>
|
||||
<text class="user-id">学号:{{ getUserId() }}</text>
|
||||
<text class="join-date">加入时间:{{ getJoinDate() }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 等级信息 -->
|
||||
<view class="level-info">
|
||||
<view class="level-badge">
|
||||
<text class="level-text">Lv.{{ getUserLevel() }}</text>
|
||||
</view>
|
||||
<view class="xp-info">
|
||||
<text class="xp-text">{{ getCurrentXP() }} / {{ getNextLevelXP() }} XP</text>
|
||||
<view class="xp-bar">
|
||||
<view class="xp-progress" :style="{ width: getXPProgress() + '%' }"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<view class="stats-grid">
|
||||
<view class="stat-item">
|
||||
<text class="stat-number">{{ totalTrainingDays }}</text>
|
||||
<text class="stat-label">训练天数</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-number">{{ completedAssignments }}</text>
|
||||
<text class="stat-label">完成作业</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-number">{{ totalTrainingTime }}</text>
|
||||
<text class="stat-label">训练时长(小时)</text>
|
||||
</view>
|
||||
<view class="stat-item">
|
||||
<text class="stat-number">{{ achievementCount }}</text>
|
||||
<text class="stat-label">获得成就</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能菜单 */
|
||||
<view class="menu-section">
|
||||
<view class="section-title">个人设置</view>
|
||||
|
||||
<view class="menu-list">
|
||||
<view class="menu-item" @click="editProfile">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon"></text>
|
||||
<text class="menu-title">编辑资料</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="changePassword">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon"></text>
|
||||
<text class="menu-title">修改密码</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="notificationSettings">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon"></text>
|
||||
<text class="menu-title">通知设置</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="privacySettings">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon">️</text>
|
||||
<text class="menu-title">隐私设置</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 训练偏好 -->
|
||||
<view class="menu-section">
|
||||
<view class="section-title">训练偏好</view>
|
||||
|
||||
<view class="menu-list">
|
||||
<view class="menu-item" @click="goalSettings">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon"></text>
|
||||
<text class="menu-title">训练目标</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="reminderSettings">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon">⏰</text>
|
||||
<text class="menu-title">训练提醒</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
<view class="menu-item" @click="favoriteExercises">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon">❤️</text>
|
||||
<text class="menu-title">喜欢的运动</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="preferencesAnalytics">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon"></text>
|
||||
<text class="menu-title">偏好分析</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 数据管理 -->
|
||||
<view class="menu-section">
|
||||
<view class="section-title">数据管理</view>
|
||||
|
||||
<view class="menu-list">
|
||||
<view class="menu-item" @click="deviceManagement">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon"></text>
|
||||
<text class="menu-title">设备管理</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="exportData">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon"></text>
|
||||
<text class="menu-title">导出数据</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="backupData">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon">☁️</text>
|
||||
<text class="menu-title">数据备份</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="clearCache">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon">️</text>
|
||||
<text class="menu-title">清除缓存</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 关于 -->
|
||||
<view class="menu-section">
|
||||
<view class="section-title">关于</view>
|
||||
|
||||
<view class="menu-list">
|
||||
<view class="menu-item" @click="helpCenter">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon">❓</text>
|
||||
<text class="menu-title">帮助中心</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="feedback">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon"></text>
|
||||
<text class="menu-title">意见反馈</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="aboutApp">
|
||||
<view class="menu-left">
|
||||
<text class="menu-icon">ℹ️</text>
|
||||
<text class="menu-title">关于应用</text>
|
||||
</view>
|
||||
<text class="menu-arrow">></text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 退出登录 -->
|
||||
<view class="logout-section">
|
||||
<button class="logout-btn" @click="logout">退出登录</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed, onUnmounted } from 'vue'
|
||||
import { onLoad, onResize } from '@dcloudio/uni-app'
|
||||
import { formatDateTime } from '../types.uts'
|
||||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||||
import { state, getCurrentUserId, setUserProfile } from '@/utils/store.uts'
|
||||
import type { UserProfile } from '@/pages/user/types.uts'
|
||||
|
||||
const userId = ref('')
|
||||
|
||||
// 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 userInfo = ref<UTSJSONObject | null>(null)
|
||||
const userAvatar = ref<string>('/static/default-avatar.png')
|
||||
const totalTrainingDays = ref<number>(0)
|
||||
const completedAssignments = ref<number>(0)
|
||||
const totalTrainingTime = ref<number>(0)
|
||||
const achievementCount = ref<number>(0)
|
||||
|
||||
|
||||
const getUserName = () : string => {
|
||||
const profile = state.userProfile
|
||||
if (profile != null) {
|
||||
const username = profile.username
|
||||
if (typeof username === 'string' && username.length > 0) {
|
||||
return username
|
||||
}
|
||||
}
|
||||
if (userInfo.value == null) {
|
||||
return '未登录'
|
||||
}
|
||||
return (userInfo.value as UTSJSONObject).getString('username') ?? '未设置'
|
||||
}
|
||||
|
||||
const getUserId = () : string => {
|
||||
if (typeof userId.value === 'string' && userId.value.length > 0) {
|
||||
return userId.value
|
||||
}
|
||||
if (userInfo.value == null) {
|
||||
return '---'
|
||||
}
|
||||
return (userInfo.value as UTSJSONObject).getString('id') ?? '未设置'
|
||||
}
|
||||
|
||||
const getJoinDate = () : string => {
|
||||
if (userInfo.value == null) return '---'
|
||||
const joinDate = (userInfo.value as UTSJSONObject).getString('created_at') ?? ''
|
||||
return joinDate.length > 0 ? formatDateTime(joinDate).split(' ')[0] : '未知'
|
||||
}
|
||||
|
||||
const getUserLevel = () : number => {
|
||||
if (userInfo.value == null) return 1
|
||||
return (userInfo.value as UTSJSONObject).getNumber('level') ?? 1
|
||||
}
|
||||
const getCurrentXP = () : number => {
|
||||
if (userInfo.value == null) return 0
|
||||
return (userInfo.value as UTSJSONObject).getNumber('current_xp') ?? 0
|
||||
}
|
||||
|
||||
const getNextLevelXP = () : number => {
|
||||
const level = getUserLevel()
|
||||
return level * 1000 // 每级需要1000XP
|
||||
}
|
||||
|
||||
const getXPProgress = () : number => {
|
||||
const current = getCurrentXP()
|
||||
const next = getNextLevelXP()
|
||||
const levelBase = (getUserLevel() - 1) * 1000
|
||||
const levelCurrent = current - levelBase
|
||||
const levelRequired = next - levelBase
|
||||
return levelRequired > 0 ? (levelCurrent / levelRequired) * 100 : 0
|
||||
} // 加载用户资料 - 优先使用 state.userProfile,然后同步数据库
|
||||
|
||||
// 加载用户统计数据
|
||||
const loadUserStats = async () => {
|
||||
try { // 获取训练记录统计
|
||||
const recordsResult = await supaClient
|
||||
.from('ak_training_records')
|
||||
.select('*', {})
|
||||
.eq('user_id', userId.value)
|
||||
.execute()
|
||||
|
||||
if (recordsResult != null && recordsResult.error == null && recordsResult.status == 200) {
|
||||
console.log(recordsResult)
|
||||
const records = recordsResult.data as UTSJSONObject[]
|
||||
|
||||
// 计算训练天数(去重日期)
|
||||
const trainingDates = new Set<string>()
|
||||
let totalMinutes = 0
|
||||
records.forEach(record => {
|
||||
const createdAt = (record as UTSJSONObject).getString('created_at') ?? ''
|
||||
if (createdAt.length > 0) {
|
||||
const date = new Date(createdAt).toDateString()
|
||||
trainingDates.add(date)
|
||||
}
|
||||
|
||||
const duration = (record as UTSJSONObject).getNumber('duration') ?? 0
|
||||
totalMinutes += duration
|
||||
})
|
||||
|
||||
totalTrainingDays.value = trainingDates.size
|
||||
totalTrainingTime.value = Math.round(totalMinutes / 60)
|
||||
}
|
||||
else
|
||||
{
|
||||
console.log(recordsResult)
|
||||
}
|
||||
// 获取完成的作业数量
|
||||
const assignmentsResult = await supaClient
|
||||
.from('ak_assignments')
|
||||
.select('*', {})
|
||||
.eq('status', 'completed')
|
||||
.execute()
|
||||
|
||||
if (assignmentsResult != null && assignmentsResult.error == null) {
|
||||
const data = assignmentsResult.data as UTSJSONObject[]
|
||||
completedAssignments.value = data.length
|
||||
}
|
||||
// 获取成就数量 - 使用高分作业作为成就代理
|
||||
const achievementsResult = await supaClient
|
||||
.from('ak_assignment_submissions')
|
||||
.select('*', {})
|
||||
.eq('student_id', userId.value)
|
||||
.gte('final_score', 90) // 90分以上的作业算作成就
|
||||
.execute()
|
||||
|
||||
if (achievementsResult != null && achievementsResult.error == null) {
|
||||
const data = achievementsResult.data as UTSJSONObject[]
|
||||
achievementCount.value = data.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(userId.value)
|
||||
console.error('加载用户统计失败:', error)
|
||||
// 使用模拟数据
|
||||
totalTrainingDays.value = 45
|
||||
completedAssignments.value = 28
|
||||
totalTrainingTime.value = 67
|
||||
achievementCount.value = 12
|
||||
}
|
||||
} // 更换头像 - 同时更新 state 和数据库
|
||||
const changeAvatar = () => {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
success: function (res) {
|
||||
const tempFilePath = res.tempFilePaths[0]
|
||||
// 这里应该上传头像到服务器
|
||||
userAvatar.value = tempFilePath // 更新 state.userProfile 中的头像 - 使用本地变量避免smart cast问题
|
||||
const currentProfile = state.userProfile
|
||||
if (currentProfile != null && typeof currentProfile.id === 'string' && (currentProfile.id?.length ?? 0) > 0) {
|
||||
const updatedProfile : UserProfile = {
|
||||
id: currentProfile.id,
|
||||
username: currentProfile.username,
|
||||
email: currentProfile.email,
|
||||
gender: currentProfile.gender,
|
||||
birthday: currentProfile.birthday,
|
||||
height_cm: currentProfile.height_cm,
|
||||
weight_kg: currentProfile.weight_kg,
|
||||
bio: currentProfile.bio,
|
||||
avatar_url: tempFilePath,
|
||||
preferred_language: currentProfile.preferred_language,
|
||||
role: currentProfile.role
|
||||
}
|
||||
setUserProfile(updatedProfile)
|
||||
}// 更新数据库(如果用户ID有效)
|
||||
const currentUserId = getCurrentUserId()
|
||||
if (currentUserId.length > 0 && currentUserId !== 'demo_user_id') {
|
||||
// 使用异步调用但不等待结果
|
||||
supaClient
|
||||
.from('ak_users')
|
||||
.update({ avatar_url: tempFilePath })
|
||||
.eq('id', currentUserId)
|
||||
.execute()
|
||||
.then(() => {
|
||||
console.log('头像更新成功')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('更新头像失败:', error)
|
||||
})
|
||||
}
|
||||
|
||||
uni.showToast({
|
||||
title: '头像更新成功',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑资料
|
||||
const editProfile = () => {
|
||||
uni.showModal({
|
||||
title: '编辑资料',
|
||||
content: '功能开发中...',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
const changePassword = () => {
|
||||
uni.showModal({
|
||||
title: '修改密码',
|
||||
content: '功能开发中...',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
// 通知设置
|
||||
const notificationSettings = () => {
|
||||
uni.showModal({
|
||||
title: '通知设置',
|
||||
content: '功能开发中...',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
// 隐私设置
|
||||
const privacySettings = () => {
|
||||
uni.showModal({
|
||||
title: '隐私设置',
|
||||
content: '功能开发中...',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
// 目标设置
|
||||
const goalSettings = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/sport/student/goal-settings'
|
||||
})
|
||||
}
|
||||
|
||||
// 提醒设置
|
||||
const reminderSettings = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/sport/student/reminder-settings'
|
||||
})
|
||||
}
|
||||
// 喜欢的运动
|
||||
const favoriteExercises = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/sport/student/favorite-exercises'
|
||||
})
|
||||
}
|
||||
|
||||
// 偏好分析
|
||||
const preferencesAnalytics = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/sport/student/preferences-analytics'
|
||||
})
|
||||
}
|
||||
|
||||
// 设备管理
|
||||
const deviceManagement = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/sport/student/device-management'
|
||||
})
|
||||
}
|
||||
// 导出数据
|
||||
const exportData = () => {
|
||||
uni.showLoading({
|
||||
title: '导出中...'
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: '导出完成',
|
||||
icon: 'success'
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// 备份数据
|
||||
const backupData = () => {
|
||||
uni.showLoading({
|
||||
title: '备份中...'
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: '备份完成',
|
||||
icon: 'success'
|
||||
})
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
// 清除缓存
|
||||
const clearCache = () => {
|
||||
uni.showModal({
|
||||
title: '清除缓存',
|
||||
content: '确定要清除所有缓存数据吗?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.showLoading({
|
||||
title: '清除中...'
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({
|
||||
title: '缓存已清除',
|
||||
icon: 'success'
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 帮助中心
|
||||
const helpCenter = () => {
|
||||
uni.showModal({
|
||||
title: '帮助中心',
|
||||
content: '功能开发中...',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
// 意见反馈
|
||||
const feedback = () => {
|
||||
uni.showModal({
|
||||
title: '意见反馈',
|
||||
content: '功能开发中...',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
// 关于应用
|
||||
const aboutApp = () => {
|
||||
uni.showModal({
|
||||
title: '关于应用',
|
||||
content: 'AI监测系统 v1.0.0\n\n 高性能AI监测管理平台',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
const loadUserProfile = async () => {
|
||||
try {
|
||||
// 如果 state.userProfile 存在,先使用它填充界面
|
||||
const profile = state.userProfile
|
||||
if (profile != null && typeof profile.id === 'string' && (profile.id?.length ?? 0) > 0) {
|
||||
userInfo.value = {
|
||||
id: profile.id,
|
||||
username: profile.username != null ? profile.username : '',
|
||||
email: profile.email != null ? profile.email : '',
|
||||
avatar_url: profile.avatar_url != null ? profile.avatar_url : '',
|
||||
created_at: new Date().toISOString() // 临时值
|
||||
} as UTSJSONObject
|
||||
|
||||
const avatarUrl = profile.avatar_url
|
||||
if (typeof avatarUrl === 'string' && (avatarUrl?.length ?? 0) > 0) {
|
||||
userAvatar.value = avatarUrl as string
|
||||
}
|
||||
}
|
||||
|
||||
// 然后从数据库获取最新数据并更新 state
|
||||
if (typeof userId.value === 'string' && userId.value.length > 0 && userId.value !== 'demo_user_id') {
|
||||
|
||||
const result = await supaClient
|
||||
.from('ak_users')
|
||||
.select('*', {})
|
||||
.eq('id', userId.value)
|
||||
.single()
|
||||
.execute()
|
||||
|
||||
if (result != null && result.error == null && result.data != null) {
|
||||
const res_data = result.data as UTSJSONObject[]
|
||||
const resultData = res_data[0]
|
||||
userInfo.value = resultData
|
||||
const avatar = (userInfo.value as UTSJSONObject).getString('avatar_url') ?? ''
|
||||
if (avatar.length > 0) {
|
||||
userAvatar.value = avatar
|
||||
}
|
||||
|
||||
// 更新 state.userProfile
|
||||
|
||||
const updatedProfile : UserProfile = {
|
||||
id: resultData.getString('id') ?? '',
|
||||
username: resultData.getString('username') ?? '',
|
||||
email: resultData.getString('email') ?? '',
|
||||
gender: resultData.getString('gender'),
|
||||
birthday: resultData.getString('birthday'),
|
||||
height_cm: resultData.getNumber('height_cm'),
|
||||
weight_kg: resultData.getNumber('weight_kg'),
|
||||
bio: resultData.getString('bio'),
|
||||
avatar_url: resultData.getString('avatar_url'),
|
||||
preferred_language: resultData.getString('preferred_language'),
|
||||
role: resultData.getString('role')
|
||||
}
|
||||
setUserProfile(updatedProfile)
|
||||
}
|
||||
}
|
||||
loadUserStats()
|
||||
} catch (error) {
|
||||
console.error('加载用户资料失败:', error)
|
||||
}
|
||||
}
|
||||
// 退出登录 - 清空 state 和重定向
|
||||
const logout = () => {
|
||||
uni.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定要退出登录吗?',
|
||||
success: function (res) {
|
||||
if (res.confirm) {
|
||||
// 使用 Promise 包装异步操作
|
||||
supaClient.signOut()
|
||||
.then(() => {
|
||||
// 清空 state
|
||||
const emptyProfile : UserProfile = { username: '', email: '' }
|
||||
setUserProfile(emptyProfile)
|
||||
|
||||
uni.reLaunch({
|
||||
url: '/pages/index/index'
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('退出登录失败:', error)
|
||||
uni.showToast({
|
||||
title: '退出失败',
|
||||
icon: 'none'
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// 生命周期
|
||||
onLoad((options : OnLoadOptions) => {
|
||||
userId.value = options['id'] ?? ''
|
||||
if (userId.value.length === 0) {
|
||||
userId.value = getCurrentUserId()
|
||||
}
|
||||
loadUserProfile()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Initialize screen width
|
||||
screenWidth.value = uni.getSystemInfoSync().windowWidth
|
||||
})
|
||||
|
||||
// Handle resize events for responsive design
|
||||
onResize((size) => {
|
||||
screenWidth.value = size.size.windowWidth
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.profile-page {
|
||||
flex: 1;
|
||||
background: #f5f6fa;
|
||||
}
|
||||
|
||||
/* 头部区域 */
|
||||
.profile-header {
|
||||
position: relative;
|
||||
padding: 60rpx 40rpx 40rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.header-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image: linear-gradient(to right, #667eea, #764ba2);
|
||||
border-radius: 0 0 40rpx 40rpx;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
margin-right: 30rpx;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 60rpx;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.avatar-edit {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.user-id,
|
||||
.join-date {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.level-info {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.level-badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 10rpx 20rpx;
|
||||
border-radius: 25rpx;
|
||||
backdrop-filter: blur(10rpx);
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
|
||||
.level-text {
|
||||
color: white;
|
||||
font-size: 24rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.xp-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.xp-text {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.xp-bar {
|
||||
height: 8rpx;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 4rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xp-progress {
|
||||
height: 100%;
|
||||
background: white;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
/* 统计网格 */
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 20rpx 30rpx;
|
||||
}
|
||||
.stat-item {
|
||||
background: white;
|
||||
border-radius: 20rpx;
|
||||
padding: 40rpx 30rpx;
|
||||
text-align: center;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
margin-right: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
width: 46%;
|
||||
flex: 0 0 46%;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 48rpx;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
line-height: 1;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 24rpx;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
/* 菜单区域 */
|
||||
.menu-section {
|
||||
margin: 0 20rpx 30rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 15rpx;
|
||||
padding-left: 10rpx;
|
||||
}
|
||||
|
||||
.menu-list {
|
||||
background: white;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 30rpx;
|
||||
border-bottom: 1px solid #f8f9fa;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.menu-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.menu-item:active {
|
||||
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.menu-left {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 32rpx;
|
||||
width: 40rpx;
|
||||
text-align: center;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 30rpx;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
font-size: 28rpx;
|
||||
color: #bdc3c7;
|
||||
}
|
||||
|
||||
/* 退出登录 */
|
||||
.logout-section {
|
||||
margin: 40rpx 20rpx;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
padding: 30rpx;
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 20rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
transition: transform 0.3s ease, background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.logout-btn:active {
|
||||
transform: scale(0.98);
|
||||
background: #c0392b;
|
||||
}
|
||||
</style>
|
||||
637
pages/sport/student/progress.uvue
Normal file
637
pages/sport/student/progress.uvue
Normal file
@@ -0,0 +1,637 @@
|
||||
<template>
|
||||
<scroll-view direction="vertical" class="progress-container"> <!-- 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="refreshData" class="refresh-btn">
|
||||
<simple-icon type="refresh" :size="16" color="#FFFFFF" />
|
||||
<text>刷新</text>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Loading State -->
|
||||
<view v-if="loading" class="loading-container">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
<!-- Content -->
|
||||
<scroll-view v-else class="content" direction="vertical" :style="{ height: contentHeight + 'px' }">
|
||||
<!-- Overall Progress -->
|
||||
<view class="progress-overview">
|
||||
<text class="section-title">总体进度</text>
|
||||
<view class="overall-stats">
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">{{ overallProgress }}%</text>
|
||||
<text class="stat-label">完成率</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">{{ completedAssignments }}</text>
|
||||
<text class="stat-label">已完成作业</text>
|
||||
</view>
|
||||
<view class="stat-card">
|
||||
<text class="stat-value">{{ totalTrainingTime }}</text>
|
||||
<text class="stat-label">训练时长(分钟)</text>
|
||||
</view>
|
||||
</view>
|
||||
</view> <!-- Progress by Subject -->
|
||||
<view class="progress-by-subject">
|
||||
<text class="section-title">各科目进度</text>
|
||||
<view v-for="subject in subjectProgress" :key="subject.getString('id')" class="subject-item">
|
||||
<view class="subject-header">
|
||||
<text class="subject-name">{{ subject.getString('name') }}</text>
|
||||
<text class="subject-percentage">{{ subject.getNumber('progress') }}%</text>
|
||||
</view>
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: (subject.getNumber('progress') ?? 0) + '%' }"></view>
|
||||
</view>
|
||||
<view class="subject-details">
|
||||
<text class="detail-text">已完成:
|
||||
{{ subject.getNumber('completed') }}/{{ subject.getNumber('total') }}</text>
|
||||
<text class="detail-text">最近训练: {{ formatDate(subject.getString('lastTraining') ?? '') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view> <!-- Recent Activities -->
|
||||
<view class="recent-activities">
|
||||
<text class="section-title">最近活动</text>
|
||||
<view v-for="activity in recentActivities" :key="activity.getString('id')" class="activity-item">
|
||||
<view class="activity-icon">
|
||||
<simple-icon :type="getActivityIcon(activity.getString('type') ?? 'info')" :size="20"
|
||||
color="#4CAF50" />
|
||||
</view>
|
||||
<view class="activity-content">
|
||||
<text class="activity-title">{{ activity.getString('title') }}</text>
|
||||
<text class="activity-desc">{{ activity.getString('description') }}</text>
|
||||
<text class="activity-time">{{ formatDateTime(activity.getString('time') ?? '') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view> <!-- Weekly Goals -->
|
||||
<view class="weekly-goals">
|
||||
<text class="section-title">本周目标</text>
|
||||
<view v-for="goal in weeklyGoals" :key="goal.getString('id')" class="goal-item">
|
||||
<view class="goal-header">
|
||||
<text class="goal-name">{{ goal.getString('name') }}</text>
|
||||
<text class="goal-status" :class="{ 'completed': isGoalCompleted(goal) }">
|
||||
{{ isGoalCompleted(goal) ? '已完成' : '进行中' }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="progress-bar">
|
||||
<view class="progress-fill" :style="{ width: (goal.getNumber('progress') ?? 0) + '%' }"></view>
|
||||
</view>
|
||||
<text class="goal-desc">{{ goal.getString('description') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { onLoad, onResize } from '@dcloudio/uni-app'
|
||||
import { state, getCurrentUserId } from '@/utils/store.uts'
|
||||
import supa from '@/components/supadb/aksupainstance.uts'
|
||||
|
||||
// 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 loading = ref(true)
|
||||
const overallProgress = ref(0)
|
||||
const completedAssignments = ref(0)
|
||||
const totalTrainingTime = ref(0)
|
||||
const subjectProgress = ref<UTSJSONObject[]>([])
|
||||
const recentActivities = ref<UTSJSONObject[]>([])
|
||||
const weeklyGoals = ref<UTSJSONObject[]>([])
|
||||
const contentHeight = ref(0)
|
||||
|
||||
// Page parameters
|
||||
const userId = ref('')
|
||||
const studentName = ref('')
|
||||
const fromStats = ref(false)// 生命周期
|
||||
// Forward declaration for loadProgressData
|
||||
let loadProgressData : () => Promise<void> = async () => {
|
||||
|
||||
}
|
||||
// Handle page load with parameters
|
||||
onLoad((options : OnLoadOptions) => {
|
||||
// Get student ID from page parameters if provided
|
||||
userId.value = options['id'] ?? ''
|
||||
if (userId.value.length > 0) {
|
||||
fromStats.value = true
|
||||
}
|
||||
studentName.value = decodeURIComponent(options['studentName'] ?? '') ?? ''
|
||||
|
||||
// Use setTimeout to avoid async function type mismatch
|
||||
setTimeout(() => {
|
||||
loadProgressData().then((res) => {
|
||||
// Data loaded successfully
|
||||
console.log(res)
|
||||
})
|
||||
}, 100)
|
||||
})
|
||||
|
||||
// 计算内容高度
|
||||
const calculateContentHeight = () => {
|
||||
const systemInfo = uni.getSystemInfoSync()
|
||||
const windowHeight = systemInfo.windowHeight
|
||||
const headerHeight = 60
|
||||
contentHeight.value = windowHeight - headerHeight
|
||||
} // 加载进度数据
|
||||
|
||||
|
||||
// 加载总体进度
|
||||
const loadOverallProgress = async (studentId : string) => {
|
||||
try {
|
||||
// 获取作业完成情况
|
||||
const assignmentsResult = await supa
|
||||
.from('ak_student_assignments')
|
||||
.select('status', {})
|
||||
.eq('student_id', studentId)
|
||||
.execute()
|
||||
|
||||
if (assignmentsResult.data != null) {
|
||||
const assignments = assignmentsResult.data as UTSJSONObject[]
|
||||
const completed = assignments.filter(a => ((a as UTSJSONObject).getString('status') ?? 'pending') === 'completed').length
|
||||
completedAssignments.value = completed
|
||||
overallProgress.value = assignments.length > 0 ? Math.round((completed / assignments.length) * 100) : 0
|
||||
}// 获取训练时长
|
||||
const trainingResult = await supa
|
||||
.from('ak_training_records')
|
||||
.select('duration', {})
|
||||
.eq('student_id', studentId)
|
||||
.execute()
|
||||
|
||||
if (trainingResult.data != null) {
|
||||
const records = trainingResult.data as UTSJSONObject[]
|
||||
totalTrainingTime.value = records.reduce((total, record) => {
|
||||
return total + ((record as UTSJSONObject).getNumber('duration') ?? 0)
|
||||
}, 0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载总体进度失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载各科目进度
|
||||
const loadSubjectProgress = async (studentId : string) => {
|
||||
|
||||
try {
|
||||
const result = await supa
|
||||
.from('ak_training_projects')
|
||||
.select(`
|
||||
id, name,
|
||||
training_records:ak_training_records(status),
|
||||
assignments:ak_student_assignments(status)
|
||||
`, {})
|
||||
.execute()
|
||||
|
||||
if (result.data != null) {
|
||||
const projects = result.data as UTSJSONObject[]
|
||||
subjectProgress.value = projects.map(project => {
|
||||
const records = (project as UTSJSONObject).getArray('training_records') as UTSJSONObject[] ?? []
|
||||
const assignments = (project as UTSJSONObject).getArray('assignments') as UTSJSONObject[] ?? []
|
||||
|
||||
const completedRecords = records.filter(r => ((r as UTSJSONObject).getString('status') ?? 'pending') === 'completed').length
|
||||
const completedAssignments = assignments.filter(a => ((a as UTSJSONObject).getString('status') ?? 'pending') === 'completed').length
|
||||
|
||||
const total = records.length + assignments.length
|
||||
const completed = completedRecords + completedAssignments
|
||||
|
||||
const progressData = new UTSJSONObject()
|
||||
progressData.set('id', (project as UTSJSONObject).getString('id') ?? '')
|
||||
progressData.set('name', (project as UTSJSONObject).getString('name') ?? '')
|
||||
progressData.set('progress', total > 0 ? Math.round((completed / total) * 100) : 0)
|
||||
progressData.set('completed', completed)
|
||||
progressData.set('total', total)
|
||||
progressData.set('lastTraining', new Date().toISOString())
|
||||
|
||||
return progressData
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载科目进度失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载最近活动
|
||||
const loadRecentActivities = async (studentId : string) => {
|
||||
|
||||
try {
|
||||
const result = await supa
|
||||
.from('ak_training_records')
|
||||
.select('*, ak_training_projects(name)', {})
|
||||
.eq('student_id', studentId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10)
|
||||
.execute()
|
||||
|
||||
if (result.data != null) {
|
||||
const records = result.data as UTSJSONObject[]
|
||||
recentActivities.value = records.map(record => {
|
||||
const activityData = new UTSJSONObject()
|
||||
activityData.set('id', (record as UTSJSONObject).getString('id') ?? '')
|
||||
activityData.set('type', 'training')
|
||||
activityData.set('title', `完成训练: ${((record as UTSJSONObject).getAny('ak_training_projects') as UTSJSONObject ?? new UTSJSONObject()).getString('name') ?? '未知项目'}`)
|
||||
activityData.set('description', `耗时: ${(record as UTSJSONObject).getNumber('duration') ?? 0}分钟`)
|
||||
activityData.set('time', (record as UTSJSONObject).getString('created_at') ?? '')
|
||||
|
||||
return activityData
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载最近活动失败:', error)
|
||||
}
|
||||
}
|
||||
// 加载周目标
|
||||
const loadWeeklyGoals = async (studentId : string) => {
|
||||
// 创建示例周目标
|
||||
const goal1 = new UTSJSONObject()
|
||||
goal1.set('id', '1')
|
||||
goal1.set('name', '完成3次训练')
|
||||
goal1.set('description', '本周完成至少3次训练课程')
|
||||
goal1.set('progress', 67)
|
||||
goal1.set('completed', false)
|
||||
|
||||
const goal2 = new UTSJSONObject()
|
||||
goal2.set('id', '2')
|
||||
goal2.set('name', '提交2份作业')
|
||||
goal2.set('description', '按时提交本周的训练作业')
|
||||
goal2.set('progress', 100)
|
||||
goal2.set('completed', true)
|
||||
|
||||
const goal3 = new UTSJSONObject()
|
||||
goal3.set('id', '3')
|
||||
goal3.set('name', '训练时长达到120分钟')
|
||||
goal3.set('description', '累计训练时长达到目标')
|
||||
goal3.set('progress', 45)
|
||||
goal3.set('completed', false)
|
||||
|
||||
weeklyGoals.value = [goal1, goal2, goal3]
|
||||
} // 刷新数据
|
||||
const refreshData = () => {
|
||||
loadProgressData().then(() => {
|
||||
uni.showToast({
|
||||
title: '刷新成功',
|
||||
icon: 'success'
|
||||
})
|
||||
}).catch((error) => {
|
||||
console.error('Refresh failed:', error)
|
||||
})
|
||||
}
|
||||
|
||||
// 返回上一页
|
||||
const goBack = () => {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString : string) : string => {
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||
} catch {
|
||||
return '暂无'
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateString : string) : string => {
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日 ${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`
|
||||
} catch {
|
||||
return '暂无'
|
||||
}
|
||||
}
|
||||
loadProgressData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
if (userId.value.length === 0) {
|
||||
uni.showToast({
|
||||
title: '登录已过期,请重新登录',
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
})
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: '/pages/user/login' })
|
||||
}, 1000)
|
||||
return
|
||||
}
|
||||
// 序列化加载各种数据以避免Promise.all兼容性问题
|
||||
await loadOverallProgress(userId.value)
|
||||
await loadSubjectProgress(userId.value)
|
||||
await loadRecentActivities(userId.value)
|
||||
await loadWeeklyGoals(userId.value)
|
||||
} catch (error) {
|
||||
console.error('加载进度数据失败:', error)
|
||||
uni.showToast({
|
||||
title: '加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
// 获取活动图标
|
||||
const getActivityIcon = (type : string) : string => {
|
||||
switch (type) {
|
||||
case 'training': return 'play'
|
||||
case 'assignment': return 'edit'
|
||||
case 'achievement': return 'trophy'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
// 安全解包目标完成状态
|
||||
const isGoalCompleted = (goal: UTSJSONObject): boolean => {
|
||||
return goal.getBoolean('completed') === true
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
screenWidth.value = uni.getSystemInfoSync().windowWidth
|
||||
calculateContentHeight()
|
||||
// Use setTimeout to avoid async function type mismatch
|
||||
setTimeout(() => {
|
||||
loadProgressData().then(() => {
|
||||
// Data loaded successfully
|
||||
}).catch((error) => {
|
||||
console.error('Failed to load data:', error)
|
||||
})
|
||||
}, 100)
|
||||
})
|
||||
|
||||
onResize((size) => {
|
||||
screenWidth.value = size.size.windowWidth
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progress-container {
|
||||
flex: 1;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.header {
|
||||
height: 60px;
|
||||
background-image: linear-gradient(to top right, #4CAF50, #45a049);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 8px 12px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.back-btn simple-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.back-btn text {
|
||||
color: #FFFFFF;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.refresh-btn {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 8px 12px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.refresh-btn simple-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.refresh-btn text {
|
||||
color: #FFFFFF;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-overview {
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.overall-stats {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.progress-by-subject {
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.subject-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.subject-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subject-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.subject-percentage {
|
||||
font-size: 14px;
|
||||
color: #4CAF50;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: #4CAF50;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.subject-details {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.detail-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.recent-activities {
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.activity-item {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 20px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.activity-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.weekly-goals {
|
||||
background-color: #FFFFFF;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.goal-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.goal-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.goal-name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.goal-status {
|
||||
font-size: 12px;
|
||||
color: #FF9800;
|
||||
background-color: #FFF3E0;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.goal-status.completed {
|
||||
color: #4CAF50;
|
||||
background-color: #E8F5E8;
|
||||
}
|
||||
|
||||
.goal-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
0
pages/sport/student/progress_setup.uvue
Normal file
0
pages/sport/student/progress_setup.uvue
Normal file
834
pages/sport/student/record-detail.uvue
Normal file
834
pages/sport/student/record-detail.uvue
Normal file
@@ -0,0 +1,834 @@
|
||||
<template>
|
||||
<scroll-view direction="vertical" class="record-detail">
|
||||
<!-- Header Card -->
|
||||
<view class="header-card">
|
||||
<view class="record-header">
|
||||
<text class="record-title">{{ getRecordTitle() }}</text>
|
||||
<view class="score-badge" :class="`score-${getScoreLevel()}`">
|
||||
<text class="score-text">{{ getRecordScore() }}分</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="record-project">{{ getProjectNameWrapper() }}</text>
|
||||
<view class="record-meta">
|
||||
<view class="meta-item">
|
||||
<text class="meta-icon"></text>
|
||||
<text class="meta-text">{{ formatDateWrapper(getRecordDate()) }}</text>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<text class="meta-icon">⏱️</text>
|
||||
<text class="meta-text">{{ formatDuration(getRecordDuration()) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Performance Data Card -->
|
||||
<view class="performance-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">训练数据</text>
|
||||
</view>
|
||||
<view class="performance-grid"> <view
|
||||
class="performance-item"
|
||||
v-for="(metric, index) in getPerformanceMetrics()"
|
||||
:key="index"
|
||||
>
|
||||
<view class="metric-icon">
|
||||
<text class="icon">{{ getMetricIcon(metric) }}</text>
|
||||
</view>
|
||||
<view class="metric-content">
|
||||
<text class="metric-value">{{ getMetricValue(metric) }}</text>
|
||||
<text class="metric-label">{{ getMetricLabel(metric) }}</text>
|
||||
<text class="metric-unit">{{ getMetricUnit(metric) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Technique Analysis Card -->
|
||||
<view class="technique-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">技术分析</text>
|
||||
</view>
|
||||
<view class="technique-list"> <view
|
||||
class="technique-item"
|
||||
v-for="(technique, index) in getTechniqueAnalysis()"
|
||||
:key="index"
|
||||
>
|
||||
<view class="technique-header">
|
||||
<text class="technique-name">{{ getTechniqueName(technique) }}</text>
|
||||
<view class="rating-stars">
|
||||
<text
|
||||
class="star"
|
||||
v-for="(star, starIndex) in 5"
|
||||
:key="starIndex"
|
||||
:class="starIndex < getTechniqueRating(technique) ? 'star-filled' : 'star-empty'"
|
||||
>
|
||||
★
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="technique-comment">{{ getTechniqueComment(technique) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Improvement Suggestions Card -->
|
||||
<view class="suggestions-card">
|
||||
<view class="card-header">
|
||||
<text class="card-title">改进建议</text>
|
||||
</view>
|
||||
<view class="suggestions-list"> <view
|
||||
class="suggestion-item"
|
||||
v-for="(suggestion, index) in getImprovementSuggestions()"
|
||||
:key="index"
|
||||
>
|
||||
<view class="suggestion-icon">
|
||||
<text class="icon"></text>
|
||||
</view>
|
||||
<view class="suggestion-content">
|
||||
<text class="suggestion-title">{{ getSuggestionTitle(suggestion) }}</text>
|
||||
<text class="suggestion-desc">{{ getSuggestionDescription(suggestion) }}</text>
|
||||
<view class="priority-tag" :class="`priority-${getSuggestionPriority(suggestion)}`">
|
||||
<text class="priority-text">{{ formatPriority(getSuggestionPriority(suggestion)) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Teacher Comments Card -->
|
||||
<view class="comments-card" v-if="hasTeacherComments()">
|
||||
<view class="card-header">
|
||||
<text class="card-title">教师点评</text>
|
||||
</view>
|
||||
<view class="comment-content"> <view class="comment-header">
|
||||
<text class="teacher-name">{{ getTeacherName() }}</text>
|
||||
<text class="comment-date">{{ formatDateWrapper(getCommentDate()) }}</text>
|
||||
</view>
|
||||
<text class="comment-text">{{ getTeacherComment() }}</text>
|
||||
<view class="comment-rating">
|
||||
<text class="rating-label">综合评价:</text>
|
||||
<view class="rating-stars">
|
||||
<text
|
||||
class="star"
|
||||
v-for="(star, index) in 5"
|
||||
:key="index"
|
||||
:class="index < getTeacherRating() ? 'star-filled' : 'star-empty'"
|
||||
>
|
||||
★
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<view class="action-buttons">
|
||||
<button class="action-btn secondary-btn" @click="viewAssignment">
|
||||
查看作业
|
||||
</button>
|
||||
<button class="action-btn primary-btn" @click="retryTraining">
|
||||
重新训练
|
||||
</button>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts"> import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { onLoad, onResize } from '@dcloudio/uni-app'
|
||||
import { getProjectName, formatDate } from '../types.uts'
|
||||
|
||||
// Reactive data
|
||||
const record = ref<UTSJSONObject>({})
|
||||
const recordId = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
|
||||
// Methods
|
||||
function loadRecordDetail() {
|
||||
loading.value = true
|
||||
|
||||
// Mock data - replace with actual API call
|
||||
setTimeout(() => {
|
||||
record.value = {
|
||||
"id": recordId.value,
|
||||
"assignment_id": "1",
|
||||
"project_name": "跳远训练",
|
||||
"date": "2024-01-15T10:30:00",
|
||||
"duration": 1800, // 30 minutes in seconds
|
||||
"score": 85,
|
||||
"performance_data": {
|
||||
"distance": { "value": 4.2, "unit": "米", "icon": "" },
|
||||
"attempts": { "value": 8, "unit": "次", "icon": "" },
|
||||
"best_distance": { "value": 4.5, "unit": "米", "icon": "" },
|
||||
"average_distance": { "value": 4.1, "unit": "米", "icon": "" }
|
||||
},
|
||||
"technique_analysis": [
|
||||
{
|
||||
"name": "助跑技术",
|
||||
"rating": 4,
|
||||
"comment": "助跑节奏良好,加速明显,起跳点控制较准确"
|
||||
},
|
||||
{
|
||||
"name": "起跳技术",
|
||||
"rating": 3,
|
||||
"comment": "起跳时机把握较好,但起跳角度需要调整"
|
||||
},
|
||||
{
|
||||
"name": "空中姿态",
|
||||
"rating": 4,
|
||||
"comment": "空中姿态保持良好,腿部动作协调"
|
||||
},
|
||||
{
|
||||
"name": "落地技术",
|
||||
"rating": 5,
|
||||
"comment": "落地缓冲充分,姿态标准"
|
||||
}
|
||||
],
|
||||
"improvement_suggestions": [
|
||||
{
|
||||
"title": "起跳角度调整",
|
||||
"description": "适当增加起跳角度,有助于提高跳跃距离",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"title": "助跑速度控制",
|
||||
"description": "在保证准确性的前提下,可以适当提高助跑速度",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"title": "力量训练加强",
|
||||
"description": "建议增加腿部力量训练,提高爆发力",
|
||||
"priority": "low"
|
||||
}
|
||||
],
|
||||
"teacher_comment": {
|
||||
"teacher_name": "张老师",
|
||||
"comment": "整体表现不错,技术动作比较标准。建议在起跳技术上多加练习,同时注意助跑与起跳的衔接。继续保持!",
|
||||
"rating": 4,
|
||||
"date": "2024-01-15T15:30:00"
|
||||
}
|
||||
} as UTSJSONObject
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
|
||||
function getRecordDate(): string {
|
||||
return record.value.getString('date') ?? ''
|
||||
}
|
||||
|
||||
function getRecordDuration(): number {
|
||||
return record.value.getNumber('duration') ?? 0
|
||||
}
|
||||
|
||||
function getRecordScore(): number {
|
||||
return record.value.getNumber('score') ?? 0
|
||||
}
|
||||
|
||||
function getScoreLevel(): string {
|
||||
const score = getRecordScore()
|
||||
if (score >= 90) return 'excellent'
|
||||
if (score >= 80) return 'good'
|
||||
if (score >= 70) return 'fair'
|
||||
return 'poor'
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return `${minutes}分${remainingSeconds}秒`
|
||||
}
|
||||
function getPerformanceMetrics(): Array<any> {
|
||||
const data = record.value.getAny('performance_data') as UTSJSONObject ?? {}
|
||||
const metrics: Array<any> = []
|
||||
|
||||
const keys = ['distance', 'attempts', 'best_distance', 'average_distance']
|
||||
const labels = ['最终距离', '训练次数', '最好成绩', '平均距离']
|
||||
|
||||
keys.forEach((key, index) => {
|
||||
const metric = (data as UTSJSONObject).getAny(key) as UTSJSONObject
|
||||
if (metric !== null) {
|
||||
metrics.push({
|
||||
icon: metric.getString('icon') ?? '',
|
||||
value: metric.getNumber('value') ?? 0,
|
||||
unit: metric.getString('unit') ?? '',
|
||||
label: labels[index]
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return metrics
|
||||
}
|
||||
function getTechniqueAnalysis(): Array<any> {
|
||||
const analysis = record.value.getArray('technique_analysis') ?? ([] as Array<any>)
|
||||
if (analysis instanceof Array) {
|
||||
return analysis as Array<any>
|
||||
}
|
||||
return []
|
||||
}
|
||||
function getImprovementSuggestions(): Array<any> {
|
||||
const suggestions = record.value.getArray('improvement_suggestions') ?? ([] as Array<any>)
|
||||
if (suggestions instanceof Array) {
|
||||
return suggestions as Array<any>
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function formatPriority(priority: string): string {
|
||||
const priorityMap = {
|
||||
'high': '高优先级',
|
||||
'medium': '中优先级',
|
||||
'low': '低优先级'
|
||||
} as UTSJSONObject
|
||||
return priorityMap.getString(priority) ?? priority
|
||||
}
|
||||
function hasTeacherComments(): boolean {
|
||||
const comment = record.value.getAny('teacher_comment')
|
||||
return comment !== null
|
||||
}
|
||||
|
||||
function getTeacherName(): string {
|
||||
const comment = record.value.getAny('teacher_comment') as UTSJSONObject ?? {}
|
||||
return (comment as UTSJSONObject).getString('teacher_name') ?? ''
|
||||
}
|
||||
|
||||
function getTeacherComment(): string {
|
||||
const comment = record.value.getAny('teacher_comment') as UTSJSONObject ?? {}
|
||||
return (comment as UTSJSONObject).getString('comment') ?? ''
|
||||
}
|
||||
|
||||
function getTeacherRating(): number {
|
||||
const comment = record.value.getAny('teacher_comment') as UTSJSONObject ?? {}
|
||||
return (comment as UTSJSONObject).getNumber('rating') ?? 0
|
||||
}
|
||||
|
||||
function getCommentDate(): string {
|
||||
const comment = record.value.getAny('teacher_comment') as UTSJSONObject ?? {}
|
||||
return (comment as UTSJSONObject).getString('date') ?? ''
|
||||
} function viewAssignment() {
|
||||
const assignmentId = record.value.getString('assignment_id') ?? ''
|
||||
if (assignmentId.length > 0) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/sport/student/assignment-detail?id=${assignmentId}`
|
||||
})
|
||||
}
|
||||
}
|
||||
function retryTraining() {
|
||||
const assignmentId = record.value.getString('assignment_id') ?? ''
|
||||
if (assignmentId.length > 0) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/sport/student/training-record?assignmentId=${assignmentId}`
|
||||
})
|
||||
}
|
||||
} // Template wrapper functions for safe property access
|
||||
function getMetricIcon(metric: any): string {
|
||||
if (metric != null && typeof metric === 'object') {
|
||||
const obj = metric as UTSJSONObject
|
||||
return obj.getString('icon') ?? ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getMetricValue(metric: any): number {
|
||||
if (metric != null && typeof metric === 'object') {
|
||||
const obj = metric as UTSJSONObject
|
||||
return obj.getNumber('value') ?? 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function getMetricLabel(metric: any): string {
|
||||
if (metric != null && typeof metric === 'object') {
|
||||
const obj = metric as UTSJSONObject
|
||||
return obj.getString('label') ?? ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getMetricUnit(metric: any): string {
|
||||
if (metric != null && typeof metric === 'object') {
|
||||
const obj = metric as UTSJSONObject
|
||||
return obj.getString('unit') ?? ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getTechniqueName(technique: any): string {
|
||||
if (technique != null && typeof technique === 'object') {
|
||||
const obj = technique as UTSJSONObject
|
||||
return obj.getString('name') ?? ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getTechniqueRating(technique: any): number {
|
||||
if (technique != null && typeof technique === 'object') {
|
||||
const obj = technique as UTSJSONObject
|
||||
return obj.getNumber('rating') ?? 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function getTechniqueComment(technique: any): string {
|
||||
if (technique != null && typeof technique === 'object') {
|
||||
const obj = technique as UTSJSONObject
|
||||
return obj.getString('comment') ?? ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getSuggestionTitle(suggestion: any): string {
|
||||
if (suggestion != null && typeof suggestion === 'object') {
|
||||
const obj = suggestion as UTSJSONObject
|
||||
return obj.getString('title') ?? ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getSuggestionDescription(suggestion: any): string {
|
||||
if (suggestion != null && typeof suggestion === 'object') {
|
||||
const obj = suggestion as UTSJSONObject
|
||||
return obj.getString('description') ?? ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getSuggestionPriority(suggestion: any): string {
|
||||
if (suggestion != null && typeof suggestion === 'object') {
|
||||
const obj = suggestion as UTSJSONObject
|
||||
return obj.getString('priority') ?? ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function getProjectNameWrapper(): string {
|
||||
return getProjectName(record.value)
|
||||
}
|
||||
|
||||
function formatDateWrapper(dateStr: string): string {
|
||||
return formatDate(dateStr)
|
||||
}
|
||||
|
||||
function getRecordTitle(): string {
|
||||
const date = getRecordDate()
|
||||
return `训练记录 - ${formatDateWrapper(date)}`
|
||||
} // Lifecycle
|
||||
onLoad((options: OnLoadOptions) => {
|
||||
recordId.value = options['id'] ?? ''
|
||||
loadRecordDetail()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
screenWidth.value = uni.getSystemInfoSync().windowWidth
|
||||
})
|
||||
|
||||
onResize((size) => {
|
||||
screenWidth.value = size.size.windowWidth
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.record-detail {
|
||||
display: flex;
|
||||
flex:1;
|
||||
background-color: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
/* Header Card */
|
||||
.header-card {
|
||||
background: linear-gradient(to bottom right, #667eea, #764ba2);
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
flex-direction:row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.record-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.score-badge {
|
||||
padding: 8rpx 16rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
|
||||
.score-excellent {
|
||||
background-color: rgba(40, 167, 69, 0.3);
|
||||
border: 1px solid rgba(40, 167, 69, 0.6);
|
||||
}
|
||||
|
||||
.score-good {
|
||||
background-color: rgba(0, 123, 255, 0.3);
|
||||
border: 1px solid rgba(0, 123, 255, 0.6);
|
||||
}
|
||||
|
||||
.score-fair {
|
||||
background-color: rgba(255, 193, 7, 0.3);
|
||||
border: 1px solid rgba(255, 193, 7, 0.6);
|
||||
}
|
||||
|
||||
.score-poor {
|
||||
background-color: rgba(220, 53, 69, 0.3);
|
||||
border: 1px solid rgba(220, 53, 69, 0.6);
|
||||
}
|
||||
|
||||
.score-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.record-project {
|
||||
font-size: 28rpx;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.record-meta {
|
||||
display: flex;
|
||||
flex-direction:row;
|
||||
gap: 30rpx;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction:row;
|
||||
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
font-size: 24rpx;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.meta-text {
|
||||
font-size: 26rpx;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Performance Card */
|
||||
.performance-card {
|
||||
background-color: white;
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: 25rpx;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.performance-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin: 0 -10rpx;
|
||||
} .performance-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
background-color: #f8f9ff;
|
||||
border-radius: 16rpx;
|
||||
width: 44%;
|
||||
flex: 0 0 44%;
|
||||
margin: 0 10rpx 20rpx;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 30rpx;
|
||||
background-color: #667eea;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 28rpx;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.metric-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 2rpx;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.metric-unit {
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Technique Card */
|
||||
.technique-card {
|
||||
background-color: white;
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.technique-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.technique-item {
|
||||
padding: 20rpx;
|
||||
background-color: #fafafa;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.technique-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.technique-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.rating-stars {
|
||||
display: flex;
|
||||
flex-direction:row;
|
||||
}
|
||||
|
||||
.star {
|
||||
font-size: 24rpx;
|
||||
margin-left: 2rpx;
|
||||
}
|
||||
|
||||
.star-filled {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.star-empty {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.technique-comment {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Suggestions Card */
|
||||
.suggestions-card {
|
||||
background-color: white;
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.suggestions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 20rpx;
|
||||
background-color: #fff8e1;
|
||||
border-radius: 12rpx;
|
||||
border-left: 4rpx solid #ffc107;
|
||||
}
|
||||
|
||||
.suggestion-icon {
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 15rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.suggestion-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.suggestion-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin-bottom: 8rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.suggestion-desc {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.priority-tag {
|
||||
display: inline-block;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.priority-high {
|
||||
background-color: rgba(220, 53, 69, 0.1);
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.priority-medium {
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.priority-low {
|
||||
background-color: rgba(40, 167, 69, 0.1);
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.priority-text {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Comments Card */
|
||||
.comments-card {
|
||||
background-color: white;
|
||||
border-radius: 20rpx;
|
||||
padding: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
background-color: #f8f9ff;
|
||||
padding: 25rpx;
|
||||
border-radius: 16rpx;
|
||||
border-left: 4rpx solid #667eea;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.teacher-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.comment-date {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
|
||||
.comment-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rating-label {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
margin-right: 15rpx;
|
||||
}
|
||||
|
||||
/* Action Buttons */
|
||||
.action-buttons {
|
||||
padding: 20rpx 0;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: linear-gradient(to bottom right, #667eea, #764ba2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background-color: white;
|
||||
color: #667eea;
|
||||
border: 2rpx solid #667eea;
|
||||
}
|
||||
|
||||
.primary-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.secondary-btn:active {
|
||||
transform: scale(0.98);
|
||||
background-color: #f8f9ff;
|
||||
}
|
||||
</style>
|
||||
1519
pages/sport/student/records.uvue
Normal file
1519
pages/sport/student/records.uvue
Normal file
File diff suppressed because it is too large
Load Diff
0
pages/sport/student/records_setup.uvue
Normal file
0
pages/sport/student/records_setup.uvue
Normal file
1186
pages/sport/student/reminder-settings.uvue
Normal file
1186
pages/sport/student/reminder-settings.uvue
Normal file
File diff suppressed because it is too large
Load Diff
469
pages/sport/student/simple-records.uvue
Normal file
469
pages/sport/student/simple-records.uvue
Normal file
@@ -0,0 +1,469 @@
|
||||
<!-- 训练记录管理 - 直接使用 supaClient 示例 -->
|
||||
<template>
|
||||
<scroll-view direction="vertical" class="records-page">
|
||||
<view class="header">
|
||||
<text class="title">我的监测记录</text>
|
||||
<button class="add-btn" @click="showAddRecord">新增记录</button>
|
||||
</view>
|
||||
|
||||
<!-- 记录列表 -->
|
||||
<scroll-view class="records-list" scroll-y="true">
|
||||
<view class="record-item" v-for="record in records" :key="getRecordId(record)">
|
||||
<view class="record-header">
|
||||
<text class="record-title">{{ getRecordTitle(record) }}</text>
|
||||
<text class="record-date">{{ formatDate(getRecordDate(record)) }}</text>
|
||||
</view>
|
||||
<view class="record-stats">
|
||||
<text class="stat">时长: {{ getRecordDuration(record) }}分钟</text>
|
||||
<text class="stat">消耗: {{ getRecordCalories(record) }}卡路里</text>
|
||||
</view>
|
||||
<view class="record-actions">
|
||||
<button class="edit-btn" @click="editRecord(record)">编辑</button>
|
||||
<button class="delete-btn" @click="deleteRecord(record)">删除</button>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 新增/编辑记录弹窗 -->
|
||||
<view class="modal" v-if="showModal" @click="hideModal">
|
||||
<view class="modal-content" @click.stop>
|
||||
<text class="modal-title">{{ isEditing ? '编辑' : '新增' }}训练记录</text>
|
||||
<input class="input" v-model="formData.title" placeholder="训练项目" />
|
||||
<input class="input" v-model="formData.duration" type="number" placeholder="训练时长(分钟)" />
|
||||
<input class="input" v-model="formData.calories" type="number" placeholder="消耗卡路里" />
|
||||
<textarea class="textarea" v-model="formData.notes" placeholder="训练备注"></textarea>
|
||||
|
||||
<view class="modal-actions">
|
||||
<button class="cancel-btn" @click="hideModal">取消</button>
|
||||
<button class="save-btn" @click="saveRecord">保存</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="uts"> import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { onLoad, onResize } from '@dcloudio/uni-app'
|
||||
import supaClient from '@/components/supadb/aksupainstance.uts'
|
||||
import { getCurrentUserId } from '@/utils/store.uts'
|
||||
|
||||
// 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 userId = ref<string>('')
|
||||
const records = ref<UTSJSONObject[]>([])
|
||||
const showModal = ref<boolean>(false)
|
||||
const isEditing = ref<boolean>(false)
|
||||
const editingRecordId = ref<string>('')
|
||||
const formData = ref({
|
||||
title: '',
|
||||
duration: '',
|
||||
calories: '',
|
||||
notes: ''
|
||||
}) const loading = ref<boolean>(false)
|
||||
const subscription = ref<any>(null)
|
||||
|
||||
// 页面加载时获取用户ID
|
||||
onLoad((options: OnLoadOptions) => {
|
||||
userId.value = options['id'] ?? getCurrentUserId()
|
||||
})
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
screenWidth.value = uni.getSystemInfoSync().windowWidth
|
||||
loadRecords()
|
||||
setupRealtimeSubscription()
|
||||
})
|
||||
|
||||
onResize((size: any) => {
|
||||
screenWidth.value = size.size.windowWidth
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理实时订阅
|
||||
if (subscription.value) {
|
||||
subscription.value.unsubscribe()
|
||||
}
|
||||
})// 获取记录相关信息
|
||||
const getRecordId = (record: UTSJSONObject): string => {
|
||||
return record.getString('id') ?? ''
|
||||
}
|
||||
|
||||
const getRecordTitle = (record: UTSJSONObject): string => {
|
||||
return record.getString('title') ?? '未命名训练'
|
||||
}
|
||||
|
||||
const getRecordDate = (record: UTSJSONObject): string => {
|
||||
return record.getString('created_at') ?? ''
|
||||
}
|
||||
|
||||
const getRecordDuration = (record: UTSJSONObject): number => {
|
||||
return record.getNumber('duration') ?? 0
|
||||
}
|
||||
|
||||
const getRecordCalories = (record: UTSJSONObject): number => {
|
||||
return record.getNumber('calories') ?? 0
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string): string => {
|
||||
if (!dateString) return ''
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
// 获取当前用户ID // 加载训练记录 - 直接使用 supaClient
|
||||
const loadRecords = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const result = await supaClient
|
||||
.from('ak_training_records')
|
||||
.select('*', {})
|
||||
.eq('student_id', userId.value)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50)
|
||||
.execute()
|
||||
|
||||
if (result.success) {
|
||||
records.value = result.data as UTSJSONObject[]
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载记录失败:', error)
|
||||
uni.showToast({
|
||||
title: '加载失败',
|
||||
icon: 'none'
|
||||
})
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
// 设置实时订阅 - 直接使用 supaClient
|
||||
const setupRealtimeSubscription = () => {
|
||||
try { subscription.value = supaClient
|
||||
.from('ak_training_records') .on('INSERT', (payload) => {
|
||||
console.log('新记录:', payload)
|
||||
if ((payload.new as UTSJSONObject).getString('student_id') === userId.value) {
|
||||
records.value.unshift(payload.new as UTSJSONObject)
|
||||
}
|
||||
})
|
||||
.on('UPDATE', (payload) => {
|
||||
console.log('记录更新:', payload)
|
||||
const recordId = (payload.new as UTSJSONObject).getString('id') ?? ''
|
||||
const index = records.value.findIndex(r => getRecordId(r) === recordId)
|
||||
if (index !== -1) {
|
||||
records.value[index] = payload.new as UTSJSONObject
|
||||
}
|
||||
})
|
||||
.on('DELETE', (payload) => {
|
||||
console.log('记录删除:', payload)
|
||||
const recordId = (payload.old as UTSJSONObject).getString('id') ?? ''
|
||||
records.value = records.value.filter(r => getRecordId(r) !== recordId)
|
||||
})
|
||||
.subscribe()
|
||||
} catch (error) {
|
||||
console.error('设置实时订阅失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存记录 - 直接使用 supaClient
|
||||
const saveRecord = async () => {
|
||||
if (!formData.value.title.trim()) {
|
||||
uni.showToast({
|
||||
title: '请输入训练项目',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try { const recordData = {
|
||||
title: formData.value.title,
|
||||
duration: parseInt(formData.value.duration) || 0,
|
||||
calories: parseInt(formData.value.calories) || 0,
|
||||
notes: formData.value.notes,
|
||||
student_id: userId.value
|
||||
}
|
||||
|
||||
let result
|
||||
if (isEditing.value) {
|
||||
// 更新记录
|
||||
result = await supaClient
|
||||
.from('ak_training_records')
|
||||
.update(recordData)
|
||||
.eq('id', editingRecordId.value)
|
||||
.single()
|
||||
.execute()
|
||||
} else {
|
||||
// 创建新记录
|
||||
result = await supaClient
|
||||
.from('ak_training_records')
|
||||
.insert(recordData)
|
||||
.single()
|
||||
.execute()
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
uni.showToast({
|
||||
title: isEditing.value ? '更新成功' : '保存成功',
|
||||
icon: 'success'
|
||||
})
|
||||
hideModal()
|
||||
loadRecords() // 重新加载数据
|
||||
} else {
|
||||
throw new Error(result.message || '保存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存记录失败:', error)
|
||||
uni.showToast({
|
||||
title: '保存失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除记录 - 直接使用 supaClient
|
||||
const deleteRecord = async (record: UTSJSONObject) => {
|
||||
const recordId = getRecordId(record)
|
||||
const title = getRecordTitle(record)
|
||||
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: `确定要删除训练记录"${title}"吗?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
const result = await supaClient
|
||||
.from('ak_training_records')
|
||||
.delete()
|
||||
.eq('id', recordId)
|
||||
.execute()
|
||||
|
||||
if (result.success) {
|
||||
uni.showToast({
|
||||
title: '删除成功',
|
||||
icon: 'success'
|
||||
})
|
||||
loadRecords()
|
||||
} else {
|
||||
throw new Error(result.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除记录失败:', error)
|
||||
uni.showToast({
|
||||
title: '删除失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 显示新增记录弹窗
|
||||
const showAddRecord = () => {
|
||||
isEditing.value = false
|
||||
editingRecordId.value = ''
|
||||
formData.value = {
|
||||
title: '',
|
||||
duration: '',
|
||||
calories: '',
|
||||
notes: ''
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
// 编辑记录
|
||||
const editRecord = (record: UTSJSONObject) => {
|
||||
isEditing.value = true
|
||||
editingRecordId.value = getRecordId(record) formData.value = {
|
||||
title: getRecordTitle(record),
|
||||
duration: getRecordDuration(record).toString(),
|
||||
calories: getRecordCalories(record).toString(),
|
||||
notes: record.getString('notes') ?? ''
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
// 隐藏弹窗
|
||||
const hideModal = () => {
|
||||
showModal.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.records-page {
|
||||
display: flex;
|
||||
flex:1;
|
||||
padding: 20rpx;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30rpx;
|
||||
padding: 20rpx;
|
||||
background: white;
|
||||
border-radius: 15rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background-image: linear-gradient(to top right, #667eea, #764ba2);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15rpx 30rpx;
|
||||
border-radius: 25rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.records-list {
|
||||
height: calc(100vh - 200rpx);
|
||||
}
|
||||
|
||||
.record-item {
|
||||
background: white;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 30rpx;
|
||||
border-radius: 15rpx;
|
||||
box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.record-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.record-date {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.record-stats {
|
||||
display: flex;
|
||||
gap: 30rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.edit-btn, .delete-btn {
|
||||
flex: 1;
|
||||
padding: 15rpx;
|
||||
border: none;
|
||||
border-radius: 10rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 弹窗样式 */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 20rpx;
|
||||
padding: 40rpx;
|
||||
width: 90%;
|
||||
max-width: 600rpx;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 30rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.input, .textarea {
|
||||
width: 100%;
|
||||
padding: 20rpx;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 10rpx;
|
||||
margin-bottom: 20rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
height: 120rpx;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
|
||||
.cancel-btn, .save-btn {
|
||||
flex: 1;
|
||||
padding: 25rpx;
|
||||
border: none;
|
||||
border-radius: 15rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background-image: linear-gradient(to top right, #667eea, #764ba2);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
1112
pages/sport/student/training-record.uvue
Normal file
1112
pages/sport/student/training-record.uvue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user