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,109 @@
# 项目编辑页面 - 媒体上传功能完成
## 添加的功能
### 1. **图片上传功能**
- **位置**: 在"基本信息"和"训练要求"之间添加了"媒体资源"部分
- **功能**:
- 支持从相册选择或拍照上传
- 显示图片预览
- 支持删除已上传的图片
- 图片压缩处理以节省存储空间
### 2. **视频上传功能**
- **位置**: 媒体资源部分中的第二个上传区域
- **功能**:
- 支持从相册选择或录制视频
- 限制视频时长为60秒
- 显示视频预览和播放控件
- 支持删除已上传的视频
### 3. **数据库集成**
- **加载**: 从数据库正确加载 `image_url``video_url` 字段
- **保存**: 在保存草稿和更新项目时正确保存媒体URL
- **字段映射**: 完整支持数据库字段和表单字段的双向映射
## 实现的方法
### 上传相关方法:
```typescript
function uploadImage() // 选择和上传图片
function uploadVideo() // 选择和上传视频
function removeImage() // 删除图片
function removeVideo() // 删除视频
function getImageUrl() // 获取图片URL
function getVideoUrl() // 获取视频URL
```
### UI组件特性
- **响应式设计**: 媒体上传区域支持小屏幕适配
- **用户体验**: 上传按钮和预览切换,清晰的视觉反馈
- **安全操作**: 删除媒体需要点击明确的删除按钮
## 技术细节
### 1. **文件选择**
```typescript
// 图片选择配置
uni.chooseImage({
count: 1, // 只允许选择1张
sizeType: ['compressed'], // 使用压缩图片
sourceType: ['album', 'camera'] // 支持相册和拍照
})
// 视频选择配置
uni.chooseVideo({
sourceType: ['album', 'camera'], // 支持相册和录制
maxDuration: 60 // 最大60秒
})
```
### 2. **数据保存**
`saveDraft()``updateProject()` 中都添加了:
```typescript
image_url: safeGet(formData.value, 'image_url', ''),
video_url: safeGet(formData.value, 'video_url', ''),
```
### 3. **样式设计**
- 虚线边框的上传区域
- 圆角预览容器
- 半透明的删除按钮悬浮在右上角
- 响应式布局支持
## 待完善功能
### 1. **文件上传服务器**
当前只是将本地临时路径存储到表单中,实际项目中需要:
- 实现文件上传到云存储服务如腾讯云COS、阿里云OSS等
- 返回永久的URL地址
- 处理上传进度和错误
### 2. **文件验证**
- 图片格式验证jpg, png, gif等
- 视频格式验证mp4, mov等
- 文件大小限制
- 文件内容安全检查
### 3. **上传进度**
- 显示上传进度条
- 支持取消上传操作
- 上传失败重试机制
## 使用说明
1. **上传图片**: 点击"上传图片"按钮,选择图片或拍照
2. **预览图片**: 上传后自动显示预览图
3. **删除图片**: 点击预览图右上角的"×"按钮
4. **上传视频**: 点击"上传视频"按钮,选择视频或录制
5. **预览视频**: 上传后显示视频播放器
6. **删除视频**: 点击预览视频右上角的"×"按钮
## 兼容性
-**uni-app-x Android**: 完全兼容
-**响应式设计**: 支持小屏幕设备
-**类型安全**: 所有操作都使用UTSJSONObject
-**错误处理**: 完整的错误提示和异常处理
媒体上传功能现已完全集成到项目编辑页面中,用户可以为训练项目添加图片和视频资源,提升项目的可视化效果和教学质量。

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>

View File

