| 这个项目属于哪个课程 | 2025数据采集与融合技术 |
|---|---|
| 组名、项目简介 | 组名:好运来 项目需求:智能运动辅助应用,针对用户上传的运动视频(以引体向上为核心),解决传统动作评估依赖主观经验、反馈延迟的问题,提供客观的动作分析与改进建议 项目目标:对用户上传的运动视频进行动作分析、评分,提供个性化改进意见,包含完整的用户成长记录和反馈系统,帮助用户科学提升运动水平 技术路线:基于Vue3+Python+openGauss的前后端分离架构,前端使用Vue3实现用户界面和可视化,后端用Python集成MediaPipe进行姿态分析算法处理,数据库采用openGauss存储用户数据和运动记录,实现引体向上动作分析系统 |
| 团队成员学号 | 102302148(谢文杰)、102302149(赖翊煊)、102302150(蔡骏)、102302151(薛雨晨)、102302108(赵雅萱)、102302111(海米沙)、102302139(尚子骐)、022304105(叶骋恺) |
| 这个项目的目标 | 通过上传的运动视频,运用人体姿态估计算法(双视角协同分析:正面看握距对称性、身体稳定性,侧面看动作完整性、躯干角度),自动识别身体关键点,分解动作周期、识别违规代偿,生成量化评分、可视化报告与个性化改进建议;同时搭建用户成长记录与反馈系统,存储用户数据与运动记录,最终打造低成本、高精度的自动化评估工具,助力个人训练、体育教育等场景的科学化训练,规避运动损伤、提升训练效果 |
| 其他参考文献 | [1] ZHOU P, CAO J J, ZHANG X Y, et al. Learning to Score Figure Skating Sport Videos [J]. IEEE Transactions on Circuits and Systems for Video Technology, 2019. 1802.02774 [2] Toshev, A., & Szegedy, C. (2014). DeepPose: Human Pose Estimation via Deep Neural Networks. DeepPose: Human Pose Estimation via Deep Neural Networks |
| 码云链接(代码已汇总,各小组成员代码不分开放) | (此处填写实际Gitee地址,暂空) |
一.项目背景
随着全民健身的深入与健身文化的普及,以引体向上为代表的自重训练,因其便捷性与高效性,成为衡量个人基础力量与身体素质的重要标志,广泛应用于学校体测、军事训练及大众健身。然而,传统的动作评估高度依赖教练员的肉眼观察与主观经验,存在标准不一、反馈延迟、难以量化等局限性。在缺少专业指导的环境中,训练者往往难以察觉自身动作模式的细微偏差,如借力、摆动、幅度不足等,这不仅影响训练效果,长期更可能导致运动损伤。如何将人工智能与计算机视觉技术,转化为每个人触手可及的“AI教练”,提供客观、即时、精准的动作反馈,已成为提升科学化训练水平的一个迫切需求。
二.项目概述:
本项目旨在开发一套基于计算机视觉的智能引体向上动作分析与评估系统。系统通过训练者上传的视频,运用先进的人体姿态估计算法,自动识别并追踪身体关键点。针对引体向上动作的复杂性,我们创新性地构建了双视角协同分析框架:正面视角专注于分析握距对称性、身体稳定性和左右平衡,确保动作的规范与基础架构;侧面视角则着重评估动作的完整性、躯干角度与发力模式,判断动作幅度与效率。通过多维度量化指标,系统能够自动分解动作周期、识别违规代偿,并生成直观的可视化报告与改进建议。最终,本项目致力于打造一个低成本、高精度的自动化评估工具,为个人训练者、体育教育及专业机构提供一种数据驱动的科学训练辅助解决方案。、
三.项目分工:
蔡骏:负责用户界面前端所需前端功能的构建。
赵雅萱:负责管理员系统构建。
薛雨晨:实现功能部署到服务器的使用,以及前后端接口的书写修订。
海米沙:墨刀进行原型设计,实时记录市场调研结果并汇报分析需求,项目logo及产品名称设计,进行软件测试。
谢文杰:负责正面评分标准制定,搭建知识库。
赖翊煊:负责侧面评分标准制定,API接口接入AI
叶骋恺:负责数据库方面创建与设计
尚子琪:负责进行爬虫爬取对应相关视频,进行软件测
四.个人贡献
用户界面前端所需前端功能的构建
4.1用户登录界面
4.1.1. LoginForm.vue - 登录表单组件
用户登录的核心表单,主要功能:
用户名和密码输入(带验证:用户名至少3位,密码至少6位)
表单验证和错误提示
调用后端登录 API (authAPI.login)
登录成功后保存 token 和用户角色到 localStorage
提供切换到注册页面的链接
加载状态显示
点击查看代码
<template><div class="form-card"><h2 class="form-title"><i class="fa fa-leaf form-icon"></i>运动数据管理</h2><h5 class="text-center text-muted mb-4">欢迎登录 {{ title }}</h5><div v-if="errorMessage" class="alert alert-danger" role="alert">{{ errorMessage }}</div><form @submit.prevent="handleSubmit"><div class="mb-4 d-flex align-items-center"><label for="login-username" class="form-label me-3 mb-0" style="font-weight: 600; width: 80px;">用户名</label><div class="input-group flex-grow-1"><inputtype="text"data-lpignore="true"class="form-control"id="login-username"v-model="formData.username"placeholder="请输入用户名(至少3位)"required></div></div><div class="mb-4 d-flex align-items-center"><label for="login-password" class="form-label me-3 mb-0" style="font-weight: 600; width: 80px;">密码</label><div class="input-group"><inputtype="password"data-lpignore="true"class="form-control"id="login-password"v-model="formData.password"placeholder="请输入密码(至少6位)"required></div></div><!-- <div class="form-check mb-4"><input type="checkbox" class="form-check-input" id="remember-me" v-model="rememberMe"><label class="form-check-label" for="remember-me" >记住我</label></div> --><button type="submit" class="btn btn-sports w-100" :disabled="loading"><i class="fa fa-sign-in mr-2"></i>{{ loading ? '登录中...' : '登录' }}</button><div class="text-center mt-3"><span>还没有账号?</span><a href="javascript:;" class="link-sports" @click="$emit('switch-to-register')">立即注册</a></div></form></div>
</template><script setup lang="ts">
import { ref, reactive } from 'vue';
import type { LoginData } from '@/types/auth';
import { authAPI } from '@/utils/api';const title = import.meta.env.VITE_TITLE ;interface Emits {(e: 'switch-to-register'): void;(e: 'login-success'): void;
}const emit = defineEmits<Emits>();const formData = reactive<LoginData>({username: '',password: ''
});const loading = ref(false);
const errorMessage = ref('');const validateForm = (): boolean => {if (!formData.username.trim() || !formData.password.trim()) {errorMessage.value = '用户名和密码不能为空!';return false;}if (formData.username.length < 3) {errorMessage.value = '用户名至少3位!';return false;}if (formData.password.length < 6) {errorMessage.value = '密码至少6位!';return false;}return true;
};const handleSubmit = async () => {errorMessage.value = '';if (!validateForm()) {return;}loading.value = true;try {const response = await authAPI.login(formData);if (response.code === 200) {localStorage.setItem('token', response.data.token);localStorage.setItem('role', JSON.stringify(response.data.user.role));// alert('登录成功!即将进入系统');emit('login-success');} else {console.log(response)errorMessage.value = response.message || '登录失败!';}} catch (error) {console.error('登录错误:', error);errorMessage.value = error instanceof Error ? error.message : '服务器错误,请稍后重试';} finally {loading.value = false;}
};
</script><style scoped>
/* 保持原有的CSS样式 */
.alert-danger {margin-bottom: 1rem;
}.form-check-label {display: block;text-align: left;width: 100%;font-size: medium;margin-left: 8px;
}
</style>
4.1.2. RegisterForm.vue - 注册表单组件
新用户注册的表单,主要功能:
用户名、密码、确认密码输入
可选的身高和体重信息(用于运动数据管理)
完整的表单验证(密码一致性、身高体重合理范围等)
调用后端注册 API (authAPI.register)
注册成功后自动切换回登录页面
表单重置功能
点击查看代码
<template><div class="form-card"><h2 class="form-title"><i class="fa fa-user-plus form-icon"></i>创建账号</h2><h5 class="text-center text-muted mb-4">加入{{ title }} 运动社区</h5><div v-if="errorMessage" class="alert alert-danger" role="alert">{{ errorMessage }}</div><form @submit.prevent="handleSubmit"><div class="mb-4"><label for="register-username" class="form-label">用户名</label><div class="input-group"><span class="input-group-text"><i class="fa fa-user"></i></span><inputtype="text"class="form-control"id="register-username"v-model="formData.username"placeholder="请输入用户名(至少3位)"required></div></div><div class="mb-4"><label for="register-password" class="form-label">密码</label><div class="input-group"><span class="input-group-text"><i class="fa fa-lock"></i></span><inputtype="password"class="form-control"id="register-password"v-model="formData.password"placeholder="请输入密码(至少6位)"required></div></div><div class="mb-4"><label for="register-confirm-password" class="form-label">确认密码</label><div class="input-group"><span class="input-group-text"><i class="fa fa-lock"></i></span><inputtype="password"class="form-control"id="register-confirm-password"v-model="formData.confirmPassword"placeholder="请再次输入密码"required></div></div><div class="row"><div class="col-md-6 mb-4"><label for="register-height" class="form-label">身高 (cm)</label><div class="input-group"><span class="input-group-text"><i class="fa fa-arrows-v"></i></span><inputtype="number"class="form-control"id="register-height"v-model.number="formData.height"placeholder="例如:175"></div></div><div class="col-md-6 mb-4"><label for="register-weight" class="form-label">体重 (kg)</label><div class="input-group"><span class="input-group-text"><i class="fa fa-balance-scale"></i></span><inputtype="number"class="form-control"id="register-weight"v-model.number="formData.weight"placeholder="例如:70"></div></div></div><button type="submit" class="btn btn-sports w-100" :disabled="loading"><i class="fa fa-check mr-2"></i>{{ loading ? '注册中...' : '注册' }}</button><div class="text-center mt-3"><span>已有账号?</span><a href="javascript:;" class="link-sports" @click="$emit('switch-to-login')">返回登录</a></div></form></div>
</template><script setup lang="ts">
import { ref, reactive } from 'vue';
import type { RegisterData } from '@/types/auth';
import { authAPI } from '@/utils/api';const title=import.meta.env.VITE_TITLE ;interface Emits {(e: 'switch-to-login'): void;
}const emit = defineEmits<Emits>();const formData = reactive<RegisterData>({username: '',password: '',confirmPassword: '',height: null,weight: null
});const loading = ref(false);
const errorMessage = ref('');const validateForm = (): boolean => {if (!formData.username.trim() || !formData.password.trim() || !formData.confirmPassword.trim()) {errorMessage.value = '用户名、密码和确认密码不能为空!';return false;}if (formData.username.length < 3) {errorMessage.value = '用户名至少3位!';return false;}if (formData.password.length < 6) {errorMessage.value = '密码至少6位!';return false;}if (formData.password !== formData.confirmPassword) {errorMessage.value = '两次输入的密码不一致!';return false;}if (formData.height && (formData.height < 50 || formData.height > 250)) {errorMessage.value = '身高请输入合理范围(50-250cm)!';return false;}if (formData.weight && (formData.weight < 10 || formData.weight > 300)) {errorMessage.value = '体重请输入合理范围(10-300kg)!';return false;}return true;
};const handleSubmit = async () => {errorMessage.value = '';if (!validateForm()) {return;}loading.value = true;try {// 移除confirmPassword字段,因为后端不需要const { ...registerData } = formData;const response = await authAPI.register(registerData);if (response.code === 200) {alert('注册成功!请登录');emit('switch-to-login');// 重置表单Object.assign(formData, {username: '',password: '',confirmPassword: '',height: null,weight: null});} else {errorMessage.value = response.message || '注册失败!';}} catch (error) {console.error('注册错误:', error);errorMessage.value = error instanceof Error ? error.message : '服务器错误,请稍后重试';} finally {loading.value = false;}
};
</script><style scoped>
/* 保持原有的CSS样式 */
.alert-danger {margin-bottom: 1rem;
}
</style>
4.1.3. SplashScreen.vue - 启动闪屏组件
应用启动时的欢迎页面,主要功能:
显示应用标题和标语("运动数据管理系统")
背景图片展示(使用 login_bg_0.jpg)
进度条动画效果
可配置显示时长(默认 3 秒)
淡出过渡动画
响应式设计,适配不同屏幕尺寸
点击查看代码
<template><div class="splash-screen" :class="{ 'splash-hidden': !visible }"><div class="splash-image"></div><div class="splash-content"><div class="splash-logo">{{ title }}</div><div class="splash-tagline">运动数据管理系统</div><div class="splash-progress-container"><div class="splash-progress"><div class="progress-bar"></div></div></div></div></div>
</template><script setup lang="ts">
import { ref, onMounted } from 'vue';const title = import.meta.env.VITE_TITLE ;interface Props {duration?: number;
}const props = withDefaults(defineProps<Props>(), {duration: 3000
});const visible = ref(true);onMounted(() => {setTimeout(() => {visible.value = false;}, props.duration);
});
</script><style scoped>
/* 保持原有的CSS样式,这里省略以节省空间 */
.splash-screen {position: fixed;top: 0;left: 0;width: 100%;height: 100%;display: flex;flex-direction: column;align-items: center;justify-content: center;z-index: 9999;transition: opacity 0.8s ease-out;background-color: #f0f7ee;overflow: hidden;
}.splash-hidden {opacity: 0;pointer-events: none;
}.splash-image {position: absolute;top: 0;left: 0;width: 100%;height: 100%;background-image: url('@/assets/login_bg_0.jpg');background-size: cover;background-position: center;opacity: 0.9;filter: brightness(0.9);z-index: 1;
}.splash-content {position: relative;z-index: 2;text-align: center;width: 90%;
}.splash-logo {font-family: 'Bubblegum Sans', cursive;font-size: 8rem;color: #ffffff;text-shadow:3px 3px 0px #2e7d32,5px 5px 10px rgba(0,0,0,0.3);margin-bottom: 20px;transform: translateY(30px);opacity: 0;animation: logoRise 1.5s ease-out forwards;line-height: 1.2;
}.splash-tagline {color: #ffffff;font-size: 1.8rem;font-weight: 500;text-shadow: 1px 1px 3px rgba(0,0,0,0.5);margin-bottom: 30px;opacity: 0;animation: fadeIn 1s ease-out 0.5s forwards;
}.splash-progress-container {display: flex;justify-content: center;width: 100%;margin-top: 50px;
}.splash-progress {width: 300px;height: 4px;background: rgba(255,255,255,0.3);border-radius: 3px;overflow: hidden;box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}.progress-bar {height: 100%;background: linear-gradient(90deg, #ffffff, #e8f5e9);width: 0%;animation: progressFill 2s linear forwards;
}@keyframes logoRise {0% { transform: translateY(30px); opacity: 0; }100% { transform: translateY(0); opacity: 1; }
}@keyframes fadeIn {from { opacity: 0; }to { opacity: 1; }
}@keyframes progressFill {from { width: 0%; }to { width: 100%; }
}@media (max-width: 768px) {.splash-logo {font-size: 6rem;}
}@media (max-width: 576px) {.splash-logo {font-size: 4rem;}.splash-tagline {font-size: 1.5rem;}.splash-progress {width: 200px;}
}
</style>
4.2 用户功能界面
4.2.1 功能组件
4.2.1.1 ChangePasswordModal.vue - 修改密码弹窗
用户修改密码的模态框组件,功能包括:
新密码和确认密码输入(至少6位)
表单验证(密码一致性检查)
调用后端 API 修改密码
加载状态和错误提示
成功后自动关闭弹窗
点击查看代码
<template><div class="modal-overlay" @click.self="close"><div class="modal-content"><!-- 模态框头部 --><div class="modal-header"><h5 class="modal-title"><i class="fa fa-lock me-2 text-warning"></i>修改密码</h5><button type="button" class="btn-close" @click="close"></button></div><!-- 模态框主体 --><div class="modal-body"><form @submit.prevent="submitChangePassword"><div class="mb-3"><label for="newPassword" class="form-label">新密码</label><inputtype="password"class="form-control"id="newPassword"v-model="passwordForm.newPassword"placeholder="请输入新密码":class="{ 'is-invalid': errors.newPassword }"><div class="invalid-feedback" v-if="errors.newPassword">{{ errors.newPassword }}</div><div class="form-text">密码长度至少6位</div></div><div class="mb-3"><label for="confirmPassword" class="form-label">确认新密码</label><inputtype="password"class="form-control"id="confirmPassword"v-model="passwordForm.confirmPassword"placeholder="请再次输入新密码":class="{ 'is-invalid': errors.confirmPassword }"><div class="invalid-feedback" v-if="errors.confirmPassword">{{ errors.confirmPassword }}</div></div></form></div><!-- 模态框底部 --><div class="modal-footer"><buttontype="button"class="btn btn-secondary"@click="close":disabled="loading">取消</button><buttontype="button"class="btn btn-primary"@click="submitChangePassword":disabled="loading"><span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>{{ loading ? '修改中...' : '确认修改' }}</button></div></div></div>
</template><script setup lang="ts">
import { ref, reactive } from 'vue'
import { authAPI } from '@/utils/api'
import { showSuccessMessage} from '@/utils/log'
import { AxiosError } from 'axios'interface PasswordForm {currentPassword: stringnewPassword: stringconfirmPassword: string
}interface Errors {currentPassword?: stringnewPassword?: stringconfirmPassword?: string
}const emit = defineEmits<{close: []success: []
}>()const loading = ref(false)
const passwordForm = reactive<PasswordForm>({currentPassword: '',newPassword: '',confirmPassword: ''
})const errors = reactive<Errors>({})// 关闭模态框
const close = () => {if (!loading.value) {emit('close')}
}// 验证表单
const validateForm = (): boolean => {// 清空之前的错误信息Object.keys(errors).forEach(key => {delete errors[key as keyof Errors]})let isValid = true// 验证新密码if (!passwordForm.newPassword.trim()) {errors.newPassword = '请输入新密码'isValid = false} else if (passwordForm.newPassword.length < 6) {errors.newPassword = '密码长度至少6位'isValid = false}// 验证确认密码if (!passwordForm.confirmPassword.trim()) {errors.confirmPassword = '请确认新密码'isValid = false} else if (passwordForm.newPassword !== passwordForm.confirmPassword) {errors.confirmPassword = '两次输入的密码不一致'isValid = false}return isValid
}// 提交修改密码
const submitChangePassword = async () => {if (!validateForm()) {return}loading.value = truetry {// 调用修改密码APIawait authAPI.changePassword(localStorage.getItem('token') || '',passwordForm.newPassword)// 显示成功消息showSuccessMessage('密码修改成功!', 1500)// 重置表单resetForm()// 触发成功事件emit('success')// 关闭模态框emit('close')} catch (error: unknown) {console.error('修改密码失败:', error)// 根据错误信息显示相应的提示if (error instanceof AxiosError && error.response?.data?.message) {showSuccessMessage(error.response.data.message, 1500)} else {showSuccessMessage('修改密码失败,请稍后重试', 1500)}} finally {loading.value = false}
}// 重置表单
const resetForm = () => {passwordForm.currentPassword = ''passwordForm.newPassword = ''passwordForm.confirmPassword = ''Object.keys(errors).forEach(key => {delete errors[key as keyof Errors]})
}
</script><style scoped>
.modal-overlay {position: fixed;top: 0;left: 0;width: 100%;height: 100%;background-color: rgba(0, 0, 0, 0.5);display: flex;align-items: center;justify-content: center;z-index: 1050;padding: 1rem;
}.modal-content {background: white;border-radius: 12px;box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);width: 100%;max-width: 500px;animation: modal-appear 0.3s ease-out;
}.modal-header {padding: 1.25rem 1.5rem;border-bottom: 1px solid #e9ecef;display: flex;align-items: center;justify-content: space-between;
}.modal-title {margin: 0;font-weight: 600;color: #2c3e50;
}.btn-close {border: none;background: none;font-size: 1.25rem;opacity: 0.7;cursor: pointer;transition: opacity 0.3s ease;
}.btn-close:hover {opacity: 1;
}.modal-body {padding: 1.5rem;
}.modal-footer {padding: 1rem 1.5rem;border-top: 1px solid #e9ecef;display: flex;gap: 0.75rem;justify-content: flex-end;
}.form-text {font-size: 0.875rem;color: #6c757d;margin-top: 0.25rem;
}@keyframes modal-appear {from {opacity: 0;transform: translateY(-20px) scale(0.95);}to {opacity: 1;transform: translateY(0) scale(1);}
}/* 响应式设计 */
@media (max-width: 576px) {.modal-overlay {padding: 0.5rem;}.modal-header,.modal-body,.modal-footer {padding: 1rem;}
}
</style>
4.2.2 布局组件
4.2.2.1. HeaderComponent.vue - 页面头部
显示欢迎信息和当前日期:
显示用户名欢迎语
显示北京时间的当前日期
简洁的标题栏设计
点击查看代码
<template><div class="row mb-2"><div class="col-12"><h2 class="text-success fw-bold">欢迎回来,{{ userName }}!</h2><p class="text-muted">{{ currentDate }}</p></div></div>
</template><script setup lang="ts">
import { computed } from 'vue';defineProps<{userName?: string;
}>();const getBeijingDate = () => {const now = new Date()// 调整为北京时间 (UTC+8)const beijingTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)return beijingTime.toISOString().split('T')[0]
}
const currentDate = computed(() => {return getBeijingDate();
});
</script>
4.2.2.2. NavBar.vue - 导航栏
顶部导航栏组件,功能包括:
Logo 显示
导航菜单(可配置多个页面)
用户头像和下拉菜单
个人资料、反馈、退出登录等操作
滚动时样式变化
点击外部自动关闭下拉菜单
点击查看代码
<!-- components/Navbar.vue -->
<template><nav class="navbar" :class="{ 'navbar-scrolled': isScrolled }"><div class="navbar-container"><!-- Logo区域 --><div class="navbar-brand"><img src="/logo.png" alt="Logo" class="logo-image" /><!-- <div class="logo-placeholder">{{ title }}</div> --></div><!-- 导航菜单 --><div class="navbar-menu"><ul class="navbar-nav"><li v-for="item in navItems" :key="item.id" class="nav-item"><ahref="#"class="nav-link":class="{ active: currentPage === item.page }"@click.prevent="changePage(item.page)">{{ item.name }}</a></li></ul></div><!-- 右侧功能区 --><div class="navbar-actions"><!-- 用户信息 --><div class="user-menu"><button class="user-toggle" @click="toggleUserMenu"><div class="user-avatar-placeholder">{{ user.name?.charAt(0) || 'U' }}</div><span class="user-name">{{ user.name || '用户' }}</span></button><div class="user-dropdown" :class="{ active: isUserMenuOpen }"><div class="user-info"><div class="user-avatar-placeholder large">{{ user.name?.charAt(0) || 'U' }}</div><div class="user-details"><h4>{{ user.name || '用户' }}</h4></div><div class="menu_button"><button @click="handleMenuClick('profile')">个人资料</button><button @click="handleMenuClick('feedback')">反馈</button><button @click="handleLogout">退出登录</button></div></div></div></div></div></div></nav>
</template><script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import './styles/NavBar.css'
import type { NavItem } from '@/types/nav'// Props
defineProps({brandName: {type: String,default: '我的应用',},navItems: {type: Array<NavItem>,default: () => [],},user: {type: Object,default: () => ({}),},currentPage: {type: String,default: 'home',},
})// Emits
const emit = defineEmits(['page-change', 'menu-click', 'logout'])// 响应式数据
const isScrolled = ref(false)
const isMenuOpen = ref(false)
const isUserMenuOpen = ref(false)// 方法const changePage = (page: string) => {emit('page-change', page)// console.log('切换到页面:', props.currentPage)closeMobileMenu()
}const closeMobileMenu = () => {isMenuOpen.value = false
}const toggleUserMenu = () => {isUserMenuOpen.value = !isUserMenuOpen.value
}const handleMenuClick = (action: string) => {emit('menu-click', action)isUserMenuOpen.value = false
}const handleLogout = () => {emit('logout')isUserMenuOpen.value = false
}// 滚动监听
const handleScroll = () => {isScrolled.value = window.scrollY > 10
}// 点击外部关闭菜单
const handleClickOutside = (event: MouseEvent) => {if (!(event.target as HTMLElement).closest('.user-menu')) {isUserMenuOpen.value = false;}
}// 生命周期
onMounted(() => {window.addEventListener('scroll', handleScroll)document.addEventListener('click', handleClickOutside)
})onUnmounted(() => {window.removeEventListener('scroll', handleScroll)document.removeEventListener('click', handleClickOutside)
})
</script><style scoped>
/* 样式与之前相同,保持简洁 */</style>
4.2.2.3. HomeComponent.vue - 首页主界面
核心功能页面,包含:
形体评估助手:上传正面/侧面视频进行 AI 分析
流式聊天对话:与 AI 助手实时对话,获取评估建议
视频上传:支持拖拽上传,进度显示
教学视频展示:精选教学视频网格展示和预览
Markdown 渲染:支持富文本格式的 AI 回复
使用 keep-alive 缓存组件状态
点击查看代码
<template><div class="home-container"><Header :userName="username" /><main class="main-content"><!-- 形体评估测试模块 --><section class="evaluation-section"><!-- <h2 class="section-title">形体评估测试</h2> --><!-- 初始对话框(禁用状态) --><div class="initial-dialog" :class="{ disabled: !chatEnabled }"><div class="dialog-header"><h3>形体评估助手</h3><span class="status-indicator" :class="{ active: chatEnabled }">{{ chatEnabled ? '评估' : '离线' }}</span></div><div class="dialog-content" ref="messageList" @scroll="handleMessageListScroll"><div class="message-list"><div class="message bot-message" v-if="!chatEnabled"><div class="avatar">🤖</div><div class="bubble"><p>请先上传视频进行评估</p></div></div><divv-for="(message, index) in chatMessages":key="index"class="message":class="message.type"><div class="avatar">{{ message.type === 'user-message' ? '👤' : '🤖' }}</div><div class="bubble"><div class="markdown-content" v-html="renderMarkdown(message.content)"></div><!-- <p>{{ message.content }}</p> --><span class="timestamp">{{ message.timestamp }}</span></div></div><div class="typing-indicator" v-if="isTyping"><div class="avatar">🤖</div><div class="bubble"><div class="typing-dots"><span></span><span></span><span></span></div></div></div></div></div><div class="dialog-input"><inputtype="text"v-model="userInput"placeholder="输入您的问题...":disabled="!chatEnabled || isWaitingResponse"@keyup.enter="sendMessage"/><buttonclass="send-btn"@click="sendMessage":disabled="!chatEnabled || isWaitingResponse || !userInput.trim()">{{ isWaitingResponse ? '发送中...' : '发送' }}</button></div></div><!-- 上传按钮 --><div class="upload-button-container"><button class="btn-primary upload-btn" @click="showUploadModal" :disabled="isWaitingResponse">{{ hasUploadedVideos ? '重新上传视频' : '开始评估' }}</button><div class="upload-status" v-if="hasUploadedVideos"><span class="status-text">已上传 {{ uploadedVideosCount }}/2 个视频</span><button class="clear-btn" @click="clearUploadedVideos" :disabled="isWaitingResponse">清除</button></div></div></section><section class="video-section"><h2 class="section-title">精选教学视频</h2><div class="video-grid"><divv-for="video in teachingVideos":key="video.id"class="video-card"@click="previewVideo(video)"><div class="video-thumbnail"><img :src="API_BASE_URL+video.thumbnail" :alt="video.title" /><div class="play-overlay"><i class="play-icon">▶</i></div></div><div class="video-info"><h3 class="video-title">{{ video.title }}</h3><p class="video-duration">{{ video.duration }}</p></div></div></div></section></main><!-- 视频上传模态框 --><div class="modal-overlay" v-if="showUploadModalFlag" @click="closeUploadModal"><div class="modal-content upload-modal" @click.stop><div class="modal-header"><h3>上传评估视频</h3><button class="close-btn" @click="closeUploadModal">×</button></div><div class="modal-body"><p class="upload-instruction">请上传正面和侧面两个角度的视频以获得准确评估</p><div class="video-upload-grid"><div class="video-upload-item"><h4>正面视频</h4><divclass="upload-area":class="{ 'has-file': uploadedVideos.front }"@click="triggerFileInput('front')"@drop="handleDrop($event, 'front')"@dragover.prevent><div class="upload-icon"><i class="icon" v-if="!uploadedVideos.front">📹</i><i class="icon" v-else>✅</i></div><p class="upload-text" v-if="!uploadedVideos.front">点击或拖拽正面视频</p><p class="upload-text" v-else>{{ uploadedVideos.front.name }}</p><p class="upload-hint">支持MP4、MOV、AVI格式</p></div><inputtype="file"ref="frontFileInput"@change="handleFileSelect($event, 'front')"accept="video/*"class="file-input"/></div><div class="video-upload-item"><h4>侧面视频</h4><divclass="upload-area":class="{ 'has-file': uploadedVideos.side }"@click="triggerFileInput('side')"@drop="handleDrop($event, 'side')"@dragover.prevent><div class="upload-icon"><i class="icon" v-if="!uploadedVideos.side">📹</i><i class="icon" v-else>✅</i></div><p class="upload-text" v-if="!uploadedVideos.side">点击或拖拽侧面视频</p><p class="upload-text" v-else>{{ uploadedVideos.side.name }}</p><p class="upload-hint">支持MP4、MOV、AVI格式</p></div><inputtype="file"ref="sideFileInput"@change="handleFileSelect($event, 'side')"accept="video/*"class="file-input"/></div></div><div class="upload-progress" v-if="uploading"><div class="progress-bar"><div class="progress-fill" :style="{ width: uploadProgress + '%' }"></div></div><p class="progress-text">上传中... {{ uploadStatus }}%</p></div></div><div class="modal-footer"><button class="btn-secondary" @click="closeUploadModal">取消</button><button class="btn-primary" @click="submitVideos" :disabled="!canSubmit || uploading">{{ uploading ? '上传中...' : '开始评估' }}</button></div></div></div><!-- 视频预览模态框 --><div class="modal-overlay" v-if="previewVideoData" @click="closePreview"><div class="modal-content" @click.stop><div class="modal-header"><h3>{{ previewVideoData.title }}</h3><button class="close-btn" @click="closePreview">×</button></div><div class="video-player"><video :src="previewVideoData.url" controls ref="videoPlayer" :key="videoKey"></video></div><div class="modal-footer"><button class="btn-primary" @click="toggleFullscreen">全屏观看</button></div></div></div></div>
</template>
<script lang="ts">
export default {name: 'Home' // 必须与 keep-alive include 中的字符串一致
}
</script><script setup lang="ts">
import './styles/Home.css'import { ref, reactive, onMounted, computed, nextTick } from 'vue'
import type{ TeachingVideo } from '@/types/video'
import { videoApi } from '@/utils/video'
import Header from './HeaderComponent.vue'
import axios from 'axios' // 导入 axios
import type { AxiosProgressEvent } from 'axios'
import type { ComponentPropsMap } from '@/types/main'
import MarkdownIt from 'markdown-it'const videoKey = ref(0)
const props = withDefaults(defineProps<ComponentPropsMap['home']>(),{options: ()=>({html: true,linkify: true,typographer: true,breaks: true,}),username: 'test'
})const md = new MarkdownIt(props.options)const renderMarkdown = (content: string) => {return md.render(content || '')
}// 文件上传相关
const frontFileInput = ref<HTMLInputElement | null>(null)
const sideFileInput = ref<HTMLInputElement | null>(null)
const showUploadModalFlag = ref(false)
const uploading = ref(false)
const uploadProgress = ref(0)
const uploadStatus = ref('')
const uploadedVideos = reactive({front: null as File | null,side: null as File | null,
})// 评估结果相关
const evaluationResult = ref<EvaluationResult | null>(null)
const chatEnabled = ref(false)const isReupload = ref(false)// 聊天相关
const chatMessages = ref<ChatMessage[]>([])
const userInput = ref('')
const isWaitingResponse = ref(false)
const isTyping = ref(false)// 教学视频相关
const teachingVideos = ref<TeachingVideo[]>([])
const previewVideoData = ref<TeachingVideo | null>(null)
const videoPlayer = ref<HTMLVideoElement | null>(null)// 类型定义
interface EvaluationResult {message: string
}interface ChatMessage {type: 'user-message' | 'bot-message'content: stringtimestamp: string
}// 计算属性
const hasUploadedVideos = computed(() => {return uploadedVideos.front !== null || uploadedVideos.side !== null
})const uploadedVideosCount = computed(() => {let count = 0if (uploadedVideos.front) count++if (uploadedVideos.side) count++return count
})const canSubmit = computed(() => {return uploadedVideos.front !== null && uploadedVideos.side !== null
})// // 处理上传按钮点击
// const handleUploadClick = () => {
// if (evaluationResult.value) {
// isReupload.value = true
// resetContext()
// }
// showUploadModalFlag.value = true
// }// 重置上下文
const resetContext = () => {chatEnabled.value = falsechatMessages.value = []userInput.value = ''isWaitingResponse.value = falseisTyping.value = falseresult.value = ''flag.value = true
}// 显示上传模态框
const showUploadModal = () => {showUploadModalFlag.value = true
}// 关闭上传模态框
const closeUploadModal = () => {showUploadModalFlag.value = falseif (isReupload.value && evaluationResult.value) {chatEnabled.value = true}isReupload.value = false
}
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
// 清除已上传的视频
const clearUploadedVideos = async() => {uploadedVideos.front = nulluploadedVideos.side = nullevaluationResult.value = nullchatEnabled.value = falsechatMessages.value = []isReupload.value = falseresetContext()await axios.get(`${API_BASE_URL}/api/clear`, {headers: {Authorization: `Bearer ${localStorage.getItem('token') || ''}`,},})}// 触发文件选择
const triggerFileInput = (type: 'front' | 'side') => {if (type === 'front' && frontFileInput.value) {frontFileInput.value.click()} else if (type === 'side' && sideFileInput.value) {sideFileInput.value.click()}
}// 处理文件选择
const handleFileSelect = (event: Event, type: 'front' | 'side') => {const target = event.target as HTMLInputElementif (target.files && target.files.length > 0) {const file = target.files[0]if (file) {// 验证文件类型和大小if (!file.type.startsWith('video/')) {alert('请上传视频文件')return}if (file.size > 100 * 1024 * 1024) {// 100MBalert('文件大小不能超过100MB')return}uploadedVideos[type] = file}}
}// 处理拖放上传
const handleDrop = (event: DragEvent, type: 'front' | 'side') => {event.preventDefault()if (event.dataTransfer && event.dataTransfer.files.length > 0) {const file = event.dataTransfer.files[0]if (file && file.type.startsWith('video/')) {// 验证文件大小if (file.size > 100 * 1024 * 1024) {alert('文件大小不能超过100MB')return}uploadedVideos[type] = file}}
}const result=ref('')
const flag=ref(true)// 提交视频进行评估
const submitVideos = async () => {if (!canSubmit.value) returnif (evaluationResult.value) {isReupload.value = trueresetContext()}showUploadModalFlag.value = true// 如果是重新上传,先清除之前的评估结果if (isReupload.value) {evaluationResult.value = nullresetContext()await axios.get(`${API_BASE_URL}/api/clear`, {headers: {Authorization: `Bearer ${localStorage.getItem('token') || ''}`,},})}uploading.value = trueuploadProgress.value = 0uploadStatus.value = '准备上传...'try {// 创建 FormDataconst formData = new FormData()if (uploadedVideos.front) {formData.append('front_video', uploadedVideos.front)}if (uploadedVideos.side) {formData.append('side_video', uploadedVideos.side)}const token = localStorage.getItem('token') || ''// 发送到 Flask 服务器const response = await axios.post(`${API_BASE_URL}/api/upload`, formData, {headers: {'Content-Type': 'multipart/form-data','Authorization': `Bearer ${token}`,},onUploadProgress: (progressEvent: AxiosProgressEvent) => {if (progressEvent.total) {const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)uploadProgress.value = progressuploadStatus.value = `上传中... ${progress}%`}},})// 处理服务器响应if (response.data.success) {uploadStatus.value = '分析视频中...'uploadProgress.value = 100setTimeout(()=>{},500)// 等待分析完成await waitForAnalysis(response.data.task_id)} else {throw new Error(response.data.message || '上传失败')}} catch (error: unknown) {console.error('上传失败:', error)uploadStatus.value = '上传失败,请重试'if (error instanceof Error){addBotMessage('视频上传失败:' + (error.message || '请检查网络连接后重试'),)}else{addBotMessage('视频上传失败:' + ( '请检查网络连接后重试'),)}// 重置上传状态setTimeout(() => {uploading.value = falseuploadProgress.value = 0uploadStatus.value = ''}, 3000)}
}// 等待分析完成
const waitForAnalysis = async (taskId: string) => {try {uploadStatus.value = '分析视频中...'// 轮询获取分析结果const checkResult = async (): Promise<void> => {try {const response = await axios.get(`${API_BASE_URL}/api/evaluate/result/${taskId}`,{headers: {Authorization: `Bearer ${localStorage.getItem('token') || ''}`,},})if (response.data.status === 'completed') {// 分析完成,获取结果evaluationResult.value = response.data.resultchatEnabled.value = true// 重置上传状态uploading.value = falseuploadProgress.value = 0uploadStatus.value = ''showUploadModalFlag.value = falseisReupload.value = falsesendMessage()} else if (response.data.status === 'processing') {// 仍在处理中,继续等待setTimeout(checkResult, 2000)} else {// 处理失败throw new Error(response.data.message || '分析失败')}} catch (error) {console.error('轮询错误:', error)throw error}}// 开始轮询await checkResult()} catch (error: unknown) {console.error('分析失败:', error)uploadStatus.value = '分析失败,请重试'if (error instanceof Error){addBotMessage('视频分析失败:' + (error.message || '请稍后重试'),)}else{addBotMessage('视频分析失败:' + ('请稍后重试'),)}// 重置上传状态setTimeout(() => {uploading.value = falseuploadProgress.value = 0uploadStatus.value = ''}, 3000)}
}// 添加机器人消息
const addBotMessage = (content: string) => {chatMessages.value.push({type: 'bot-message',content,timestamp: new Date().toLocaleTimeString(),})
}// 添加用户消息
const addUserMessage = (content: string) => {chatMessages.value.push({type: 'user-message',content,timestamp: new Date().toLocaleTimeString(),})
}const messageList = ref<HTMLElement | null>(null)let userHasScrolled = false
let lastScrollTop = 0
const scrollToBottom = () => {nextTick(() => {// 否则滚动到消息列表底部if (messageList.value) {messageList.value.scrollTo({top: messageList.value.scrollHeight,behavior: 'smooth',})}})
}
const isNearBottom = (threshold = 100) => {if (!messageList.value) return trueconst { scrollTop, scrollHeight, clientHeight } = messageList.valuereturn scrollHeight - scrollTop - clientHeight <= threshold
}// 智能滚动函数
const smartScrollToBottom = () => {setTimeout(() => {if (!userHasScrolled || isNearBottom()) {scrollToBottom()}}, 1000)
}// 监听用户滚动行为
const handleMessageListScroll = () => {if (messageList.value) {const { scrollTop, scrollHeight, clientHeight } = messageList.value// 如果用户向上滚动,标记为手动滚动if (scrollTop < lastScrollTop) {userHasScrolled = true}// 如果用户滚动到底部附近,重置手动滚动标记if (scrollHeight - scrollTop - clientHeight < 50) {userHasScrolled = false}lastScrollTop = scrollTop}
}// 重置滚动状态(当新消息发送时)
const resetScrollState = () => {userHasScrolled = false
}// chatEnabled.value = true
// 发送消息 - 流式响应版本const save_history= async() => {setTimeout(async() => {try {const token = localStorage.getItem('token') || ''const response = await fetch(`${API_BASE_URL}/api/save`, {method: 'POST',headers: {'Content-Type': 'application/json','Authorization': `Bearer ${token}`,},body: JSON.stringify({message: result.value,}),})if (!response.ok) {throw new Error('网络响应不正常')}await response.json()} catch (error) {console.error('保存历史记录失败:', error)}}, 1000)}const sendMessage = async () => {if ( isWaitingResponse.value || !chatEnabled.value|| !evaluationResult.value) returnlet message = ''if (userInput.value.trim()){message = userInput.value.trim()userInput.value = ''resetScrollState()addUserMessage(message)}else if (evaluationResult.value) {message = evaluationResult.value.message}isWaitingResponse.value = trueisTyping.value = truetry {// 发送消息到服务器const token = localStorage.getItem('token') || ''const response = await fetch(`${API_BASE_URL}/api/chat`, {method: 'POST',headers: {'Content-Type': 'application/json','Authorization': `Bearer ${token}`,},body: JSON.stringify({message: message,// 可以添加评估结果作为上下文}),})if (!response.ok) {throw new Error('网络响应不正常')}const reader = response.body?.getReader()if (!reader) {throw new Error('无法读取响应流')}// 添加初始机器人消息const botMessageIndex = chatMessages.value.length// addBotMessage('')addBotMessage('')// 读取流式数据while (true) {const { done, value } = await reader.read()if (done) break// 将接收到的数据添加到消息中const text = new TextDecoder().decode(value)const lines = text.split('\n').filter((line) => line.trim() !== '')for (const line of lines) {let datatry {data = JSON.parse(line.substring(6).trim()) // 去掉 "data:" 前缀} catch (e) {console.error('解析数据失败:', e)console.log('原始数据:', text)continue}if (flag.value){result.value += data.content}if (chatMessages.value[botMessageIndex]) {chatMessages.value[botMessageIndex].content += data.contentisTyping.value = false} else {// 如果消息不存在,创建新的消息addBotMessage(data.content)}smartScrollToBottom()}}save_history()} catch (error: unknown) {console.error('发送消息失败:', error)addBotMessage('抱歉,我暂时无法回复,请稍后重试。')} finally {isWaitingResponse.value = falseisTyping.value = falseflag.value=false}
}// 预览视频
const previewVideo = (video: TeachingVideo) => {previewVideoData.value = {...video,url:API_BASE_URL+video.url}videoKey.value+=1
}// 关闭预览
const closePreview = () => {previewVideoData.value = null
}// 切换全屏
const toggleFullscreen = () => {if (videoPlayer.value) {if (videoPlayer.value.requestFullscreen) {videoPlayer.value.requestFullscreen()}}
}// 获取教学视频列表
const fetchTeachingVideos = async () => {try {const token=localStorage.getItem('token')||''const data=await videoApi.fetch_all_videos(token)const mockVideos: TeachingVideo[]=data.datateachingVideos.value = mockVideos} catch (error) {console.error('获取教学视频失败:', error)}
}// 组件挂载后获取教学视频
onMounted(() => {fetchTeachingVideos()})
</script><style scoped></style>
4.2.2.4. TrainComponent.vue - 训练管理页面
训练计划和数据统计页面,功能包括:
折线图统计:引体向上练习次数趋势图(可切换周次)
训练计划创建:设置日期、项目、目标数量、备注
计划统计卡片:总计划数、完成数、完成率
月度统计:支持本周/本月/近三月/半年/全部时间筛选
本周计划列表:显示当前周的训练计划
计划编辑:修改目标、填写实际完成数量、标记完成状态
计划删除:删除不需要的训练计划
帮助说明弹窗
点击查看代码
<template><div class="train-wrapper"><!-- 左侧:训练数据统计 --><div class="train-stats"><div class="stats-header"><h2 class="stats-title">训练数据统计</h2><button class="help-btn" @click="showHelpModal = true">?</button></div><!-- 图表区域 --><div class="chart-section"><div class="chart-header"><div class="chart-tabs"><buttonclass="tab-btn":class="{ active: activeTab === '练习次数趋势' }"@click="activeTab = '练习次数趋势'">引体向上练习次数趋势</button></div><div class="week-nav"><button class="week-btn" @click="prevWeek">← 上周</button><span class="week-label">{{ currentWeekLabel }}</span><button class="week-btn" @click="nextWeek" :disabled="isCurrentWeek">下周 →</button></div></div><div class="chart-container"><div v-if="weekData.length === 0" class="chart-placeholder"><div class="chart-icon">📊</div><p class="chart-text">本周暂无训练数据</p></div><div v-else class="line-chart"><div class="chart-y-axis"><span v-for="tick in yAxisTicks" :key="tick" class="y-tick">{{ tick }}</span></div><div class="chart-content"><svg class="chart-svg" :viewBox="`0 0 ${chartWidth} ${chartHeight}`"><!-- 网格线 --><linev-for="tick in yAxisTicks":key="`grid-${tick}`":x1="0":y1="getYPosition(tick)":x2="chartWidth":y2="getYPosition(tick)"class="grid-line"/><!-- 折线 --><polyline :points="linePoints" class="chart-line" /><!-- 数据点 --><circlev-for="(point, index) in weekData":key="index":cx="getXPosition(index)":cy="getYPosition(point.count)"r="4"class="chart-point"/><!-- 数据标签 --><textv-for="(point, index) in weekData":key="`label-${index}`":x="getXPosition(index)":y="getYPosition(point.count) - 10"class="chart-label">{{ point.count }}</text></svg><!-- X轴标签 --><div class="chart-x-axis"><span v-for="(point, index) in weekData" :key="index" class="x-label">{{ point.label }}</span></div></div></div></div></div><!-- 日期计划设置 --><div class="plan-setting-section"><h3 class="section-title">设置训练计划</h3><div class="plan-form"><div class="form-row"><label class="form-label">选择日期</label><input type="date" v-model="planForm.date" class="form-input" /></div><div class="form-row"><label class="form-label">训练项目</label><inputtype="text"v-model="planForm.project"placeholder="例如:引体向上、俯卧撑等"class="form-input"/></div><div class="form-row"><label class="form-label">目标数量</label><inputtype="number"v-model="planForm.target"placeholder="例如:10"class="form-input"/></div><div class="form-row"><label class="form-label">备注说明</label><textareav-model="planForm.note"placeholder="可选,添加训练说明或注意事项"class="form-textarea"rows="3"></textarea></div><div class="form-actions"><button class="btn-cancel" @click="resetForm">重置</button><button class="btn-submit" @click="submitPlan">创建计划</button></div></div></div></div><!-- 右侧:训练计划统计 --><div class="plan-stats"><h2 class="plan-title">训练计划统计</h2><!-- 统计卡片 --><div class="stats-cards"><div class="stat-card"><div class="card-label">训练计划数量</div><div class="card-value">{{ totalPlans }}</div></div><div class="stat-card"><div class="card-label">已完成计划数量</div><div class="card-value">{{ completedPlans }}</div></div><div class="stat-card"><div class="card-label">计划完成率</div><div class="card-value">{{ completionRate }}%</div></div></div><!-- 月度训练计划统计 --><div class="monthly-stats"><h3 class="section-title">月度训练计划统计</h3><div class="month-tabs"><buttonclass="month-tab":class="{ active: timeRange === 'week' }"@click="timeRange = 'week'">本周</button><buttonclass="month-tab":class="{ active: timeRange === 'month' }"@click="timeRange = 'month'">本月</button><buttonclass="month-tab":class="{ active: timeRange === 'threeMonths' }"@click="timeRange = 'threeMonths'">近三月</button><buttonclass="month-tab":class="{ active: timeRange === 'halfYear' }"@click="timeRange = 'halfYear'">半年内</button><buttonclass="month-tab":class="{ active: timeRange === 'all' }"@click="timeRange = 'all'">全部时间</button></div><div v-if="filteredPlans.length === 0" class="monthly-empty"><div class="empty-icon">📅</div><p class="empty-text">暂无月度数据</p></div><div v-else class="monthly-data"><div class="data-summary"><div class="summary-item"><span class="summary-label">计划总数</span><span class="summary-value">{{ filteredPlans.length }}</span></div><div class="summary-item"><span class="summary-label">已完成</span><span class="summary-value completed">{{ filteredCompletedCount }}</span></div><div class="summary-item"><span class="summary-label">未完成</span><span class="summary-value pending">{{filteredPlans.length - filteredCompletedCount}}</span></div></div><div class="data-list"><div v-for="(plan, index) in filteredPlans" :key="index" class="data-item"><span class="item-date">{{ plan.date }}</span><span class="item-project">{{ plan.project }}</span><span class="item-status" :class="{ completed: plan.completed }">{{ plan.completed ? '✓' : '○' }}</span></div></div></div></div><!-- 本周训练计划 --><div class="weekly-plan"><h3 class="section-title">本周训练计划</h3><div v-if="weeklyPlans.length === 0" class="weekly-empty"><div class="empty-icon">📝</div><p class="empty-text">本周暂无训练计划</p></div><div v-else class="weekly-list"><divv-for="(plan, index) in weeklyPlans":key="index"class="plan-item"@click="openEditModal(plan)"><div class="plan-date">{{ formatDate(plan.date) }}</div><div class="plan-content"><div class="plan-project">{{ plan.project }}</div><div class="plan-target">目标:{{ plan.target }}个<span v-if="plan.actualCount > 0" class="actual-count">/ 实际:{{ plan.actualCount }}个</span></div><div v-if="plan.note" class="plan-note">{{ plan.note }}</div></div><div class="plan-status" :class="getPlanStatusClass(plan)">{{ getPlanStatusText(plan) }}</div></div></div></div></div><!-- 帮助说明弹窗 --><div class="modal-overlay" v-if="showHelpModal" @click="showHelpModal = false"><div class="modal-content help-modal" @click.stop><button class="close-btn" @click="showHelpModal = false">✕</button><h2 class="modal-title">📊 折线图使用说明</h2><div class="help-content"><div class="help-section"><h3 class="help-subtitle">功能介绍</h3><p class="help-text">折线图展示您每周的引体向上训练目标数量趋势,帮助您直观了解训练计划的安排情况。</p></div><div class="help-section"><h3 class="help-subtitle">如何使用</h3><ul class="help-list"><li><strong>查看本周数据:</strong>图表默认显示本周(周日至周六)的训练计划</li><li><strong>切换周次:</strong>点击"上周"/"下周"按钮可以查看不同周的数据</li><li><strong>数据来源:</strong>图表数据来自您创建的训练计划中的目标数量</li><li><strong>折线含义:</strong>蓝色折线连接每天的目标数量,帮助您看出训练强度的变化</li></ul></div><div class="help-section"><h3 class="help-subtitle">图表说明</h3><ul class="help-list"><li><strong>横轴(X轴):</strong>显示一周七天(周日到周六)</li><li><strong>纵轴(Y轴):</strong>显示训练目标数量</li><li><strong>数据点:</strong>蓝色圆点表示当天的目标数量</li><li><strong>数字标签:</strong>数据点上方显示具体的目标个数</li></ul></div><div class="help-section"><h3 class="help-subtitle">温馨提示</h3><p class="help-text">💡 建议合理安排训练强度,循序渐进。如果某天没有训练计划,图表会显示为0。</p></div></div></div></div><!-- 编辑计划弹窗 --><div class="modal-overlay" v-if="showEditModal" @click="closeEditModal"><div class="modal-content" @click.stop><button class="close-btn" @click="closeEditModal">✕</button><h2 class="modal-title">编辑训练计划</h2><div class="modal-form"><div class="form-row"><label class="form-label">日期</label><input type="text" :value="editForm.date" class="form-input" disabled /></div><div class="form-row"><label class="form-label">训练项目</label><input type="text" :value="editForm.project" class="form-input" disabled /></div><div class="form-row"><label class="form-label">目标数量</label><input type="number" v-model="editForm.target" class="form-input" /></div><div class="form-row"><label class="form-label">实际完成数量</label><inputtype="number"v-model.number="editForm.actualCount"placeholder="填写实际完成的个数"class="form-input"min="0"/><span class="form-hint">填写后将自动标记为已完成</span></div><div class="form-row"><label class="form-label">完成状态</label><div class="checkbox-group"><label class="checkbox-label"><inputtype="checkbox"v-model="editForm.completed"class="checkbox-input":disabled="editForm.actualCount > 0"/><span>已完成</span></label></div></div><div class="form-row"><label class="form-label">备注说明</label><textarea v-model="editForm.note" class="form-textarea" rows="3"></textarea></div><div class="modal-actions"><button class="btn-cancel" @click="closeEditModal">取消</button><button class="btn-delete" @click="deletePlan">删除</button><button class="btn-submit" @click="savePlan">保存</button></div></div></div></div></div>
</template><script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'const activeTab = ref('练习次数趋势')
const timeRange = ref('week')
const currentWeekOffset = ref(0) // 0表示本周,-1表示上周,1表示下周// 图表配置
const chartWidth = 600
const chartHeight = 200
const chartPadding = 20// API配置
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000'// 获取token
const getToken = () => {return localStorage.getItem('token') || ''
}// ============ API 数据类型定义 ============export interface PlanItem {id?: numberdate: stringproject: stringtarget: stringnote: stringcompleted: booleanactualCount: number
}// 创建训练计划 - 请求参数
export interface CreatePlanRequest {date: stringproject: stringtarget: stringnote: string
}// 创建训练计划 - 响应
export interface CreatePlanResponse {code: numbermessage: stringdata: PlanItem
}// 获取训练计划列表 - 请求参数
export interface GetPlanListRequest {timeRange?: stringkeyword?: string
}// 获取训练计划列表 - 响应
export interface GetPlanListResponse {code: numbermessage: stringdata: {list: PlanItem[]total: number}
}// 更新训练计划 - 请求参数
export interface UpdatePlanRequest {target?: stringnote?: stringactualCount?: numbercompleted?: boolean
}// 更新训练计划 - 响应
export interface UpdatePlanResponse {code: numbermessage: stringdata: PlanItem
}// 删除训练计划 - 响应
export interface DeletePlanResponse {code: numbermessage: string
}// 获取训练日期 - 请求参数
export interface GetTrainedDatesRequest {year: numbermonth: number
}// 获取训练日期 - 响应
export interface GetTrainedDatesResponse {code: numbermessage: stringdata: string[]
}// 获取训练统计 - 请求参数
export interface GetStatisticsRequest {timeRange?: string
}// 获取训练统计 - 响应
export interface GetStatisticsResponse {code: numbermessage: stringdata: {totalPlans: numbercompletedPlans: numbercompletionRate: numberweeklyData: Array<{date: stringcount: number}>}
}// 训练计划列表
const plansList = ref<PlanItem[]>([])
const loading = ref(false)// ============ API 调用函数 ============// 日期格式化函数
const formatDateString = (dateStr: string): string => {const date = new Date(dateStr)const year = date.getFullYear()const month = String(date.getMonth() + 1).padStart(2, '0')const day = String(date.getDate()).padStart(2, '0')return `${year}-${month}-${day}`
}// 获取训练计划列表
const fetchPlanList = async () => {const token = getToken()if (!token) returnloading.value = truetry {const response = await fetch(`${API_BASE_URL}/api/training-plan/list`, {method: 'GET',headers: {Authorization: `Bearer ${token}`,'Content-Type': 'application/json',},})if (!response.ok) throw new Error('获取计划列表失败')const result: GetPlanListResponse = await response.json()if (result.code === 200) {// 格式化日期plansList.value = result.data.list.map((item) => ({...item,date: formatDateString(item.date),}))}} catch (error) {console.error('获取计划列表失败:', error)} finally {loading.value = false}
}// 计划表单
const planForm = reactive({date: '',project: '',target: '',note: '',
})// 帮助弹窗
const showHelpModal = ref(false)// 编辑弹窗
const showEditModal = ref(false)
const editingPlan = ref<PlanItem | null>(null)
const editForm = reactive({date: '',project: '',target: '',note: '',completed: false,actualCount: 0,
})// 计算统计数据
const totalPlans = computed(() => plansList.value.length)
const completedPlans = computed(() => plansList.value.filter((p) => p.completed).length)
const completionRate = computed(() => {if (totalPlans.value === 0) return '0.0'return ((completedPlans.value / totalPlans.value) * 100).toFixed(1)
})// 根据时间范围过滤计划
const filteredPlans = computed(() => {const now = new Date()now.setHours(0, 0, 0, 0)let startDate: Datelet endDate: Dateswitch (timeRange.value) {case 'week': {// 本周:从本周日到本周六startDate = new Date(now)startDate.setDate(now.getDate() - now.getDay())startDate.setHours(0, 0, 0, 0)endDate = new Date(startDate)endDate.setDate(startDate.getDate() + 7)endDate.setHours(0, 0, 0, 0)break}case 'month': {// 本月:从本月1号到下月1号startDate = new Date(now.getFullYear(), now.getMonth(), 1)startDate.setHours(0, 0, 0, 0)endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1)endDate.setHours(0, 0, 0, 0)break}case 'threeMonths': {// 近三月:从三个月前的1号到下月1号startDate = new Date(now.getFullYear(), now.getMonth() - 3, 1)startDate.setHours(0, 0, 0, 0)endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1)endDate.setHours(0, 0, 0, 0)break}case 'halfYear': {// 半年内:从六个月前的1号到下月1号startDate = new Date(now.getFullYear(), now.getMonth() - 6, 1)startDate.setHours(0, 0, 0, 0)endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1)endDate.setHours(0, 0, 0, 0)break}case 'all': {return plansList.value}default:startDate = new Date(0)endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1)}return plansList.value.filter((plan) => {const planDate = new Date(plan.date + 'T00:00:00') // 添加时间部分避免时区问题return planDate >= startDate && planDate < endDate})
})// 过滤范围内已完成的计划数
const filteredCompletedCount = computed(() => {return filteredPlans.value.filter((p) => p.completed).length
})// 获取本周计划(使用与图表相同的周计算逻辑)
const weeklyPlans = computed(() => {const { weekStart, weekEnd } = getCurrentWeekRange()// 只显示当前查看周的计划return plansList.value.filter((plan) => {const planDate = new Date(plan.date + 'T00:00:00') // 添加时间部分避免时区问题return planDate >= weekStart && planDate < weekEnd})
})// 获取当前显示周的起止日期
const getCurrentWeekRange = () => {const now = new Date()const weekStart = new Date(now)weekStart.setDate(now.getDate() - now.getDay() + currentWeekOffset.value * 7)weekStart.setHours(0, 0, 0, 0)const weekEnd = new Date(weekStart)weekEnd.setDate(weekStart.getDate() + 7)return { weekStart, weekEnd }
}// 当前周标签
const currentWeekLabel = computed(() => {const { weekStart, weekEnd } = getCurrentWeekRange()const startMonth = weekStart.getMonth() + 1const startDay = weekStart.getDate()const endMonth = weekEnd.getMonth() + 1const endDay = weekEnd.getDate() - 1if (currentWeekOffset.value === 0) {return `本周 (${startMonth}/${startDay} - ${endMonth}/${endDay})`}return `${startMonth}/${startDay} - ${endMonth}/${endDay}`
})// 是否是当前周
const isCurrentWeek = computed(() => currentWeekOffset.value >= 0)// 获取本周每天的数据
const weekData = computed(() => {const { weekStart } = getCurrentWeekRange()const data = []const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']for (let i = 0; i < 7; i++) {const date = new Date(weekStart)date.setDate(weekStart.getDate() + i)// 使用本地日期格式,避免时区问题const year = date.getFullYear()const month = String(date.getMonth() + 1).padStart(2, '0')const day = String(date.getDate()).padStart(2, '0')const dateStr = `${year}-${month}-${day}`// 查找该日期的引体向上计划const plan = plansList.value.find((p) => p.date === dateStr && p.project.includes('引体向上'))// 优先使用实际完成数量,如果没有则使用目标数量const actualCount = plan?.actualCount || 0const count = plan ? (actualCount > 0 ? actualCount : parseInt(plan.target) || 0) : 0data.push({label: weekDays[i],date: dateStr,count: count,})}return data
})// Y轴刻度
const yAxisTicks = computed(() => {const maxCount = Math.max(...weekData.value.map((d) => d.count), 10)const step = Math.ceil(maxCount / 5)const ticks = []for (let i = 0; i <= 5; i++) {ticks.push(step * i)}return ticks.reverse()
})// 获取Y坐标
const getYPosition = (value: number) => {const maxValue = yAxisTicks.value[0]??10const ratio = value / maxValuereturn chartHeight - ratio * (chartHeight - chartPadding * 2) - chartPadding
}// 获取X坐标
const getXPosition = (index: number) => {const step = chartWidth / 7return step * index + step / 2
}// 折线路径点
const linePoints = computed(() => {return weekData.value.map((point, index) => `${getXPosition(index)},${getYPosition(point.count)}`).join(' ')
})// 切换周
const prevWeek = () => {currentWeekOffset.value--
}const nextWeek = () => {if (!isCurrentWeek.value) {currentWeekOffset.value++}
}// 格式化日期
const formatDate = (dateStr: string) => {const date = new Date(dateStr)const month = date.getMonth() + 1const day = date.getDate()const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']const weekDay = weekDays[date.getDay()]return `${month}月${day}日 ${weekDay}`
}// 获取计划状态文本
const getPlanStatusText = (plan: PlanItem) => {const actualCount = plan.actualCount || 0if (actualCount === 0) {return '未完成'}const target = parseInt(plan.target) || 0if (target === 0) {return '已完成'}if (actualCount >= target) {return '已完成'}const percentage = Math.round((actualCount / target) * 100)return `完成${percentage}%`
}// 获取计划状态样式类
const getPlanStatusClass = (plan: PlanItem) => {const actualCount = plan.actualCount || 0if (actualCount === 0) {return 'pending'}const target = parseInt(plan.target) || 0if (target === 0 || actualCount >= target) {return 'completed'}return 'partial'
}// 重置表单
const resetForm = () => {planForm.date = ''planForm.project = ''planForm.target = ''planForm.note = ''
}// 提交计划
const submitPlan = async () => {if (!planForm.date || !planForm.project || !planForm.target) {alert('请填写必填项:日期、训练项目和目标数量')return}const token = getToken()if (!token) {alert('请先登录')return}loading.value = truetry {const response = await fetch(`${API_BASE_URL}/api/training-plan`, {method: 'POST',headers: {Authorization: `Bearer ${token}`,'Content-Type': 'application/json',},body: JSON.stringify({date: planForm.date,project: planForm.project,target: planForm.target,note: planForm.note,}),})if (!response.ok) throw new Error('创建计划失败')const result: CreatePlanResponse = await response.json()if (result.code === 200) {// 格式化日期并添加到本地列表const newPlan = {...result.data,date: formatDateString(result.data.date),}plansList.value.push(newPlan)alert('训练计划创建成功!')resetForm()} else {alert(result.message || '创建失败')}} catch (error) {console.error('创建计划失败:', error)alert('创建计划失败,请重试')} finally {loading.value = false}
}// 打开编辑弹窗
const openEditModal = (plan: PlanItem) => {editingPlan.value = planeditForm.date = plan.dateeditForm.project = plan.projecteditForm.target = plan.targeteditForm.note = plan.noteeditForm.completed = plan.completededitForm.actualCount = plan.actualCount || 0showEditModal.value = true
}// 关闭编辑弹窗
const closeEditModal = () => {showEditModal.value = falseeditingPlan.value = null
}// 保存修改
const savePlan = async () => {if (!editingPlan.value) returneditingPlan.value.target = editForm.targeteditingPlan.value.note = editForm.noteeditingPlan.value.actualCount = editForm.actualCount// 根据实际完成数量自动设置完成状态const target = parseInt(editForm.target) || 0if (editForm.actualCount >= target && editForm.actualCount > 0) {// 实际数量达到或超过目标,标记为已完成editingPlan.value.completed = true} else if (editForm.actualCount > 0) {// 有实际数量但未达到目标,标记为部分完成(也算已完成)editingPlan.value.completed = true} else {// 没有实际数量,使用手动设置的状态editingPlan.value.completed = editForm.completed}const token = getToken()const planId = editingPlan.value.idif (!token || !planId) {alert('更新失败:缺少必要信息')return}const updateData = {target: editForm.target,note: editForm.note,actualCount: editForm.actualCount,completed: editingPlan.value.completed,}loading.value = truetry {const response = await fetch(`${API_BASE_URL}/api/training-plan/${planId}`, {method: 'PUT',headers: {Authorization: `Bearer ${token}`,'Content-Type': 'application/json',},body: JSON.stringify(updateData),})const result: UpdatePlanResponse = await response.json()if (result.code === 200) {// 格式化日期并更新本地数据const updatedData = {...result.data,date: formatDateString(result.data.date),}Object.assign(editingPlan.value, updatedData)alert('计划已更新!')closeEditModal()} else {alert(`更新失败: ${result.message}`)}} catch (error) {console.error('更新计划失败:', error)alert(`更新计划失败: ${error}`)} finally {loading.value = false}
}// 删除计划
const deletePlan = async () => {if (!editingPlan.value) returnif (!confirm('确定要删除这个训练计划吗?')) returnconst token = getToken()const planId = editingPlan.value.idif (!token || !planId) {alert('删除失败:缺少必要信息')return}loading.value = truetry {const response = await fetch(`${API_BASE_URL}/api/training-plan/${planId}`, {method: 'DELETE',headers: {Authorization: `Bearer ${token}`,},})if (!response.ok) throw new Error('删除计划失败')const result: DeletePlanResponse = await response.json()if (result.code === 200) {// 从本地列表删除const index = plansList.value.indexOf(editingPlan.value)if (index > -1) {plansList.value.splice(index, 1)}alert('计划已删除!')closeEditModal()} else {alert(result.message || '删除失败')}} catch (error) {console.error('删除计划失败:', error)alert('删除计划失败,请重试')} finally {loading.value = false}
}// 组件挂载时加载数据
onMounted(() => {fetchPlanList()
})
</script><style scoped>
.train-wrapper {display: flex;gap: 20px;padding: 20px;
}/* 左侧训练数据统计 */
.train-stats {flex: 1;background: #fff;border-radius: 8px;padding: 20px;
}.stats-header {display: flex;justify-content: space-between;align-items: center;margin-bottom: 20px;
}.stats-title {font-size: 18px;font-weight: 600;color: #303133;
}.help-btn {width: 24px;height: 24px;border-radius: 50%;border: 1px solid #dcdfe6;background: #fff;color: #909399;cursor: pointer;font-size: 14px;
}.help-btn:hover {border-color: #409eff;color: #409eff;
}.chart-section {margin-bottom: 30px;
}.chart-header {display: flex;justify-content: space-between;align-items: center;margin-bottom: 20px;border-bottom: 1px solid #ebeef5;
}.chart-tabs {flex: 1;
}.tab-btn {padding: 10px 20px;border: none;background: none;color: #606266;cursor: pointer;font-size: 14px;position: relative;
}.tab-btn.active {color: #409eff;
}.tab-btn.active::after {content: '';position: absolute;bottom: -1px;left: 0;right: 0;height: 2px;background: #409eff;
}.week-nav {display: flex;align-items: center;gap: 12px;padding-bottom: 10px;
}.week-btn {padding: 4px 12px;border: 1px solid #dcdfe6;border-radius: 4px;background: #fff;color: #606266;font-size: 13px;cursor: pointer;
}.week-btn:hover:not(:disabled) {color: #409eff;border-color: #409eff;
}.week-btn:disabled {opacity: 0.5;cursor: not-allowed;
}.week-label {font-size: 13px;color: #606266;min-width: 150px;text-align: center;
}.chart-container {min-height: 300px;display: flex;align-items: center;justify-content: center;background: #fafafa;border-radius: 8px;padding: 20px;
}.line-chart {width: 100%;display: flex;gap: 20px;
}.chart-y-axis {display: flex;flex-direction: column;justify-content: space-between;padding: 20px 0;
}.y-tick {font-size: 12px;color: #909399;text-align: right;min-width: 30px;
}.chart-content {flex: 1;display: flex;flex-direction: column;
}.chart-svg {width: 100%;height: 200px;
}.grid-line {stroke: #ebeef5;stroke-width: 1;
}.chart-line {fill: none;stroke: #409eff;stroke-width: 2;
}.chart-point {fill: #409eff;stroke: #fff;stroke-width: 2;
}.chart-label {fill: #303133;font-size: 12px;text-anchor: middle;font-weight: 500;
}.chart-x-axis {display: flex;justify-content: space-around;margin-top: 10px;
}.x-label {font-size: 12px;color: #909399;text-align: center;flex: 1;
}.chart-placeholder {text-align: center;
}.chart-icon {font-size: 48px;margin-bottom: 12px;
}.chart-text {color: #909399;font-size: 14px;
}.plan-setting-section {margin-top: 30px;
}.section-title {font-size: 16px;font-weight: 600;color: #303133;margin-bottom: 16px;
}.plan-form {background: #fafafa;padding: 20px;border-radius: 8px;
}.form-row {margin-bottom: 16px;
}.form-label {display: block;font-size: 14px;color: #606266;margin-bottom: 8px;font-weight: 500;
}.form-input {width: 100%;padding: 10px 12px;border: 1px solid #dcdfe6;border-radius: 4px;font-size: 14px;outline: none;transition: border-color 0.2s;
}.form-input:focus {border-color: #409eff;
}.form-textarea {width: 100%;padding: 10px 12px;border: 1px solid #dcdfe6;border-radius: 4px;font-size: 14px;outline: none;resize: vertical;font-family: inherit;transition: border-color 0.2s;
}.form-textarea:focus {border-color: #409eff;
}.form-actions {display: flex;gap: 12px;justify-content: flex-end;margin-top: 20px;
}.btn-cancel,
.btn-submit {padding: 10px 24px;border-radius: 4px;font-size: 14px;cursor: pointer;transition: all 0.2s;
}.btn-cancel {background: #fff;border: 1px solid #dcdfe6;color: #606266;
}.btn-cancel:hover {color: #409eff;border-color: #409eff;
}.btn-submit {background: #409eff;border: none;color: #fff;
}.btn-submit:hover {background: #66b1ff;
}.empty-icon {font-size: 48px;margin-bottom: 12px;
}.empty-text {color: #909399;font-size: 14px;
}/* 右侧训练计划统计 */
.plan-stats {width: 480px;background: #fff;border-radius: 8px;padding: 20px;
}.plan-title {font-size: 18px;font-weight: 600;color: #303133;margin-bottom: 20px;
}.stats-cards {display: grid;grid-template-columns: repeat(3, 1fr);gap: 12px;margin-bottom: 30px;
}.stat-card {background: #f5f7fa;padding: 16px;border-radius: 8px;text-align: center;
}.card-label {font-size: 12px;color: #909399;margin-bottom: 8px;
}.card-value {font-size: 24px;font-weight: 600;color: #303133;
}.monthly-stats {margin-bottom: 30px;
}.month-tabs {display: flex;gap: 8px;margin-bottom: 16px;flex-wrap: wrap;
}.month-tab {padding: 6px 12px;border: 1px solid #dcdfe6;border-radius: 4px;background: #fff;color: #606266;font-size: 13px;cursor: pointer;transition: all 0.2s;
}.month-tab:hover {color: #409eff;border-color: #409eff;
}.month-tab.active {background: #409eff;color: #fff;border-color: #409eff;
}.monthly-empty {padding: 40px 20px;text-align: center;background: #fafafa;border-radius: 8px;
}.monthly-data {background: #fafafa;border-radius: 8px;padding: 16px;
}.data-summary {display: flex;justify-content: space-around;margin-bottom: 16px;padding-bottom: 16px;border-bottom: 1px solid #ebeef5;
}.summary-item {text-align: center;
}.summary-label {display: block;font-size: 12px;color: #909399;margin-bottom: 4px;
}.summary-value {display: block;font-size: 20px;font-weight: 600;color: #303133;
}.summary-value.completed {color: #52c41a;
}.summary-value.pending {color: #faad14;
}.data-list {max-height: 200px;overflow-y: auto;
}.data-item {display: flex;align-items: center;gap: 12px;padding: 8px 0;border-bottom: 1px solid #ebeef5;
}.data-item:last-child {border-bottom: none;
}.item-date {font-size: 12px;color: #909399;min-width: 80px;
}.item-project {flex: 1;font-size: 13px;color: #606266;
}.item-status {font-size: 16px;color: #dcdfe6;
}.item-status.completed {color: #52c41a;
}.weekly-plan {margin-top: 30px;
}.weekly-empty {padding: 40px 20px;text-align: center;background: #fafafa;border-radius: 8px;
}.weekly-list {display: flex;flex-direction: column;gap: 12px;
}.plan-item {background: #fafafa;padding: 16px;border-radius: 8px;display: flex;gap: 12px;align-items: flex-start;cursor: pointer;transition: all 0.2s;
}.plan-item:hover {background: #e6f0ff;transform: translateX(4px);
}.plan-date {font-size: 13px;color: #909399;min-width: 80px;flex-shrink: 0;
}.plan-content {flex: 1;
}.plan-project {font-size: 15px;font-weight: 500;color: #303133;margin-bottom: 4px;
}.plan-target {font-size: 13px;color: #606266;margin-bottom: 4px;
}.actual-count {color: #52c41a;font-weight: 500;margin-left: 8px;
}.plan-note {font-size: 12px;color: #909399;margin-top: 4px;
}.plan-status {font-size: 13px;padding: 4px 12px;border-radius: 4px;flex-shrink: 0;font-weight: 500;
}.plan-status.pending {background: #fff7e6;color: #faad14;
}.plan-status.completed {background: #e6f7e6;color: #52c41a;
}.plan-status.partial {background: #e6f0ff;color: #409eff;
}/* 弹窗样式 */
.modal-overlay {position: fixed;top: 0;left: 0;right: 0;bottom: 0;background: rgba(0, 0, 0, 0.5);display: flex;align-items: center;justify-content: center;z-index: 1000;
}.modal-content {background: #fff;border-radius: 12px;padding: 30px;width: 90%;max-width: 500px;max-height: 80vh;overflow-y: auto;position: relative;box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}.close-btn {position: absolute;top: 15px;right: 15px;background: none;border: none;font-size: 24px;color: #909399;cursor: pointer;width: 32px;height: 32px;display: flex;align-items: center;justify-content: center;border-radius: 50%;transition: all 0.2s;
}.close-btn:hover {background: #f5f7fa;color: #606266;
}.modal-title {font-size: 20px;font-weight: 600;color: #303133;margin-bottom: 24px;padding-right: 40px;
}.modal-form {display: flex;flex-direction: column;gap: 16px;
}.checkbox-group {padding: 10px 0;
}.checkbox-label {display: flex;align-items: center;gap: 8px;cursor: pointer;font-size: 14px;color: #606266;
}.checkbox-input {width: 18px;height: 18px;cursor: pointer;
}.checkbox-input:disabled {opacity: 0.5;cursor: not-allowed;
}.form-hint {display: block;font-size: 12px;color: #909399;margin-top: 4px;
}.modal-actions {display: flex;gap: 12px;justify-content: flex-end;margin-top: 24px;
}.btn-delete {padding: 10px 24px;border-radius: 4px;font-size: 14px;cursor: pointer;transition: all 0.2s;background: #fff;border: 1px solid #f56c6c;color: #f56c6c;margin-right: auto;
}.btn-delete:hover {background: #f56c6c;color: #fff;
}/* 帮助弹窗样式 */
.help-modal {max-width: 600px;
}.help-content {display: flex;flex-direction: column;gap: 24px;
}.help-section {padding-bottom: 20px;border-bottom: 1px solid #ebeef5;
}.help-section:last-child {border-bottom: none;padding-bottom: 0;
}.help-subtitle {font-size: 16px;font-weight: 600;color: #303133;margin-bottom: 12px;
}.help-text {font-size: 14px;line-height: 1.8;color: #606266;margin: 0;
}.help-list {list-style: none;padding: 0;margin: 0;display: flex;flex-direction: column;gap: 12px;
}.help-list li {font-size: 14px;line-height: 1.6;color: #606266;padding-left: 20px;position: relative;
}.help-list li::before {content: '•';position: absolute;left: 0;color: #409eff;font-weight: bold;
}.help-list li strong {color: #303133;font-weight: 500;
}
</style>
4.2.2.5. HistoryComponent.vue - 历史记录页面
查看和管理历史训练记录:
搜索功能:关键词搜索历史记录
记录列表:显示项目、时间、评分
分页功能:每页10条记录
详情查看:弹窗显示详细评价和改进措施
日历组件:右侧显示训练日历,标记训练过的日期
空状态提示
点击查看代码
<template><div class="history-wrapper"><!-- 左侧主内容 --><div class="history"><h2 class="page-title">历史记录查询</h2><!-- 查询条件区域 --><div class="search-section"><!-- <div class="search-item"><label>时间范围</label><select v-model="searchForm.timeRange"><option value="本月">本月</option><option value="本周">本周</option><option value="近三月">近三月</option></select></div> --><div class="search-item"><label>搜索关键词</label><input type="text" v-model="searchForm.keyword" placeholder="搜索记录..." /></div></div><!-- 表格区域 --><div class="table-section"><table class="history-table"><thead><tr><th>历史记录</th><th>时间/时间</th><th>评分</th><th>操作</th></tr></thead><tbody><tr v-for="(item, index) in paginatedList" :key="index"><td><span class="index-badge">{{ (currentPage - 1) * pageSize + index + 1 }}</span>{{ item.project }}</td><td>{{ item.time }}</td><td><span class="score-tag" :class="getScoreClass(item.score)">{{ item.score }}</span></td><td><a href="javascript:;" class="action-link" @click="openDetailModal(item)">查看详情</a></td></tr></tbody></table></div><!-- 空状态 --><div class="empty-state" v-if="paginatedList.length === 0"><div class="empty-icon">📄</div><p class="empty-title">暂无历史记录</p><p class="empty-desc">开始新的测试后将在此处显示您的历史记录</p></div><!-- 分页 --><div class="pagination" v-if="totalPages > 1"><button class="page-btn" :disabled="currentPage === 1" @click="currentPage--">上一页</button><span class="page-info">{{ currentPage }} / {{ totalPages }}</span><button class="page-btn" :disabled="currentPage === totalPages" @click="currentPage++">下一页</button></div></div><!-- 右侧日历 --><div class="calendar-widget"><div class="calendar-header"><button class="nav-btn" @click="prevMonth"><</button><span class="month-title">{{ currentYear }}年{{ currentMonth + 1 }}月</span><button class="nav-btn" @click="nextMonth">></button></div><div class="calendar-weekdays"><span v-for="day in weekDays" :key="day">{{ day }}</span></div><div class="calendar-days"><spanv-for="(day, index) in calendarDays":key="index"class="day-cell":class="{'other-month': !day.currentMonth,trained: day.trained,today: day.isToday,}">{{ day.date }}</span></div></div><!-- 详情弹窗 --><div class="modal-overlay" v-if="showModal" @click="closeModal"><div class="modal-content" @click.stop><button class="close-btn" @click="closeModal">✕</button><h2 class="modal-title">{{ currentDetail.project }}</h2><div class="modal-info-row"><span class="modal-label">测试时间:</span><span class="modal-value">{{ currentDetail.time }}</span></div><div class="modal-info-row"><span class="modal-label">评分:</span><span class="modal-score" :class="getScoreClass(currentDetail.score)">{{ currentDetail.score }}</span></div><div class="modal-evaluation"><h3 class="modal-section-title">评价与改进措施</h3><div class="modal-evaluation-content">{{ currentDetail.evaluation }}</div></div></div></div></div>
</template><script setup lang="ts">
import './styles/History.css'import { ref, reactive, computed, onMounted, watch } from 'vue'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || ''// 获取token的函数(后续根据你的认证方式修改)
const getToken = () => {return localStorage.getItem('token') || ''
}interface HistoryItem {id?: numberproject: stringtime: stringdate: Datescore: number
}const searchForm = reactive({timeRange: '近三月',keyword: '',
})const loading = ref(false)
const trainedDatesFromServer = ref<string[]>([])// 日历相关
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
const currentYear = ref(new Date().getFullYear())
const currentMonth = ref(new Date().getMonth())// ============ API 调用函数 ============// 获取历史记录列表
const fetchHistoryList = async () => {const token = getToken()if (!token) returnloading.value = truetry {const response = await fetch(`${API_BASE_URL}/api/history`, {method: 'GET',headers: {Authorization: `Bearer ${token}`,'Content-Type': 'application/json',},})if (!response.ok) throw new Error('获取历史记录失败')const data = await response.json()// console.log('历史记录数据:', data)rawHistoryList.value = data.data.data.map((item: HistoryItem) => ({id: item.id,project: item.project,time: item.time,date: new Date(item.date),score: item.score,}))} catch (error) {console.error('获取历史记录失败:', error)} finally {loading.value = false}
}// 获取单条历史记录详情(从服务器)
const fetchHistoryDetailFromServer = async (id: number) => {const token = getToken()if (!token) return nulltry {const response = await fetch(`${API_BASE_URL}/api/history/detail/${id}`, {method: 'GET',headers: {Authorization: `Bearer ${token}`,'Content-Type': 'application/json',},})if (!response.ok) throw new Error('获取历史详情失败')const data = await response.json()// 期望返回格式: { code: 200, message: 'success', data: { project, time, score, evaluation } }return data.data} catch (error) {console.error('获取历史详情失败:', error)return null}
}// ============ 生命周期和监听 ============// 组件挂载时加载数据
onMounted(() => {// 如果需要从服务器加载,取消下面注释fetchHistoryList()})// ============ 计算属性 ============// 训练过的日期
const trainedDates = computed(() => {// 优先使用服务器数据if (trainedDatesFromServer.value.length > 0) {return trainedDatesFromServer.value.map((dateStr) => {const d = new Date(dateStr)return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`})}// 否则从本地历史记录提取return rawHistoryList.value.map((item) => {const d = item.datereturn `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`})
})// 生成日历天数
const calendarDays = computed(() => {const days: { date: number; currentMonth: boolean; trained: boolean; isToday: boolean }[] = []const year = currentYear.valueconst month = currentMonth.value// 当月第一天是星期几const firstDay = new Date(year, month, 1).getDay()// 当月天数const daysInMonth = new Date(year, month + 1, 0).getDate()// 上月天数const daysInPrevMonth = new Date(year, month, 0).getDate()const today = new Date()const todayStr = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`// 上月的日期for (let i = firstDay - 1; i >= 0; i--) {const date = daysInPrevMonth - iconst dateStr = `${year}-${month - 1}-${date}`days.push({date,currentMonth: false,trained: trainedDates.value.includes(dateStr),isToday: false,})}// 当月的日期for (let i = 1; i <= daysInMonth; i++) {const dateStr = `${year}-${month}-${i}`days.push({date: i,currentMonth: true,trained: trainedDates.value.includes(dateStr),isToday: dateStr === todayStr,})}// 下月的日期(补齐到42天)const remaining = 42 - days.lengthfor (let i = 1; i <= remaining; i++) {const dateStr = `${year}-${month + 1}-${i}`days.push({date: i,currentMonth: false,trained: trainedDates.value.includes(dateStr),isToday: false,})}return days
})const prevMonth = () => {if (currentMonth.value === 0) {currentMonth.value = 11currentYear.value--} else {currentMonth.value--}
}const nextMonth = () => {if (currentMonth.value === 11) {currentMonth.value = 0currentYear.value++} else {currentMonth.value++}
}// 分页相关
const currentPage = ref(1)
const pageSize = 10
// 原始数据列表(本地模拟数据,后续可删除)
const rawHistoryList = ref<HistoryItem[]>([// {// id: 1,// project: '引体向上8个',// time: '2025-11-28 09:24',// date: new Date('2025-11-28'),// score: 78,// },])// 计算过滤后的列表
const historyList = computed(() => {return rawHistoryList.value.filter((item) => {// 时间范围过滤let startDate: Dateswitch (searchForm.timeRange) {default:startDate = new Date(0)}if (item.date < startDate) return false// 关键词过滤if (searchForm.keyword && !item.project.includes(searchForm.keyword)) return falsereturn true})
})// 总页数
const totalPages = computed(() => Math.ceil(historyList.value.length / pageSize))// 当前页数据
const paginatedList = computed(() => {const start = (currentPage.value - 1) * pageSizereturn historyList.value.slice(start, start + pageSize)
})// 搜索条件变化时重置页码
watch([() => searchForm.timeRange, () => searchForm.keyword], () => {currentPage.value = 1// 如果需要从服务器加载,取消下面注释// fetchHistoryList({ timeRange: searchForm.timeRange, keyword: searchForm.keyword })
})// 根据分数返回样式类
const getScoreClass = (score: number) => {if (score >= 90) return 'score-excellent'if (score >= 70) return 'score-good'return 'score-normal'
}// 弹窗相关
const showModal = ref(false)
const currentDetail = ref({project: '',time: '',score: 0,evaluation: '',
})// 模拟详情数据
const detailData: Record<string, string> = {}const openDetailModal = async (item: HistoryItem) => {// 先显示弹窗,使用本地数据currentDetail.value = {project: item.project,time: item.time,score: item.score,evaluation: detailData[item.project] || '加载中...',}showModal.value = true// 如果有 id,尝试从服务器获取详细数据if (item.id) {const serverDetail = await fetchHistoryDetailFromServer(item.id)if (serverDetail) {currentDetail.value = {project: serverDetail.project || item.project,time: serverDetail.time || item.time,score: serverDetail.score || item.score,evaluation: serverDetail.evaluation || detailData[item.project] || '暂无评价信息',}} else {// 服务器获取失败,使用本地模拟数据currentDetail.value.evaluation = detailData[item.project] || '暂无评价信息'}}
}const closeModal = () => {showModal.value = false
}
</script><style scoped></style>
4.2.2.6. FeedBack.vue - 反馈弹窗
用户意见反馈组件:
反馈内容输入(必填,最多500字)
邮箱地址输入(选填)
字符计数显示
提交状态管理
成功提示弹窗
表单验证和重置
点击查看代码
<template><!-- 反馈弹窗遮罩层 --><div v-if="visible" class="feedback-modal-mask" ><div class="feedback-modal" @click.stop><!-- 弹窗头部 --><div class="modal-header"><h2>意见反馈</h2><button class="close-btn" @click="handleClose"><i class="icon-close"></i></button></div><!-- 弹窗内容 --><div class="modal-content"><div class="form-group"><label for="feedback-content">反馈内容 <span class="required">*</span></label><textareaid="feedback-content"v-model="feedbackForm.content"class="feedback-textarea"placeholder="请详细描述您遇到的问题或建议..."rows="4"maxlength="500"></textarea><div class="char-count">{{ feedbackForm.content.length }}/500</div></div><div class="form-group"><label for="email">邮箱地址</label><inputid="email"v-model="feedbackForm.email"type="email"class="email-input"placeholder="选填,方便我们回复您"/></div></div><!-- 弹窗底部按钮 --><div class="modal-footer"><button class="cancel-btn" @click="handleClose">取消</button><buttonclass="submit-btn":disabled="!feedbackForm.content.trim() || submitting"@click="handleSubmit"><span v-if="submitting">提交中...</span><span v-else>提交反馈</span></button></div></div><!-- 成功提示弹窗 --><div v-if="showSuccess" class="success-modal"><div class="success-content" @click.stop><i class="icon-success"></i><h3>反馈提交成功</h3><p>感谢您的反馈,我们会尽快处理</p><button class="confirm-btn" @click="handleSuccessConfirm">确定</button></div></div></div>
</template><script setup lang="ts">
import './styles/FeedBack.css'import { authAPI } from '@/utils/api';
import { ref, reactive, watch } from 'vue'// 定义组件Props
interface Props {visible: boolean
}// 定义组件Emits
interface Emits {(e: 'update:visible', value: boolean): void(e: 'submitted', data: { content: string; email?: string }): void
}const props = defineProps<Props>()
const emit = defineEmits<Emits>()// 反馈表单数据
const feedbackForm = reactive({content: '',email: '',
})// 状态管理
const submitting = ref(false)
const showSuccess = ref(false)// 监听visible变化,确保能正确响应外部控制
watch(() => props.visible,(newVal) => {if (!newVal) {// 关闭时重置表单resetForm()}},
)// 关闭弹窗
const handleClose = () => {emit('update:visible', false)
}// // 点击遮罩层关闭
// const handleMaskClick = () => {
// handleClose()
// }// 重置表单
const resetForm = () => {feedbackForm.content = ''feedbackForm.email = ''submitting.value = falseshowSuccess.value = false
}// 处理提交
const handleSubmit = async () => {if (!feedbackForm.content.trim()) {return}submitting.value = truetry {const token = localStorage.getItem('token') || '';// 这里调用你的API// await submitFeedback(feedbackForm)// 模拟API调用const response= await authAPI.feedback(token,{content: feedbackForm.content,email: feedbackForm.email || undefined,} );// await new Promise((resolve) => setTimeout(resolve, 1500))// 提交成功后显示成功提示if (response === 'success'){showSuccess.value = true}// 通知父组件提交成功emit('submitted', {content: feedbackForm.content,email: feedbackForm.email || undefined,})} catch (error) {console.error('提交反馈失败:', error)// 这里可以添加错误提示} finally {submitting.value = false}
}// 成功提示确认
const handleSuccessConfirm = () => {showSuccess.value = falsehandleClose()
}
</script><style scoped></style>
4.2.3 样式文件夹
包含各个组件对应的 CSS 样式文件:
4.2.3.1 FeedBack.css - 反馈弹窗样式
点击查看代码
.feedback-modal-mask {position: fixed;top: 0;left: 0;right: 0;bottom: 0;background: rgba(0, 0, 0, 0.5);display: flex;justify-content: center;align-items: center;z-index: 1000;animation: fadeIn 0.3s ease;
}.feedback-modal {background: white;border-radius: 12px;box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);width: 90%;max-width: 500px;max-height: 90vh;overflow: hidden;animation: slideUp 0.3s ease;
}.modal-header {display: flex;justify-content: space-between;align-items: center;padding: 20px 24px;border-bottom: 1px solid #f0f0f0;
}.modal-header h2 {margin: 0;font-size: 18px;font-weight: 600;color: #333;
}.close-btn {background: none;border: none;font-size: 20px;cursor: pointer;color: #999;width: 32px;height: 32px;border-radius: 50%;display: flex;align-items: center;justify-content: center;transition: background 0.3s;
}.close-btn:hover {background: #f5f5f5;color: #666;
}.icon-close::before {content: '×';
}.modal-content {padding: 24px;max-height: calc(90vh - 140px);overflow-y: auto;
}.form-group {margin-bottom: 20px;
}label {display: block;margin-bottom: 8px;font-weight: 500;color: #333;font-size: 14px;
}.required {color: #ff4757;
}.feedback-textarea {width: 100%;padding: 12px 15px;border: 1px solid #ddd;border-radius: 8px;font-family: inherit;font-size: 14px;resize: vertical;transition:border-color 0.3s,box-shadow 0.3s;box-sizing: border-box;
}.feedback-textarea:focus {outline: none;border-color: #4a6cf7;box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.2);
}.char-count {text-align: right;font-size: 12px;color: #888;margin-top: 5px;
}.email-input {width: 100%;padding: 12px 15px;border: 1px solid #ddd;border-radius: 8px;font-size: 14px;transition:border-color 0.3s,box-shadow 0.3s;box-sizing: border-box;
}.email-input:focus {outline: none;border-color: #4a6cf7;box-shadow: 0 0 0 2px rgba(74, 108, 247, 0.2);
}.modal-footer {display: flex;justify-content: flex-end;gap: 12px;padding: 16px 24px;border-top: 1px solid #f0f0f0;background: #fafafa;
}.cancel-btn,
.submit-btn {padding: 10px 20px;border: none;border-radius: 6px;font-size: 14px;font-weight: 500;cursor: pointer;transition: all 0.3s;min-width: 80px;
}.cancel-btn {background: #f8f9fa;color: #555;border: 1px solid #ddd;
}.cancel-btn:hover {background: #e9ecef;
}.submit-btn {background: #4a6cf7;color: white;
}.submit-btn:hover:not(:disabled) {background: #3a5ce5;transform: translateY(-1px);box-shadow: 0 4px 8px rgba(74, 108, 247, 0.3);
}.submit-btn:disabled {background: #ccc;cursor: not-allowed;transform: none;box-shadow: none;
}/* 成功提示弹窗 */
.success-modal {position: fixed;top: 0;left: 0;right: 0;bottom: 0;background: rgba(0, 0, 0, 0.5);display: flex;justify-content: center;align-items: center;z-index: 1001;animation: fadeIn 0.3s ease;
}.success-content {background: white;border-radius: 12px;padding: 30px;text-align: center;max-width: 300px;box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);animation: slideUp 0.3s ease;
}.icon-success {display: inline-block;width: 60px;height: 60px;border-radius: 50%;background: #4cd964;color: white;font-size: 30px;line-height: 60px;margin-bottom: 15px;
}.icon-success::before {content: '✓';
}.success-content h3 {margin: 0 0 10px 0;color: #333;font-size: 18px;
}.success-content p {margin: 0 0 20px 0;color: #666;font-size: 14px;
}.confirm-btn {background: #4a6cf7;color: white;border: none;padding: 10px 20px;border-radius: 6px;cursor: pointer;font-size: 14px;transition: background 0.3s;min-width: 80px;
}.confirm-btn:hover {background: #3a5ce5;
}/* 动画效果 */
@keyframes fadeIn {from {opacity: 0;}to {opacity: 1;}
}@keyframes slideUp {from {opacity: 0;transform: translateY(20px);}to {opacity: 1;transform: translateY(0);}
}/* 响应式设计 */
@media (max-width: 576px) {.feedback-modal {width: 95%;margin: 10px;}.modal-header {padding: 16px 20px;}.modal-content {padding: 20px;}.modal-footer {padding: 12px 20px;flex-direction: column;}.cancel-btn,.submit-btn {width: 100%;}
}
4.2.3.2 History.css - 历史记录页面样式
点击查看代码
.history-wrapper {display: flex;gap: 20px;padding-top: 20px;
}.history {flex: 1;padding: 20px;background: #fff;border-radius: 8px;min-height: calc(100vh - 140px);display: flex;flex-direction: column;
}.page-title {font-size: 18px;font-weight: 600;color: #333;margin-bottom: 20px;
}/* 日历样式 */
.calendar-widget {background: #fff;border: 1px solid #ebeef5;border-radius: 8px;padding: 12px;width: 260px;height: fit-content;box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);flex-shrink: 0;
}.calendar-header {display: flex;justify-content: space-between;align-items: center;margin-bottom: 10px;
}.month-title {font-size: 14px;font-weight: 500;color: #333;
}.nav-btn {background: none;border: none;cursor: pointer;padding: 4px 8px;color: #666;font-size: 14px;
}.nav-btn:hover {color: #409eff;
}.calendar-weekdays {display: grid;grid-template-columns: repeat(7, 1fr);text-align: center;margin-bottom: 6px;
}.calendar-weekdays span {font-size: 12px;color: #909399;padding: 4px 0;
}.calendar-days {display: grid;grid-template-columns: repeat(7, 1fr);gap: 2px;
}.day-cell {display: flex;align-items: center;justify-content: center;width: 30px;height: 30px;font-size: 12px;color: #606266;border-radius: 50%;margin: 0 auto;
}.day-cell.other-month {color: #c0c4cc;
}.day-cell.today {background: #409eff;color: #fff;
}.day-cell.trained {position: relative;
}.day-cell.trained::after {content: '';position: absolute;width: 28px;height: 28px;border: 2px solid #f56c6c;border-radius: 50%;
}.search-section {display: flex;gap: 20px;margin-bottom: 20px;flex-wrap: wrap;
}.search-item {display: flex;flex-direction: column;gap: 8px;
}.search-item label {font-size: 14px;color: #666;
}.search-item select,
.search-item input {padding: 8px 12px;border: 1px solid #dcdfe6;border-radius: 4px;font-size: 14px;min-width: 180px;outline: none;
}.search-item select:focus,
.search-item input:focus {border-color: #409eff;
}.table-section {margin-top: 20px;
}.history-table {width: 100%;border-collapse: collapse;
}.history-table th,
.history-table td {padding: 12px 16px;text-align: left;border-bottom: 1px solid #ebeef5;
}.history-table th {background: #fafafa;color: #909399;font-weight: 500;font-size: 14px;
}.history-table td {color: #606266;font-size: 14px;
}.index-badge {display: inline-flex;align-items: center;justify-content: center;width: 24px;height: 24px;background: #e6f0ff;color: #409eff;border-radius: 50%;font-size: 12px;margin-right: 8px;
}.score-tag {padding: 4px 12px;border-radius: 4px;font-size: 12px;font-weight: 500;
}.score-excellent {background: #e6f7e6;color: #52c41a;
}.score-good {background: #e6f7e6;color: #52c41a;
}.score-normal {background: #fff7e6;color: #faad14;
}.action-link {color: #409eff;text-decoration: none;font-size: 14px;
}.action-link:hover {text-decoration: underline;
}.empty-state {text-align: center;padding: 60px 20px;color: #909399;
}.empty-icon {font-size: 48px;margin-bottom: 16px;
}.empty-title {font-size: 16px;color: #606266;margin-bottom: 8px;
}.empty-desc {font-size: 14px;color: #909399;
}.table-section {flex: 1;
}/* 分页样式 */
.pagination {display: flex;justify-content: center;align-items: center;gap: 16px;padding: 20px 0;margin-top: auto;
}.page-btn {padding: 8px 16px;border: 1px solid #dcdfe6;border-radius: 4px;background: #fff;color: #606266;cursor: pointer;font-size: 14px;transition: all 0.2s;
}.page-btn:hover:not(:disabled) {color: #409eff;border-color: #409eff;
}.page-btn:disabled {color: #c0c4cc;cursor: not-allowed;
}.page-info {font-size: 14px;color: #606266;
}/* 弹窗样式 */
.modal-overlay {position: fixed;top: 0;left: 0;right: 0;bottom: 0;background: rgba(0, 0, 0, 0.5);display: flex;align-items: center;justify-content: center;z-index: 1000;
}.modal-content {background: #fff;border-radius: 12px;padding: 30px;width: 90%;max-width: 600px;max-height: 80vh;overflow-y: auto;position: relative;box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}.close-btn {position: absolute;top: 15px;right: 15px;background: none;border: none;font-size: 24px;color: #909399;cursor: pointer;width: 32px;height: 32px;display: flex;align-items: center;justify-content: center;border-radius: 50%;transition: all 0.2s;
}.close-btn:hover {background: #f5f7fa;color: #606266;
}.modal-title {font-size: 22px;font-weight: 600;color: #303133;margin-bottom: 20px;padding-right: 40px;
}.modal-info-row {display: flex;align-items: center;margin-bottom: 16px;font-size: 15px;
}.modal-label {color: #909399;min-width: 90px;
}.modal-value {color: #606266;
}.modal-score {font-size: 28px;font-weight: 600;padding: 4px 14px;border-radius: 6px;
}.modal-score.score-excellent {color: #52c41a;background: #e6f7e6;
}.modal-score.score-good {color: #52c41a;background: #e6f7e6;
}.modal-score.score-normal {color: #faad14;background: #fff7e6;
}.modal-evaluation {margin-top: 24px;
}.modal-section-title {font-size: 16px;font-weight: 600;color: #303133;margin-bottom: 12px;
}.modal-evaluation-content {background: #f5f7fa;padding: 16px;border-radius: 8px;line-height: 1.8;color: #606266;white-space: pre-line;font-size: 14px;
}
4.2.3.3 Home.css - 首页样式
点击查看代码
.markdown-content {line-height: 1.5;
}.markdown-content :deep(h1) {font-size: 2em;margin: 0.67em 0;border-bottom: 1px solid #eee;padding-bottom: 0.3em;
}.markdown-content :deep(h2) {font-size: 1.5em;margin: 0.83em 0;border-bottom: 1px solid #eee;padding-bottom: 0.3em;
}.markdown-content :deep(h3) {font-size: 1.17em;margin: 1em 0;
}.markdown-content :deep(p) {margin: 1em 0;
}.markdown-content :deep(a) {color: #0366d6;text-decoration: none;
}.markdown-content :deep(a:hover) {text-decoration: underline;
}.markdown-content :deep(code) {background-color: #f6f8fa;padding: 0.2em 0.4em;border-radius: 3px;font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
}.markdown-content :deep(pre) {background-color: #f6f8fa;padding: 16px;border-radius: 6px;overflow: auto;
}.markdown-content :deep(blockquote) {border-left: 4px solid #dfe2e5;padding-left: 16px;margin-left: 0;color: #6a737d;
}.home-container {max-width: 1200px;margin: 0 auto;padding: 20px;font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;color: #333;
}.main-content {display: flex;flex-direction: column;gap: 40px;
}.section-title {font-size: 1.8rem;color: #2c3e50;margin-bottom: 20px;padding-bottom: 10px;border-bottom: 2px solid #eaeaea;
}.evaluation-section {height: 700px;
}
/* 初始对话框样式 */
.initial-dialog {background: #fff;border-radius: 12px;box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);margin-bottom: 20px;overflow: hidden;
}.initial-dialog.disabled {opacity: 0.7;pointer-events: none;
}.dialog-header {display: flex;justify-content: space-between;align-items: center;padding: 15px 20px;background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);color: white;
}.dialog-header h3 {margin: 0;font-size: 1.2rem;
}.status-indicator {padding: 4px 12px;border-radius: 20px;font-size: 0.8rem;background: rgba(255, 255, 255, 0.2);
}.status-indicator.active {background: #4caf50;
}.dialog-content {height: 450px;overflow-y: auto;padding: 15px;background: #f8f9fa;
}.message-list {display: flex;flex-direction: column;gap: 15px;justify-content: flex-end;
}.message {display: flex;gap: 10px;max-width: 80%;
}.user-message {align-self: flex-end;flex-direction: row-reverse;
}.bot-message {align-self: flex-start;
}.avatar {width: 36px;height: 36px;border-radius: 50%;display: flex;align-items: center;justify-content: center;background: #e9ecef;font-size: 1.2rem;flex-shrink: 0;
}.user-message .avatar {background: #007bff;color: white;
}.bubble {background: white;padding: 1px 16px;border-radius: 18px;box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);position: relative;
}.user-message .bubble {background: #007bff;color: white;
}.timestamp {font-size: 0.7rem;color: #6c757d;display: block;margin-top: 5px;
}.user-message .timestamp {color: rgba(255, 255, 255, 0.8);
}.typing-indicator {display: flex;gap: 10px;align-self: flex-start;
}.typing-dots {display: flex;gap: 4px;
}.typing-dots span {width: 8px;height: 8px;border-radius: 50%;background: #6c757d;animation: typing 1.4s infinite ease-in-out;
}.typing-dots span:nth-child(1) {animation-delay: -0.32s;
}
.typing-dots span:nth-child(2) {animation-delay: -0.16s;
}@keyframes typing {0%,80%,100% {transform: scale(0.8);opacity: 0.5;}40% {transform: scale(1);opacity: 1;}
}.dialog-input {display: flex;padding: 15px;gap: 10px;border-top: 1px solid #eaeaea;
}.dialog-input input {flex: 1;padding: 12px 16px;border: 1px solid #ddd;border-radius: 25px;outline: none;transition: border-color 0.3s;
}.dialog-input input:focus {border-color: #007bff;
}.dialog-input input:disabled {background: #f8f9fa;cursor: not-allowed;
}.send-btn {padding: 12px 24px;background: #007bff;color: white;border: none;border-radius: 25px;cursor: pointer;transition: background 0.3s;
}.send-btn:hover:not(:disabled) {background: #0056b3;
}.send-btn:disabled {background: #6c757d;cursor: not-allowed;
}/* 上传按钮容器 */
.upload-button-container {text-align: center;margin-bottom: 20px;
}.upload-btn {padding: 12px 30px;font-size: 1.1rem;border-radius: 25px;
}.upload-status {margin-top: 10px;display: flex;align-items: center;justify-content: center;gap: 10px;
}.status-text {color: #28a745;font-weight: 500;
}.clear-btn {padding: 4px 12px;background: #dc3545;color: white;border: none;border-radius: 15px;cursor: pointer;font-size: 0.8rem;
}.clear-btn:hover {background: #c82333;
}
.clear-btn:disabled {background: #e0aeb4;cursor: not-allowed;
}/* 上传模态框样式 */
.upload-modal {max-width: 700px;
}.upload-instruction {text-align: center;color: #6c757d;margin-bottom: 20px;
}.video-upload-grid {display: grid;grid-template-columns: 1fr 1fr;gap: 20px;margin-bottom: 20px;
}.video-upload-item h4 {margin-bottom: 10px;color: #2c3e50;text-align: center;
}.upload-area {border: 2px dashed #ccc;border-radius: 10px;padding: 30px 20px;text-align: center;cursor: pointer;transition: all 0.3s ease;background-color: #f9f9f9;height: 150px;display: flex;flex-direction: column;justify-content: center;align-items: center;
}.upload-area:hover {border-color: #3498db;background-color: #f0f8ff;
}.upload-area.has-file {border-color: #28a745;background-color: #f8fff9;
}.upload-icon {font-size: 2.5rem;margin-bottom: 10px;
}.upload-text {font-size: 1rem;margin-bottom: 8px;font-weight: 500;
}.upload-hint {color: #7f8c8d;font-size: 0.8rem;
}.file-input {display: none;
}.upload-progress {margin: 20px 0;
}.progress-bar {width: 100%;height: 8px;background: #e9ecef;border-radius: 4px;overflow: hidden;
}.progress-fill {height: 100%;background: linear-gradient(90deg, #3498db, #2ecc71);transition: width 0.3s ease;
}.progress-text {text-align: center;margin-top: 8px;color: #6c757d;font-size: 0.9rem;
}/* 按钮样式 */
.btn-primary {background: #3498db;color: white;border: none;padding: 10px 20px;border-radius: 5px;cursor: pointer;font-size: 1rem;transition: background 0.3s ease;
}.btn-primary:hover:not(:disabled) {background: #2980b9;
}.btn-primary:disabled {background: #bdc3c7;cursor: not-allowed;
}.btn-secondary {background: #6c757d;color: white;border: none;padding: 10px 20px;border-radius: 5px;cursor: pointer;font-size: 1rem;transition: background 0.3s ease;
}.btn-secondary:hover {background: #5a6268;
}/* 结果区域样式 */
.result-container {margin-top: 30px;
}.result-title {font-size: 1.5rem;margin-bottom: 15px;color: #2c3e50;
}.result-box {background: #f8f9fa;border-radius: 10px;padding: 20px;box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}.result-item {display: flex;margin-bottom: 15px;padding-bottom: 10px;border-bottom: 1px solid #eaeaea;
}.result-item:last-child {border-bottom: none;
}.result-item .label {font-weight: 600;min-width: 100px;color: #2c3e50;
}.result-item .value {flex: 1;
}/* 视频网格样式 */
.video-grid {display: grid;grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));gap: 20px;
}.video-card {background: #fff;border-radius: 10px;overflow: hidden;box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);transition:transform 0.3s ease,box-shadow 0.3s ease;cursor: pointer;
}.video-card:hover {transform: translateY(-5px);box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15);
}.video-thumbnail {position: relative;height: 160px;overflow: hidden;
}.video-thumbnail img {width: 100%;height: 100%;object-fit: cover;
}.play-overlay {position: absolute;top: 0;left: 0;right: 0;bottom: 0;background: rgba(0, 0, 0, 0.3);display: flex;align-items: center;justify-content: center;opacity: 0;transition: opacity 0.3s ease;
}.video-card:hover .play-overlay {opacity: 1;
}.play-icon {color: white;font-size: 2.5rem;
}.video-info {padding: 15px;
}.video-title {font-size: 1.1rem;margin-bottom: 8px;color: #2c3e50;
}.video-duration {color: #7f8c8d;font-size: 0.9rem;
}/* 模态框样式 */
.modal-overlay {position: fixed;top: 0;left: 0;right: 0;bottom: 0;background: rgba(0, 0, 0, 0.7);display: flex;align-items: center;justify-content: center;z-index: 1000;padding: 20px;
}.modal-content {background: white;border-radius: 10px;width: 100%;max-width: 800px;max-height: 90vh;overflow: hidden;display: flex;flex-direction: column;
}.modal-header {display: flex;justify-content: space-between;align-items: center;padding: 15px 20px;border-bottom: 1px solid #eaeaea;
}.modal-header h3 {margin: 0;color: #2c3e50;
}.close-btn {background: none;border: none;font-size: 1.5rem;cursor: pointer;color: #7f8c8d;
}.close-btn:hover {color: #e74c3c;
}.modal-body {padding: 20px;flex: 1;overflow-y: auto;
}.video-player {flex: 1;display: flex;justify-content: center;align-items: center;padding: 20px;
}.video-player video {width: 100%;max-height: 400px;border-radius: 5px;
}.modal-footer {padding: 15px 20px;border-top: 1px solid #eaeaea;display: flex;justify-content: flex-end;gap: 10px;
}/* 响应式设计 */
@media (max-width: 768px) {.home-container {padding: 15px;}.section-title {font-size: 1.5rem;}.video-upload-grid {grid-template-columns: 1fr;}.message {max-width: 90%;}.video-grid {grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));}.modal-content {max-height: 80vh;}.video-player video {max-height: 300px;}
}@media (max-width: 480px) {.video-grid {grid-template-columns: 1fr;}.dialog-header {padding: 12px 15px;}.dialog-content {height: 250px;padding: 10px;}.dialog-input {padding: 12px;}.modal-body {padding: 15px;}
}
4.2.3.4 NavBar.css - 导航栏样式
点击查看代码
.navbar {position: fixed;top: 0;left: 0;right: 0;background-color: #f0f7ee;backdrop-filter: blur(10px);border-bottom: 1px solid rgba(0, 0, 0, 0.1);transition: all 0.3s ease;z-index: 1000;
}.navbar-scrolled {background: rgba(255, 255, 255, 0.98);box-shadow: 0 2px 20px rgba(0, 0, 0, 0.1);
}.navbar-container {display: flex;align-items: center;justify-content: space-between;/* max-width: 1200px; */margin: 0 auto;padding: 0 20px;height: 64px;width: 90vw;
}
.logo-image {/* width: 40px; */height: 40px;margin-left: 10px;
}
.logo-placeholder {margin: auto;
}.navbar-brand {font-size: 1.5rem;font-weight: bold;color: #4f46e5;margin-left: 100px;margin-right: auto;display: flex;align-items: center;justify-content: space-between;
}.navbar-menu {margin-left: 5%;width: 100%;
}.navbar-nav {display: flex;flex-direction: row; /* 默认值,可省略 */flex-wrap: nowrap;list-style: none;padding: 0;margin: 0;
}
.nav-item {white-space: nowrap; /* 防止文本换行 */
}.nav-link {display: block;max-width: auto;padding: 8px 16px;text-decoration: none;color: inherit;border-radius: 6px;transition: all 0.3s ease;/* font-weight: 500; */
}.nav-link:hover {background: rgba(0, 0, 0, 0.05);
}.nav-link.active {background: #4696e5;color: white;
}/* 右侧功能区 */
.navbar-actions {display: flex;align-items: center;gap: 16px;margin-left: auto;margin-right: 50px;
}/* 用户菜单容器 */
.user-menu {position: relative;
}/* 用户切换按钮 */
.user-toggle {display: flex;align-items: center;gap: 8px;padding: 6px 12px 6px 6px;background: rgba(255, 255, 255, 0.9);border: 1px solid rgba(0, 0, 0, 0.1);border-radius: 24px;color: #333;cursor: pointer;transition: all 0.3s ease;font-size: 14px;font-weight: 500;box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}.user-toggle:hover {background: white;box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);transform: translateY(-1px);border-color: rgba(0, 0, 0, 0.15);
}/* 用户头像占位符 */
.user-avatar-placeholder {width: 28px;height: 28px;border-radius: 50%;background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);display: flex;align-items: center;justify-content: center;color: white;font-weight: 600;font-size: 12px;flex-shrink: 0;
}.user-avatar-placeholder.large {width: 44px;height: 44px;font-size: 16px;
}/* 用户名 */
.user-name {font-weight: 500;font-size: 13px;max-width: 100px;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;color: #1f2937;
}/* 用户下拉菜单 */
.user-dropdown {position: absolute;top: 100%;right: 0;margin-top: 8px;background: white;border-radius: 12px;box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);width: 200px;opacity: 0;visibility: hidden;transform: translateY(-10px) scale(0.95);transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);z-index: 1000;border: 1px solid rgba(0, 0, 0, 0.08);overflow: hidden; /* 改回hidden */
}.user-dropdown.active {opacity: 1;visibility: visible;transform: translateY(0) scale(1);
}/* 用户信息区域 */
.user-info {width: 100%;display: flex;align-items: center;gap: 0px;padding: 16px;background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);border-radius: 8px 8px 0 0;border-bottom: 1px solid rgba(0, 0, 0, 0.06);flex-direction: column;
}.user-details h4 {margin: 0;font-size: 15px;font-weight: 600;color: #1f2937;line-height: 1.3;
}.user-details p {margin: 2px 0 0 0;font-size: 12px;color: #6b7280;line-height: 1.4;
}.menu_button {display: flex;flex-direction: column;width: 100%;margin-top: 10px;
}
.menu_button button {display: flex;align-items: center;justify-content: flex-start;width: 100%;padding: 12px 16px;border: none;border-radius: 8px;color: #4a5568;font-size: 14px;font-weight: 500;cursor: pointer;transition: all 0.3s ease;position: relative;overflow: hidden;box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);background: linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}/* 悬停效果 */
.menu_button button:hover {background: #f7fafc;color: #2d3748;transform: translateY(-1px);box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08);
}/* 点击效果 */
.menu_button button:active {transform: translateY(0);box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}/* 退出登录按钮特殊样式 */
.menu_button button:last-child {color: #e53e3e;/* margin-top: 8px; */border-top: 1px solid rgba(0, 0, 0, 0.06);padding-top: 14px;
}.menu_button button:last-child:hover {background: rgba(229, 62, 62, 0.05);color: #c53030;
}
五.前端展示





六.心得体会
通过这个项目,我不仅掌握了 Vue 3 和 TypeScript 的开发技能,更重要的是建立了完整的前端工程化思维。我学会了如何分析需求、设计架构、解决问题、优化体验。这些能力将成为我未来职业发展的重要基础。
同时,我也认识到自己还有很多不足:对性能优化的理解还不够深入,对设计模式的运用还不够熟练,对工程化工具的掌握还需加强。但正是这些不足,给了我继续学习和进步的方向。
这个项目是我前端学习道路上的一个重要里程碑,它让我从一个初学者成长为能够独立完成复杂项目的开发者。我相信,这段经历将成为我职业生涯中宝贵的财富。