Initial commit of akmon project

This commit is contained in:
2026-01-20 08:04:15 +08:00
commit 77a2bab985
1309 changed files with 343305 additions and 0 deletions

View File

@@ -0,0 +1,899 @@
<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>