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

891 lines
21 KiB
Plaintext

<!-- 训练项目管理 - 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>