智能运动辅助应用-形之助-综合实践
综合实践
组名、项目简介:<组名:好运来,项目需求:智能运动辅助应用,项目目标:对用户上传的视频进行分析评分以及改进意见。项目开展技术路线:这是一个基于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 | IEEE Conference Publication | IEEE Xplore
[3] Vue 3 组合式API文档 合成API常见问题解答 |Vue.js
Gitee地址:
项目背景:
随着全民健身的深入与健身文化的普及,以引体向上为代表的自重训练,因其便捷性与高效性,成为衡量个人基础力量与身体素质的重要标志,广泛应用于学校体测、军事训练及大众健身。然而,传统的动作评估高度依赖教练员的肉眼观察与主观经验,存在标准不一、反馈延迟、难以量化等局限性。在缺少专业指导的环境中,训练者往往难以察觉自身动作模式的细微偏差,如借力、摆动、幅度不足等,这不仅影响训练效果,长期更可能导致运动损伤。如何将人工智能与计算机视觉技术,转化为每个人触手可及的“AI教练”,提供客观、即时、精准的动作反馈,已成为提升科学化训练水平的一个迫切需求。
项目概述:
本项目旨在开发一套基于计算机视觉的智能引体向上动作分析与评估系统。系统通过训练者上传的视频,运用先进的人体姿态估计算法,自动识别并追踪身体关键点。针对引体向上动作的复杂性,我们创新性地构建了双视角协同分析框架:正面视角专注于分析握距对称性、身体稳定性和左右平衡,确保动作的规范与基础架构;侧面视角则着重评估动作的完整性、躯干角度与发力模式,判断动作幅度与效率。通过多维度量化指标,系统能够自动分解动作周期、识别违规代偿,并生成直观的可视化报告与改进建议。最终,本项目致力于打造一个低成本、高精度的自动化评估工具,为个人训练者、体育教育及专业机构提供一种数据驱动的科学训练辅助解决方案。、
项目分工:
蔡骏:负责用户界面前端所需前端功能的构建。
赵雅萱:负责管理员系统构建。
薛雨晨:实现功能部署到服务器的使用,以及前后端接口的书写修订。
海米沙:墨刀进行原型设计,实时记录市场调研结果并汇报分析需求,项目logo及产品名称设计,进行软件测试。
谢文杰:负责正面评分标准制定,搭建知识库。
赖翊煊:负责侧面评分标准制定,API接口接入AI
叶骋恺:负责数据库方面创建与设计
尚子琪:负责进行爬虫爬取对应相关视频,进行软件测试
管理员系统构建
我负责管理员系统前端的构建,以下是具体的介绍
一、核心组件与页面结构
- 管理员页面入口核心代码片段
点击查看代码
<template>
<Info :data="dashboardData" />
<LoginChart :chart-data="chartData" :loading="loading" />
<PendingFeedbackCard ... />
<MediaManagement ... />
</template>
<script setup lang="ts">
// 数据类型定义
interface DashboardData { /* 统计数据结构 */ }
interface ChartData { /* 图表数据结构 */ }
// 响应式数据
const dashboardData = ref<DashboardData>({...})
const chartData = ref<ChartData>({...})
const videoList = ref<VideoItem[]>([])
// API请求函数
const fetchDashboardStats = async () => await apiRequest('/api/admin/dashboard/stats')
const fetchChartData = async () => await apiRequest('/api/admin/dashboard/chart-data')
// 其他API函数(视频上传/删除、反馈处理等)
// 初始化函数
const fetchData = async () => {
const [statsData, chartDataResponse] = await Promise.all([
fetchDashboardStats(),
fetchChartData()
])
dashboardData.value = statsData.data
chartData.value = chartDataResponse.data
}
// 权限校验
const checkAuthStatus = async () => {
const token = localStorage.getItem('token')
if (!token) router.push('/login')
const data = await authAPI.verifyToken(token)
if (data.role !== 'admin') router.push('/main')
}
</script>
- 数据统计卡片组件核心代码片段
接收并展示平台 4 类核心运营数据:今日登录量、注册用户数、待处理反馈数、媒体文件数;
数值千分位格式化,提升可读性;
响应式布局(大屏多列、小屏单列),差异化配色 + hover 交互
点击查看代码
<template>
<div class="stats-cards">
<!-- 循环渲染4类数据卡片 -->
<div v-for="(card, index) in cardsData" :key="index" class="stat-card" :class="`card-${index + 1}`">
<div class="card-icon"><component :is="card.icon" /></div>
<div class="card-content">
<div class="card-title">{{ card.title }}</div>
<div class="card-value">{{ formatNumber(card.value) }}</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, withDefaults } from 'vue';
// 1. 定义数据类型:约束父组件传递的参数格式
interface Props {
data?: {
loginCount: number; // 今日登录量
onlineUsers: number; // 注册用户总数
pendingFeedback: number; // 待处理反馈数
mediaFiles: number; // 媒体文件总数
};
}
// 2. 设置默认值:空数据场景下避免渲染异常
const props = withDefaults(defineProps<Props>(), {
data: () => ({
loginCount: 0,
onlineUsers: 0,
pendingFeedback: 0,
mediaFiles: 0
})
});
// 3. 数值格式化函数:千分位展示(如1234 → 1,234)
const formatNumber = (num: number): string => {
return new Intl.NumberFormat('zh-CN').format(num);
};
// 4. 卡片数据映射:将原始数据转换为渲染格式
const cardsData = ref([
{
title: '今日登录量',
value: props.data.loginCount,
icon: { template: '<div class="icon">👤</div>' }
},
{
title: '当前注册用户',
value: props.data.onlineUsers,
icon: { template: '<div class="icon">👥</div>' }
},
{
title: '待处理反馈',
value: props.data.pendingFeedback,
icon: { template: '<div class="icon">⚠️</div>' }
},
{
title: '媒体文件数',
value: props.data.mediaFiles,
icon: { template: '<div class="icon">📁</div>' }
}
]);
</script>
<style scoped lang="scss">
// 5. 响应式样式:适配不同屏幕
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 16px;
padding: 16px;
// 小屏适配(≤768px)
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
// 6. 卡片样式:差异化配色+hover交互
.stat-card {
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: transform 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
}
// 差异化配色
&.card-1 { background-color: rgba(24, 144, 255, 0.05); }
&.card-2 { background-color: rgba(82, 196, 26, 0.05); }
&.card-3 { background-color: rgba(250, 173, 20, 0.05); }
&.card-4 { background-color: rgba(255, 77, 79, 0.05); }
}
.card-icon {
font-size: 24px;
margin-bottom: 8px;
}
.card-title {
font-size: 14px;
color: #666;
margin-bottom: 4px;
}
.card-value {
font-size: 20px;
font-weight: 600;
}
</style>
3.登录趋势折线图(LoginChart.vue)
功能说明
可视化展示 7 天 / 30 天用户登录量与活跃用户数趋势;
支持加载 / 错误 / 空数据 / 正常 4 种状态适配;
图表实例生命周期管理,避免内存泄漏。
点击查看代码
<template>
<div class="login-chart-container">
<!-- 1. 加载状态 -->
<div v-if="loading" class="loading-state">
<div class="spinner"></div>
<span>图表加载中...</span>
</div>
<!-- 2. 错误状态 -->
<div v-else-if="error" class="error-state">
<span>{{ error }}</span>
<button @click="initChart" class="retry-btn">重试</button>
</div>
<!-- 3. 空数据状态 -->
<div v-else-if="!hasValidData" class="empty-state">暂无登录数据</div>
<!-- 4. 正常渲染图表 -->
<div v-else class="chart-wrapper">
<canvas ref="chartCanvas"></canvas>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { Chart, type ChartConfiguration } from 'chart.js/auto';
// 1. 定义接收的图表数据类型
interface Props {
chartData: {
dates: string[]; // X轴:日期
loginData: number[]; // Y轴:登录量
activeData: number[]; // Y轴:活跃用户数
dateRange: string; // 时间范围(7天/30天)
};
loading?: boolean; // 加载状态
}
// 2. 设置默认值
const props = withDefaults(defineProps<Props>(), {
loading: false,
chartData: () => ({
dates: [],
loginData: [],
activeData: [],
dateRange: '7天'
})
});
// 3. 核心状态管理
const chartCanvas = ref<HTMLCanvasElement | null>(null);
const chartInstance = ref<Chart | null>(null);
const error = ref<string | null>(null);
// 4. 数据有效性校验
const hasValidData = computed(() => {
return props.chartData.dates.length > 0 && props.chartData.loginData.length > 0;
});
// 5. 初始化图表
const initChart = () => {
try {
// 校验前置条件
if (!chartCanvas.value || !hasValidData.value) return;
// 销毁旧实例:防止内存泄漏
if (chartInstance.value) {
chartInstance.value.destroy();
chartInstance.value = null;
}
// 创建新图表实例
const ctx = chartCanvas.value.getContext('2d');
if (!ctx) throw new Error('无法获取Canvas上下文');
const chartConfig: ChartConfiguration<'line'> = {
type: 'line',
data: {
labels: props.chartData.dates,
datasets: [
{
label: '登录量',
data: props.chartData.loginData,
borderColor: '#1890ff',
backgroundColor: 'rgba(24, 144, 255, 0.1)',
borderWidth: 2,
tension: 0.3, // 折线平滑度
fill: true
},
{
label: '活跃用户',
data: props.chartData.activeData,
borderColor: '#52c41a',
backgroundColor: 'rgba(82, 196, 26, 0.1)',
borderWidth: 2,
tension: 0.3,
fill: true
}
]
},
options: {
responsive: true, // 自适应宽度
maintainAspectRatio: false, // 取消宽高比限制
plugins: {
legend: { display: false } // 隐藏图例
},
scales: {
y: {
beginAtZero: true, // Y轴从0开始
grid: {
color: '#f0f0f0' // 网格线颜色
}
},
x: {
grid: {
display: false // 隐藏X轴网格线
}
}
},
interaction: {
intersect: false, // tooltip不强制相交
mode: 'index' // 鼠标hover时显示同X轴所有数据
}
}
};
chartInstance.value = new Chart(ctx, chartConfig);
error.value = null; // 清除错误
} catch (err) {
error.value = '图表加载失败:' + (err as Error).message;
}
};
// 6. 监听数据变化:实时更新图表
const updateChart = () => {
if (chartInstance.value && hasValidData.value) {
chartInstance.value.data.labels = props.chartData.dates;
(chartInstance.value.data.datasets[0] as any).data = props.chartData.loginData;
(chartInstance.value.data.datasets[1] as any).data = props.chartData.activeData;
chartInstance.value.update();
}
};
// 7. 生命周期管理
onMounted(() => {
if (!props.loading && hasValidData.value) {
initChart();
}
});
onUnmounted(() => {
// 组件卸载时销毁图表:释放内存
if (chartInstance.value) {
chartInstance.value.destroy();
chartInstance.value = null;
}
});
// 8. 监听props变化:数据更新时刷新图表
watch([() => props.chartData, () => props.loading], () => {
if (!props.loading && hasValidData.value) {
if (chartInstance.value) {
updateChart();
} else {
initChart();
}
}
}, { deep: true });
</script>
<style scoped lang="scss">
.login-chart-container {
width: 100%;
height: 400px;
padding: 16px;
box-sizing: border-box;
}
.loading-state, .error-state, .empty-state {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #666;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #f0f0f0;
border-top: 4px solid #1890ff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 8px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.retry-btn {
margin-top: 8px;
padding: 4px 12px;
border: 1px solid #1890ff;
border-radius: 4px;
background: #fff;
color: #1890ff;
cursor: pointer;
&:hover {
background: #f5f8ff;
}
}
.chart-wrapper {
width: 100%;
height: 100%;
}
</style>
代码核心作用
多状态适配:覆盖加载 / 错误 / 空数据 / 正常场景,提升用户体验;
生命周期管理:组件卸载时销毁图表,避免内存泄漏;
交互优化:tooltip 跟随鼠标、折线平滑、网格线美化;
数据监听:props 变化时实时更新图表,无需重新创建实例。
4.:视频资源管理
功能说明
支持视频上传(点击选择 / 拖拽上传),含类型(仅视频)、大小(≤2GB)校验;
展示视频列表,支持预览、删除、刷新操作;
操作状态隔离(仅禁用当前操作项),避免批量阻塞。
点击查看代码
<template>
<div class="media-management">
<!-- 1. 头部统计 -->
<div class="header">
<h3>媒体内容管理</h3>
<div class="stats">
<span>视频总数: {{ videos.length }}</span>
<span>总大小: {{ formatFileSize(totalSize) }}</span>
</div>
</div>
<!-- 2. 上传区域 -->
<div class="upload-section">
<div class="upload-form">
<!-- 2.1 视频名称 -->
<div class="form-group">
<label>视频名称 <span class="required">*</span></label>
<input
v-model="uploadForm.name"
class="form-input"
placeholder="请输入视频名称"
@blur="validateName"
/>
<div v-if="formErrors.name" class="error-message">{{ formErrors.name }}</div>
</div>
<!-- 2.2 视频文件上传 -->
<div class="form-group">
<label>选择视频文件 <span class="required">*</span></label>
<div
class="file-upload-area"
:class="{ 'drag-over': dragOver, 'has-file': uploadForm.file }"
@click="triggerFileInput"
@drop="handleFileDrop"
@dragover.prevent="dragOver = true"
@dragleave="dragOver = false"
>
<template v-if="!uploadForm.file">
<div class="upload-hint">点击选择或拖拽视频文件到此区域</div>
<div class="upload-tip">支持MP4/AVI/MOV格式,最大2GB</div>
</template>
<template v-else>
<div class="file-name">{{ uploadForm.file.name }}</div>
<div class="file-size">{{ formatFileSize(uploadForm.file.size) }}</div>
</template>
<input
ref="fileInput"
type="file"
accept="video/*"
@change="handleFileSelect"
class="file-input-hidden"
/>
</div>
<div v-if="formErrors.file" class="error-message">{{ formErrors.file }}</div>
</div>
<!-- 2.3 上传操作 -->
<div class="upload-actions">
<button
@click="uploadVideo"
:disabled="!canUpload || uploading"
class="btn primary"
>
<span v-if="uploading">上传中...</span>
<span v-else>上传至服务器</span>
</button>
<button
@click="resetUploadForm"
:disabled="uploading"
class="btn default"
>
重置
</button>
</div>
</div>
</div>
<!-- 3. 视频列表 -->
<div class="video-list-section">
<div class="section-header">
<h4>视频列表 ({{ videos.length }})</h4>
<button @click="refreshVideos" :disabled="loading" class="btn small">刷新列表</button>
</div>
<!-- 3.1 加载状态 -->
<div v-if="loading" class="loading-state">加载中...</div>
<!-- 3.2 视频列表 -->
<div v-else class="video-list">
<div
v-for="video in videos"
:key="video.id"
class="video-item"
:class="{ deleting: deletingId === video.id }"
>
<div class="video-preview">🎬</div>
<div class="video-info">
<div class="video-name">{{ video.name }}</div>
<div class="video-meta">
<span>上传时间: {{ formatTime(video.uploadTime) }}</span>
<span>大小: {{ formatFileSize(video.size) }}</span>
</div>
</div>
<div class="video-actions">
<button @click="previewVideo(video)" class="btn small" v-if="video.url">预览</button>
<button
@click="deleteVideo(video.id)"
:disabled="deletingId === video.id"
class="btn small danger"
>
<span v-if="deletingId === video.id">删除中...</span>
<span v-else>删除</span>
</button>
</div>
</div>
<!-- 3.3 空状态 -->
<div v-if="videos.length === 0" class="empty-state">暂无视频文件</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
// 1. 定义数据类型
interface VideoItem {
id: number;
name: string;
uploadTime: string;
size: number;
url?: string;
annotation?: string;
}
// 2. Props与事件
interface Props {
videos: VideoItem[];
loading?: boolean;
}
interface Emits {
(e: 'video-upload', formData: { name: string; file: File }): void;
(e: 'video-delete', videoId: number): void;
(e: 'refresh'): void;
}
const props = withDefaults(defineProps<Props>(), {
videos: () => [],
loading: false
});
const emit = defineEmits<Emits>();
// 3. 上传表单状态
const uploadForm = ref({
name: '',
file: null as File | null
});
const formErrors = ref<{ name?: string; file?: string }>({});
const uploading = ref(false);
const deletingId = ref<number | null>(null);
const dragOver = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
// 4. 计算属性
// 4.1 总文件大小
const totalSize = computed(() => {
return props.videos.reduce((sum, video) => sum + video.size, 0);
});
// 4.2 能否上传(名称+文件都有,且无错误)
const canUpload = computed(() => {
return !!uploadForm.value.name && !!uploadForm.value.file && Object.keys(formErrors.value).length === 0;
});
// 5. 表单校验
// 5.1 名称校验
const validateName = () => {
if (!uploadForm.value.name) {
formErrors.value.name = '请输入视频名称';
} else {
delete formErrors.value.name;
}
};
// 5.2 文件校验
const validateFile = (file: File) => {
formErrors.value.file = '';
// 类型校验:仅视频
if (!file.type.startsWith('video/')) {
formErrors.value.file = '请选择MP4/AVI/MOV等视频格式文件';
return false;
}
// 大小校验:≤2GB
const maxSize = 2 * 1024 * 1024 * 1024;
if (file.size > maxSize) {
formErrors.value.file = '文件大小不能超过2GB';
return false;
}
return true;
};
// 6. 上传相关方法
// 6.1 触发文件选择框
const triggerFileInput = () => {
fileInput.value?.click();
};
// 6.2 选择文件
const handleFileSelect = (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
if (validateFile(file)) {
uploadForm.value.file = file;
// 自动填充名称(无名称时)
if (!uploadForm.value.name) {
uploadForm.value.name = file.name.replace(/\.[^/.]+$/, '');
}
}
}
};
// 6.3 拖拽上传
const handleFileDrop = (e: DragEvent) => {
e.preventDefault();
dragOver.value = false;
const file = e.dataTransfer?.files[0];
if (file) {
if (validateFile(file)) {
uploadForm.value.file = file;
if (!uploadForm.value.name) {
uploadForm.value.name = file.name.replace(/\.[^/.]+$/, '');
}
}
}
};
// 6.4 提交上传
const uploadVideo = async () => {
validateName();
if (!canUpload.value) return;
uploading.value = true;
try {
await emit('video-upload', {
name: uploadForm.value.name,
file: uploadForm.value.file!
});
resetUploadForm();
} catch (err) {
alert('上传失败:' + (err as Error).message);
} finally {
uploading.value = false;
}
};
// 6.5 重置表单
const resetUploadForm = () => {
uploadForm.value = {
name: '',
file: null
};
formErrors.value = {};
if (fileInput.value) {
fileInput.value.value = '';
}
};
// 7. 视频操作方法
// 7.1 删除视频
const deleteVideo = async (id: number) => {
if (!confirm('确定删除该视频吗?删除后不可恢复!')) return;
deletingId.value = id;
try {
await emit('video-delete', id);
} catch (err) {
alert('删除失败:' + (err as Error).message);
} finally {
deletingId.value = null;
}
};
// 7.2 预览视频
const previewVideo = (video: VideoItem) => {
if (video.url) {
window.open(video.url, '_blank');
} else {
alert('该视频暂无预览链接');
}
};
// 7.3 刷新列表
const refreshVideos = () => {
emit('refresh');
};
// 8. 工具函数
// 8.1 文件大小格式化
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`;
};
// 8.2 时间格式化
const formatTime = (timeStr: string): string => {
return new Date(timeStr).toLocaleString('zh-CN');
};
// 9. 监听表单名称变化:实时校验
watch(() => uploadForm.value.name, validateName);
</script>
<style scoped lang="scss">
.media-management {
width: 100%;
padding: 16px;
box-sizing: border-box;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h3 {
margin: 0;
color: #333;
}
.stats {
color: #666;
span {
margin-left: 16px;
}
}
}
.upload-section {
background: #f9f9f9;
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.form-group {
margin-bottom: 16px;
label {
display: block;
margin-bottom: 4px;
color: #333;
.required {
color: #ff4d4f;
}
}
.form-input {
width: 100%;
padding: 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
box-sizing: border-box;
}
textarea {
width: 100%;
padding: 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
box-sizing: border-box;
resize: vertical;
}
}
.file-upload-area {
width: 100%;
height: 120px;
border: 2px dashed #d9d9d9;
border-radius: 4px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
&.drag-over {
border-color: #1890ff;
background: #f5f8ff;
}
&.has-file {
border-style: solid;
border-color: #52c41a;
background: #f6ffed;
}
.upload-hint {
font-size: 14px;
color: #666;
margin-bottom: 4px;
}
.upload-tip {
font-size: 12px;
color: #999;
}
.file-name {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.file-size {
font-size: 12px;
color: #666;
}
}
.file-input-hidden {
display: none;
}
.error-message {
color: #ff4d4f;
font-size: 12px;
margin-top: 4px;
}
.upload-actions {
display: flex;
gap: 8px;
}
.btn {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
border: none;
font-size: 14px;
&.primary {
background: #1890ff;
color: #fff;
&:disabled {
background: #8cc5ff;
cursor: not-allowed;
}
}
&.default {
background: #f0f0f0;
color: #333;
&:disabled {
background: #f5f5f5;
cursor: not-allowed;
}
}
&.small {
padding: 4px 8px;
font-size: 12px;
}
&.danger {
background: #ff4d4f;
color: #fff;
&:disabled {
background: #ff8080;
cursor: not-allowed;
}
}
}
.video-list-section {
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
h4 {
margin: 0;
color: #333;
}
}
}
.video-list {
border: 1px solid #d9d9d9;
border-radius: 8px;
overflow: hidden;
}
.video-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&.deleting {
opacity: 0.6;
}
}
.video-preview {
font-size: 24px;
margin-right: 16px;
width: 40px;
text-align: center;
}
.video-info {
flex: 1;
.video-name {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.video-meta {
font-size: 12px;
color: #666;
span {
margin-right: 16px;
}
}
}
.video-actions {
display: flex;
gap: 8px;
}
.loading-state, .empty-state {
padding: 24px;
text-align: center;
color: #666;
}
</style>
点击查看代码
<template>
<div class="pending-feedback-card">
<!-- 1. 卡片头部 -->
<div class="card-header">
<span class="title">待处理反馈</span>
<span class="badge">{{ pendingCount }}</span>
</div>
<!-- 2. 筛选区域 -->
<div class="filters">
<div class="filter-group">
<label>类型筛选:</label>
<select v-model="selectedType" @change="resetPage" class="filter-select">
<option value="">全部类型</option>
<option v-for="type in uniqueTypes" :key="type">{{ type }}</option>
</select>
</div>
<div class="filter-group">
<label>状态筛选:</label>
<select v-model="selectedStatus" @change="resetPage" class="filter-select">
<option value="">全部状态</option>
<option value="待处理">待处理</option>
<option value="已处理">已处理</option>
</select>
</div>
</div>
<!-- 3. 反馈列表 -->
<div class="feedback-list">
<!-- 3.1 筛选后列表 -->
<div v-if="currentPageData.length" class="feedback-items">
<div
v-for="item in currentPageData"
:key="item.id"
class="feedback-item"
@mouseenter="hoveredItem = item.id"
@mouseleave="hoveredItem = null"
:class="{ 'processed-item': item.status === '已处理' }"
>
<div class="item-time">{{ formatRelativeTime(item.time) }}</div>
<div class="item-content">
<div class="content-title">{{ item.content.title }}</div>
<div class="content-desc">{{ item.content.desc }}</div>
</div>
<div class="item-type">{{ item.type }}</div>
<!-- 仅待处理反馈显示操作按钮 -->
<div class="item-actions" v-if="hoveredItem === item.id && item.status === '待处理'">
<button @click="handleProcess(item)" class="btn process">处理</button>
<button @click="handleIgnore(item)" class="btn ignore">忽略</button>
</div>
</div>
</div>
<!-- 3.2 空状态 -->
<div v-else class="empty-state">
{{ filteredFeedbackList.length ? '暂无更多反馈' : '暂无符合条件的反馈' }}
</div>
<!-- 4. 分页控件 -->
<div class="pagination" v-if="filteredFeedbackList.length">
<button @click="currentPage = 1" :disabled="currentPage === 1" class="page-btn">首页</button>
<button @click="currentPage--" :disabled="currentPage === 1" class="page-btn">上一页</button>
<span class="page-info">第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span>
<button @click="currentPage++" :disabled="currentPage === totalPages" class="page-btn">下一页</button>
<button @click="currentPage = totalPages" :disabled="currentPage === totalPages" class="page-btn">末页</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
// 1. 定义数据类型
interface FeedbackContent {
title: string;
desc: string;
}
interface FeedbackItem {
id: number;
time: string; // 反馈时间(ISO格式)
content: FeedbackContent;
type: string; // 反馈类型(如:功能建议、bug反馈、体验优化)
status: '待处理' | '已处理'; // 处理状态
}
// 2. Props与事件
interface Props {
feedbackList: FeedbackItem[];
}
interface Emits {
(e: 'feedbackProcessed', itemId: number): void;
(e: 'feedbackIgnored', itemId: number): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// 3. 筛选与分页状态
const selectedType = ref(''); // 选中的反馈类型
const selectedStatus = ref(''); // 选中的处理状态
const currentPage = ref(1); // 当前页码
const pageSize = ref(10); // 每页显示10条
const hoveredItem = ref<number | null>(null); // 鼠标悬浮的反馈ID
// 4. 计算属性
// 4.1 待处理反馈总数
const pendingCount = computed(() => {
return props.feedbackList.filter(item => item.status === '待处理').length;
});
// 4.2 所有唯一的反馈类型(自动提取)
const uniqueTypes = computed(() => {
return [...new Set(props.feedbackList.map(item => item.type))];
});
// 4.3 筛选后的反馈列表
const filteredFeedbackList = computed(() => {
return props.feedbackList.filter(item => {
// 类型筛选:为空则全部,否则匹配类型
const typeMatch = !selectedType.value || item.type === selectedType.value;
// 状态筛选:为空则全部,否则匹配状态
const statusMatch = !selectedStatus.value || item.status === selectedStatus.value;
return typeMatch && statusMatch;
});
});
// 4.4 总页数
const totalPages = computed(() => {
return Math.ceil(filteredFeedbackList.value.length / pageSize.value);
});
// 4.5 当前页展示的数据
const currentPageData = computed(() => {
const startIndex = (currentPage.value - 1) * pageSize.value;
const endIndex = startIndex + pageSize.value;
return filteredFeedbackList.value.slice(startIndex, endIndex);
});
// 5. 方法
// 5.1 重置页码(筛选条件变更时)
const resetPage = () => {
currentPage.value = 1;
};
// 5.2 处理反馈(标记为已处理)
const handleProcess = (item: FeedbackItem) => {
emit('feedbackProcessed', item.id);
// 本地更新状态(实时反馈,无需重新请求接口)
const index = props.feedbackList.findIndex(i => i.id === item.id);
if (index !== -1) {
props.feedbackList[index].status = '已处理';
}
};
// 5.3 忽略反馈(移除该反馈)
const handleIgnore = (item: FeedbackItem) => {
emit('feedbackIgnored', item.id);
// 本地移除(实时反馈)
const index = props.feedbackList.findIndex(i => i.id === item.id);
if (index !== -1) {
props.feedbackList.splice(index, 1);
}
};
// 5.4 相对时间格式化(如:30分钟前、2小时前、12-22)
const formatRelativeTime = (timeStr: string): string => {
const feedbackTime = new Date(timeStr);
const now = new Date();
const diffMs = now.getTime() - feedbackTime.getTime(); // 时间差(毫秒)
// 计算时间差
const diffMinutes = Math.floor(diffMs / 60000); // 分钟
const diffHours = Math.floor(diffMs / 3600000); // 小时
const diffDays = Math.floor(diffMs / 86400000); // 天
// 小于60分钟:X分钟前
if (diffMinutes < 60) {
return `${diffMinutes}分钟前`;
}
// 小于24小时:X小时前
if (diffHours < 24) {
return `${diffHours}小时前`;
}
// 大于等于24小时:月/日
return feedbackTime.toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit'
});
};
// 6. 监听
// 6.1 监听原始反馈列表变化:重置筛选和分页
watch(() => props.feedbackList, () => {
selectedType.value = '';
selectedStatus.value = '';
currentPage.value = 1;
}, { deep: true });
// 6.2 监听总页数变化:防止页码超出范围
watch(totalPages, (newTotal) => {
if (currentPage.value > newTotal && newTotal > 0) {
currentPage.value = newTotal;
}
});
</script>
<style scoped lang="scss">
.pending-feedback-card {
width: 100%;
border: 1px solid #d9d9d9;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.card-header {
padding: 12px 16px;
background: #f5f5f5;
display: flex;
align-items: center;
.title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.badge {
margin-left: 8px;
padding: 2px 8px;
background: #ff4d4f;
color: #fff;
border-radius: 10px;
font-size: 12px;
}
}
.filters {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
display: flex;
gap: 24px;
.filter-group {
display: flex;
align-items: center;
gap: 8px;
label {
font-size: 14px;
color: #666;
}
.filter-select {
padding: 4px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
color: #333;
cursor: pointer;
}
}
}
.feedback-list {
padding: 8px 0;
.empty-state {
padding: 24px;
text-align: center;
color: #666;
font-size: 14px;
}
}
.feedback-item {
padding: 12px 16px;
display: flex;
align-items: flex-start;
gap: 16px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s ease;
&:hover {
background-color: #f9f9f9;
}
&.processed-item {
opacity: 0.7;
.content-title {
text-decoration: line-through;
}
}
&:last-child {
border-bottom: none;
}
}
.item-time {
font-size: 12px;
color: #999;
min-width: 80px;
padding-top: 2px;
}
.item-content {
flex: 1;
.content-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
}
.content-desc {
font-size: 13px;
color: #666;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
.item-type {
min-width: 80px;
font-size: 12px;
color: #1890ff;
background-color: #e6f7ff;
padding: 2px 8px;
border-radius: 12px;
text-align: center;
}
.item-actions {
display: flex;
gap: 8px;
padding-top: 2px;
}
.btn {
padding: 4px 8px;
border-radius: 4px;
border: none;
font-size: 12px;
cursor: pointer;
&.process {
background-color: #52c41a;
color: #fff;
}
&.ignore {
background-color: #ff4d4f;
color: #fff;
}
}
.pagination {
padding: 16px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.page-btn {
padding: 4px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 12px;
&:disabled {
color: #999;
cursor: not-allowed;
background: #f5f5f5;
}
&:hover:not(:disabled) {
border-color: #1890ff;
color: #1890ff;
}
}
.page-info {
font-size: 12px;
color: #666;
margin: 0 8px;
}
}
</style>
点击查看代码
const apiRequest = async (url: string, options: RequestInit = {}) => {
const token = localStorage.getItem('token') || ''
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...options.headers
}
}
const response = await fetch(`${API_BASE_URL}${url}`, { ...defaultOptions, ...options })
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
return await response.json()
}
点击查看代码
// 视频管理
const uploadVideo = async (formData: FormData) =>
await apiRequest('/api/admin/media/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
body: formData
})
const deleteVideo = async (videoId: number) =>
await apiRequest(`/api/admin/media/videos/${videoId}`, { method: 'DELETE' })
// 反馈处理
const processFeedback = async (feedbackId: number) =>
await apiRequest(`/api/admin/feedback/${feedbackId}/process`, { method: 'PUT' })
点击查看代码
const checkAuthStatus = async () => {
const token = localStorage.getItem('token')
if (!token) {
router.push('/login')
return
}
const data = await authAPI.verifyToken(token)
if (data.role !== 'admin') {
showSuccessMessage('权限不足!', 2000)
router.push('/main')
}
}
点击查看代码
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')
四、样式与 UI 组件
- 统计卡片样式
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
padding: 20px;
}
.stat-card {
display: flex;
align-items: center;
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: transform 0.3s ease;
}
.stat-card:hover { transform: translateY(-2px); }
作用:
定义统计卡片的布局(网格布局自适应屏幕)
设置卡片样式(阴影、圆角、hover 动画),提升用户体验
2. 通用按钮与表单样式核心代码片段
点击查看代码
.btn-primary {
background: #3498db;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
transition: background 0.3s ease;
}
.btn-primary:hover:not(:disabled) { background: #2980b9; }
.btn-primary:disabled { background: #bdc3c7; cursor: not-allowed; }
总结
管理员前端代码主要实现以下功能:
数据可视化:通过统计卡片、图表展示平台关键数据(登录量、注册用户等)
媒体管理:支持教学视频的上传、删除和列表展示
反馈处理:处理用户提交的反馈,标记为 "已处理" 或 "忽略"
权限控制:仅管理员可访问后台,通过token验证身份和角色
API 交互:封装统一的请求逻辑,与后端接口通信获取 / 提交数据
代码采用模块化设计,通过组件拆分(如Info、MediaManagement)和 API 封装,保证了可维护性和扩展性。
浙公网安备 33010602011771号