hookehuyr

feat(学生详情页): 重构学生详情页并添加作业记录功能

- 使用 van-tabs 重构标签页切换组件,提升用户体验
- 新增作业记录标签页,支持图片、视频和音频的展示与播放
- 实现点赞功能及相关API调用
- 优化多媒体播放控制逻辑,避免同时播放多个媒体
- 添加样式优化,提升页面整体美观度
......@@ -2,7 +2,7 @@
* @Author: hookehuyr hookehuyr@gmail.com
* @Date: 2025-06-19 17:12:19
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-06-19 17:27:16
* @LastEditTime: 2025-06-19 21:32:23
* @FilePath: /mlaj/src/views/teacher/studentPage.vue
* @Description: 学生详情页面
-->
......@@ -85,7 +85,7 @@
</div>
<!-- 功能按钮 -->
<div class="bg-white mt-2 p-4">
<div class="mt-2 p-4">
<!-- 状态筛选 -->
<div class="flex items-center justify-end mb-4">
<div @click="showStatusPopup = true" class="flex items-center text-sm text-gray-600 cursor-pointer">
......@@ -93,11 +93,7 @@
<van-icon name="arrow-down" size="14" class="ml-1" />
</div>
</div>
<div class="flex items-center justify-between mb-3">
<div @click="activeTab = 'statistics'"
:class="['flex-1 text-center py-2 text-sm font-medium cursor-pointer', activeTab === 'statistics' ? 'text-green-600 border-b-2 border-green-600' : 'text-gray-500']">
打卡统计
</div>
<!-- <div class="flex items-center justify-between mb-3">
<div @click="activeTab = 'homework'"
:class="['flex-1 text-center py-2 text-sm font-medium cursor-pointer', activeTab === 'homework' ? 'text-green-600 border-b-2 border-green-600' : 'text-gray-500']">
作业记录
......@@ -106,12 +102,24 @@
:class="['flex-1 text-center py-2 text-sm font-medium cursor-pointer', activeTab === 'evaluation' ? 'text-green-600 border-b-2 border-green-600' : 'text-gray-500']">
班主任点评
</div>
<div @click="activeTab = 'statistics'"
:class="['flex-1 text-center py-2 text-sm font-medium cursor-pointer', activeTab === 'statistics' ? 'text-green-600 border-b-2 border-green-600' : 'text-gray-500']">
打卡统计
</div>
</div> -->
<div class="px-4 py-3 bg-white" style="position: relative;">
<van-tabs v-model:active="activeTab" color="#10b981" sticky animated swipeable @change="handleTabChange">
<van-tab title="作业记录" name="homework"></van-tab>
<van-tab title="班主任点评" name="evaluation"></van-tab>
<van-tab title="打卡统计" name="statistics"></van-tab>
</van-tabs>
</div>
<!-- 记录列表 -->
<van-list v-if="activeTab === 'statistics'" v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
<!-- 记录列表 -->
<van-list v-show="activeTab === 'statistics'" v-model:loading="recordLoading" :finished="recordFinished"
finished-text="没有更多了" @load="onRecordLoad">
<div v-for="record in filteredRecords" :key="record.id"
class="flex items-center justify-between py-4 border-b border-gray-100 last:border-b-0">
class="flex items-center justify-between py-4 border-b border-gray-100 last:border-b-0 bg-white px-4">
<div class="flex items-center flex-1">
<div class="mr-4">
<div style="display: flex; justify-content: center;">
......@@ -135,6 +143,77 @@
</div>
</div>
</van-list>
<!--作业记录 -->
<van-list v-show="activeTab === 'homework' && checkinDataList.length" v-model:loading="loading"
:finished="finished" finished-text="没有更多了" @load="onLoad" class="space-y-4">
<div class="post-card" v-for="post in checkinDataList" :key="post.id">
<div class="post-header">
<van-row>
<van-col span="4">
<van-image round width="2.5rem" height="2.5rem"
:src="post.user.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'" fit="cover" />
</van-col>
<van-col span="17">
<div class="user-info">
<div class="username">{{ post.user.name }}</div>
<div class="post-time">{{ post.user.time }}</div>
</div>
</van-col>
<van-col span="3">
</van-col>
</van-row>
</div>
<div class="post-content">
<div class="post-text">{{ post.content }}</div>
<div class="post-media">
<div v-if="post.images.length" class="post-images">
<van-image width="30%" fit="cover" v-for="(image, index) in post.images" :key="index" :src="image"
radius="5" @click="openImagePreview(index, post)" />
</div>
<van-image-preview v-if="currentPost" v-model:show="showImagePreview" :images="currentPost.images"
:start-position="startPosition" :show-index="true" @change="onChange" />
<div v-for="(v, idx) in post.videoList" :key="idx">
<!-- 视频封面和播放按钮 -->
<div v-if="v.video && !v.isPlaying" class="relative w-full rounded-lg overflow-hidden"
style="aspect-ratio: 16/9; margin-bottom: 1rem;">
<img :src="v.videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_2.png'" :alt="v.content"
class="w-full h-full object-cover" />
<div class="absolute inset-0 flex items-center justify-center cursor-pointer bg-black/20"
@click="startPlay(v)">
<div
class="w-16 h-16 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors">
<van-icon name="play-circle-o" class="text-white" size="40" />
</div>
</div>
</div>
<!-- 视频播放器 -->
<VideoPlayer v-if="v.video && v.isPlaying" :video-url="v.video"
class="post-video rounded-lg overflow-hidden" :ref="el => {
if (el) {
// 确保不重复添加
if (!videoPlayers?.includes(el)) {
videoPlayers?.push(el);
}
}
}" @onPlay="handleVideoPlay(player, post)" @onPause="handleVideoPause(post)" />
</div>
<AudioPlayer v-if="post.audio.length" :songs="post.audio" class="post-audio" :id="post.id" :ref="el => {
if (el) {
// 确保不重复添加
if (!audioPlayers?.includes(el)) {
audioPlayers?.push(el);
}
}
}" @play="(player) => handleAudioPlay(player, post)" />
</div>
</div>
<div class="post-footer">
<van-icon @click="handLike(post)" name="good-job" class="like-icon" :color="post.is_liked ? 'red' : ''" />
<span class="like-count">{{ post.likes }}</span>
</div>
</div>
</van-list>
</div>
<!-- 状态筛选弹窗 -->
......@@ -159,11 +238,20 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { showConfirmDialog, showSuccessToast, showFailToast, showLoadingToast } from 'vant';
import VideoPlayer from "@/components/ui/VideoPlayer.vue";
import AudioPlayer from "@/components/ui/AudioPlayer.vue";
import { useTitle } from '@vueuse/core';
import dayjs from 'dayjs';
import { getTaskDetailAPI, getUploadTaskListAPI, delUploadTaskInfoAPI, likeUploadTaskInfoAPI, dislikeUploadTaskInfoAPI } from "@/api/checkin";
const router = useRouter()
const route = useRoute()
useTitle(route.meta.title);
// 学生信息
const studentInfo = ref({
......@@ -185,7 +273,7 @@ const studentStats = ref({
})
// 当前选中的标签页
const activeTab = ref('statistics')
const activeTab = ref('homework')
// 状态筛选
const statusFilter = ref('按状态')
......@@ -240,8 +328,8 @@ const records = ref([
])
// 列表加载状态
const loading = ref(false)
const finished = ref(false)
const recordLoading = ref(false)
const recordFinished = ref(false)
/**
* 过滤后的记录列表
......@@ -277,25 +365,33 @@ const onStatusSelect = (option) => {
}
/**
* 加载更多数据
* 加载更多记录数据
*/
const onLoad = () => {
const onRecordLoad = () => {
setTimeout(() => {
loading.value = false
finished.value = true
recordLoading.value = false
recordFinished.value = true
}, 1000)
}
/**
* 组件挂载时初始化数据
*/
onMounted(() => {
const checkinDataList = ref([]);
onMounted(async () => {
// 从路由参数获取学生ID
const studentId = route.params.id
console.log('学生详情页面已加载,学生ID:', studentId)
// 这里可以根据studentId调用API获取学生详细信息
loadStudentData(studentId)
const current_date = route.query.date;
if (current_date) {
onLoad(current_date);
} else {
onLoad(dayjs().format('YYYY-MM-DD'));
}
})
/**
......@@ -306,9 +402,290 @@ const loadStudentData = (studentId) => {
// 这里可以调用API获取实际数据
console.log('加载学生数据:', studentId)
}
// 处理标签页切换
const handleTabChange = (name) => {
// 先更新activeTab值
activeTab.value = name;
nextTick(() => {
// 停止所有视频和音频播放
if (videoPlayers.value) {
videoPlayers.value.forEach(player => {
console.warn(player);
if (player && typeof player?.pause === 'function') {
player?.pause();
}
});
}
stopAllAudio();
})
};
// 存储所有视频播放器的引用
const videoPlayers = ref([]);
// 存储所有音频播放器的引用
const audioPlayers = ref([]);
// 组件卸载前清理播放器引用和事件监听器
onBeforeUnmount(() => {
// 停止所有视频和音频播放
if (videoPlayers.value) {
videoPlayers.value.forEach(player => {
if (player && typeof player?.pause === 'function') {
player?.pause();
}
});
}
stopAllAudio();
// 清空引用数组
if (videoPlayers.value) videoPlayers.value = [];
if (audioPlayers.value) audioPlayers.value = [];
});
/**
* 开始播放指定帖子的视频
* @param {Object} post - 要播放视频的帖子对象
*/
const startPlay = (post) => {
// 确保checkinDataList.value是一个数组
if (checkinDataList.value) {
// 先暂停所有其他视频
checkinDataList.value.forEach(p => {
p.videoList.forEach(v => {
if (v.id !== post.id) {
v.isPlaying = false;
}
});
});
}
// 设置当前视频为播放状态
post.isPlaying = true;
};
/**
* 处理视频播放事件
* @param {Object} player - 视频播放器实例
* @param {Object} post - 包含视频的帖子对象
*/
const handleVideoPlay = (player, post) => {
stopAllAudio();
};
/**
* 处理视频暂停事件
* @param {Object} post - 包含视频的帖子对象
*/
const handleVideoPause = (post) => {
// 视频暂停时不改变isPlaying状态,保持播放器可见
// 这样用户可以继续从暂停处播放
};
/**
* 停止除当前播放器外的所有其他视频
* @param {Object} currentPlayer - 当前播放的视频播放器实例
* @param {Object} currentPost - 当前播放的帖子对象
*/
const stopOtherVideos = (currentPlayer, currentPost) => {
// 确保videoPlayers.value是一个数组
if (videoPlayers.value) {
// 暂停其他视频播放器
videoPlayers.value.forEach(player => {
if (player !== currentPlayer && player.pause) {
player.pause();
}
});
}
// 更新其他帖子的播放状态
checkinDataList.value.forEach(p => {
p.videoList.forEach(v => {
if (v.id !== currentPost.id) {
v.isPlaying = false;
}
});
});
};
/**
* 处理音频播放事件
* @param {Object} player - 音频播放器实例
* @param {Object} post - 包含音频的帖子对象
*/
const handleAudioPlay = (player, post) => {
// 停止其他音频播放
stopOtherAudio(player, post);
};
const stopOtherAudio = (currentPlayer, currentPost) => {
// 确保audioPlayers.value是一个数组
if (audioPlayers.value) {
// 暂停其他音频播放器
audioPlayers.value.forEach(player => {
if (player.id !== currentPost.id && player.pause) {
player.pause();
}
});
}
// 更新其他帖子的播放状态
checkinDataList.value.forEach(post => {
if (post.id !== currentPost.id) {
post.isPlaying = false;
}
});
// 停止所有视频播放
stopAllVideos();
}
const stopAllAudio = () => {
// 确保audioPlayers.value是一个数组
if (!audioPlayers.value) return;
audioPlayers.value?.forEach(player => {
// 使用组件暴露的pause方法
if (typeof player.pause === 'function') {
player.pause();
}
});
// 更新所有帖子的播放状态
checkinDataList.value.forEach(post => {
if (post.audio.length) {
post.isPlaying = false;
}
});
}
/**
* 停止所有视频播放
*/
const stopAllVideos = () => {
// 确保videoPlayers.value是一个数组
if (!videoPlayers.value) return;
// 更新所有帖子的播放状态
checkinDataList.value.forEach(p => {
p.videoList.forEach(v => {
v.isPlaying = false;
});
});
};
// 图片预览相关
const showImagePreview = ref(false);
const startPosition = ref(0);
const currentPost = ref(null);
// 打开图片预览
const openImagePreview = (index, post) => {
currentPost.value = post;
startPosition.value = index;
showImagePreview.value = true;
}
// 图片切换事件处理
const onChange = (index) => {
startPosition.value = index;
}
const handLike = async (post) => {
if (!post.is_liked) {
const { code, data } = await likeUploadTaskInfoAPI({ checkin_id: post.id, })
if (code) {
showSuccessToast('点赞成功')
post.likes++;
post.is_liked = true;
}
} else {
const { code, data } = await dislikeUploadTaskInfoAPI({ checkin_id: post.id, })
if (code) {
showSuccessToast('取消点赞成功')
post.likes--;
post.is_liked = false;
}
}
}
const loading = ref(false)
const finished = ref(false)
const limit = ref(3)
const page = ref(0)
const onLoad = async (date) => {
const nextPage = page.value;
const current_date = date || route.query.date || dayjs().format('YYYY-MM-DD');
//
const res = await getUploadTaskListAPI({
limit: limit.value,
page: nextPage,
task_id: route.query.id,
date: current_date
});
if (res.code) {
// 整理数据结构
checkinDataList.value = [...checkinDataList.value, ...formatData(res.data)];
finished.value = res.data.checkin_list.length < limit.value;
page.value = nextPage + 1;
}
loading.value = false;
};
const formatData = (data) => {
let formattedData = [];
formattedData = data?.checkin_list.map((item, index) => {
let images = [];
let audio = [];
let videoList = [];
if (item.file_type === 'image') {
images = item.files.map(file => {
return file.value;
});
} else if (item.file_type === 'video') {
videoList = item.files.map(file => {
return {
id: file.meta_id,
video: file.value,
videoCover: file.cover,
isPlaying: false,
}
})
} else if (item.file_type === 'audio') {
audio = item.files.map(file => {
return {
title: file.name ? file.name : '打卡音频',
artist: file.artist ? file.artist : '',
url: file.value,
cover: file.cover ? file.cover : '',
}
})
}
return {
id: item.id,
task_id: item.task_id,
user: {
name: item.username,
avatar: item.avatar,
time: item.created_time_desc,
},
content: item.note,
images,
videoList,
audio,
isPlaying: false,
likes: item.like_count,
is_liked: item.is_like,
is_my: item.is_my,
file_type: item.file_type,
}
})
return formattedData;
}
</script>
<style scoped>
<style scoped lang="less">
/* 自定义样式 */
.van-circle {
font-size: 12px;
......@@ -328,4 +705,75 @@ const loadStudentData = (studentId) => {
.border-b-2 {
border-bottom-width: 2px;
}
.post-card {
// margin: 1rem 0;
padding: 1rem;
background-color: #FFF;
border-radius: 5px;
.post-header {
margin-bottom: 1rem;
}
.user-info {
margin-left: 0.5rem;
.username {
font-weight: 500;
}
.post-time {
color: gray;
font-size: 0.8rem;
}
}
.post-menu {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.post-content {
.post-text {
color: #666;
margin-bottom: 1rem;
}
.post-media {
.post-images {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.post-video {
margin: 1rem 0;
width: 100%;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.post-audio {
margin: 1rem 0;
}
}
}
.post-footer {
margin-top: 1rem;
color: #666;
.like-icon {
margin-right: 0.25rem;
}
.like-count {
font-size: 0.9rem;
}
}
}
</style>
......