Initial commit of akmon project
This commit is contained in:
109
pages/sport/teacher/PROJECT_EDIT_MEDIA_UPLOAD.md
Normal file
109
pages/sport/teacher/PROJECT_EDIT_MEDIA_UPLOAD.md
Normal 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
|
||||
- ✅ **错误处理**: 完整的错误提示和异常处理
|
||||
|
||||
媒体上传功能现已完全集成到项目编辑页面中,用户可以为训练项目添加图片和视频资源,提升项目的可视化效果和教学质量。
|
||||
899
pages/sport/teacher/analytics.uvue
Normal file
899
pages/sport/teacher/analytics.uvue
Normal 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>
|
||||
656
pages/sport/teacher/assignments.uvue
Normal file
656
pages/sport/teacher/assignments.uvue
Normal 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>
|
||||
2404
pages/sport/teacher/class-training/index.uvue
Normal file
2404
pages/sport/teacher/class-training/index.uvue
Normal file
File diff suppressed because it is too large
Load Diff
1188
pages/sport/teacher/create-assignment.uvue
Normal file
1188
pages/sport/teacher/create-assignment.uvue
Normal file
File diff suppressed because it is too large
Load Diff
819
pages/sport/teacher/dashboard.uvue
Normal file
819
pages/sport/teacher/dashboard.uvue
Normal 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>
|
||||
378
pages/sport/teacher/migration-tool.uvue
Normal file
378
pages/sport/teacher/migration-tool.uvue
Normal 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>
|
||||
886
pages/sport/teacher/project-create.uvue
Normal file
886
pages/sport/teacher/project-create.uvue
Normal 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>
|
||||
928
pages/sport/teacher/project-detail.uvue
Normal file
928
pages/sport/teacher/project-detail.uvue
Normal 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>
|
||||
1448
pages/sport/teacher/project-edit.uvue
Normal file
1448
pages/sport/teacher/project-edit.uvue
Normal file
File diff suppressed because it is too large
Load Diff
890
pages/sport/teacher/projects.uvue
Normal file
890
pages/sport/teacher/projects.uvue
Normal 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>
|
||||
1052
pages/sport/teacher/records.uvue
Normal file
1052
pages/sport/teacher/records.uvue
Normal file
File diff suppressed because it is too large
Load Diff
633
pages/sport/teacher/student-detail.uvue
Normal file
633
pages/sport/teacher/student-detail.uvue
Normal 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>
|
||||
777
pages/sport/teacher/students.uvue
Normal file
777
pages/sport/teacher/students.uvue
Normal 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>
|
||||
Reference in New Issue
Block a user