637 lines
16 KiB
Plaintext
637 lines
16 KiB
Plaintext
<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> |