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

929 lines
22 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<scroll-view direction="vertical" class="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>