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

900 lines
22 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<scroll-view direction="vertical" class="analytics-container" :scroll-y="true" :enable-back-to-top="true">
<!-- Header -->
<view class="header">
<text class="title">数据分析</text>
<view class="filter-bar">
<input v-model="startDate" placeholder="开始日期 (YYYY-MM-DD)" type="date" class="date-input" @input="onStartDateChange" />
<input v-model="endDate" placeholder="结束日期 (YYYY-MM-DD)" type="date" class="date-input" @input="onEndDateChange" />
<button @click="refreshData" class="refresh-btn">
<text>刷新</text>
</button>
</view>
</view>
<!-- Content -->
<view v-if="error !== null" class="error-container">
<text class="error-text">{{ error }}</text>
<button class="retry-btn" @click="retryLoad">重试</button>
</view>
<scroll-view v-else class="content" :class="{ 'large-screen': isLargeScreen }" scroll-y="true">
<!-- Overview Cards -->
<view class="overview-section">
<text class="section-title">概览统计</text> <view class="cards-grid" :class="{ 'large-grid': isLargeScreen }">
<view v-for="(card, index) in overviewCards" :key="index" class="overview-card">
<text class="card-value">{{ card.value ?? '0' }}</text>
<text class="card-label">{{ card.label ?? '' }}</text>
<text class="card-change" :class="card.changeClass ?? ''">
{{ card.change ?? '' }}
</text>
</view>
</view>
</view>
<!-- Charts Section -->
<view class="charts-section">
<text class="section-title">趋势分析</text>
<!-- Assignment Completion Chart -->
<view class="chart-container">
<text class="chart-title">作业完成率趋势</text>
<view class="chart-content">
<ak-charts v-if="completionRateData.length > 0"
:option="completionRateChartOption"
canvas-id="completion-rate-chart"
class="chart-canvas" />
<view v-else class="chart-placeholder">
<text class="chart-text">暂无数据</text>
</view>
</view>
</view>
<!-- Performance Distribution -->
<view class="chart-container">
<text class="chart-title">成绩分布</text>
<view class="chart-content">
<ak-charts v-if="performanceData.length > 0"
:option="performanceChartOption"
canvas-id="performance-chart"
class="chart-canvas" />
<view v-else class="chart-placeholder">
<text class="chart-text">暂无数据</text>
</view>
</view>
</view>
<!-- Activity Distribution -->
<view class="chart-container">
<text class="chart-title">学生活跃度分布</text>
<view class="chart-content">
<ak-charts v-if="activityDistributionData.length > 0"
:option="activityDistributionChartOption"
canvas-id="activity-distribution-chart"
class="chart-canvas" />
<view v-else class="chart-placeholder">
<text class="chart-text">暂无数据</text>
</view>
</view>
</view>
</view>
<!-- Top Performers Section -->
<view class="performers-section">
<text class="section-title">优秀学员</text>
<view class="performers-list"> <view v-for="(performer, index) in topPerformers" :key="index" class="performer-card">
<view class="performer-rank">
<text class="rank-text">{{ index + 1 }}</text>
</view>
<view class="performer-info">
<text class="performer-name">{{ getPerformerName(performer) }}</text>
<text class="performer-score">得分: {{ getPerformerScore(performer) }}</text>
</view>
<view class="performer-badge" :class="getBadgeClass(index)">
<text class="badge-text">{{ getBadgeText(index) }}</text>
</view>
</view>
</view>
</view>
<!-- Recent Activities -->
<view class="activities-section">
<text class="section-title">近期活动</text>
<view class="activities-list">
<view v-for="(activity, index) in recentActivities" :key="index" class="activity-item">
<view class="activity-icon">
<simple-icon :type="getActivityIcon(activity)" :size="20" color="#6366F1" />
</view>
<view class="activity-content">
<text class="activity-title">{{ getActivityTitle(activity) }}</text>
<text class="activity-time">{{ getActivityTime(activity) }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { onResize } from '@dcloudio/uni-app'
import supa from '@/components/supadb/aksupainstance.uts'
import AkCharts from '@/uni_modules/ak-charts/components/ak-charts.uvue'
// 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
})
// Reactive state
const loading = ref(false)
const error = ref<string | null>(null)
// Analytics parameters
const teacherId = ref('current_teacher_id') // 从用户状态获取
const startDate = ref('')
const endDate = ref('')
// Data arrays using UTSJSONObject
const statisticsData = ref<UTSJSONObject[]>([])
const topPerformers = ref<UTSJSONObject[]>([])
const chartData = ref<UTSJSONObject[]>([])
const performanceData = ref<UTSJSONObject[]>([])
const recentActivities = ref<UTSJSONObject[]>([])
// Chart specific data
const completionRateData = ref<number[]>([])
const completionRateLabels = ref<string[]>([])
const activityDistributionData = ref<number[]>([])
const activityDistributionLabels = ref<string[]>([])
// Computed properties data
const overviewCards = ref<UTSJSONObject[]>([])
const updateOverviewCards = () => {
if (statisticsData.value.length > 0) {
const stats = statisticsData.value[0]
const totalStudents = stats.get('total_students')
const totalAssignments = stats.get('total_assignments')
const completionRate = stats.get('completion_rate')
const averageScore = stats.get('average_score')
overviewCards.value = [
{
key: 'students',
value: (totalStudents != null ? parseFloat(totalStudents.toString()) : 0).toString(),
label: '学员总数',
change: '+12%',
changeClass: 'positive'
},
{
key: 'assignments',
value: (totalAssignments != null ? parseFloat(totalAssignments.toString()) : 0).toString(),
label: '作业总数',
change: '+8%',
changeClass: 'positive'
},
{
key: 'completion',
value: (completionRate != null ? parseFloat(completionRate.toString()) : 0).toFixed(1) + '%',
label: '完成率',
change: '+5%',
changeClass: 'positive'
},
{
key: 'score',
value: (averageScore != null ? parseFloat(averageScore.toString()) : 0).toFixed(1),
label: '平均分',
change: '+2.1',
changeClass: 'positive'
}
]
}
}
// Helper function to format date labels - moved before usage
const formatDateLabel = (dateStr: string): string => {
try {
const date = new Date(dateStr)
return `${date.getMonth() + 1}/${date.getDate()}`
} catch (e) {
return dateStr
}
}
// Chart data processing functions - defined before use
const processChartData = (data: UTSJSONObject[]) => {
const rates: number[] = []
const labels: string[] = []
data.forEach((item: UTSJSONObject) => {
const dateKeyValue = item.get('date_key')
const dateKey = dateKeyValue != null ? dateKeyValue.toString() : ''
const valueValue = item.get('value')
const value = valueValue != null ? parseFloat(valueValue.toString()) : 0
// 格式化日期标签
const formattedDate = formatDateLabel(dateKey)
labels.push(formattedDate)
rates.push(value)
})
completionRateData.value = rates
completionRateLabels.value = labels
}
const generateMockChartData = () => {
// 生成模拟的完成率趋势数据最近7天
const rates: number[] = []
const labels: string[] = []
for (let i = 6; i >= 0; i--) {
const date = new Date()
date.setDate(date.getDate() - i)
const label = `${date.getMonth() + 1}/${date.getDate()}`
const rate = Math.round(75 + Math.random() * 20) // 75-95%的随机完成率
labels.push(label)
rates.push(rate)
}
completionRateData.value = rates
completionRateLabels.value = labels
}
const generateMockActivities = () => {
// 生成模拟近期活动
recentActivities.value = [
{
type: 'assignment_submitted',
title: '张三提交了跑步训练作业',
time: '2小时前'
} as UTSJSONObject,
{
type: 'project_completed',
title: '李四完成了力量训练项目',
time: '4小时前'
} as UTSJSONObject,
{
type: 'new_record',
title: '王五创造了新的个人记录',
time: '6小时前'
} as UTSJSONObject
]
}
// Chart options for ak-charts
const completionRateChartOption = computed(() => {
return {
type: 'area',
data: completionRateData.value,
labels: completionRateLabels.value,
color: '#6366F1'
}
})
const performanceChartOption = computed(() => {
if (performanceData.value.length === 0) {
return {
type: 'horizontalBar',
data: [] as number[],
labels: [] as string[],
color: '#10B981'
}
}
const data: number[] = []
const labels: string[] = []
performanceData.value.forEach((item: UTSJSONObject) => {
const rangeValue = item.get('range')
const range = rangeValue != null ? rangeValue.toString() : ''
const countValue = item.get('count')
const count = countValue != null ? parseFloat(countValue.toString()) : 0
labels.push(range)
data.push(count)
})
return {
type: 'horizontalBar',
data: data,
labels: labels,
color: '#10B981'
}
})
const activityDistributionChartOption = computed(() => {
return {
type: 'doughnut',
data: activityDistributionData.value,
labels: activityDistributionLabels.value,
color: ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF']
}
})
// Expose reactive state for template
const loadTeacherAnalytics = async () => {
try {
loading.value = true
error.value = null
const params = new UTSJSONObject()
params.set('teacher_id', teacherId.value == 'current_teacher_id' ? null : teacherId.value)
params.set('start_date', startDate.value ?? null)
params.set('end_date', endDate.value ?? null)
const result = await supa.from('').rpc('get_teacher_analytics', params).execute()
if (result.error != null) {
throw new Error(result.error.toString())
}
if (result.data != null) {
statisticsData.value = [result.data as UTSJSONObject]
updateOverviewCards()
}
} catch (err: any) {
console.error('获取教师统计数据失败:', err)
error.value = `获取统计数据失败: ${err.message ?? err.toString()}`
} finally {
loading.value = false
}
}
const loadTopPerformers = async () => {
try {
const params = new UTSJSONObject()
params.set('teacher_id', teacherId.value == 'current_teacher_id' ? null : teacherId.value)
params.set('start_date', startDate.value ?? null)
params.set('end_date', endDate.value ?? null)
params.set('limit', 10)
const result = await supa.from('').rpc('get_top_performers', params).execute()
if (result.error != null) {
throw new Error(result.error.toString())
}
if (Array.isArray(result.data)) {
topPerformers.value = result.data as UTSJSONObject[]
} else {
topPerformers.value = []
}
} catch (err: any) {
console.error('获取优秀学员数据失败:', err)
// 不设置全局错误,避免覆盖主要数据错误
}
}
const loadChartData = async () => {
try {
const params = new UTSJSONObject()
params.set('teacher_id', teacherId.value == 'current_teacher_id' ? null : teacherId.value)
params.set('start_date', startDate.value ?? null)
params.set('end_date', endDate.value ?? null)
params.set('type', 'completion_rate')
const result = await supa.from('').rpc('get_chart_data', params).execute()
if (result.error != null) {
throw new Error(result.error.toString())
}
if (Array.isArray(result.data)) {
chartData.value = result.data as UTSJSONObject[]
processChartData(result.data as UTSJSONObject[])
} else {
chartData.value = []
generateMockChartData()
}
} catch (err: any) {
console.error('获取图表数据失败:', err)
generateMockChartData() // 生成模拟数据
}
}
const loadRecentActivities = async () => {
try {
const params = new UTSJSONObject()
params.set('teacher_id', teacherId.value == 'current_teacher_id' ? null : teacherId.value)
params.set('limit', 20)
const result = await supa.from('').rpc('get_recent_activities', params).execute()
if (result.error != null) {
throw new Error(result.error.toString())
}
if (Array.isArray(result.data)) {
recentActivities.value = result.data as UTSJSONObject[]
} else {
recentActivities.value = []
generateMockActivities() // 如果 RPC 失败,生成模拟数据
}
} catch (err: any) {
console.error('获取近期活动数据失败:', err)
generateMockActivities() // 生成模拟数据
}
}
const initializeDates = () => {
const now = new Date()
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
endDate.value = now.toISOString().split('T')[0]
startDate.value = thirtyDaysAgo.toISOString().split('T')[0]
}
const generateMockData = () => {
// 生成模拟图表数据
performanceData.value = [
{ range: '90-100分', count: 15 },
{ range: '80-89分', count: 25 },
{ range: '70-79分', count: 30 },
{ range: '60-69分', count: 20 },
{ range: '60分以下', count: 10 }
]
// 生成学生活跃度分布数据
activityDistributionData.value = [45, 25, 20, 8, 2]
activityDistributionLabels.value = ['高度活跃', '活跃', '一般', '较少活跃', '不活跃']
}
const loadAnalyticsData = async () => {
loading.value = true
error.value = null
// 并发加载所有数据
await Promise.all([
loadTeacherAnalytics(),
loadTopPerformers(),
loadChartData(),
loadRecentActivities()
])
// 生成模拟性能分布数据
generateMockData()
loading.value = false
}
const retryLoad = () => {
loadAnalyticsData()
}
const refreshData = () => {
loadAnalyticsData()
}
const onStartDateChange = (event: any) => {
// Since we're using v-model, the value is already updated
// Just trigger data reload
loadAnalyticsData()
}
const onEndDateChange = (event: any) => {
// Since we're using v-model, the value is already updated
// Just trigger data reload
loadAnalyticsData()
}
// UTSJSONObject safe access methods
const getPerformerName = (performer: UTSJSONObject): string => {
const name = performer.get('name')
return name != null ? name.toString() : '未知学员'
}
const getPerformerScore = (performer: UTSJSONObject): string => {
const score = performer.get('score')
const scoreNumber = score != null ? parseFloat(score.toString()) : 0
return scoreNumber.toFixed(1)
}
const getBadgeClass = (index: number): string => {
if (index === 0) return 'gold'
if (index === 1) return 'silver'
if (index === 2) return 'bronze'
return 'default'
}
const getBadgeText = (index: number): string => {
if (index === 0) return '金牌'
if (index === 1) return '银牌'
if (index === 2) return '铜牌'
return '优秀'
}
const getActivityIcon = (activity: UTSJSONObject): string => {
const type = activity.get('type')
const typeString = type != null ? type.toString() : ''
switch (typeString) {
case 'assignment_submitted': return 'file'
case 'project_completed': return 'trophy'
case 'new_record': return 'star'
default: return 'bell'
}
}
const getActivityTitle = (activity: UTSJSONObject): string => {
const title = activity.get('title')
return title != null ? title.toString() : ''
}
const getActivityTime = (activity: UTSJSONObject): string => {
const time = activity.get('time')
return time != null ? time.toString() : ''
}
// Lifecycle hooks
onMounted(() => {
initializeDates()
loadAnalyticsData()
// 如果没有真实数据,生成模拟图表数据
if (completionRateData.value.length === 0) {
generateMockChartData()
}
// Initialize screen width
screenWidth.value = uni.getSystemInfoSync().windowWidth
})
onResize((size) => {
screenWidth.value = size.size.windowWidth
})
</script>
<style scoped>
.analytics-container {
display: flex;
flex:1;
background-image: linear-gradient(to bottom right, #667eea, #764ba2);
padding-bottom: 40rpx;
box-sizing: border-box;
}
.header {
padding: 40rpx 30rpx 30rpx;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.title {
font-size: 48rpx;
font-weight: bold;
color: #FFFFFF;
margin-bottom: 30rpx;
}
.filter-bar {
flex-direction: row;
align-items: center;
flex-wrap: wrap;
}
.filter-bar .date-input {
margin-right: 20rpx;
}
.filter-bar .filter-btn {
margin-left: 0;
}
.date-input {
flex: 1;
min-width: 200rpx;
height: 70rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 15rpx;
padding: 0 20rpx;
color: #FFFFFF;
border: none;
font-size: 28rpx;
}
.date-input::placeholder {
color: rgba(255, 255, 255, 0.7);
}
.refresh-btn {
height: 70rpx;
padding: 0 30rpx;
background: rgba(255, 255, 255, 0.2);
border-radius: 15rpx;
border: none;
color: #FFFFFF;
font-size: 28rpx;
}
.refresh-btn:active {
background: rgba(255, 255, 255, 0.3);
}
.content {
flex: 1;
padding: 30rpx;
background: #F8FAFC;
border-radius: 30rpx 30rpx 0 0;
margin-top: 20rpx;
}
.content.large-screen {
padding: 40rpx;
}
.section-title {
font-size: 36rpx;
font-weight: bold;
color: #1E293B;
margin-bottom: 30rpx;
}
/* Overview Cards */
.overview-section {
margin-bottom: 40rpx;
}
.cards-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin: 0 -10rpx;
}
.cards-grid .overview-card {
width: 44%;
flex: 0 0 44%;
margin: 0 10rpx 20rpx;
}
.cards-grid.large-grid .overview-card:nth-child(2n) {
margin-right: 20rpx;
}
.cards-grid.large-grid .overview-card:nth-child(4n) {
margin-right: 0;
}
.overview-card {
background: #FFFFFF;
padding: 30rpx;
border-radius: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
align-items: center;
}
.card-value {
font-size: 42rpx;
font-weight: bold;
color: #6366F1;
margin-bottom: 10rpx;
}
.card-label {
font-size: 26rpx;
color: #64748B;
margin-bottom: 10rpx;
}
.card-change {
font-size: 24rpx;
font-weight: 400;
}
.card-change.positive {
color: #10B981;
}
.card-change.negative {
color: #EF4444;
}
/* Charts Section */
.charts-section {
margin-bottom: 40rpx;
}
.chart-container {
background: #FFFFFF;
padding: 30rpx;
border-radius: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
margin-bottom: 20rpx;
}
.chart-title {
font-size: 32rpx;
font-weight: bold;
color: #1E293B;
margin-bottom: 20rpx;
}
.chart-content {
min-height: 300rpx;
}
.chart-canvas {
width: 100%;
height: 300rpx;
}
.chart-placeholder {
height: 300rpx;
background: #F1F5F9;
border-radius: 15rpx;
justify-content: center;
align-items: center;
}
.chart-text {
color: #64748B;
font-size: 28rpx;
}
/* Performers Section */
.performers-section {
margin-bottom: 40rpx;
}
.performers-list {
}
.performers-list .performer-card {
margin-bottom: 15rpx;
}
.performers-list .performer-card:last-child {
margin-bottom: 0;
}
.performer-card {
background: #FFFFFF;
padding: 25rpx;
border-radius: 15rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
flex-direction: row;
align-items: center;
}
.performer-card .performer-avatar {
margin-right: 20rpx;
} .performer-rank {
width: 60rpx;
height: 60rpx;
background: #F1F5F9;
border-radius: 30rpx;
justify-content: center;
align-items: center;
}
.rank-text {
font-size: 24rpx;
font-weight: bold;
color: #64748B;
}
.performer-info {
flex: 1;
}
.performer-name {
font-size: 30rpx;
font-weight: 400;
color: #1E293B;
margin-bottom: 8rpx;
}
.performer-score {
font-size: 26rpx;
color: #64748B;
}
.performer-badge {
padding: 8rpx 16rpx;
border-radius: 20rpx;
}
.performer-badge.gold {
background: #FEF3C7;
}
.performer-badge.silver {
background: #F3F4F6;
}
.performer-badge.bronze {
background: #FED7AA;
}
.performer-badge.default {
background: #EDE9FE;
}
.badge-text {
font-size: 22rpx;
font-weight: 400;
}
.performer-badge.gold .badge-text {
color: #D97706;
}
.performer-badge.silver .badge-text {
color: #6B7280;
}
.performer-badge.bronze .badge-text {
color: #EA580C;
}
.performer-badge.default .badge-text {
color: #7C3AED;
}
/* Activities Section */
.activities-section {
margin-bottom: 40rpx;
}
.activities-list {
}
.activities-list .activity-item {
margin-bottom: 15rpx;
}
.activities-list .activity-item:last-child {
margin-bottom: 0;
}
.activity-item {
background: #FFFFFF;
padding: 25rpx;
border-radius: 15rpx;
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
flex-direction: row;
align-items: center;
}
.activity-item .activity-icon {
margin-right: 20rpx;
}
.activity-icon {
width: 80rpx;
height: 80rpx;
background: #EDE9FE;
border-radius: 40rpx;
justify-content: center;
align-items: center;
}
.activity-content {
flex: 1;
}
.activity-title {
font-size: 28rpx;
color: #1E293B;
margin-bottom: 8rpx;
}
.activity-time {
font-size: 24rpx;
color: #64748B;
}
/* Error State */
.error-container {
flex: 1;
justify-content: center;
align-items: center;
padding: 60rpx;
}
.error-text {
font-size: 32rpx;
color: #EF4444;
margin-bottom: 30rpx;
text-align: center;
}
.retry-btn {
padding: 20rpx 40rpx;
background: #6366F1;
color: #FFFFFF;
border: none;
border-radius: 15rpx;
font-size: 28rpx;
}
</style>