@@ -0,0 +1,656 @@
<template>
<scroll-view direction="vertical" class="assignments-management" :enable-back-to-top="true">
<!-- Header -->
<view class="page-header">
<text class="page-title">作业管理</text>
<button class="create-btn" @click="createAssignment">
<text class="create-icon">+</text>
</button>
</view> <!-- Search and Filter -->
<view class="search-filter-bar">
<view class="search-input-wrapper">
<text class="search-icon"></text>
<input class="search-input" placeholder="搜索作业..." :value="searchQuery" @input="handleSearch" />
</view>
<view class="filter-selector" @click="showStatusPicker">
<text class="filter-label">{{ getCurrentFilterLabel() }}</text>
<text class="filter-arrow">▼</text>
</view>
</view>
<!-- Quick Stats -->
<view class="quick-stats">
<view class="stat-item">
<text class="stat-number">{{ getActiveAssignments() }}</text>
<text class="stat-text">进行中</text>
</view>
<view class="stat-item">
<text class="stat-number">{{ getPendingReviews() }}</text>
<text class="stat-text">待批改</text>
</view>
</view>
<!-- Assignments List -->
<view class="assignments-container">
<view class="assignments-list">
<view class="assignment-card" v-for="(assignment, index) in filteredAssignments" :key="index"
@click="viewAssignmentDetail(assignment)">
<view class="card-header">
<view class="assignment-info">
<text
class="assignment-title">{{ assignment.getString('title') ?? assignment.getString('name') ?? '未命名作业' }}</text>
<text class="project-name">{{ assignment.getString('project_name') ?? '' }}</text>
</view>
<view class="status-badge" :class="`status-${assignment.getString('status') ?? 'active'}`">
<text
class="status-text">{{ formatAssignmentStatusLocal(assignment.getString('status') ?? 'active') }}</text>
</view>
</view>
<view class="card-content">
<view class="assignment-meta">
<view class="meta-item">
<text class="meta-text">{{ assignment.getNumber('participants') ?? 0 }}人参与</text>
</view>
<view class="meta-item">
<text
class="meta-text">{{ formatDateLocal(assignment.getString('deadline') ?? '') }}截止</text>
</view>
</view>
<view class="progress-info">
<text class="progress-text">已提交
{{ assignment.getNumber('submitted') ?? 0 }}/{{ assignment.getNumber('participants') ?? 0 }}</text>
<view class="progress-bar">
<view class="progress-fill" :style="`width: ${getProgressPercentage(assignment)}%`">
</view>
</view>
</view>
</view>
<view class="card-actions">
<button class="action-btn secondary-btn" @click.stop="editAssignment(assignment)">
编辑
</button>
<button class="action-btn primary-btn" @click.stop="reviewSubmissions(assignment)">
批改
</button>
</view>
</view>
</view>
<!-- Empty State -->
<view class="empty-state" v-if="filteredAssignments.length === 0">
<text class="empty-icon"></text>
<text class="empty-title">暂无作业</text>
<text class="empty-desc">{{ getEmptyStateMessage() }}</text>
<button class="empty-action-btn" @click="createAssignment">
创建作业
</button>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import {
formatDate,
formatAssignmentStatus
} from '../types.uts'
// Local wrapper functions to avoid unref issues
const formatDateLocal = (dateStr : string) : string => {
return formatDate(dateStr)
}
const formatAssignmentStatusLocal = (status : string) : string => {
return formatAssignmentStatus(status)
}
// Reactive data
const assignments = ref<Array<UTSJSONObject>>([])
const filteredAssignments = ref<Array<UTSJSONObject>>([])
const searchQuery = ref('')
const currentStatusFilter = ref('all')
const currentFilterIndex = ref(0)
const loading = ref(true)
const statusFilters = [
{ label: '全部', value: 'all' },
{ label: '进行中', value: 'active' },
{ label: '已完成', value: 'completed' },
{ label: '已截止', value: 'expired' }
]
const statusFilterLabels = statusFilters.map(filter => filter.label as string)
// Methods
function filterAssignments() {
let filtered = assignments.value
// Status filter
if (currentStatusFilter.value !== 'all') {
filtered = filtered.filter(assignment =>
(assignment.getString('status') ?? 'active') === currentStatusFilter.value
)
}
// Search filter
if (searchQuery.value.trim() !== '') {
const query = searchQuery.value.toLowerCase()
filtered = filtered.filter(assignment => {
const title = (assignment.getString('title') ?? assignment.getString('name') ?? '').toLowerCase()
const project = (assignment.getString('project_name') ?? '').toLowerCase()
return title.includes(query) || project.includes(query)
})
}
filteredAssignments.value = filtered
}
function loadAssignments() {
loading.value = true
// Mock data - replace with actual API call
setTimeout(() => {
assignments.value = [
{
"id": "1",
"title": "跳远基础技术训练",
"project_name": "跳远训练",
"status": "active",
"deadline": "2024-01-25T23:59:59",
"participants": 28,
"submitted": 15,
"pending_review": 8,
"average_score": 82.5,
"created_at": "2024-01-15T10:00:00"
},
{
"id": "2",
"title": "短跑起跑技术",
"project_name": "短跑训练",
"status": "active",
"deadline": "2024-01-30T23:59:59",
"participants": 25,
"submitted": 20,
"pending_review": 5,
"average_score": 78.3,
"created_at": "2024-01-12T14:30:00"
},
{
"id": "3",
"title": "篮球运球基础",
"project_name": "篮球技能",
"status": "completed",
"deadline": "2024-01-20T23:59:59",
"participants": 30,
"submitted": 30,
"pending_review": 0,
"average_score": 85.7,
"created_at": "2024-01-08T09:15:00"
},
{
"id": "4",
"title": "足球传球练习",
"project_name": "足球基础",
"status": "expired",
"deadline": "2024-01-18T23:59:59",
"participants": 22,
"submitted": 18,
"pending_review": 2,
"average_score": 76.8,
"created_at": "2024-01-05T16:45:00"
}
]
filterAssignments()
loading.value = false
}, 1000)
}
function handleSearch() {
filterAssignments()
}
function setStatusFilter(status : string) {
currentStatusFilter.value = status
} function getCurrentFilterLabel() : string {
const filter = statusFilters.find(f => f.value === currentStatusFilter.value)
return filter != null ? (filter.label as string) : '全部'
} function showStatusPicker() {
const itemArray = statusFilters.map(filter => filter.label as string)
uni.showActionSheet({
itemList: itemArray,
success: (res) => {
const selectedFilter = statusFilters[res.tapIndex]
if (selectedFilter != null) {
currentFilterIndex.value = res.tapIndex
setStatusFilter(selectedFilter.value as string)
}
},
fail: (err) => {
console.log('用户取消选择', err)
}
})
}
function getTotalAssignments() : number {
return assignments.value.length
}
function getActiveAssignments() : number {
return assignments.value.filter(a => (a.getString('status') ?? 'active') === 'active').length
}
function getCompletedAssignments() : number {
return assignments.value.filter(a => (a.getString('status') ?? 'active') === 'completed').length
}
function getPendingReviews() : number {
return assignments.value.reduce((total, assignment) => {
return total + (assignment.getNumber('pending_review') ?? 0)
}, 0)
}
function getProgressPercentage(assignment : UTSJSONObject) : number {
const participants = assignment.getNumber('participants') ?? 0
const submitted = assignment.getNumber('submitted') ?? 0
if (participants <= 0) return 0
return Math.round((submitted / participants) * 100)
}
function getAssignmentAverageScore(assignment : UTSJSONObject) : string {
const score = assignment.getNumber('average_score') ?? 0
return score > 0 ? score.toFixed(1) : '--'
}
function getEmptyStateMessage() : string {
if (searchQuery.value.trim() !== '') {
return '没有找到匹配的作业'
}
if (currentStatusFilter.value !== 'all') {
return `没有${statusFilters.find(f => f.value === currentStatusFilter.value)?.label}的作业`
}
return '还没有创建任何作业,点击下方按钮创建第一个作业'
}
function createAssignment() {
uni.navigateTo({
url: '/pages/sport/teacher/create-assignment'
})
}
function viewAssignmentDetail(assignment : UTSJSONObject) {
const assignmentId = assignment.getString('id') ?? ''
uni.navigateTo({
url: `/pages/sport/teacher/assignment-detail?id=${assignmentId}`
})
}
function editAssignment(assignment : UTSJSONObject) {
const assignmentId = assignment.getString('id') ?? ''
uni.navigateTo({
url: `/pages/sport/teacher/edit-assignment?id=${assignmentId}`
})
}
function reviewSubmissions(assignment : UTSJSONObject) {
const assignmentId = assignment.getString('id') ?? ''
uni.navigateTo({
url: `/pages/sport/teacher/review-submissions?assignmentId=${assignmentId}`
})
}
// Lifecycle
onLoad(() => {
loadAssignments()
})
// Watch
watch([searchQuery, currentStatusFilter], () => {
filterAssignments()
})
</script>
<style>
.assignments-management {
flex:1;
background-color: #f8f9fa;
padding: 20rpx;
padding-bottom: 40rpx;
box-sizing: border-box;
}
/* Header */
.page-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-bottom: 25rpx;
}
.page-title {
font-size: 40rpx;
font-weight: bold;
color: #333;
}
.create-btn {
width: 60rpx;
height: 60rpx;
border-radius: 30rpx;
background-image: linear-gradient(to bottom right, #667eea, #764ba2);
border: none;
color: white;
display: flex;
align-items: center;
justify-content: center;
}
.create-icon {
font-size: 32rpx;
font-weight: bold;
}
/* Search and Filter Bar */
.search-filter-bar {
display: flex;
margin-bottom: 20rpx;
position: relative;
}
.search-filter-bar .search-input-wrapper {
margin-right: 15rpx;
}
.search-filter-bar .filter-selector {
margin-left: 0;
}
.search-input-wrapper {
flex: 1;
flex-direction: row;
display: flex;
align-items: center;
background-color: white;
border-radius: 25rpx;
padding: 15rpx 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.search-icon {
font-size: 28rpx;
margin-right: 15rpx;
opacity: 0.6;
}
.search-input {
flex: 1;
font-size: 28rpx;
border: none;
background: none;
}
.filter-selector {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
min-width: 140rpx;
padding: 15rpx 20rpx;
background-color: white;
border-radius: 25rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.filter-label {
font-size: 28rpx;
color: #333;
margin-right: 10rpx;
}
.filter-arrow {
font-size: 20rpx;
color: #999;
}
/* Quick Stats */
.quick-stats {
display: flex;
flex-direction: row;
margin-bottom: 25rpx;
}
.quick-stats .stat-item {
margin-right: 15rpx;
}
.quick-stats .stat-item:last-child {
margin-right: 0;
}
.stat-item {
flex: 1;
background-color: white;
border-radius: 16rpx;
padding: 20rpx;
text-align: center;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.stat-number {
font-size: 32rpx;
font-weight: bold;
color: #667eea;
display: block;
margin-bottom: 5rpx;
}
.stat-text {
font-size: 24rpx;
color: #666;
}
/* Assignments List */
.assignments-container {
flex-direction: column;
margin-bottom: 20rpx;
}
.assignments-list {
display: flex;
flex-direction: column;
}
.assignments-list .assignment-card {
margin-bottom: 15rpx;
}
.assignments-list .assignment-card:last-child {
margin-bottom: 0;
}
.assignment-card {
background-color: white;
border-radius: 16rpx;
padding: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
margin-bottom: 15rpx;
}
.card-header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15rpx;
}
.assignment-info {
flex: 1;
margin-right: 15rpx;
}
.assignment-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 5rpx;
display: block;
}
.project-name {
font-size: 24rpx;
color: #666;
}
.status-badge {
padding: 6rpx 12rpx;
border-radius: 12rpx;
white-space: nowrap;
}
.status-active {
background-color: rgba(0, 123, 255, 0.1);
border: 1rpx solid rgba(0, 123, 255, 0.3);
}
.status-completed {
background-color: rgba(40, 167, 69, 0.1);
border: 1rpx solid rgba(40, 167, 69, 0.3);
}
.status-expired {
background-color: rgba(220, 53, 69, 0.1);
border: 1rpx solid rgba(220, 53, 69, 0.3);
}
.status-text {
font-size: 22rpx;
font-weight: 400;
}
.status-active .status-text {
color: #007bff;
}
.status-completed .status-text {
color: #28a745;
}
.status-expired .status-text {
color: #dc3545;
}
.card-content {
margin-bottom: 15rpx;
}
.assignment-meta {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 15rpx;
}
.meta-item {
display: flex;
align-items: center;
}
.meta-text {
font-size: 24rpx;
color: #666;
}
.progress-info {
margin-bottom: 10rpx;
}
.progress-text {
font-size: 24rpx;
color: #666;
margin-bottom: 8rpx;
display: block;
}
.progress-bar {
height: 6rpx;
background-color: #f0f0f0;
border-radius: 3rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-image: linear-gradient(to bottom, #667eea, #764ba2);
border-radius: 3rpx;
transition: width 0.3s ease;
}
.card-actions {
display: flex;
padding-top: 15rpx;
border-top: 1rpx solid #f0f0f0;
}
.card-actions .action-btn {
margin-right: 12rpx;
}
.card-actions .action-btn:last-child {
margin-right: 0;
}
.action-btn {
flex: 1;
height: 60rpx;
border-radius: 30rpx;
font-size: 26rpx;
font-weight: 400;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.primary-btn {
background-image: linear-gradient(to bottom right, #667eea, #764ba2);
color: white;
}
.secondary-btn {
background-color: #f8f9ff;
color: #667eea;
border: 2rpx solid #e0e6ff;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 80rpx 40rpx;
margin-bottom: 40rpx;
}
.empty-icon {
font-size: 100rpx;
margin-bottom: 20rpx;
opacity: 0.5;
display: block;
}
.empty-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
display: block;
}
.empty-desc {
font-size: 24rpx;
color: #666;
line-height: 1.5;
margin-bottom: 30rpx;
}
.empty-action-btn {
padding: 15rpx 30rpx;
background-image: linear-gradient(to bottom right, #667eea, #764ba2);
border-radius: 25rpx;
color: white;
font-size: 26rpx;
font-weight: 400;
border: none;
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,819 @@
<!-- 教师仪表板 - UTSJSONObject 优化版本 -->
<template> <scroll-view direction="vertical" class="teacher-dashboard">
<!-- 加载状态 -->
<view class="loading-overlay" v-if="loading">
<text class="loading-text">加载中...</text>
</view>
<!-- 错误状态 -->
<view class="error-overlay" v-if="error != '' && loading == false">
<text class="error-text">{{ error }}</text>
<button class="retry-btn" @click="retryLoad">
<text class="retry-text">重试</text>
</button>
</view>
<view class="header">
<text class="title">教师工作台</text>
<text class="welcome">欢迎回来,{{ teacherName }}</text>
</view>
<!-- 消息中心入口 -->
<view class="message-section">
<view class="message-card" @click="navigateToMessages">
<text class="message-icon">💬</text>
<view class="message-info">
<text class="message-title">消息中心</text>
<text class="message-desc">查看您的消息和通知</text>
</view>
<text class="message-badge" v-if="unreadMessageCount > 0">{{ unreadMessageCount > 99 ? '99+' : unreadMessageCount }}</text>
<text class="message-arrow"></text>
</view>
</view>
<!-- 快速统计 -->
<view class="stats-section">
<view class="stats-grid">
<view class="stat-card">
<text class="stat-icon">📋</text>
<text class="stat-number">{{ stats.total_assignments }}</text>
<text class="stat-label">总作业数</text>
</view>
<view class="stat-card">
<text class="stat-icon">✅</text>
<text class="stat-number">{{ stats.completed_assignments }}</text>
<text class="stat-label">已完成</text>
</view>
<view class="stat-card">
<text class="stat-icon">⏰</text>
<text class="stat-number">{{ stats.pending_review }}</text>
<text class="stat-label">待评阅</text>
</view>
<view class="stat-card" @click="navigateToStudents">
<text class="stat-icon">👥</text>
<text class="stat-number">{{ stats.total_students }}</text>
<text class="stat-label">学生总数</text>
</view>
</view>
</view>
<!-- 快速操作 -->
<view class="actions-section">
<text class="section-title">快速操作</text>
<view class="actions-grid">
<view class="action-card" @click="navigateToProjects">
<text class="action-icon">🏋️‍♂️</text>
<text class="action-title">项目管理</text>
<text class="action-desc">创建和管理训练项目</text>
</view>
<view class="action-card" @click="navigateToAssignments">
<text class="action-icon">📝</text>
<text class="action-title">作业管理</text>
<text class="action-desc">布置和管理训练作业</text>
</view>
<view class="action-card" @click="navigateToRecords">
<text class="action-icon">📊</text>
<text class="action-title">记录管理</text>
<text class="action-desc">查看学生训练记录</text>
</view>
<view class="action-card" @click="navigateToAnalytics">
<text class="action-icon">📈</text>
<text class="action-title">数据分析</text>
<text class="action-desc">训练数据统计分析</text>
</view>
</view>
</view> <!-- 最近活动 -->
<view class="recent-section">
<text class="section-title">最近活动</text>
<view v-if="loading" class="loading-activities">
<text class="loading-text">加载活动中...</text>
</view>
<view v-else-if="recentActivities.length == 0" class="empty-state">
<text class="empty-icon">📭</text>
<text class="empty-text">暂无最近活动</text>
</view> <view v-else class="activities-list">
<view v-for="activity in recentActivities" :key="activity.id" class="activity-item">
<text class="activity-icon">{{ activity.type == 'assignment' ? '📝' : (activity.type == 'project' ? '🏋️‍♀️' : (activity.type == 'record' ? '📊' : (activity.type == 'evaluation' ? '✅' : '📌'))) }}</text>
<view class="activity-content">
<text class="activity-title">{{ activity.title != null && activity.title != '' ? activity.title : (activity.description != null && activity.description != '' ? activity.description : '无标题') }}</text>
<text class="activity-time">{{ formatDateTimeLocal(activity.created_at) }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
import type { AkReqResponse } from '@/uni_modules/ak-req/index.uts'
import type {
StatisticsData
} from '../types.uts'
import {
formatDateTime,
getUserName
} from '../types.uts'
import { getCurrentUserId, getCurrentUserClassId } from '@/utils/store.uts'
import { MsgDataServiceReal } from '@/utils/msgDataServiceReal.uts'
import {MessageStats} from '@/utils/msgTypes.uts'
// 本地格式化函数,用于模板调用
const formatDateTimeLocal = (dateStr: string): string => {
return formatDateTime(dateStr)
}
// 定义教师统计数据类型
type TeacherStats = {
total_assignments: number
completed_assignments: number
pending_review: number
total_students: number
}
// 定义作业数据类型(用于统计)
type AssignmentData = {
id: string
status: string
}
// 定义用户数据类型
type UserData = {
id: string
}
// 定义教师活动类型(统一用于显示)
type TeacherActivity = {
id: string
title: string | null
description: string | null
status: string | null
type: string | null
created_at: string
updated_at: string
}
// 响应式数据
const teacherName = ref<string>('教师')
const stats = ref<TeacherStats>({
total_assignments: 0,
completed_assignments: 0,
pending_review: 0,
total_students: 0
})
const recentActivities = ref<Array<TeacherActivity>>([])
const loading = ref<boolean>(false)
const error = ref<string>('')
// 消息相关数据
const unreadMessageCount = ref<number>(0)
// 导航函数
const navigateToProjects = () => {
uni.navigateTo({
url: '/pages/sport/teacher/projects'
})
}
const navigateToAssignments = () => {
uni.navigateTo({
url: '/pages/sport/teacher/assignments'
})
}
const navigateToRecords = () => {
uni.navigateTo({
url: '/pages/sport/teacher/records'
})
}
const navigateToAnalytics = () => {
uni.navigateTo({
url: '/pages/sport/teacher/analytics'
})
}
const navigateToStudents = () => {
uni.navigateTo({
url: '/pages/sport/teacher/students'
})
}
const navigateToMessages = () => {
try {
uni.navigateTo({
url: '/pages/msg/index',
success: () => {
console.log('成功导航到消息页面')
},
fail: (err) => {
console.error('导航到消息页面失败:', err)
uni.showToast({
title: '页面跳转失败',
icon: 'none'
})
}
})
} catch (error) {
console.error('导航到消息页面异常:', error)
uni.showToast({
title: '页面跳转异常',
icon: 'none'
})
}
}
// 加载教师统计数据
const loadTeacherStats = async () => {
console.log('=== loadTeacherStats 开始 ===')
try {
const currentUser = getCurrentUserId()
if (currentUser == null || currentUser == '') {
console.warn('用户未登录,设置默认统计数据')
stats.value = {
total_assignments: 0,
completed_assignments: 0,
pending_review: 0,
total_students: 0
} as TeacherStats
return
}
// 先设置默认值防止UI显示异常
stats.value = {
total_assignments: 0,
completed_assignments: 0,
pending_review: 0,
total_students: 0
} as TeacherStats
// 获取作业统计
try {
const assignmentStatsResponse = await supa
.from('ak_assignments')
.select('*', { count: 'exact', head: true })
.eq('teacher_id', currentUser)
.execute()
if (assignmentStatsResponse.status >= 200 && assignmentStatsResponse.status < 300) {
stats.value.total_assignments = assignmentStatsResponse.total ?? 0
}
} catch (err) {
console.error('作业统计请求异常:', err)
}
// 获取已完成作业统计
try {
const completedStatsResponse = await supa
.from('ak_assignments')
.select('*', { count: 'exact', head: true })
.eq('teacher_id', currentUser)
.eq('status', 'completed')
.execute()
if (completedStatsResponse.status >= 200 && completedStatsResponse.status < 300) {
stats.value.completed_assignments = completedStatsResponse.total ?? 0
}
} catch (err) {
console.error('已完成作业统计请求异常:', err)
}
// 获取待评阅作业统计
try {
const pendingStatsResponse = await supa
.from('ak_assignments')
.select('*', { count: 'exact', head: true })
.eq('teacher_id', currentUser)
.eq('status', 'submitted')
.execute()
if (pendingStatsResponse.status >= 200 && pendingStatsResponse.status < 300) {
stats.value.pending_review = pendingStatsResponse.total ?? 0
}
} catch (err) {
console.error('待评阅作业统计请求异常:', err)
}
// 获取学生统计 - 基于当前用户的班级
try {
const currentUserClassId = getCurrentUserClassId()
if (currentUserClassId != null && currentUserClassId !== '') {
// 获取同班级的学生数量
const studentStatsResponse = await supa
.from('ak_users')
.select('*', { count: 'exact', head: true })
.eq('role', 'student')
.eq('class_id', currentUserClassId)
.execute()
if (studentStatsResponse.status >= 200 && studentStatsResponse.status < 300) {
stats.value.total_students = studentStatsResponse.total ?? 0
console.log('同班级学生数量:', stats.value.total_students)
}
} else {
console.warn('当前用户未分配班级,无法统计学生数量')
stats.value.total_students = 0
}
} catch (err) {
console.error('学生统计请求异常:', err)
}
} catch (error) {
console.error('loadTeacherStats整体失败:', error)
// 设置默认值,避免页面卡死
stats.value = {
total_assignments: 0,
completed_assignments: 0,
pending_review: 0,
total_students: 0
} as TeacherStats
}
}
// 加载最近活动数据
const loadRecentActivities = async () => {
console.log('=== loadRecentActivities 开始 ===')
try {
const currentUser = getCurrentUserId()
if (currentUser == null || currentUser == '') {
console.warn('用户未登录,设置空活动列表')
recentActivities.value = []
return
}
// 先设置空数组避免UI异常
recentActivities.value = []
// 获取最近的作业活动
const activitiesResponse = await supa
.from('ak_assignments')
.select('id, title, description, status, created_at, updated_at', {})
.eq('teacher_id', currentUser)
.order('updated_at', { ascending: false })
.limit(5)
.execute()
if (activitiesResponse.status >= 200 && activitiesResponse.status < 300 && activitiesResponse.data != null) {
const rawData = activitiesResponse.data as Array<UTSJSONObject>
// 将UTSJSONObject转换为TeacherActivity类型
const processedData = rawData.map((item): TeacherActivity => {
return {
id: (item['id'] as string) ?? '',
title: (item['title'] as string) ?? null,
description: (item['description'] as string) ?? null,
status: (item['status'] as string) ?? null,
type: 'assignment', // 默认为作业类型
created_at: (item['created_at'] as string) ?? '',
updated_at: (item['updated_at'] as string) ?? ''
}
})
recentActivities.value = processedData
} else {
console.warn('获取最近活动失败:', activitiesResponse.status)
recentActivities.value = []
}
} catch (error) {
console.error('loadRecentActivities失败:', error)
recentActivities.value = [] // 设置空数组避免 UI 错误
}
}// 加载消息统计
const loadMessageStats = async () => {
console.log('=== loadMessageStats 开始 ===')
try {
const currentUser = getCurrentUserId()
console.log('loadMessageStats - 当前用户ID:', currentUser, '类型:', typeof currentUser)
if (currentUser === null || currentUser === '') {
console.warn('用户未登录,无法加载消息统计,但会设置默认值')
// 设置默认值后直接返回确保Promise正常resolve
unreadMessageCount.value = 0
console.log('=== loadMessageStats 提前结束(用户未登录)===')
return
}
// 先设置默认值
unreadMessageCount.value = 0
console.log('正在获取消息统计...')
const result = await MsgDataServiceReal.getMessageStats(currentUser)
console.log('消息统计响应:', result)
if (result.status === 200 && result.data != null) {
let unreadCount =0;
if (Array.isArray(result.data)) {
const stats = result.data[0];
unreadCount = stats.unread_messages
}
else
{
const stats = result.data;
unreadCount = stats.unread_messages
}
unreadMessageCount.value = unreadCount;
console.log('设置未读消息数:', unreadMessageCount.value);
} else {
console.warn('获取消息统计失败:', result.status, result.error)
}
} catch (error) {
console.error('loadMessageStats失败:', error)
// 静默失败,不显示错误提示
unreadMessageCount.value = 0
}
console.log('=== loadMessageStats 结束 ===')
} // 初始化函数 - 简化版
const loadDashboardData = async () => {
console.log('=== loadDashboardData 开始 ===')
if (loading.value) {
console.log('已经在加载中,跳过重复请求')
return
}
loading.value = true
error.value = ''
try {
console.log('开始顺序加载数据...')
// 简单的顺序加载不搞复杂的Promise.all
console.log('1. 加载教师统计...')
await loadTeacherStats()
console.log('2. 加载最近活动...')
await loadRecentActivities()
console.log('3. 加载消息统计...')
await loadMessageStats()
console.log('所有数据加载完成')
} catch (err) {
console.error('数据加载失败:', err)
error.value = '数据加载失败,请重试'
} finally {
loading.value = false
console.log('=== loadDashboardData 结束 ===')
}
}
// 简化版加载函数,用于调试
// 重试加载数据
const retryLoad = () => {
console.log('用户点击重试按钮')
loadDashboardData()
}
// 生命周期
onMounted(() => {
console.log('Dashboard页面已挂载')
// 获取当前教师名
const teacherId = getCurrentUserId()
console.log('当前用户ID:', teacherId)
if (teacherId!='') {
teacherName.value = '教师-' + teacherId.substring(0, 6)
} else {
teacherName.value = '教师'
console.warn('用户未登录或ID为空')
}
loadDashboardData()
})
</script>
<style> .teacher-dashboard {
flex:1;
background-color: #f5f5f5;
padding: 32rpx;
box-sizing: border-box;
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.loading-text {
font-size: 28rpx;
color: #666666;
}
.error-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.95);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 40rpx;
}
.error-text {
font-size: 28rpx;
color: #ff3b30;
text-align: center;
margin-bottom: 32rpx;
}
.retry-btn {
background-color: #007aff;
color: #ffffff;
border: none;
border-radius: 8rpx;
padding: 16rpx 32rpx;
}
.retry-text {
font-size: 28rpx;
color: #ffffff;
}
.stat-card, .action-card {
/* Remove text-align, font-size, color, font-weight from here */
}
/* Move all text-related styles to the corresponding .stat-icon, .stat-number, .stat-label, .action-icon, etc. selectors for <text> only */
.stat-icon, .stat-number, .stat-label, .action-icon, .action-title, .action-desc, .empty-text, .activity-icon, .activity-title, .activity-time {
display: inline-block;
}
.header {
margin-bottom: 40rpx;
}
.title {
font-size: 48rpx;
font-weight: bold;
color: #333333;
margin-bottom: 8rpx;
}
.welcome {
font-size: 28rpx;
color: #666666;
}
/* 消息中心样式 */
.message-section {
margin-bottom: 32rpx;
}
.message-card {
display: flex;
flex-direction: row;
align-items: center;
background: linear-gradient(to top right, #667eea , #764ba2 );
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
transition: transform 0.2s ease, box-shadow 0.2s ease;
position: relative;
}
.message-card:hover {
transform: translateY(-2rpx);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
}
.message-icon {
font-size: 40rpx;
margin-right: 24rpx;
}
.message-info {
flex: 1;
}
.message-title {
font-size: 30rpx;
font-weight: bold;
color: #ffffff;
margin-bottom: 4rpx;
}
.message-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
.message-badge {
position: absolute;
top: 12rpx;
right: 60rpx;
background: #ff3b30;
color: #ffffff;
font-size: 20rpx;
padding: 6rpx 12rpx;
border-radius: 16rpx;
min-width: 32rpx;
text-align: center;
box-shadow: 0 2px 8px rgba(255, 59, 48, 0.3);
}
.message-badge .badge-text {
font-weight: bold;
}
.message-arrow {
font-size: 32rpx;
color: rgba(255, 255, 255, 0.7);
margin-left: 16rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333333;
margin-bottom: 24rpx;
}
/* 统计卡片样式 */
.stats-section {
margin-bottom: 40rpx;
}
.stats-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.stats-grid .stat-card {
margin-right: 24rpx;
margin-bottom: 24rpx;
}
.stats-grid .stat-card:last-child {
margin-right: 0;
}
.stat-card {
flex: 1;
min-width: 200rpx;
background: #ffffff;
border-radius: 16rpx;
padding: 32rpx;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-icon, .stat-number, .stat-label {
display: inline-block;
text-align: center;
}
.stat-icon {
font-size: 48rpx;
margin-bottom: 16rpx;
}
.stat-number {
font-size: 36rpx;
font-weight: bold;
color: #007aff;
margin-bottom: 8rpx;
}
.stat-label {
font-size: 24rpx;
color: #666666;
}
/* 操作卡片样式 */
.actions-section {
margin-bottom: 40rpx;
}
.actions-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.actions-grid .action-card {
margin-right: 24rpx;
margin-bottom: 24rpx;
}
.actions-grid .action-card:last-child {
margin-right: 0;
}
.action-card {
flex: 1;
min-width: 280rpx;
background: #ffffff;
border-radius: 16rpx;
padding: 32rpx;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.action-card:hover {
transform: translateY(-4rpx);
}
.action-icon {
font-size: 56rpx;
margin-bottom: 16rpx;
text-align: center;
}
.action-title {
font-size: 28rpx;
font-weight: bold;
color: #333333;
margin-bottom: 8rpx;
text-align: center;
}
.action-desc {
font-size: 24rpx;
color: #666666;
line-height: 1.4;
text-align: center;
}
/* 最近活动样式 */
.recent-section {
background: #ffffff;
border-radius: 16rpx;
padding: 32rpx;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
} .empty-state {
padding: 40rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.empty-icon {
font-size: 64rpx;
margin-bottom: 16rpx;
opacity: 0.5;
}
.empty-text {
font-size: 28rpx;
color: #999999;
text-align: center;
}
.loading-activities {
padding: 40rpx;
display: flex;
align-items: center;
justify-content: center;
}
.activities-list {
display: flex;
flex-direction: column;
}
.activities-list .activity-item {
margin-bottom: 16rpx;
}
.activities-list .activity-item:last-child {
margin-bottom: 0;
}
.activity-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 16rpx;
border-radius: 12rpx;
background: #f8f9fa;
}
.activity-icon {
font-size: 32rpx;
margin-right: 16rpx;
}
.activity-content {
flex: 1;
}
.activity-title {
font-size: 28rpx;
color: #333333;
margin-bottom: 4rpx;
}
.activity-time {
font-size: 22rpx;
color: #999999;
}
/* 响应式设计 */
@media screen and (max-width: 768px) {
.stats-grid
{
flex-direction: row;
}
.actions-grid {
flex-direction: row;
justify-content: center;
}
.stat-card,
.action-card {
min-width: auto;
}
}
</style>

View File

@@ -0,0 +1,378 @@
<template>
<view class="migration-container">
<view class="page-header">
<text class="page-title">数据库迁移工具</text>
<text class="page-subtitle">评分标准JSON结构化迁移</text>
</view>
<scroll-view class="scroll-container" direction="vertical">
<view class="content-wrapper">
<!-- 迁移状态 -->
<view class="status-section">
<view class="section-title">迁移状态</view>
<view class="status-item" :class="{ 'success': migrationCompleted, 'pending': !migrationCompleted }">
<text class="status-text">{{ migrationStatus }}</text>
</view>
</view>
<!-- 迁移操作 -->
<view class="action-section">
<view class="section-title">执行迁移</view>
<button @click="executeMigration" class="migration-btn" :disabled="isExecuting">
<text>{{ isExecuting ? '执行中...' : '执行评分标准JSON迁移' }}</text>
</button>
<text class="help-text">此操作将把评分标准从字符串格式迁移到JSON格式</text>
</view>
<!-- 验证操作 -->
<view class="action-section">
<view class="section-title">验证结果</view>
<button @click="validateMigration" class="validate-btn" :disabled="isValidating">
<text>{{ isValidating ? '验证中...' : '验证迁移结果' }}</text>
</button>
<text class="help-text">检查迁移是否成功完成</text>
</view>
<!-- 结果显示 -->
<view v-if="migrationResult" class="result-section">
<view class="section-title">执行结果</view>
<view class="result-content">
<text class="result-text">{{ migrationResult }}</text>
</view>
</view>
<!-- 验证结果 -->
<view v-if="validationResult" class="result-section">
<view class="section-title">验证结果</view>
<view class="result-content">
<text class="result-text">{{ validationResult }}</text>
</view>
</view>
<!-- 警告信息 -->
<view class="warning-section">
<text class="warning-title">⚠️ 重要说明</text>
<text class="warning-text">• 迁移会自动创建备份表</text>
<text class="warning-text">• 确保在非生产环境先测试</text>
<text class="warning-text">• 迁移完成后可删除此页面</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
export default {
data() {
return {
isExecuting: false,
isValidating: false,
migrationCompleted: false,
migrationStatus: '等待执行迁移',
migrationResult: '',
validationResult: ''
}
},
methods: {
async executeMigration() {
this.isExecuting = true
this.migrationResult = ''
this.migrationStatus = '正在执行迁移...'
try {
// 执行迁移SQL脚本
const migrationSQL = `
-- 步骤1创建备份表
CREATE TABLE IF NOT EXISTS ak_training_projects_backup_20250611 AS
SELECT * FROM ak_training_projects;
-- 步骤2确保 scoring_criteria 字段为 JSONB 类型
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'ak_training_projects'
AND column_name = 'scoring_criteria'
AND data_type != 'jsonb'
) THEN
ALTER TABLE ak_training_projects
ALTER COLUMN scoring_criteria TYPE JSONB
USING CASE
WHEN scoring_criteria IS NULL OR scoring_criteria = '' THEN NULL
WHEN scoring_criteria ~ '^[\\s\\t\\n\\r]*\\{.*\\}[\\s\\t\\n\\r]*$' THEN scoring_criteria::JSONB
ELSE jsonb_build_object('legacy_text', scoring_criteria)
END;
END IF;
END $$;
-- 步骤3为没有评分标准的项目添加默认JSON结构
UPDATE ak_training_projects
SET scoring_criteria = jsonb_build_object(
'criteria', jsonb_build_array(
jsonb_build_object(
'min_score', 90,
'max_score', 100,
'description', '优秀:表现卓越,超出预期'
),
jsonb_build_object(
'min_score', 80,
'max_score', 89,
'description', '良好:表现良好,符合要求'
),
jsonb_build_object(
'min_score', 70,
'max_score', 79,
'description', '及格:基本达标,有待改进'
),
jsonb_build_object(
'min_score', 0,
'max_score', 69,
'description', '不及格:未达标准,需要重练'
)
),
'scoring_method', 'comprehensive',
'weight_distribution', jsonb_build_object(
'technique', 0.4,
'effort', 0.3,
'improvement', 0.3
)
)
WHERE scoring_criteria IS NULL
OR scoring_criteria = '{}'
OR jsonb_typeof(scoring_criteria) != 'object'
OR NOT (scoring_criteria ? 'criteria');
-- 步骤4创建索引
CREATE INDEX IF NOT EXISTS idx_ak_training_projects_scoring_criteria
ON ak_training_projects USING GIN (scoring_criteria);
`
// 使用RPC执行SQL
const result = await supa.rpc('exec_sql', { sql: migrationSQL })
if (result.status >= 200 && result.status < 300) {
this.migrationCompleted = true
this.migrationStatus = '迁移执行完成'
this.migrationResult = '✅ 评分标准JSON迁移执行成功\n\n已完成\n• 创建备份表\n• 转换字段类型为JSONB\n• 添加默认评分标准\n• 创建性能索引'
uni.showToast({
title: '迁移成功',
icon: 'success'
})
} else {
throw new Error(result.error?.message ?? '迁移执行失败')
}
} catch (error) {
console.error('迁移执行失败:', error)
this.migrationStatus = '迁移执行失败'
this.migrationResult = `❌ 迁移执行失败:${error}\n\n请手动在数据库中执行 migrate_scoring_criteria_simple.sql 脚本`
uni.showToast({
title: '迁移失败',
icon: 'error'
})
} finally {
this.isExecuting = false
}
},
async validateMigration() {
this.isValidating = true
this.validationResult = ''
try {
// 验证迁移结果
const validationSQL = `SELECT
COUNT(*) as total_projects,
COUNT(CASE WHEN scoring_criteria ? 'criteria' THEN 1 END) as json_format_count,
COUNT(CASE WHEN jsonb_typeof(scoring_criteria) = 'object' THEN 1 END) as valid_json_count
FROM ak_training_projects;`
const result = await supa.rpc('exec_sql', { sql: validationSQL })
const status = result['status'] as number | null
if (status != null && status >= 200 && status < 300 && result['data'] != null) {
const data = Array.isArray(result['data']) ? (result['data'] as Array<UTSJSONObject>)[0] : result['data'] as UTSJSONObject
const totalProjects = (data['total_projects'] as number) ?? 0
const jsonFormatCount = (data['json_format_count'] as number) ?? 0
const validJsonCount = (data['valid_json_count'] as number) ?? 0
this.validationResult = ` 验证结果:\n\n• 总项目数:${totalProjects}\n• JSON格式项目${jsonFormatCount}\n• 有效JSON结构${validJsonCount}\n\n${
jsonFormatCount === totalProjects && validJsonCount === totalProjects
? '✅ 所有项目都已成功迁移到JSON格式'
: '⚠️ 部分项目可能需要检查'
}`
// 获取示例数据
const exampleSQL = `
SELECT title, scoring_criteria->'criteria' as criteria
FROM ak_training_projects
WHERE scoring_criteria IS NOT NULL
LIMIT 2;
`
const exampleResult = await supa.rpc('exec_sql', { sql: exampleSQL }) as UTSJSONObject
const exampleStatus = exampleResult['status'] as number | null
if (exampleStatus != null && exampleStatus >= 200 && exampleStatus < 300 && exampleResult['data'] != null) {
this.validationResult += '\n\n 示例数据:\n'
const examples = Array.isArray(exampleResult['data']) ? (exampleResult['data'] as Array<UTSJSONObject>) : [exampleResult['data'] as UTSJSONObject]
examples.forEach((item: UTSJSONObject, index: number) => {
const title = item['title'] as string
const criteria = item['criteria'] as string
this.validationResult += `\n${index + 1}. ${title}\n 评分标准:${JSON.stringify(criteria, null, 2)}\n`
})
}
} else {
throw new Error('验证查询失败')
}
} catch (error) {
console.error('验证失败:', error)
this.validationResult = `❌ 验证失败:${error}\n\n请手动检查数据库中的 ak_training_projects 表`
} finally {
this.isValidating = false
}
}
}
}
</script>
<style scoped>
.migration-container {
flex: 1;
background-image: linear-gradient(to bottom right, #667eea, #764ba2);
min-height: 100vh;
display: flex;
flex-direction: column;
}
.page-header {
padding: 40rpx 30rpx 30rpx;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
}
.page-title {
font-size: 48rpx;
font-weight: bold;
color: #FFFFFF;
margin-bottom: 10rpx;
}
.page-subtitle {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
}
.scroll-container {
flex: 1;
}
.content-wrapper {
padding: 30rpx;
background: #F8FAFC;
border-radius: 30rpx 30rpx 0 0;
margin-top: 20rpx;
min-height: calc(100vh - 160rpx);
}
.status-section,
.action-section,
.result-section,
.warning-section {
background: #FFFFFF;
padding: 30rpx;
border-radius: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
margin-bottom: 30rpx;
}
.section-title {
font-size: 36rpx;
font-weight: bold;
color: #1E293B;
margin-bottom: 20rpx;
}
.status-item {
padding: 20rpx;
border-radius: 15rpx;
text-align: center;
}
.status-item.pending {
background: #FEF3C7;
border: 2rpx solid #F59E0B;
}
.status-item.success {
background: #D1FAE5;
border: 2rpx solid #10B981;
}
.status-text {
font-size: 28rpx;
font-weight: 400;
color: #374151;
}
.migration-btn,
.validate-btn {
width: 100%;
height: 80rpx;
border-radius: 15rpx;
font-size: 32rpx;
font-weight: 400;
border: none;
margin-bottom: 15rpx;
}
.migration-btn {
background-image: linear-gradient(to bottom right, #6366F1, #8B5CF6);
color: #FFFFFF;
}
.validate-btn {
background-image: linear-gradient(to bottom right, #10B981, #059669);
color: #FFFFFF;
}
.migration-btn:disabled,
.validate-btn:disabled {
opacity: 0.5;
}
.help-text {
font-size: 24rpx;
color: #6B7280;
text-align: center;
}
.result-content {
background: #F9FAFB;
padding: 20rpx;
border-radius: 15rpx;
border: 2rpx solid #E5E7EB;
}
.result-text {
font-size: 26rpx;
color: #374151;
line-height: 1.6;
white-space: pre-line;
}
.warning-title {
font-size: 32rpx;
font-weight: bold;
color: #F59E0B;
margin-bottom: 15rpx;
}
.warning-text {
font-size: 26rpx;
color: #6B7280;
margin-bottom: 10rpx;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,886 @@
<template>
<scroll-view direction="vertical" class="project-create" :scroll-y="true" :enable-back-to-top="true">
<!-- Header -->
<view class="page-header">
<text class="page-title">创建训练项目</text>
</view>
<!-- Form Container -->
<view class="form-container">
<form @submit="handleSubmit">
<!-- Basic Information -->
<view class="form-section">
<text class="section-title">基本信息</text>
<view class="form-group">
<text class="form-label">项目名称 *</text>
<input class="form-input" v-model="title" placeholder="请输入项目名称" maxlength="50" />
</view>
<view class="form-group">
<text class="form-label">项目描述</text>
<textarea class="form-textarea" v-model="description" placeholder="请输入项目描述"
maxlength="500"></textarea>
</view>
<view class="form-group">
<text class="form-label">训练类别 *</text>
<button class="form-selector" @click="showCategoryPicker">
<text class="selector-text">{{ category ?? '选择训练类别' }}</text>
<text class="selector-arrow">></text>
</button>
</view>
<view class="form-group">
<text class="form-label">难度等级 *</text>
<view class="difficulty-options">
<button class="difficulty-btn" v-for="(level, index) in difficultyLevels" :key="index"
:class="{ active: difficulty === level.value }"
@click="setDifficulty(level.getString('value')??'')">
<text class="difficulty-icon">{{ level.icon }}</text>
<text class="difficulty-text">{{ level.label }}</text>
</button>
</view>
</view>
</view>
<!-- Training Requirements -->
<view class="form-section">
<text class="section-title">训练要求</text>
<view class="requirements-list">
<view class="requirement-item" v-for="(requirement, index) in requirements" :key="index">
<input class="requirement-input" v-model="requirement.text" placeholder="输入训练要求" />
<button class="remove-btn" @click="removeRequirement(index)">
<text class="remove-icon">×</text>
</button>
</view>
</view>
<button class="add-requirement-btn" @click="addRequirement">
<text class="add-icon">+</text>
<text class="add-text">添加要求</text>
</button>
</view>
<!-- Scoring Criteria -->
<view class="form-section">
<text class="section-title">评分标准</text>
<view class="scoring-list">
<view class="scoring-item" v-for="(criteria, index) in scoringCriteria" :key="index">
<view class="score-range-group">
<input class="score-input" v-model="criteria.min_score" placeholder="最低分"
type="number" />
<text class="score-separator">-</text>
<input class="score-input" v-model="criteria.max_score" placeholder="最高分"
type="number" />
</view>
<input class="criteria-input" v-model="criteria.description" placeholder="评分标准描述" />
<button class="remove-btn" @click="removeCriteria(index)">
<text class="remove-icon">×</text>
</button>
</view>
</view>
<button class="add-criteria-btn" @click="addCriteria">
<text class="add-icon">+</text>
<text class="add-text">添加标准</text>
</button>
</view>
<!-- Performance Metrics -->
<view class="form-section">
<text class="section-title">绩效指标</text>
<view class="metrics-list">
<view class="metric-item" v-for="(metric, index) in performanceMetrics" :key="index">
<input class="metric-name" v-model="metric.name" placeholder="指标名称" />
<input class="metric-unit" v-model="metric.unit" placeholder="单位" />
<button class="remove-btn" @click="removeMetric(index)">
<text class="remove-icon">×</text>
</button>
</view>
</view>
<button class="add-metric-btn" @click="addMetric">
<text class="add-icon">+</text>
<text class="add-text">添加指标</text>
</button>
</view>
<!-- Action Buttons -->
<view class="action-buttons">
<button class="action-btn secondary-btn" @click="saveDraft">
保存草稿
</button>
<button class="action-btn primary-btn" @click="submitProject">
创建项目
</button>
</view>
</form>
</view>
<!-- Category Picker Modal -->
<view class="modal-overlay" v-if="showCategoryModal" @click="hideCategoryPicker">
<view class="category-modal" @click.stop>
<view class="modal-header">
<text class="modal-title">选择训练类别</text>
<button class="modal-close-btn" @click="hideCategoryPicker">×</button>
</view>
<view class="category-list"> <button class="category-option" v-for="(categoryItem, index) in categories"
:key="index" @click="selectCategory(categoryItem)">
<text class="category-icon">{{ categoryItem.icon }}</text>
<text class="category-name">{{ categoryItem.name }}</text>
</button>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import supaClient from '../../../components/supadb/aksupainstance.uts'
// Type definitions
type CategoryItem = {
name : string
value : string
icon : string
}
type RequirementItem = {
text : string
}
type ScoringCriteriaItem = {
min_score : string
max_score : string
description : string
}
type PerformanceMetricItem = {
name : string
unit : string
}
// 1-dimensional reactive refs
const title = ref<string>('')
const description = ref<string>('')
const category = ref<string>('')
const difficulty = ref<string>('')
// Array refs for dynamic lists - using regular arrays
const requirements = ref<Array<RequirementItem>>([{ text: '' } as RequirementItem])
const scoringCriteria = ref<Array<ScoringCriteriaItem>>([
{ min_score: '', max_score: '', description: '' } as ScoringCriteriaItem
])
const performanceMetrics = ref<Array<PerformanceMetricItem>>([
{ name: '', unit: '' } as PerformanceMetricItem
])
// UI state
const showCategoryModal = ref(false)
const loading = ref(false)
// Computed formData for database operations
const formData = computed(() => {
return {
title: title.value,
description: description.value,
category: category.value,
difficulty: difficulty.value,
requirements: requirements.value,
scoring_criteria: scoringCriteria.value,
performance_metrics: performanceMetrics.value,
status: 'draft'
}
})
const categories : Array<CategoryItem> = [
{ name: '田径运动', icon: '', value: 'athletics' },
{ name: '球类运动', icon: '⚽', value: 'ball_sports' },
{ name: '游泳运动', icon: '', value: 'swimming' },
{ name: '体操运动', icon: '', value: 'gymnastics' },
{ name: '武术运动', icon: '', value: 'martial_arts' },
{ name: '健身运动', icon: '', value: 'fitness' }
]
const difficultyLevels = [
{ label: '初级', value: 'beginner', icon: '' },
{ label: '中级', value: 'intermediate', icon: '' },
{ label: '高级', value: 'advanced', icon: '' },
{ label: '专家', value: 'expert', icon: '' }
]
function initializeForm() {
title.value = ''
description.value = ''
category.value = ''
difficulty.value = ''
requirements.value = [{ text: '' } as RequirementItem]
scoringCriteria.value = [{ min_score: '', max_score: '', description: '' } as ScoringCriteriaItem]
performanceMetrics.value = [{ name: '', unit: '' } as PerformanceMetricItem]
}
function showCategoryPicker() {
showCategoryModal.value = true
}
function hideCategoryPicker() {
showCategoryModal.value = false
}
function selectCategory(categoryItem : any) {
const categoryObj = categoryItem as CategoryItem
category.value = categoryObj.name
hideCategoryPicker()
}
function setDifficulty(difficultyValue : string) {
difficulty.value = difficultyValue
}
function addRequirement() {
requirements.value.push({ text: '' } as RequirementItem)
}
function removeRequirement(index : number) {
if (requirements.value.length > 1) {
requirements.value.splice(index, 1)
}
}
function addCriteria() {
scoringCriteria.value.push({ min_score: '', max_score: '', description: '' } as ScoringCriteriaItem)
}
function removeCriteria(index : number) {
if (scoringCriteria.value.length > 1) {
scoringCriteria.value.splice(index, 1)
}
}
function addMetric() {
performanceMetrics.value.push({ name: '', unit: '' } as PerformanceMetricItem)
}
function removeMetric(index : number) {
if (performanceMetrics.value.length > 1) {
performanceMetrics.value.splice(index, 1)
}
}
function validateForm() : boolean {
if (title.value.trim() === '') {
uni.showToast({
title: '请输入项目名称',
icon: 'none'
})
return false
}
if (category.value.trim() === '') {
uni.showToast({
title: '请选择训练类别',
icon: 'none'
})
return false
}
if (difficulty.value.trim() === '') {
uni.showToast({
title: '请选择难度等级',
icon: 'none'
})
return false
}
return true
}
async function saveDraft() {
if (title.value.trim() === '') {
uni.showToast({
title: '请至少输入项目名称',
icon: 'none'
})
return
}
loading.value = true
try {
// Convert form data to database format
const objectives = requirements.value
.map(req => req.text)
.filter(text => text.trim().length > 0)
// Create scoring criteria JSON structure
type MappedCriteria = {
min_score : number
max_score : number
description : string
}
const mappedCriteria : Array<MappedCriteria> = []
for (let i = 0; i < scoringCriteria.value.length; i++) {
const criteria = scoringCriteria.value[i]
mappedCriteria.push({
min_score: parseInt(criteria.min_score) ?? 0,
max_score: parseInt(criteria.max_score) ?? 100,
description: criteria.description
} as MappedCriteria)
}
const filteredCriteria = mappedCriteria.filter((item : MappedCriteria) : boolean => {
return item.description.trim().length > 0
})
const scoringCriteriaJson = {
criteria: filteredCriteria,
scoring_method: "comprehensive",
weight_distribution: {
technique: 0.4, // 技术动作权重 40%
effort: 0.3, // 努力程度权重 30%
improvement: 0.3 // 进步幅度权重 30%
}
}
const equipmentRequired = performanceMetrics.value
.map(metric => metric.name)
.filter(name => name.trim().length > 0)
const insertResult = await supaClient
.from('ak_training_projects')
.insert({
title: title.value,
description: description.value,
sport_type: category.value,
difficulty_level: difficulty.value,
is_active: false, // draft is inactive image_url: '',
video_url: '',
objectives: objectives,
scoring_criteria: scoringCriteriaJson,
equipment_required: equipmentRequired,
created_at: new Date().toISOString(), updated_at: new Date().toISOString()
})
.execute()
if (insertResult.error != null) {
console.error('Error saving draft:', insertResult.error)
uni.showToast({
title: '保存草稿失败',
icon: 'none'
})
} else {
uni.showToast({
title: '草稿保存成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
} catch (error) {
console.error('Error saving draft:', error)
uni.showToast({
title: '保存草稿失败',
icon: 'none'
})
} finally {
loading.value = false
}
} async function submitProject() {
if (!validateForm()) return
loading.value = true
try {
// Convert form data to database format
const objectives = requirements.value
.map(req => req.text)
.filter(text => text.trim().length > 0)
// Create scoring criteria JSON structure
type MappedCriteria = {
min_score : number
max_score : number
description : string
}
const mappedCriteria : Array<MappedCriteria> = []
for (let i = 0; i < scoringCriteria.value.length; i++) {
const criteria = scoringCriteria.value[i]
mappedCriteria.push({
min_score: parseInt(criteria.min_score) ?? 0,
max_score: parseInt(criteria.max_score) ?? 100,
description: criteria.description
} as MappedCriteria)
}
const filteredCriteria = mappedCriteria.filter((item : MappedCriteria) : boolean => {
return item.description.trim().length > 0
})
const scoringCriteriaJson = {
criteria: filteredCriteria,
scoring_method: "comprehensive",
weight_distribution: {
technique: 0.4, // 技术动作权重 40%
effort: 0.3, // 努力程度权重 30%
improvement: 0.3 // 进步幅度权重 30%
}
}
const equipmentRequired = performanceMetrics.value
.map((metric : PerformanceMetricItem) => metric.name)
.filter((name : string) => name.trim().length > 0)
const insertResult = await supaClient
.from('ak_training_projects')
.insert({
title: title.value,
description: description.value,
sport_type: category.value,
difficulty_level: difficulty.value,
is_active: true, // active project
image_url: '',
video_url: '',
objectives: objectives,
scoring_criteria: scoringCriteriaJson,
equipment_required: equipmentRequired,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
})
.execute()
if (insertResult.error != null) {
console.error('Error creating project:', insertResult.error)
uni.showToast({
title: '项目创建失败',
icon: 'none'
})
} else {
uni.showToast({
title: '项目创建成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 1500)
}
} catch (error) {
console.error('Error creating project:', error)
uni.showToast({
title: '项目创建失败',
icon: 'none'
})
} finally {
loading.value = false
}
}
function handleSubmit(event : Event) {
event.preventDefault()
submitProject()
}
// Lifecycle
onLoad(() => {
initializeForm()
})
</script>
<style>
.project-create {
flex:1;
background-color: #f5f5f5;
padding: 20rpx;
padding-bottom: 40rpx;
box-sizing: border-box;
}
/* Header */
.page-header {
margin-bottom: 25rpx;
}
.page-title {
font-size: 40rpx;
font-weight: bold;
color: #333;
}
/* Form Container */
.form-container {
background-color: white;
border-radius: 20rpx;
padding: 30rpx;
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.05);
}
.form-section {
margin-bottom: 40rpx;
}
.form-section:last-child {
margin-bottom: 0;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 25rpx;
display: block;
}
/* Form Controls */
.form-group {
margin-bottom: 25rpx;
}
.form-label {
font-size: 28rpx;
color: #333;
margin-bottom: 10rpx;
display: block;
font-weight: 400;
}
.form-input {
width: 100%;
height: 88rpx;
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
padding: 0 20rpx;
font-size: 28rpx;
color: #333;
background-color: #fff;
}
.form-input:focus {
border-color: #667eea;
outline: none;
}
.form-textarea {
width: 100%;
min-height: 160rpx;
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
padding: 20rpx;
font-size: 28rpx;
color: #333;
background-color: #fff;
resize: vertical;
}
.form-textarea:focus {
border-color: #667eea;
outline: none;
}
.form-selector {
width: 100%;
height: 88rpx;
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
padding: 0 20rpx;
background-color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
}
.selector-text {
font-size: 28rpx;
color: #333;
}
.selector-arrow {
font-size: 24rpx;
color: #666;
}
/* Difficulty Options */
.difficulty-options {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin: 0 -7.5rpx;
}
.difficulty-options .difficulty-btn {
width: 45%;
flex: 0 0 45%;
margin: 0 7.5rpx 15rpx;
}
.difficulty-btn {
height: 100rpx;
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
background-color: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.difficulty-btn .difficulty-text {
margin-top: 8rpx;
}
.difficulty-btn.active {
border-color: #667eea;
background-color: #f8f9ff;
}
.difficulty-icon {
font-size: 32rpx;
}
.difficulty-text {
font-size: 24rpx;
color: #333;
font-weight: 400;
}
/* Dynamic Lists */
.requirements-list,
.scoring-list,
.metrics-list {
margin-bottom: 20rpx;
}
.requirement-item,
.scoring-item,
.metric-item {
display: flex;
align-items: center;
margin-bottom: 15rpx;
}
.requirement-item .remove-btn,
.scoring-item .remove-btn,
.metric-item .remove-btn {
margin-left: 15rpx;
}
.requirement-input,
.criteria-input,
.metric-name {
flex: 1;
height: 70rpx;
border: 2rpx solid #e0e0e0;
border-radius: 8rpx;
padding: 0 15rpx;
font-size: 26rpx;
color: #333;
}
.score-range-group {
display: flex;
align-items: center;
min-width: 200rpx;
}
.score-range-group .score-input {
margin-right: 10rpx;
}
.score-range-group .score-input:last-child {
margin-right: 0;
margin-left: 10rpx;
}
.score-input {
width: 80rpx;
height: 70rpx;
border: 2rpx solid #e0e0e0;
border-radius: 8rpx;
padding: 0 10rpx;
font-size: 26rpx;
text-align: center;
}
.score-separator {
font-size: 24rpx;
color: #666;
}
.metric-unit {
width: 120rpx;
height: 70rpx;
border: 2rpx solid #e0e0e0;
border-radius: 8rpx;
padding: 0 15rpx;
font-size: 26rpx;
text-align: center;
}
.remove-btn {
width: 60rpx;
height: 60rpx;
border-radius: 30rpx;
background-color: #ff4757;
border: none;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.remove-icon {
font-size: 32rpx;
color: white;
font-weight: bold;
}
/* Add Buttons */
.add-requirement-btn,
.add-criteria-btn,
.add-metric-btn {
width: 100%;
height: 70rpx;
border: 2rpx dashed #667eea;
border-radius: 8rpx;
background-color: #f8f9ff;
display: flex;
align-items: center;
justify-content: center;
}
.add-requirement-btn .add-text,
.add-criteria-btn .add-text,
.add-metric-btn .add-text {
margin-left: 10rpx;
}
.add-icon {
font-size: 28rpx;
color: #667eea;
font-weight: bold;
}
.add-text {
font-size: 26rpx;
color: #667eea;
font-weight: 400;
}
/* Action Buttons */
.action-buttons {
display: flex;
margin-top: 40rpx;
}
.action-buttons .action-btn {
margin-right: 20rpx;
}
.action-buttons .action-btn:last-child {
margin-right: 0;
}
.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-image: linear-gradient(to bottom right, #667eea, #764ba2);
color: white;
}
.secondary-btn {
background-color: #f5f5f5;
color: #666;
border: 2rpx solid #e0e0e0;
}
/* Category Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.category-modal {
width: 90%;
max-width: 600rpx;
background-color: white;
border-radius: 20rpx;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.modal-close-btn {
width: 60rpx;
height: 60rpx;
border-radius: 30rpx;
background-color: #f5f5f5;
border: none;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
color: #666;
}
.category-list {
padding: 20rpx;
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin: 0 -7.5rpx;
}
.category-list .category-option {
width: 45%;
flex: 0 0 45%;
margin: 0 7.5rpx 15rpx;
}
.category-option {
height: 120rpx;
border: 2rpx solid #e0e0e0;
border-radius: 12rpx;
background-color: white;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.category-option .category-name {
margin-top: 8rpx;
}
.category-option:active {
background-color: #f8f9ff;
border-color: #667eea;
}
.category-icon {
font-size: 32rpx;
}
.category-name {
font-size: 24rpx;
color: #333;
font-weight: 400;
}
</style>

View File

@@ -0,0 +1,928 @@
<template>
<scroll-view direction="vertical" class="project-detail" :class="{ 'small-screen': !isLargeScreen }" :scroll-y="true" :enable-back-to-top="true">
<!-- Header Card -->
<view class="header-card">
<view class="project-header">
<text class="project-title">{{ getProjectDisplayNameWrapper(project) }}</text>
<view class="status-badge" :class="`status-${project.getString('status') ?? 'active'}`">
<text class="status-text">{{ formatProjectStatusLocal(project.getString('status') ?? 'active') }}</text>
</view>
</view>
<text class="project-description">{{ getProjectDescriptionWrapper(project) }}</text>
<view class="project-meta">
<view class="meta-row">
<view class="meta-item">
<text class="meta-icon"></text>
<text class="meta-text">{{ getProjectCategoryWrapper(project) }}</text>
</view>
<view class="meta-item">
<text class="meta-icon">⭐</text>
<text class="meta-text">{{ formatDifficultyWrapper(getProjectDifficultyWrapper(project)) }}</text>
</view>
</view>
<view class="meta-row">
<view class="meta-item">
<text class="meta-icon"></text>
<text class="meta-text">{{ getAssignmentCount() }}个作业</text>
</view>
<view class="meta-item">
<text class="meta-icon"></text>
<text class="meta-text">{{ getRecordCount() }}条记录</text>
</view>
</view>
</view>
</view>
<!-- Statistics Card -->
<view class="stats-card">
<view class="card-header">
<text class="card-title">统计概览</text>
</view>
<view class="stats-grid">
<view class="stat-item">
<text class="stat-value">{{ getTotalStudents() }}</text>
<text class="stat-label">参与学生</text>
<text class="stat-icon"></text>
</view>
<view class="stat-item">
<text class="stat-value">{{ getAverageScore() }}</text>
<text class="stat-label">平均分数</text>
<text class="stat-icon"></text>
</view>
<view class="stat-item">
<text class="stat-value">{{ getCompletionRate() }}%</text>
<text class="stat-label">完成率</text>
<text class="stat-icon">✅</text>
</view>
<view class="stat-item">
<text class="stat-value">{{ getImprovementRate() }}%</text>
<text class="stat-label">进步率</text>
<text class="stat-icon"></text>
</view>
</view>
</view>
<!-- Training Requirements Card -->
<view class="requirements-card">
<view class="card-header">
<text class="card-title">训练要求</text>
</view>
<view class="requirements-content">
<view class="requirement-section">
<text class="section-title">基础要求</text>
<view class="requirement-list"> <view
class="requirement-item"
v-for="(req, index) in getBasicRequirements()"
:key="'req-' + index"
>
<text class="requirement-icon">{{ getRequirementIcon(req) }}</text>
<text class="requirement-text">{{ getRequirementText(req) }}</text>
</view>
</view>
</view>
<view class="requirement-section">
<text class="section-title">评分标准</text>
<view class="scoring-table"> <view
class="scoring-row"
v-for="(criteria, index) in getScoringCriteria()"
:key="'criteria-' + index"
>
<view class="score-range">{{ getCriteriaRange(criteria) }}</view>
<view class="score-desc">{{ getCriteriaDescription(criteria) }}</view>
</view>
</view>
</view>
</view>
</view>
<!-- Recent Assignments Card -->
<view class="assignments-card">
<view class="card-header">
<text class="card-title">近期作业</text>
<text class="view-all-btn" @click="viewAllAssignments">查看全部</text>
</view>
<view class="assignments-list"> <view
class="assignment-item"
v-for="(assignment, index) in getRecentAssignments()"
:key="'assignment-' + index"
@click="viewAssignmentDetail(assignment)"
>
<view class="assignment-content">
<text class="assignment-title">{{ getAssignmentDisplayNameWrapper(assignment) }}</text>
<text class="assignment-date">{{ formatDateWrapper(getAssignmentCreatedAtLocal(assignment)) }}</text>
</view>
<view class="assignment-stats">
<text class="participants">{{ getAssignmentParticipants(assignment) }}人参与</text>
<view class="status-dot" :class="`status-${getAssignmentStatusWrapper(assignment)}`"></view>
</view>
</view>
</view>
</view>
<!-- Performance Trends Card -->
<view class="trends-card">
<view class="card-header">
<text class="card-title">成绩趋势</text>
</view>
<view class="trends-content">
<view class="chart-placeholder">
<text class="chart-text">成绩趋势图</text>
<text class="chart-desc">显示最近30天的平均成绩变化</text>
</view>
<view class="trend-summary">
<view class="trend-item">
<text class="trend-label">本周平均:</text>
<text class="trend-value">{{ getWeeklyAverage() }}分</text>
<text class="trend-change positive">+{{ getWeeklyChange() }}%</text>
</view>
<view class="trend-item">
<text class="trend-label">本月平均:</text>
<text class="trend-value">{{ getMonthlyAverage() }}分</text>
<text class="trend-change negative">{{ getMonthlyChange() }}%</text>
</view>
</view>
</view>
</view>
<!-- Action Buttons -->
<view class="action-buttons">
<button class="action-btn secondary-btn" @click="editProject">
编辑项目
</button>
<button class="action-btn primary-btn" @click="createAssignment">
创建作业
</button>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { getProjectId, getProjectDisplayName, getProjectDescription,
getProjectCategory, getProjectDifficulty, getAssignmentDisplayName,
getAssignmentStatus, getAssignmentId, formatDifficulty,
formatDate, formatProjectStatus, getAssignmentCreatedAt } from '../types.uts'
// Reactive data
const project = ref<UTSJSONObject>({})
const projectId = ref('')
const assignments = ref<Array<UTSJSONObject>>([])
const statistics = ref<UTSJSONObject>({})
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 loadProjectDetail() {
loading.value = true
// Mock data - replace with actual API calls
setTimeout(() => {
project.value = {
"id": projectId.value,
"title": "跳远技术训练",
"description": "全面训练跳远技术,包括助跑、起跳、空中姿态和落地等各个环节",
"category": "田径运动",
"difficulty": "intermediate",
"status": "active",
"basic_requirements": [
{ "icon": "", "text": "助跑距离12-16步节奏均匀" },
{ "icon": "", "text": "单脚起跳,起跳点准确" },
{ "icon": "✈️", "text": "空中保持良好姿态" },
{ "icon": "", "text": "双脚并拢前伸落地" }
],
"scoring_criteria": [
{ "range": "90-100分", "description": "动作标准,技术熟练,成绩优异" },
{ "range": "80-89分", "description": "动作较标准,技术较熟练" },
{ "range": "70-79分", "description": "动作基本标准,需要继续练习" },
{ "range": "60-69分", "description": "动作不标准,需要重点改进" }
]
} as UTSJSONObject
assignments.value = [
{
"id": "1",
"title": "跳远基础训练",
"created_at": "2024-01-15T10:00:00",
"participants": 25,
"status": "active"
},
{
"id": "2",
"title": "跳远技术提升",
"created_at": "2024-01-10T14:30:00",
"participants": 22,
"status": "completed"
},
{
"id": "3",
"title": "跳远考核测试",
"created_at": "2024-01-08T09:15:00",
"participants": 28,
"status": "completed"
}
]
statistics.value = {
"total_students": 28,
"average_score": 82.5,
"completion_rate": 85,
"improvement_rate": 12,
"weekly_average": 84.2,
"weekly_change": 3.5,
"monthly_average": 81.8,
"monthly_change": -1.2,
"assignment_count": 8,
"record_count": 156
} as UTSJSONObject
loading.value = false
}, 1000)
}
// 监听屏幕尺寸变化
onMounted(() => {
screenWidth.value = uni.getSystemInfoSync().windowWidth
})
onResize((size) => {
screenWidth.value = size.size.windowWidth
})
// Lifecycle
onLoad((options: OnLoadOptions) => {
const id = options['id']
if (id !== null) {
projectId.value = id as string
} else {
projectId.value = ''
}
loadProjectDetail()
})
// Helper functions for data access
function getAssignmentCount(): number {
return (statistics.value.get('assignment_count') as number) ?? 0
}
function getRecordCount(): number {
return (statistics.value.get('record_count') as number) ?? 0
}
function getTotalStudents(): number {
return (statistics.value.get('total_students') as number) ?? 0
}
function getAverageScore(): string {
const score = (statistics.value.get('average_score') as number) ?? 0
return score.toFixed(1)
}
function getCompletionRate(): number {
return (statistics.value.get('completion_rate') as number) ?? 0
}
function getImprovementRate(): number {
return (statistics.value.get('improvement_rate') as number) ?? 0
} function getBasicRequirements(): Array<UTSJSONObject> {
const requirements = project.value.get('basic_requirements') as Array<any> ?? []
if (requirements instanceof Array) {
return requirements.map((item: any) => item as UTSJSONObject)
}
return []
} function getScoringCriteria(): Array<UTSJSONObject> {
const criteriaData = project.value.get('scoring_criteria') ?? null
if (criteriaData != null && typeof criteriaData === 'object') {
// New JSON format: {criteria: [{min_score, max_score, description}], ...}
const criteriaObj = criteriaData as UTSJSONObject
const criteria = criteriaObj.get('criteria') as Array<any> ?? []
if (criteria instanceof Array) {
return criteria.map((item: any) => {
const itemObj = item as UTSJSONObject
const minScore = itemObj.get('min_score') ?? 0
const maxScore = itemObj.get('max_score') ?? 100
const description = itemObj.get('description') ?? ''
return {
range: `${minScore}-${maxScore}分`,
description: description.toString()
} as UTSJSONObject
})
}
}
// Fallback: Legacy format or hardcoded data
const legacyCriteria = project.value.get('scoring_criteria') as Array<any> ?? []
if (legacyCriteria instanceof Array) {
return legacyCriteria.map((item: any) => item as UTSJSONObject)
}
return []
}
function getRecentAssignments(): Array<UTSJSONObject> {
return assignments.value.slice(0, 3)
}
function getAssignmentParticipants(assignment: UTSJSONObject): number {
return (assignment.get('participants') as number) ?? 0
}
function getWeeklyAverage(): string {
const score = (statistics.value.get('weekly_average') as number) ?? 0
return score.toFixed(1)
}
function getWeeklyChange(): string {
const change = (statistics.value.get('weekly_change') as number) ?? 0
return Math.abs(change).toFixed(1)
}
function getMonthlyAverage(): string {
const score = (statistics.value.get('monthly_average') as number) ?? 0
return score.toFixed(1)
}
function getMonthlyChange(): string {
const change = (statistics.value.get('monthly_change') as number) ?? 0
return change.toFixed(1)
}
function viewAllAssignments() {
uni.navigateTo({
url: `/pages/sport/teacher/assignments?projectId=${projectId.value}`
})
}
function viewAssignmentDetail(assignment: UTSJSONObject) {
const assignmentId = getAssignmentId(assignment)
uni.navigateTo({
url: `/pages/sport/teacher/assignment-detail?id=${assignmentId}`
})
}
function editProject() {
uni.navigateTo({
url: `/pages/sport/teacher/project-edit?id=${projectId.value}`
})
}
function createAssignment() {
uni.navigateTo({
url: `/pages/sport/teacher/create-assignment?projectId=${projectId.value}`
})
}
// Template helper functions
const getProjectDisplayNameWrapper = (project: UTSJSONObject): string => getProjectDisplayName(project)
const getProjectDescriptionWrapper = (project: UTSJSONObject): string => getProjectDescription(project)
const getProjectCategoryWrapper = (project: UTSJSONObject): string => getProjectCategory(project)
const getProjectDifficultyWrapper = (project: UTSJSONObject): number => getProjectDifficulty(project)
const getAssignmentDisplayNameWrapper = (assignment: UTSJSONObject): string => getAssignmentDisplayName(assignment)
const getAssignmentStatusWrapper = (assignment: UTSJSONObject): string => getAssignmentStatus(assignment)
const formatDifficultyWrapper = (difficulty: number): string => formatDifficulty(difficulty)
const formatDateWrapper = (date: string): string => formatDate(date)
const formatProjectStatusLocal = (status: string): string => formatProjectStatus(status)
const getAssignmentCreatedAtLocal = (assignment: UTSJSONObject): string => getAssignmentCreatedAt(assignment)
// Scoring criteria helpers for template
function getCriteriaRange(criteria: UTSJSONObject): string {
// Handles both legacy and new format
if (criteria.getString('range')!=null) {
return criteria.getString('range')?? ''
}
const min = criteria.get('min_score') ?? ''
const max = criteria.get('max_score') ?? ''
if (min !== '' && max !== '') {
return `${min}-${max}分`
}
return ''
}
function getCriteriaDescription(criteria: UTSJSONObject): string {
return criteria.getString('description') ?? ''
}
// Requirement helpers
function getRequirementIcon(req: UTSJSONObject): string {
return req.getString('icon') ?? ''
}
function getRequirementText(req: UTSJSONObject): string {
return req.getString('text') ?? ''
}
</script>
<style>
.project-detail {
background-color: #f5f5f5;
height: 100vh;
padding: 20rpx;
padding-bottom: 40rpx;
box-sizing: border-box;
}
/* Header Card */
.header-card {
background-image: linear-gradient(to bottom right, #667eea, #764ba2);
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
color: white;
}
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.project-title {
font-size: 36rpx;
font-weight: bold;
flex: 1;
}
.status-badge {
padding: 8rpx 16rpx;
border-radius: 16rpx;
margin-left: 20rpx;
}
.status-active {
background-color: rgba(40, 167, 69, 0.3);
border: 1px solid rgba(40, 167, 69, 0.6);
}
.status-inactive {
background-color: rgba(108, 117, 125, 0.3);
border: 1px solid rgba(108, 117, 125, 0.6);
}
.status-text {
font-size: 24rpx;
color: white;
}
.project-description {
font-size: 28rpx;
line-height: 1.6;
margin-bottom: 25rpx;
opacity: 0.9;
}
.project-meta {
display: flex;
flex-direction: column;
}
.project-meta .meta-row {
margin-bottom: 15rpx;
}
.project-meta .meta-row:last-child {
margin-bottom: 0;
}
.meta-row {
display: flex;
justify-content: space-between;
}
.meta-item {
display: flex;
align-items: center;
flex: 1;
}
.meta-icon {
font-size: 24rpx;
margin-right: 8rpx;
}
.meta-text {
font-size: 26rpx;
opacity: 0.9;
}
/* Statistics Card */
.stats-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 {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25rpx;
}
.card-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.stats-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.stat-item {
width: 300rpx;
margin-right: 20rpx;
margin-bottom: 20rpx;
text-align: center;
padding: 25rpx;
background-color: #f8f9ff;
border-radius: 16rpx;
position: relative;
}
.stat-item:nth-child(2n) {
margin-right: 0;
}
.stat-value {
font-size: 36rpx;
font-weight: bold;
color: #667eea;
display: block;
margin-bottom: 8rpx;
}
.stat-label {
font-size: 26rpx;
color: #666;
display: block;
}
.stat-icon {
position: absolute;
top: 15rpx;
right: 15rpx;
font-size: 32rpx;
opacity: 0.3;
}
/* 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);
}
.requirement-section {
margin-bottom: 30rpx;
}
.requirement-section:last-child {
margin-bottom: 0;
}
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 15rpx;
display: block;
}
.requirement-list {
display: flex;
flex-direction: column;
}
.requirement-list .requirement-item {
margin-bottom: 12rpx;
}
.requirement-list .requirement-item:last-child {
margin-bottom: 0;
}
.requirement-item {
display: flex;
align-items: center;
padding: 15rpx;
background-color: #f8f9ff;
border-radius: 12rpx;
}
.requirement-icon {
font-size: 24rpx;
margin-right: 12rpx;
width: 32rpx;
text-align: center;
}
.requirement-text {
font-size: 26rpx;
color: #333;
flex: 1;
}
.scoring-table {
border-radius: 12rpx;
overflow: hidden;
border: 1rpx solid #eee;
}
.scoring-row {
display: flex;
border-bottom: 1rpx solid #eee;
}
.scoring-row:nth-child(4) {
border-bottom: none;
}
.score-range {
width: 160rpx;
padding: 15rpx;
background-color: #f8f9ff;
font-size: 26rpx;
font-weight: bold;
color: #667eea;
text-align: center;
border-right: 1rpx solid #eee;
}
.score-desc {
flex: 1;
padding: 15rpx;
font-size: 26rpx;
color: #333;
background-color: white;
}
/* Assignments Card */
.assignments-card {
background-color: white;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.05);
}
.view-all-btn {
font-size: 26rpx;
color: #667eea;
padding: 8rpx 16rpx;
border-radius: 12rpx;
background-color: rgba(102, 126, 234, 0.1);
}
.assignments-list {
display: flex;
flex-direction: column;
}
.assignment-item {
display: flex;
justify-content: space-between;
margin-bottom: 15rpx;
align-items: center;
padding: 20rpx;
background-color: #fafafa;
border-radius: 12rpx;
}
.assignment-content {
flex: 1;
}
.assignment-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 5rpx;
display: block;
}
.assignment-date {
font-size: 24rpx;
color: #666;
}
.assignment-stats {
display: flex;
align-items: center;
}
.assignment-stats .participants {
margin-right: 15rpx;
}
.participants {
font-size: 24rpx;
color: #666;
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 6rpx;
}
.status-dot.status-active {
background-color: #28a745;
}
.status-dot.status-completed {
background-color: #6c757d;
}
/* Trends Card */
.trends-card {
background-color: white;
border-radius: 20rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 20rpx rgba(0, 0, 0, 0.05);
}
.chart-placeholder {
height: 200rpx;
background-image: linear-gradient(to bottom right, #f8f9ff, #e3f2fd);
border-radius: 16rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 25rpx;
border: 2rpx dashed #667eea;
}
.chart-text {
font-size: 32rpx;
font-weight: bold;
color: #667eea;
margin-bottom: 8rpx;
}
.chart-desc {
font-size: 24rpx;
color: #666;
}
.trend-summary {
display: flex;
justify-content: space-around;
}
.trend-item {
text-align: center;
}
.trend-label {
font-size: 24rpx;
color: #666;
display: block;
margin-bottom: 5rpx;
}
.trend-value {
font-size: 32rpx;
font-weight: bold;
color: #333;
display: block;
margin-bottom: 5rpx;
}
.trend-change {
font-size: 24rpx;
font-weight: bold;
padding: 4rpx 8rpx;
border-radius: 8rpx;
}
.trend-change.positive {
color: #28a745;
background-color: rgba(40, 167, 69, 0.1);
}
.trend-change.negative {
color: #dc3545;
background-color: rgba(220, 53, 69, 0.1);
}
/* 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-of-type {
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;
}
/* 小屏幕专用样式 */
.small-screen .stats-grid {
flex-direction: column;
}
.small-screen .stat-item {
width: 100%;
margin-right: 0;
}
.small-screen .action-buttons {
flex-direction: column;
}
.small-screen .action-btn {
margin-right: 0;
margin-bottom: 15rpx;
}
.small-screen .action-btn:last-of-type {
margin-bottom: 0;
}
/* 响应式布局 - 小屏幕优化 */
@media (max-width: 768px) {
.stats-grid {
flex-direction: column;
}
.stat-item {
width: 100%;
margin-right: 0;
}
.meta-row {
flex-direction: column;
}
.meta-item {
margin-bottom: 10rpx;
}
.action-buttons {
flex-direction: column;
}
.action-btn {
margin-right: 0;
margin-bottom: 15rpx;
}
.action-btn:last-of-type {
margin-bottom: 0;
}
.project-detail {
padding: 15rpx;
}
.header-card, .stats-card, .requirements-card, .assignments-card, .trends-card {
padding: 20rpx;
margin-bottom: 15rpx;
}
}
/* Android 兼容性优化 */
.project-detail {
display: flex;
flex:1;
/* 确保滚动容器正确 */
overflow-y: auto;
/* 移除可能不支持的属性 */
-webkit-overflow-scrolling: touch;
}
/* 修复可能的渲染问题 */
.header-card, .stats-card, .requirements-card, .assignments-card, .trends-card {
/* 强制硬件加速 */
transform: translateZ(0);
/* 确保背景正确渲染 */
background-clip: padding-box;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,890 @@
<!-- 训练项目管理 - UTSJSONObject 优化版本 -->
<template>
<scroll-view direction="vertical" class="projects-container" :scroll-y="true" :enable-back-to-top="true">
<!-- 统计概览 -->
<supadb
ref="statsRef"
collection="ak_training_projects"
:filter="statsFilter"
getcount="exact"
@process-data="handleStatsData"
@error="handleError">
</supadb>
<view class="stats-section">
<view class="stats-grid">
<view class="stat-card">
<view class="stat-number">{{ stats.total_projects }}</view>
<view class="stat-label">总项目数</view>
</view>
<view class="stat-card">
<view class="stat-number">{{ stats.active_projects }}</view>
<view class="stat-label">激活项目</view>
</view>
<view class="stat-card">
<view class="stat-number">{{ stats.popular_projects }}</view>
<view class="stat-label">热门项目</view>
</view>
<view class="stat-card">
<view class="stat-number">{{ stats.avg_difficulty }}</view>
<view class="stat-label">平均难度</view>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="actions-section">
<button class="action-btn primary" @click="createProject">
<text class="btn-icon">+</text>
<text class="btn-text">新建项目</text>
</button>
<button class="action-btn secondary" @click="importProjects">
<text class="btn-icon">📤</text>
<text class="btn-text">导入项目</text>
</button>
<button class="action-btn secondary" @click="exportProjects">
<text class="btn-icon">📥</text>
<text class="btn-text">导出项目</text>
</button>
</view>
<!-- 筛选器 -->
<view class="filter-section">
<view class="search-box">
<input
:value="searchKeyword"
class="search-input"
placeholder="搜索项目名称..."
@input="handleSearch"
/>
</view>
<view class="filter-row">
<view class="filter-item" @click="showCategoryPicker">
<text class="filter-label">分类</text>
<text class="filter-value">{{ selectedCategoryText }}</text>
<text class="filter-arrow">></text>
</view>
<view class="filter-item" @click="showDifficultyPicker">
<text class="filter-label">难度</text>
<text class="filter-value">{{ selectedDifficultyText }}</text>
<text class="filter-arrow">></text>
</view>
<view class="filter-item" @click="showStatusPicker">
<text class="filter-label">状态</text>
<text class="filter-value">{{ selectedStatusText }}</text>
<text class="filter-arrow">></text>
</view>
</view>
</view>
<!-- 项目列表 -->
<view class="projects-section">
<supadb
ref="projectsRef"
collection="ak_training_projects"
:filter="projectsFilter"
getcount="exact"
:orderby="sortOrder"
:page-size="pageState.pageSize"
@process-data="handleProjectsData"
@error="handleError">
</supadb>
<view v-if="pageState.loading" class="loading-state">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="pageState.error" class="error-state">
<text class="error-text">{{ pageState.error }}</text>
<button class="retry-btn" @click="retryLoad">重试</button>
</view>
<view v-else-if="projects.length === 0" class="empty-state">
<text class="empty-icon">🏋️‍♂️</text>
<text class="empty-text">暂无训练项目</text>
<button class="create-btn" @click="createProject">创建第一个项目</button>
</view>
<view v-else class="projects-list">
<view v-for="project in projects" :key="project['id']" class="project-card" @click="viewProject(project)">
<view class="card-header"> <view class="project-info">
<text class="project-name">{{ project.getString('name') ?? project.getString('title') ?? '未命名项目' }}</text>
<text class="project-category">{{ project.getString('category') ?? '' }}</text>
</view> <view class="project-badges"> <view class="difficulty-badge" :style="{ backgroundColor: getDifficultyColor(project) }">
<text class="badge-text">{{ formatDifficultyLocal(project.getNumber('difficulty') ?? 1) }}</text>
</view> <view class="status-badge" :style="{ backgroundColor: getStatusColor(project) }">
<text class="badge-text">{{ formatStatusLocal(project.getBoolean('is_active') ?? true) }}</text>
</view>
</view>
</view>
<view class="card-body">
<text class="project-description">{{ project.getString('description') ?? '暂无描述' }}</text>
<view class="project-meta">
<view class="meta-item">
<text class="meta-icon">⏱️</text>
<text class="meta-text">{{ project.getNumber('duration') ?? project.getNumber('duration_minutes') ?? 30 }}分钟</text>
</view>
<view class="meta-item">
<text class="meta-icon">📊</text>
<text class="meta-text">使用{{ project.getNumber('usage_count') ?? 0 }}次</text>
</view>
<view class="meta-item">
<text class="meta-icon">📅</text>
<text class="meta-text">{{ formatDateLocal(project.getString('created_at') ?? '') }}</text>
</view>
</view>
</view>
<view class="card-actions">
<button class="action-btn-small primary" @click.stop="editProject(project)">编辑</button>
<button class="action-btn-small" @click.stop="toggleProjectStatus(project)">
{{ (project.getBoolean('is_active') ?? true) ? '停用' : '启用' }}
</button>
<button class="action-btn-small danger" @click.stop="deleteProject(project)">删除</button>
</view>
</view>
</view>
<!-- 分页器 -->
<view class="pagination" v-if="pageState.total > pageState.pageSize">
<button class="page-btn" :disabled="pageState.currentPage <= 1" @click="changePage(pageState.currentPage - 1)">
上一页
</button>
<text class="page-info">
{{ pageState.currentPage }} / {{ Math.ceil(pageState.total / pageState.pageSize) }}
</text>
<button class="page-btn" :disabled="pageState.currentPage >= Math.ceil(pageState.total / pageState.pageSize)" @click="changePage(pageState.currentPage + 1)">
下一页
</button>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
import type {
ProjectData,
PageState,
StatsData
} from '../types.uts'
import {
formatDifficulty,
formatStatus,
formatDate,
createPageState,
getProjectDifficultyColor,
getStatusColor,
getProjectStatusColor,
PROJECT_CATEGORIES,
DIFFICULTY_OPTIONS,
STATUS_OPTIONS
} from '../types.uts'
// Local wrapper functions to avoid unref issues
const formatDifficultyLocal = (difficulty: number): string => {
return formatDifficulty(difficulty)
}
const formatStatusLocal = (isActive: boolean): string => {
return formatStatus(isActive)
}
const formatDateLocal = (dateStr: string): string => {
return formatDate(dateStr)
}
// 响应式数据
const projects = ref<ProjectData[]>([])
const stats = ref<StatsData>({
total_projects: 0,
active_projects: 0,
popular_projects: 0,
avg_difficulty: '0.0'
})
const pageState = ref<PageState>(createPageState(12))
// 筛选状态
const searchKeyword = ref<string>('')
const selectedCategoryIndex = ref<number>(0)
const selectedDifficultyIndex = ref<number>(0)
const selectedStatusIndex = ref<number>(0)
const sortByIndex = ref<number>(0)
// 组件引用
const statsRef = ref<SupadbComponentPublicInstance | null>(null)
const projectsRef = ref<SupadbComponentPublicInstance | null>(null)
// 选项数组
const categoryOptions = PROJECT_CATEGORIES
const difficultyOptions = DIFFICULTY_OPTIONS
const statusOptions = STATUS_OPTIONS
const sortOptions = [
{ value: 'created_at.desc', text: '创建时间(最新)' },
{ value: 'created_at.asc', text: '创建时间(最旧)' },
{ value: 'name.asc', text: '名称(A-Z)' },
{ value: 'name.desc', text: '名称(Z-A)' },
{ value: 'difficulty.asc', text: '难度(简单到困难)' },
{ value: 'difficulty.desc', text: '难度(困难到简单)' }
] // 计算属性
const selectedCategoryText = computed(() => {
const option = categoryOptions[selectedCategoryIndex.value]
return option != null ? (option.text as string) : ''
})
const selectedDifficultyText = computed(() => {
const option = difficultyOptions[selectedDifficultyIndex.value]
return option != null ? (option.text as string) : ''
})
const selectedStatusText = computed(() => {
const option = statusOptions[selectedStatusIndex.value]
return option != null ? (option.text as string) : ''
})
const statsFilter = computed(() => new UTSJSONObject())
const projectsFilter = computed(() => {
const filter = new UTSJSONObject()
// 分类筛选
const categoryOption = categoryOptions[selectedCategoryIndex.value]
const categoryValue = categoryOption != null ? (categoryOption.value as string) : ''
if (categoryValue !== '') {
filter.set('category', categoryValue)
}
// 难度筛选
const difficultyOption = difficultyOptions[selectedDifficultyIndex.value]
const difficultyValue = difficultyOption != null ? (difficultyOption.value as string) : ''
if (difficultyValue !== '') {
filter.set('difficulty', parseInt(difficultyValue as string))
}
// 状态筛选
const statusOption = statusOptions[selectedStatusIndex.value]
const statusValue = statusOption != null ? (statusOption.value as string) : ''
if (statusValue !== '') {
filter.set('is_active', statusValue === 'active')
}
// 搜索关键词
if (searchKeyword.value.trim() !== '') {
const nameFilter = new UTSJSONObject()
nameFilter.set('contains', searchKeyword.value.trim())
filter.set('name', nameFilter)
}
return filter
})
const sortOrder = computed(() => {
const sortOption = sortOptions[sortByIndex.value]
return sortOption != null ? (sortOption.value as string) : 'created_at.desc'
})
// 样式计算函数
const getDifficultyColor = (project: ProjectData): string => {
return getProjectDifficultyColor(project.getNumber('difficulty') ?? 1)
}
const getStatusColor = (project: ProjectData): string => {
return getProjectStatusColor(project)
}
// 数据处理函数
const handleStatsData = (result: UTSJSONObject) => {
const data = result.get('data')
if (data != null && Array.isArray(data)) {
const totalProjects = data.length
let activeProjects = 0
let popularProjects = 0
let difficultySum = 0
for (let i = 0; i < data.length; i++) {
const project = data[i] as ProjectData
if (project.getBoolean('is_active') ?? true) {
activeProjects++
}
if ((project.getNumber('usage_count') ?? 0) > 5) {
popularProjects++
}
difficultySum += project.getNumber('difficulty') ?? 1
}
const avgDifficulty = totalProjects > 0 ? (difficultySum / totalProjects).toFixed(1) : '0.0'
stats.value = {
total_projects: totalProjects,
active_projects: activeProjects,
popular_projects: popularProjects,
avg_difficulty: avgDifficulty
} as StatsData
}
}
const handleProjectsData = (result: UTSJSONObject) => {
const data = result.get('data')
const total = result.get('total') as number
if (data != null && Array.isArray(data)) {
projects.value = data as ProjectData[]
}
if (total != null) {
pageState.value.total = total
}
pageState.value.loading = false
}
const handleError = (error: any) => {
console.error('Projects load error:', error)
pageState.value.loading = false
pageState.value.error = '数据加载失败'
uni.showToast({
title: '数据加载失败',
icon: 'error'
})
}
// 操作函数
const loadData = () => {
pageState.value.loading = true
pageState.value.error = null
if (statsRef.value != null) {
statsRef.value?.refresh?.()
}
if (projectsRef.value != null) {
projectsRef.value?.refresh?.()
}
}
const retryLoad = () => {
loadData()
}
const handleSearch = () => {
pageState.value.currentPage = 1
loadData()
}
const changePage = (page: number) => {
pageState.value.currentPage = page
loadData()
}
// 选择器函数
const showCategoryPicker = () => {
const itemList = categoryOptions.map(item => item.text as string)
uni.showActionSheet({
itemList,
success: (res) => {
if (typeof res.tapIndex === 'number') {
selectedCategoryIndex.value = res.tapIndex
pageState.value.currentPage = 1
loadData()
}
}
})
}
const showDifficultyPicker = () => {
const itemList = difficultyOptions.map(item => item.text as string)
uni.showActionSheet({
itemList,
success: (res) => {
if (typeof res.tapIndex === 'number') {
selectedDifficultyIndex.value = res.tapIndex
pageState.value.currentPage = 1
loadData()
}
}
})
}
const showStatusPicker = () => {
const itemList = statusOptions.map(item => item.text as string)
uni.showActionSheet({
itemList,
success: (res) => {
if (typeof res.tapIndex === 'number') {
selectedStatusIndex.value = res.tapIndex
pageState.value.currentPage = 1
loadData()
}
}
})
}
// 业务操作函数
const createProject = () => {
uni.navigateTo({
url: '/pages/sport/teacher/project-create'
})
}
const viewProject = (project: ProjectData) => {
const projectId = project.getString('id') ?? ''
uni.navigateTo({
url: `/pages/sport/teacher/project-detail?id=${projectId}`
})
}
const editProject = (project: ProjectData) => {
const projectId = project.getString('id') ?? ''
uni.navigateTo({
url: `/pages/sport/teacher/project-edit?id=${projectId}`
})
}
const toggleProjectStatus = (project: ProjectData) => {
const currentStatus = project.getBoolean('is_active') ?? true
const newStatus = !currentStatus
const statusText = newStatus ? '启用' : '停用'
const projectName = project.getString('name') ?? project.getString('title') ?? '未命名项目'
uni.showModal({
title: '确认操作',
content: `确定要${statusText}项目"${projectName}"吗?`,
success: (res) => {
if (res.confirm) {
// TODO: 调用API更新状态
uni.showToast({
title: `${statusText}成功`,
icon: 'success'
})
loadData()
}
}
})
}
const deleteProject = (project: ProjectData) => {
const projectName = project.getString('name') ?? project.getString('title') ?? '未命名项目'
uni.showModal({
title: '确认删除',
content: `确定要删除项目"${projectName}"吗?此操作不可恢复`,
success: (res) => {
if (res.confirm) {
// TODO: 调用API删除项目
uni.showToast({
title: '删除成功',
icon: 'success'
})
loadData()
}
}
})
}
const importProjects = () => {
uni.showToast({
title: '功能开发中',
icon: 'none'
})
}
const exportProjects = () => {
uni.showToast({
title: '功能开发中',
icon: 'none'
})
}
// 生命周期
onMounted(() => {
loadData()
})
const onShow = () => {
loadData()
}
</script>
<style>
.projects-container {
display: flex;
flex:1;
background-color: #f5f5f5;
padding: 32rpx;
padding-bottom: 40rpx;
box-sizing: border-box;
}
/* 统计样式 */
.stats-section {
margin-bottom: 32rpx;
}
.stats-grid {
display: flex;
flex-direction: row;
}
.stats-grid .stat-card {
margin-right: 24rpx;
}
.stats-grid .stat-card:last-child {
margin-right: 0;
}
.stat-card {
flex: 1;
background: #ffffff;
border-radius: 16rpx;
padding: 32rpx;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.stat-number {
font-size: 48rpx;
font-weight: bold;
color: #007aff;
margin-bottom: 8rpx;
}
.stat-label {
font-size: 24rpx;
color: #666666;
}
/* 操作按钮样式 */
.actions-section {
display: flex;
flex-direction: row;
margin-bottom: 32rpx;
}
.actions-section .action-btn {
margin-right: 16rpx;
}
.actions-section .action-btn:last-child {
margin-right: 0;
}
.action-btn {
display: flex;
flex-direction: row;
align-items: center;
padding: 20rpx 32rpx;
border-radius: 12rpx;
border: none;
font-size: 28rpx;
}
.action-btn.primary {
background-color: #007aff;
color: #ffffff;
}
.action-btn.secondary {
background-color: #ffffff;
color: #333333;
border: 1px solid #e5e5e5;
}
.btn-icon {
margin-right: 8rpx;
font-size: 32rpx;
}
/* 筛选器样式 */
.filter-section {
background: #ffffff;
border-radius: 16rpx;
padding: 32rpx;
margin-bottom: 32rpx;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.search-box {
margin-bottom: 24rpx;
}
.search-input {
width: 100%;
padding: 24rpx;
border: 1px solid #e5e5e5;
border-radius: 12rpx;
font-size: 28rpx;
background-color: #f8f9fa;
}
.filter-row {
display: flex;
flex-direction: row;
}
.filter-row .filter-item {
margin-right: 16rpx;
}
.filter-row .filter-item:last-child {
margin-right: 0;
}
.filter-item {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 20rpx;
border: 1px solid #e5e5e5;
border-radius: 12rpx;
background-color: #f8f9fa;
}
.filter-label {
font-size: 24rpx;
color: #666666;
}
.filter-value {
font-size: 28rpx;
color: #333333;
}
.filter-arrow {
font-size: 20rpx;
color: #999999;
}
/* 项目列表样式 */
.projects-section {
background: #ffffff;
border-radius: 16rpx;
padding: 32rpx;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.loading-state,
.error-state,
.empty-state {
text-align: center;
padding: 64rpx;
}
.loading-text,
.error-text,
.empty-text {
font-size: 28rpx;
color: #666666;
}
.empty-icon {
font-size: 96rpx;
margin-bottom: 24rpx;
opacity: 0.5;
}
.retry-btn,
.create-btn {
margin-top: 24rpx;
padding: 16rpx 32rpx;
background-color: #007aff;
color: #ffffff;
border: none;
border-radius: 12rpx;
font-size: 28rpx;
}
.projects-list {
display: flex;
flex-direction: column;
}
.projects-list .project-card {
margin-bottom: 24rpx;
}
.projects-list .project-card:last-child {
margin-bottom: 0;
}
.project-card { border: 1px solid #e5e5e5;
border-radius: 16rpx;
padding: 32rpx;
background: #ffffff;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.project-card:hover {
border-color: #007aff;
box-shadow: 0 4px 16px rgba(0, 122, 255, 0.1);
}
.card-header {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16rpx;
}
.project-info {
flex: 1;
}
.project-name {
font-size: 32rpx;
font-weight: bold;
color: #333333;
margin-bottom: 8rpx;
}
.project-category {
font-size: 24rpx;
color: #666666;
}
.project-badges {
display: flex;
flex-direction: row;
}
.project-badges .badge {
margin-right: 8rpx;
}
.project-badges .badge:last-child {
margin-right: 0;
}
.difficulty-badge,
.status-badge {
padding: 8rpx 16rpx;
border-radius: 12rpx;
}
.badge-text {
font-size: 22rpx;
color: #ffffff;
}
.card-body {
margin-bottom: 24rpx;
}
.project-description {
font-size: 28rpx;
color: #666666;
line-height: 1.5;
margin-bottom: 16rpx;
}
.project-meta {
display: flex;
flex-direction: row;
}
.project-meta .meta-item {
margin-right: 24rpx;
}
.project-meta .meta-item:last-child {
margin-right: 0;
}
.meta-item {
display: flex;
flex-direction: row;
align-items: center;
}
.meta-icon {
font-size: 20rpx;
margin-right: 8rpx;
}
.meta-text {
font-size: 24rpx;
color: #666666;
}
.card-actions {
display: flex;
flex-direction: row;
}
.card-actions .action-btn-small {
margin-right: 16rpx;
}
.card-actions .action-btn-small:last-child {
margin-right: 0;
}
.action-btn-small {
padding: 12rpx 20rpx;
border-radius: 8rpx;
border: 1px solid #007aff;
background-color: #ffffff;
color: #007aff;
font-size: 24rpx;
}
.action-btn-small.primary {
background-color: #007aff;
color: #ffffff;
}
.action-btn-small.danger {
border-color: #ff3b30;
color: #ff3b30;
}
/* 分页样式 */ .pagination {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-top: 32rpx;
}
.pagination .page-btn {
margin-right: 16rpx;
}
.pagination .page-btn:last-child {
margin-right: 0;
}
.page-btn {
padding: 16rpx 24rpx;
background-color: #007aff;
color: #ffffff;
border: none;
border-radius: 12rpx;
font-size: 26rpx;
}
.page-btn:disabled {
background-color: #cccccc;
color: #999999;
}
.page-info {
font-size: 26rpx;
color: #666666;
}
/* 响应式设计 */
@media screen and (max-width: 768px) {
.stats-grid {
flex-direction: column;
}
.actions-section {
flex-direction: column;
}
.filter-row {
flex-direction: column;
}
.project-meta {
flex-direction: column;
}
.project-meta > * {
margin-bottom: 8rpx;
}
.project-meta > *:last-child {
margin-bottom: 0;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,633 @@
<!-- 学生详情页面 -->
<template>
<scroll-view direction="vertical" class="student-detail-container">
<!-- Header -->
<view class="header">
<view class="back-btn" @click="goBack">
<text class="back-icon"></text>
<text class="back-text">返回</text>
</view>
<text class="title">学生详情</text>
</view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-container">
<text class="loading-text">加载学生详情中...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="error" class="error-container">
<text class="error-text">{{ error }}</text>
<button class="retry-btn" @click="loadStudentDetail">重试</button>
</view>
<!-- 学生详情内容 -->
<view v-else-if="student" class="content">
<!-- 学生基本信息 -->
<view class="student-info-card">
<view class="avatar-section">
<view class="avatar-container">
<image v-if="student?.avatar_url" :src="student?.avatar_url" class="student-avatar" mode="aspectFill" />
<view v-else class="student-avatar-placeholder">
<text class="avatar-text">{{ getInitials(student?.username) }}</text>
</view>
</view>
</view>
<view class="basic-info">
<text class="student-name">{{ student?.username != null ? student?.username as string : '' }}</text>
<text class="student-id">学号: {{ student?.phone != null ? student?.phone as string : '未设置' }}</text>
<text class="student-email">邮箱: {{ student?.email != null ? student?.email as string : '未设置' }}</text>
<text class="student-gender">性别: {{ getGenderText(student?.gender != null ? student?.gender as string : '') }}</text>
<text class="student-birthday">生日: {{ formatBirthday(student?.birthday != null ? student?.birthday as string : '') }}</text>
<text class="student-physical">身高: {{ student?.height_cm != null ? (student?.height_cm as number) + 'cm' : '未设置' }} | 体重: {{ student?.weight_kg != null ? (student?.weight_kg as number) + 'kg' : '未设置' }}</text>
<text class="student-bio" v-if="student?.bio != null && student?.bio !== ''">个人简介: {{ student?.bio as string }}</text>
<text class="join-date">注册时间: {{ formatDate(student?.created_at != null ? student?.created_at as string : '') }}</text>
</view>
</view>
<!-- 健康数据卡片 -->
<view class="health-cards">
<!-- 体温卡片 -->
<view class="health-card">
<view class="health-header">
<text class="health-icon">🌡️</text>
<text class="health-title">体温监测</text>
</view>
<view class="health-content">
<text class="health-current">{{ student?.latest_temperature != null ? student?.latest_temperature as number : '--' }}°C</text>
<text class="health-time">{{ formatTime(student?.temperature_time != null ? student?.temperature_time as string : '') }}</text>
<text class="health-status" :class="getTemperatureStatus(student?.latest_temperature != null ? student?.latest_temperature as number : null)">
{{ getTemperatureStatusText(student?.latest_temperature != null ? student?.latest_temperature as number : null) }}
</text>
</view>
</view>
<!-- 心率卡片 -->
<view class="health-card">
<view class="health-header">
<text class="health-icon">❤️</text>
<text class="health-title">心率监测</text>
</view>
<view class="health-content">
<text class="health-current">{{ student?.latest_heart_rate != null ? student?.latest_heart_rate as number : '--' }} bpm</text>
<text class="health-time">{{ formatTime(student?.heart_rate_time != null ? student?.heart_rate_time as string : '') }}</text>
<text class="health-status" :class="getHeartRateStatus(student?.latest_heart_rate != null ? student?.latest_heart_rate as number : null)">
{{ getHeartRateStatusText(student?.latest_heart_rate != null ? student?.latest_heart_rate as number : null) }}
</text>
</view>
</view>
<!-- 血氧卡片 -->
<view class="health-card">
<view class="health-header">
<text class="health-icon">🫁</text>
<text class="health-title">血氧监测</text>
</view>
<view class="health-content">
<text class="health-current">{{ student?.latest_oxygen_level != null ? student?.latest_oxygen_level as number : '--' }}%</text>
<text class="health-time">{{ formatTime(student?.oxygen_level_time != null ? student?.oxygen_level_time as string : '') }}</text>
<text class="health-status" :class="getOxygenStatus(student?.latest_oxygen_level != null ? student?.latest_oxygen_level as number : null)">
{{ getOxygenStatusText(student?.latest_oxygen_level != null ? student?.latest_oxygen_level as number : null) }}
</text>
</view>
</view>
<!-- 步数卡片 -->
<view class="health-card">
<view class="health-header">
<text class="health-icon">👟</text>
<text class="health-title">步数统计</text>
</view>
<view class="health-content">
<text class="health-current">{{ student?.latest_steps != null ? student?.latest_steps as number : '--' }} 步</text>
<text class="health-time">{{ formatTime(student?.steps_time != null ? student?.steps_time as string : '') }}</text>
<text class="health-status normal">今日活动</text>
</view>
</view>
</view>
<!-- 历史数据按钮 -->
<view class="actions-section">
<button class="action-btn primary" @click="viewHealthHistory">
<text class="action-text">查看健康历史数据</text>
</button>
<button class="action-btn secondary" @click="viewTrainingRecords">
<text class="action-text">查看训练记录</text>
</button>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
// Supabase查询返回的学生类型
export type StudentRecord = {
id: string,
username: string,
email: string,
phone: string,
avatar_url: string,
gender: string,
birthday: string,
height_cm: number,
weight_kg: number,
bio: string,
school_id: string,
grade_id: string,
class_id: string,
role: string,
created_at: string,
updated_at: string
}
// 学生详情数据类型 - 基于ak_users表字段
type StudentDetail = {
id: string
username: string // ak_users.username
email: string // ak_users.email (NOT NULL in DB)
phone: string | null // ak_users.phone (用作学号)
avatar_url: string | null // ak_users.avatar_url
gender: string | null // ak_users.gender
birthday: string | null // ak_users.birthday (date type)
height_cm: number | null // ak_users.height_cm (integer type)
weight_kg: number | null // ak_users.weight_kg (integer type)
bio: string | null // ak_users.bio
school_id: string | null // ak_users.school_id (uuid)
grade_id: string | null // ak_users.grade_id (uuid)
class_id: string | null // ak_users.class_id (uuid)
role: string | null // ak_users.role
created_at: string // ak_users.created_at
updated_at: string | null // ak_users.updated_at
// 健康数据字段(模拟)
latest_temperature: number | null
temperature_time: string | null
latest_heart_rate: number | null
heart_rate_time: string | null
latest_oxygen_level: number | null
oxygen_level_time: string | null
latest_steps: number | null
steps_time: string | null
}
// 响应式数据
const student = ref<StudentDetail | null>(null)
const loading = ref<boolean>(false)
const error = ref<string>('')
const studentId = ref<string>('')
// 返回按钮
const goBack = () => {
uni.navigateBack()
}
// 获取姓名首字母
const getInitials = (name: string | null): string => {
if (name == null || name === '') return 'N'
const words = name.trim().split(' ')
if (words.length >= 2) {
return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase()
}
return name.charAt(0).toUpperCase()
}
// 格式化日期
const formatDate = (dateStr: string): string => {
if (dateStr == null || dateStr === '') return '未知'
try {
const date = new Date(dateStr)
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
} catch (e) {
return '日期格式错误'
}
}
// 格式化生日
const formatBirthday = (birthday: string | null): string => {
if (birthday == null || birthday === '') return '未设置'
try {
const date = new Date(birthday)
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
} catch (e) {
return '日期格式错误'
}
}
// 获取性别文本
const getGenderText = (gender: string | null): string => {
switch (gender) {
case 'male': return '男'
case 'female': return '女'
case 'other': return '其他'
default: return '未设置'
}
}
// 格式化时间
const formatTime = (timeStr: string | null): string => {
if (timeStr == null || timeStr === '') return '暂无数据'
try {
const date = new Date(timeStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMins < 60) {
return diffMins <= 0 ? '刚刚' : `${diffMins}分钟前`
} else if (diffHours < 24) {
return `${diffHours}小时前`
} else if (diffDays < 7) {
return `${diffDays}天前`
} else {
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
}
} catch (e) {
return '时间格式错误'
}
}
// 体温状态判断
const getTemperatureStatus = (temp: number | null): string => {
if (temp == null) return 'unknown'
if (temp < 36.0 || temp > 37.5) return 'abnormal'
return 'normal'
}
const getTemperatureStatusText = (temp: number | null): string => {
if (temp == null) return '无数据'
if (temp < 36.0) return '体温偏低'
if (temp > 37.5) return '体温偏高'
return '正常'
}
// 心率状态判断
const getHeartRateStatus = (rate: number | null): string => {
if (rate == null) return 'unknown'
if (rate < 60 || rate > 100) return 'abnormal'
return 'normal'
}
const getHeartRateStatusText = (rate: number | null): string => {
if (rate == null) return '无数据'
if (rate < 60) return '心率偏低'
if (rate > 100) return '心率偏高'
return '正常'
}
// 血氧状态判断
const getOxygenStatus = (level: number | null): string => {
if (level == null) return 'unknown'
if (level < 95) return 'abnormal'
return 'normal'
}
const getOxygenStatusText = (level: number | null): string => {
if (level == null) return '无数据'
if (level < 95) return '血氧偏低'
return '正常'
}
// 查看健康历史数据
const viewHealthHistory = () => {
uni.navigateTo({
url: `/pages/sport/teacher/student-health-history?id=${studentId.value}`
})
}
// 查看训练记录
const viewTrainingRecords = () => {
uni.navigateTo({
url: `/pages/sport/teacher/student-training-records?id=${studentId.value}`
})
} // 加载学生详情
const loadStudentDetail = async () => {
if (studentId.value == null || studentId.value === '') return
loading.value = true
error.value = ''
try {
console.log('开始加载学生详情:', studentId.value)
// 查询学生基本信息 - 基于ak_users表的实际字段
const response = await supa
.from('ak_users')
.select(`
id,
username,
email,
phone,
avatar_url,
gender,
birthday,
height_cm,
weight_kg,
bio,
school_id,
grade_id,
class_id,
role,
created_at,
updated_at
`, {})
.eq('id', studentId.value)
.eq('role', 'student')
.single()
.executeAs<StudentRecord>()
if (response.status >= 200 && response.status < 300 && response.data != null) {
// UTS supabase .single() 返回的data无法直接点语法或下标访问需转为普通对象
const studentObj =response.data as StudentRecord;
// 生成模拟健康数据
const mockTemp = 36.0 + Math.random() * 2.0 // 36.0-38.0度
const mockHeartRate = 60 + Math.random() * 40 // 60-100 bpm
const mockOxygen = 95 + Math.random() * 5 // 95-100%
const mockSteps = Math.floor(Math.random() * 10000) // 0-10000步
const mockTime = new Date().toISOString()
student.value = {
id: studentObj.id != null ? studentObj.id : '',
username: studentObj.username != null ? studentObj.username : '未命名',
email: studentObj.email != null ? studentObj.email : '',
phone: studentObj.phone != null ? studentObj.phone : null,
avatar_url: studentObj.avatar_url != null ? studentObj.avatar_url : null,
gender: studentObj.gender != null ? studentObj.gender : null,
birthday: studentObj.birthday != null ? studentObj.birthday : null,
height_cm: studentObj.height_cm != null ? studentObj.height_cm : null,
weight_kg: studentObj.weight_kg != null ? studentObj.weight_kg : null,
bio: studentObj.bio != null ? studentObj.bio : null,
school_id: studentObj.school_id != null ? studentObj.school_id : null,
grade_id: studentObj.grade_id != null ? studentObj.grade_id : null,
class_id: studentObj.class_id != null ? studentObj.class_id : null,
role: studentObj.role != null ? studentObj.role : null,
created_at: studentObj.created_at != null ? studentObj.created_at : '',
updated_at: studentObj.updated_at != null ? studentObj.updated_at : null,
// 模拟健康数据
latest_temperature: mockTemp,
temperature_time: mockTime,
latest_heart_rate: mockHeartRate,
heart_rate_time: mockTime,
latest_oxygen_level: mockOxygen,
oxygen_level_time: mockTime,
latest_steps: mockSteps,
steps_time: mockTime
} as StudentDetail
// 类型保护避免Smart cast错误
const stu = student.value
console.log('学生详情加载成功:', stu != null ? stu.username : '')
} else {
error.value = '未找到该学生信息'
console.error('学生信息查询失败:', response.status, response.error)
}
} catch (e) {
error.value = '网络错误,请稍后重试'
console.error('加载学生详情异常:', e)
} finally {
loading.value = false
}
} // Lifecycle
// 页面参数options兼容UTS转为普通对象再访
// Lifecycle
onLoad((options: OnLoadOptions) => {
const id = options['id']
if (id !== null) {
studentId.value = id as string
} else {
studentId.value = ''
}
loadStudentDetail()
})
</script>
<style scoped>
.student-detail-container {
flex: 1;
background-color: #f5f5f5;
}
.header {
padding: 16px 20px;
background-color: #ffffff;
border-bottom: 1px solid #e5e5e5;
flex-direction: row;
align-items: center;
}
.back-btn {
flex-direction: row;
align-items: center;
margin-right: 16px;
}
.back-icon {
font-size: 24px;
color: #007AFF;
margin-right: 4px;
}
.back-text {
font-size: 16px;
color: #007AFF;
}
.title {
font-size: 20px;
font-weight: bold;
color: #333;
}
.loading-container, .error-container {
flex: 1;
justify-content: center;
align-items: center;
padding: 40px 20px;
}
.loading-text, .error-text {
font-size: 16px;
color: #666;
text-align: center;
}
.retry-btn {
margin-top: 16px;
padding: 12px 24px;
background-color: #007AFF;
color: #ffffff;
border-radius: 8px;
border: none;
font-size: 16px;
}
.content {
padding: 16px;
}
.student-info-card {
background-color: #ffffff;
border-radius: 12px;
padding: 20px;
margin-bottom: 16px;
align-items: center;
}
.avatar-section {
margin-bottom: 16px;
}
.avatar-container {
align-items: center;
}
.student-avatar {
width: 80px;
height: 80px;
border-radius: 40px;
}
.student-avatar-placeholder {
width: 80px;
height: 80px;
border-radius: 40px;
background-color: #007AFF;
justify-content: center;
align-items: center;
}
.avatar-text {
color: #ffffff;
font-size: 24px;
font-weight: bold;
}
.basic-info {
align-items: center;
display: flex;
flex-direction: column;
justify-content: flex-start;
overflow-wrap: normal;
}
.student-name {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 8px;
}
.student-id, .student-email, .student-gender, .student-birthday, .student-physical, .student-bio, .join-date {
font-size: 14px;
color: #666;
margin-bottom: 4px;
}
.student-bio {
font-size: 13px;
line-height: 1.4;
color: #888;
margin-top: 8px;
}
.health-cards {
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
.health-card {
width: 48%;
background-color: #ffffff;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.health-header {
flex-direction: row;
align-items: center;
margin-bottom: 12px;
}
.health-icon {
font-size: 20px;
margin-right: 8px;
}
.health-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.health-content {
align-items: center;
}
.health-current {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.health-time {
font-size: 12px;
color: #999;
margin-bottom: 8px;
}
.health-status {
font-size: 12px;
padding: 4px 8px;
border-radius: 12px;
text-align: center;
}
.health-status.normal {
color: #22c55e;
background-color: #dcfce7;
}
.health-status.abnormal {
color: #ef4444;
background-color: #fef2f2;
}
.health-status.unknown {
color: #6b7280;
background-color: #f3f4f6;
}
.actions-section {
margin-top: 20px;
}
.action-btn {
width: 100%;
padding: 16px;
border-radius: 12px;
border: none;
margin-bottom: 12px;
font-size: 16px;
font-weight: bold;
}
.action-btn.primary {
background-color: #007AFF;
color: #ffffff;
}
.action-btn.secondary {
background-color: #f8f9fa;
color: #333;
border: 1px solid #dee2e6;
}
.action-text {
color: inherit;
}
</style>

View File

@@ -0,0 +1,777 @@
<!-- 学生列表页面 -->
<template>
<scroll-view direction="vertical" class="students-container">
<!-- Header -->
<view class="header">
<text class="title">学生列表</text>
<text class="subtitle">本人权限下的学生信息</text>
</view>
<!-- 搜索栏 -->
<view class="search-section">
<view class="search-box">
<text class="search-icon">🔍</text>
<input :value="searchQuery" placeholder="搜索学生姓名" class="search-input" @input="onSearchInput" />
</view>
</view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-container">
<text class="loading-text">加载学生数据中...</text>
</view>
<!-- 错误状态 -->
<view v-else-if="error" class="error-container">
<text class="error-text">{{ error }}</text>
<button class="retry-btn" @click="loadStudents">重试</button>
</view>
<!-- 学生列表 -->
<view v-else class="students-list">
<view v-if="filteredStudents.length === 0" class="empty-state">
<text class="empty-icon">👥</text>
<text class="empty-text">暂无学生数据</text>
</view>
<view v-for="student in filteredStudents" :key="student.id" class="student-card"
:class="{ 'abnormal-student': student.has_abnormal_vitals, 'not-arrived-student': student.inside_fence === false }" @click="viewStudentDetail(student.id)">
<view v-if="student.has_abnormal_vitals" class="abnormal-badge">
<text class="abnormal-icon">⚠️</text>
<text class="abnormal-text">{{ student.abnormal_count }}项异常</text>
</view>
<!-- 学生基本信息 -->
<view class="student-header">
<view class="avatar-container">
<image v-if="student.avatar" :src="student.avatar" class="student-avatar" mode="aspectFill" />
<view v-else class="student-avatar-placeholder">
<text class="avatar-text">{{ getInitials(student.name) }}</text>
</view>
</view>
<view class="student-info">
<text class="student-name">{{ student.name }}</text>
<text class="student-id">学号: {{ student.student_id ?? '未设置' }}</text>
</view>
</view>
<!-- 健康数据 -->
<view class="health-data">
<view class="health-item">
<text class="health-icon">🌡️</text>
<view class="health-info">
<text class="health-label">体温</text>
<text class="health-value" :class="getTemperatureStatus(student.latest_temperature)">
{{ student.latest_temperature != null ? (student.latest_temperature as number).toFixed(1) : '--' }}°C
</text>
<text class="health-time">{{ formatTime(student.temperature_time as string | null) }}</text>
</view>
</view>
<view class="health-item">
<text class="health-icon">❤️</text>
<view class="health-info">
<text class="health-label">心率</text>
<text class="health-value" :class="getHeartRateStatus(student.latest_heart_rate)">
{{ typeof student.latest_heart_rate === 'number' ? student.latest_heart_rate : '--' }}
bpm
</text>
<text class="health-time">{{ formatTime(student.heart_rate_time as string | null) }}</text>
</view>
</view>
<view class="health-item">
<text class="health-icon">🫁</text>
<view class="health-info">
<text class="health-label">血氧</text>
<text class="health-value" :class="getOxygenStatus(student.latest_oxygen_level)">
{{ typeof student.latest_oxygen_level === 'number' ? student.latest_oxygen_level : '--' }}%
</text>
<text
class="health-time">{{ formatTime(student.oxygen_level_time as string | null) }}</text>
</view>
</view>
<view class="health-item">
<text class="health-icon">👟</text>
<view class="health-info">
<text class="health-label">步数</text>
<text class="health-value normal">
{{ typeof student.latest_steps === 'number' ? student.latest_steps : '--' }} 步
</text>
<text class="health-time">{{ formatTime(student.steps_time as string | null) }}</text>
</view>
</view>
</view>
<!-- 到校 / 围栏状态 -->
<view class="arrival-status" :class="student.inside_fence == true ? 'arrived' : 'not-arrived'">
<text class="arrival-dot">●</text>
<text class="arrival-text">{{ getArrivalText(student) }}</text>
</view>
<!-- 箭头指示 -->
<view class="arrow-container">
<text class="arrow"></text>
</view>
</view>
</view>
</scroll-view>
</template>
<script setup lang="uts">
import { ref, onMounted, computed } from 'vue'
import supa from '@/components/supadb/aksupainstance.uts'
import { getCurrentUserId, getCurrentUserClassId } from '@/utils/store.uts'
// 学生数据类型定义
type Student = {
id : string
name : string
student_id : string | null
avatar : string | null
latest_temperature : number | null
temperature_time : string | null
latest_heart_rate : number | null
heart_rate_time : string | null
latest_oxygen_level : number | null
oxygen_level_time : string | null
latest_steps : number | null
steps_time : string | null
created_at : string
// 新增异常标识
has_abnormal_vitals : boolean
abnormal_count : number
class_name ?: string // 班级名称
// 位置相关(新增)
lat ?: number | null
lng ?: number | null
location_time ?: string | null
inside_fence ?: boolean // 是否在围栏内(到校)
distance_m ?: number | null // 距离围栏中心米数
}
// 健康数据异常检测类型
type VitalAbnormalResult = {
abnormal : boolean;
count : number;
}
// 响应式数据
const students = ref<Array<Student>>([])
const loading = ref<boolean>(false)
const error = ref<string>('')
const searchQuery = ref<string>('')
// 围栏配置(到校判断)
const fenceCenterLat = ref<number>(0)
const fenceCenterLng = ref<number>(0)
const fenceRadiusM = ref<number>(120) // 米
const fenceLoaded = ref<boolean>(false)
// 计算过滤后的学生列表
const filteredStudents = computed<Array<Student>>(() => {
if (searchQuery.value.trim() == '') {
return students.value;
}
console.log(searchQuery.value)
return students.value.filter(student => {
if (typeof student.name == 'string') {
return student.name.toLocaleLowerCase().includes(searchQuery.value.toLocaleLowerCase());
}
return false;
});
})
// 判断生理指标是否异常
const checkVitalAbnormal = (
temp : number | null,
heartRate : number | null,
oxygen : number | null
) : VitalAbnormalResult => {
let abnormalCount = 0
// 体温异常判断 (正常范围: 36.0-37.5°C)
if (temp !== null && (temp < 36.0 || temp > 37.5)) {
abnormalCount++
}
// 心率异常判断 (正常范围: 60-100 bpm)
if (heartRate !== null && (heartRate < 60 || heartRate > 100)) {
abnormalCount++
}
// 血氧异常判断 (正常范围: ≥95%)
if (oxygen !== null && oxygen < 95) {
abnormalCount++
}
const result = {
abnormal: abnormalCount > 0,
count: abnormalCount
} as VitalAbnormalResult
return result
}
// 获取体温状态
const getTemperatureStatus = (temp : number | null) : string => {
if (temp === null) return 'unknown';
if (temp < 36.0 || temp > 37.5) return 'abnormal';
return 'normal'
}
// 获取心率状态
const getHeartRateStatus = (rate : number | null) : string => {
if (rate === null) return 'unknown';
if (rate < 60 || rate > 100) return 'abnormal';
return 'normal';
}
// 获取血氧状态
const getOxygenStatus = (level : number | null) : string => {
if (level === null) return 'unknown';
if (level < 95) return 'abnormal';
return 'normal';
} // 搜索输入处理
const onSearchInput = (e:UniInputEvent) => {
searchQuery.value = e.detail.value;
}
// 获取姓名首字母
const getInitials = (name : string) : string => {
if (name.trim() === '') return 'N';
const words = name.trim().split(' ');
if (words.length >= 2) {
return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase();
}
return name.charAt(0).toUpperCase();
}
// 格式化时间
const formatTime = (timeStr : string | null) : string => {
if (timeStr === null || timeStr === '') return '暂无数据'
try {
const date = new Date(timeStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMins < 60) {
return diffMins <= 0 ? '刚刚' : `${diffMins}分钟前`
} else if (diffHours < 24) {
return `${diffHours}小时前`
} else if (diffDays < 7) {
return `${diffDays}天前`
} else {
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`
}
} catch (e) {
return '时间格式错误'
}
}
// 查看学生详情
const viewStudentDetail = (studentId : string) => {
uni.navigateTo({
url: `/pages/sport/teacher/student-detail?id=${studentId}`
})
} // 加载学生数据
const loadStudents = async () => {
loading.value = true;
error.value = '';
try {
const currentUser = getCurrentUserId();
if (currentUser === null || currentUser === '') {
error.value = '用户未登录';
return;
}
// 获取当前用户的class_id
const currentUserClassId = getCurrentUserClassId();
if (currentUserClassId === null || currentUserClassId === '') {
error.value = '用户未分配班级';
return;
}
console.log('开始加载学生数据...', '当前用户班级ID:', currentUserClassId);
// 直接获取同班级的学生ID
const studentsResponse = await supa
.from('ak_users')
.select('id, username,phone, avatar_url, class_id', {})
.eq('role', 'student')
.eq('class_id', currentUserClassId) // 使用当前用户的class_id
.execute()
if (studentsResponse.data == null || studentsResponse.status < 200 || studentsResponse.status >= 300) {
console.error('获取学生列表失败:', studentsResponse.status, studentsResponse.error)
students.value = []
return
}
const studentIds = (studentsResponse.data as Array<UTSJSONObject>).map(u => u['id'] as string)
if (studentIds.length === 0) {
console.warn('当前班级中没有学生')
students.value = []
return
}
console.log('找到同班级学生ID:', studentIds)
const response = await supa
.from('ak_users')
.select(`
id,
username,
email,
avatar_url,
class_id
`, {})
.eq('role', 'student')
.eq('class_id', currentUserClassId)
.limit(50)
.execute();
if (response.status >= 200 && response.status < 300 && response.data != null) {
const studentsData = response.data as Array<UTSJSONObject>;
// 处理学生基本信息,健康数据使用模拟值进行演示
const studentsWithHealth = studentsData.map((student) : Student => {
const studentId = student['id'] as string;
const studentName = (student['username'] != null && student['username'] !== '') ? student['username'] as string : '未命名';
const avatar = student['avatar_url'] as string | null;
// 生成模拟健康数据用于演示(实际项目中应该从真实的健康数据表获取)
const mockTemp = 36.0 + Math.random() * 2.0 // 36.0-38.0度
const mockHeartRate = 60 + Math.random() * 40 // 60-100 bpm
const mockOxygen = 95 + Math.random() * 5 // 95-100%
const mockSteps = Math.floor(Math.random() * 10000) // 0-10000步
const mockTime = new Date().toISOString()
// 判断是否有异常指标
const vitalCheck = checkVitalAbnormal(mockTemp, mockHeartRate, mockOxygen)
const baseStudent: Student = {
id: studentId,
name: studentName,
student_id: (student['email'] != null && student['email'] !== '') ? student['email'] as string : null,
avatar: avatar,
latest_temperature: mockTemp,
temperature_time: mockTime,
latest_heart_rate: mockHeartRate,
heart_rate_time: mockTime,
latest_oxygen_level: mockOxygen,
oxygen_level_time: mockTime,
latest_steps: mockSteps,
steps_time: mockTime,
created_at: '',
has_abnormal_vitals: vitalCheck.abnormal,
abnormal_count: vitalCheck.count,
lat: null,
lng: null,
location_time: null,
inside_fence: false,
distance_m: null
}
return baseStudent
})
// 按异常情况排序:异常的排在前面,异常数量多的排在更前面
studentsWithHealth.sort((a, b) => {
// 首先按是否有异常排序
if (a.has_abnormal_vitals && !b.has_abnormal_vitals) return -1
if (!a.has_abnormal_vitals && b.has_abnormal_vitals) return 1
// 如果都有异常,按异常数量排序
if (a.has_abnormal_vitals && b.has_abnormal_vitals) {
return b.abnormal_count - a.abnormal_count
}
// 都正常的话按姓名排序
const nameA = a.name != null ? a.name : '';
const nameB = b.name != null ? b.name : '';
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return 0;
})
students.value = studentsWithHealth
// 进一步加载位置数据并计算围栏
// 模拟位置数据
for (let i = 0; i < students.value.length; i++) {
const s = students.value[i]
// 随机生成一个在中心附近 300m 内的点;让部分学生超出围栏
const rand = Math.random()
const maxDist = 0.003 // 粗略对应几百米
const dLat = (Math.random() - 0.5) * maxDist
const dLng = (Math.random() - 0.5) * maxDist
s.lat = fenceCenterLat.value + dLat
s.lng = fenceCenterLng.value + dLng + (rand < 0.2 ? 0.01 : 0) // 20% 故意偏移较远,模拟未到校
s.location_time = new Date().toISOString()
// 计算距离
if (s.lat != null && s.lng != null) {
const R = 6371000.0
const toRad = (d: number): number => d * Math.PI / 180.0
const dLat = toRad(s.lat - fenceCenterLat.value)
const dLng = toRad(s.lng - fenceCenterLng.value)
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(fenceCenterLat.value)) * Math.cos(toRad(s.lat)) * Math.sin(dLng / 2) * Math.sin(dLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
s.distance_m = Math.round(R * c)
s.inside_fence = s.distance_m <= fenceRadiusM.value
} else {
s.inside_fence = false
}
}
console.log(`成功加载 ${students.value.length} 个同班级学生数据`)
console.log(`异常学生数量: ${studentsWithHealth.filter(s => s.has_abnormal_vitals).length}`)
} else {
error.value = '加载学生健康数据失败'
console.error('学生健康数据查询失败:', response.status, response.error)
}
} catch (e) {
error.value = '网络错误,请稍后重试'
console.error('加载学生数据异常:', e)
} finally {
loading.value = false
}
}
// 页面加载时获取数据
onMounted(() => {
loadStudents()
})
// ========= 位置 / 围栏逻辑 =========
// 加载围栏配置(示例:从假设表 school_fences 读取)
const loadFenceConfig = async (): Promise<void> => {
// 真实情况根据学校或班级ID查询中心点与半径
try {
// 这里做演示:如果尚未加载,就给一个固定坐标(示例坐标:上海市中心近似)
if (!fenceLoaded.value) {
fenceCenterLat.value = 31.2304
fenceCenterLng.value = 121.4737
fenceRadiusM.value = 150 // 半径 150m
fenceLoaded.value = true
}
} catch (e) {
console.error('加载围栏配置失败', e)
}
}
// 加载位置信息(假设有 device_locations 表student_id, lat, lng, recorded_at
const loadLocationsAndComputeFence = async (classId: string): Promise<void> => {
try {
// 示例:直接模拟位置数据(真实项目改为 supa 查询)
for (let i = 0; i < students.value.length; i++) {
const s = students.value[i]
// 随机生成一个在中心附近 300m 内的点;让部分学生超出围栏
const rand = Math.random()
const maxDist = 0.003 // 粗略对应几百米
const dLat = (Math.random() - 0.5) * maxDist
const dLng = (Math.random() - 0.5) * maxDist
s.lat = fenceCenterLat.value + dLat
s.lng = fenceCenterLng.value + dLng + (rand < 0.2 ? 0.01 : 0) // 20% 故意偏移较远,模拟未到校
s.location_time = new Date().toISOString()
// 计算距离
if (s.lat != null && s.lng != null) {
s.distance_m = computeDistanceMeters(fenceCenterLat.value, fenceCenterLng.value, s.lat, s.lng)
s.inside_fence = s.distance_m <= fenceRadiusM.value
} else {
s.inside_fence = false
}
}
} catch (e) {
console.error('加载位置数据失败', e)
}
}
// 计算两点间距离Haversine 简化)
const computeDistanceMeters = (lat1: number, lng1: number, lat2: number, lng2: number): number => {
const R = 6371000.0
const toRad = (d: number): number => d * Math.PI / 180.0
const dLat = toRad(lat2 - lat1)
const dLng = toRad(lng2 - lng1)
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) * Math.sin(dLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
const dist = R * c
return Math.round(dist)
}
// 显示到校状态文字
const getArrivalText = (s: Student): string => {
if (s.inside_fence === true) {
return '已到校'
}
const distStr = s.distance_m != null ? (s.distance_m as number).toString() + 'm' : '未知距离'
return '未到校 • 距离围栏中心' + distStr
}
</script>
<style scoped>
.students-container {
flex: 1;
background-color: #f5f5f5;
}
.header {
padding: 20px;
background-color: #ffffff;
border-bottom: 1px solid #e5e5e5;
}
.title {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.subtitle {
font-size: 14px;
color: #666;
}
.search-section {
padding: 16px 20px;
background-color: #ffffff;
border-bottom: 1px solid #e5e5e5;
}
.search-box {
flex-direction: row;
align-items: center;
background-color: #f8f9fa;
border-radius: 8px;
padding: 12px 16px;
border: 1px solid #e9ecef;
}
.search-icon {
font-size: 16px;
margin-right: 8px;
color: #6c757d;
}
.search-input {
flex: 1;
font-size: 16px;
color: #333;
}
.loading-container,
.error-container,
.empty-state {
flex: 1;
justify-content: center;
align-items: center;
padding: 40px 20px;
}
.loading-text,
.error-text,
.empty-text {
font-size: 16px;
color: #666;
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.retry-btn {
margin-top: 16px;
padding: 12px 24px;
background-color: #007AFF;
color: #ffffff;
border-radius: 8px;
border: none;
font-size: 16px;
}
.students-list {
padding: 16px;
}
.student-card {
background-color: #ffffff;
border-radius: 12px;
margin-bottom: 16px;
padding: 16px;
flex-direction: column;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
}
.abnormal-student {
border-left: 4px solid #ff4757;
background-color: #fff5f5;
}
.not-arrived-student {
border-left: 4px solid #ffa502;
background-color: #fff8e6;
}
.abnormal-badge {
position: absolute;
top: 12px;
right: 12px;
flex-direction: row;
align-items: center;
background-color: #ff4757;
padding: 4px 8px;
border-radius: 12px;
}
.abnormal-icon {
font-size: 12px;
margin-right: 4px;
}
.abnormal-text {
font-size: 10px;
color: #ffffff;
font-weight: bold;
}
.student-header {
flex-direction: row;
align-items: center;
flex: 1;
}
.avatar-container {
margin-right: 12px;
}
.student-avatar {
width: 48px;
height: 48px;
border-radius: 24px;
}
.student-avatar-placeholder {
width: 48px;
height: 48px;
border-radius: 24px;
background-color: #007AFF;
justify-content: center;
align-items: center;
}
.avatar-text {
color: #ffffff;
font-size: 16px;
font-weight: bold;
}
.student-info {
flex: 1;
}
.student-name {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.student-id {
font-size: 14px;
color: #666;
}
.health-data {
flex: 2;
margin-left: 16px;
}
.health-item {
flex-direction: row;
align-items: center;
margin-bottom: 8px;
}
.health-icon {
font-size: 16px;
margin-right: 8px;
width: 20px;
}
.health-info {
flex: 1;
flex-direction: row;
align-items: center;
}
.health-label {
font-size: 12px;
color: #666;
width: 30px;
margin-right: 8px;
}
.health-value {
font-size: 14px;
font-weight: bold;
color: #333;
margin-right: 8px;
min-width: 60px;
}
.health-value.normal {
color: #27ae60;
}
.health-value.abnormal {
color: #e74c3c;
background-color: #ffebee;
padding: 2px 6px;
border-radius: 4px;
}
.health-value.unknown {
color: #95a5a6;
}
.health-time {
font-size: 11px;
color: #999;
}
.arrow-container {
margin-left: 16px;
}
.arrow {
font-size: 20px;
color: #ccc;
}
/* 到校状态样式 */
.arrival-status {
margin-top: 8px;
flex-direction: row;
align-items: center;
}
.arrival-dot {
font-size: 10px;
margin-right: 6px;
}
.arrival-status.arrived .arrival-dot {
color: #2ecc71;
}
.arrival-status.not-arrived .arrival-dot {
color: #e67e22;
}
.arrival-text {
font-size: 12px;
color: #555;
}
.arrival-status.not-arrived .arrival-text {
color: #d35400;
font-weight: bold;
}
</style>