hookehuyr

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

- 使用 van-tabs 重构标签页切换组件,提升用户体验
- 新增作业记录标签页,支持图片、视频和音频的展示与播放
- 实现点赞功能及相关API调用
- 优化多媒体播放控制逻辑,避免同时播放多个媒体
- 添加样式优化,提升页面整体美观度
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
2 * @Author: hookehuyr hookehuyr@gmail.com 2 * @Author: hookehuyr hookehuyr@gmail.com
3 * @Date: 2025-06-19 17:12:19 3 * @Date: 2025-06-19 17:12:19
4 * @LastEditors: hookehuyr hookehuyr@gmail.com 4 * @LastEditors: hookehuyr hookehuyr@gmail.com
5 - * @LastEditTime: 2025-06-19 17:27:16 5 + * @LastEditTime: 2025-06-19 21:32:23
6 * @FilePath: /mlaj/src/views/teacher/studentPage.vue 6 * @FilePath: /mlaj/src/views/teacher/studentPage.vue
7 * @Description: 学生详情页面 7 * @Description: 学生详情页面
8 --> 8 -->
...@@ -85,7 +85,7 @@ ...@@ -85,7 +85,7 @@
85 </div> 85 </div>
86 86
87 <!-- 功能按钮 --> 87 <!-- 功能按钮 -->
88 - <div class="bg-white mt-2 p-4"> 88 + <div class="mt-2 p-4">
89 <!-- 状态筛选 --> 89 <!-- 状态筛选 -->
90 <div class="flex items-center justify-end mb-4"> 90 <div class="flex items-center justify-end mb-4">
91 <div @click="showStatusPopup = true" class="flex items-center text-sm text-gray-600 cursor-pointer"> 91 <div @click="showStatusPopup = true" class="flex items-center text-sm text-gray-600 cursor-pointer">
...@@ -93,11 +93,7 @@ ...@@ -93,11 +93,7 @@
93 <van-icon name="arrow-down" size="14" class="ml-1" /> 93 <van-icon name="arrow-down" size="14" class="ml-1" />
94 </div> 94 </div>
95 </div> 95 </div>
96 - <div class="flex items-center justify-between mb-3"> 96 + <!-- <div class="flex items-center justify-between mb-3">
97 - <div @click="activeTab = 'statistics'"
98 - :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']">
99 - 打卡统计
100 - </div>
101 <div @click="activeTab = 'homework'" 97 <div @click="activeTab = 'homework'"
102 :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']"> 98 :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']">
103 作业记录 99 作业记录
...@@ -106,12 +102,24 @@ ...@@ -106,12 +102,24 @@
106 :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']"> 102 :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']">
107 班主任点评 103 班主任点评
108 </div> 104 </div>
105 + <div @click="activeTab = 'statistics'"
106 + :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']">
107 + 打卡统计
108 + </div>
109 + </div> -->
110 + <div class="px-4 py-3 bg-white" style="position: relative;">
111 + <van-tabs v-model:active="activeTab" color="#10b981" sticky animated swipeable @change="handleTabChange">
112 + <van-tab title="作业记录" name="homework"></van-tab>
113 + <van-tab title="班主任点评" name="evaluation"></van-tab>
114 + <van-tab title="打卡统计" name="statistics"></van-tab>
115 + </van-tabs>
109 </div> 116 </div>
110 117
111 - <!-- 记录列表 --> 118 + <!-- 记录列表 -->
112 - <van-list v-if="activeTab === 'statistics'" v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad"> 119 + <van-list v-show="activeTab === 'statistics'" v-model:loading="recordLoading" :finished="recordFinished"
120 + finished-text="没有更多了" @load="onRecordLoad">
113 <div v-for="record in filteredRecords" :key="record.id" 121 <div v-for="record in filteredRecords" :key="record.id"
114 - class="flex items-center justify-between py-4 border-b border-gray-100 last:border-b-0"> 122 + class="flex items-center justify-between py-4 border-b border-gray-100 last:border-b-0 bg-white px-4">
115 <div class="flex items-center flex-1"> 123 <div class="flex items-center flex-1">
116 <div class="mr-4"> 124 <div class="mr-4">
117 <div style="display: flex; justify-content: center;"> 125 <div style="display: flex; justify-content: center;">
...@@ -135,6 +143,77 @@ ...@@ -135,6 +143,77 @@
135 </div> 143 </div>
136 </div> 144 </div>
137 </van-list> 145 </van-list>
146 +
147 + <!--作业记录 -->
148 + <van-list v-show="activeTab === 'homework' && checkinDataList.length" v-model:loading="loading"
149 + :finished="finished" finished-text="没有更多了" @load="onLoad" class="space-y-4">
150 + <div class="post-card" v-for="post in checkinDataList" :key="post.id">
151 + <div class="post-header">
152 + <van-row>
153 + <van-col span="4">
154 + <van-image round width="2.5rem" height="2.5rem"
155 + :src="post.user.avatar || 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'" fit="cover" />
156 + </van-col>
157 + <van-col span="17">
158 + <div class="user-info">
159 + <div class="username">{{ post.user.name }}</div>
160 + <div class="post-time">{{ post.user.time }}</div>
161 + </div>
162 + </van-col>
163 + <van-col span="3">
164 + </van-col>
165 + </van-row>
166 + </div>
167 + <div class="post-content">
168 + <div class="post-text">{{ post.content }}</div>
169 + <div class="post-media">
170 + <div v-if="post.images.length" class="post-images">
171 + <van-image width="30%" fit="cover" v-for="(image, index) in post.images" :key="index" :src="image"
172 + radius="5" @click="openImagePreview(index, post)" />
173 + </div>
174 + <van-image-preview v-if="currentPost" v-model:show="showImagePreview" :images="currentPost.images"
175 + :start-position="startPosition" :show-index="true" @change="onChange" />
176 + <div v-for="(v, idx) in post.videoList" :key="idx">
177 + <!-- 视频封面和播放按钮 -->
178 + <div v-if="v.video && !v.isPlaying" class="relative w-full rounded-lg overflow-hidden"
179 + style="aspect-ratio: 16/9; margin-bottom: 1rem;">
180 + <img :src="v.videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_2.png'" :alt="v.content"
181 + class="w-full h-full object-cover" />
182 + <div class="absolute inset-0 flex items-center justify-center cursor-pointer bg-black/20"
183 + @click="startPlay(v)">
184 + <div
185 + class="w-16 h-16 rounded-full bg-black/50 flex items-center justify-center hover:bg-black/70 transition-colors">
186 + <van-icon name="play-circle-o" class="text-white" size="40" />
187 + </div>
188 + </div>
189 + </div>
190 + <!-- 视频播放器 -->
191 + <VideoPlayer v-if="v.video && v.isPlaying" :video-url="v.video"
192 + class="post-video rounded-lg overflow-hidden" :ref="el => {
193 + if (el) {
194 + // 确保不重复添加
195 + if (!videoPlayers?.includes(el)) {
196 + videoPlayers?.push(el);
197 + }
198 + }
199 + }" @onPlay="handleVideoPlay(player, post)" @onPause="handleVideoPause(post)" />
200 + </div>
201 + <AudioPlayer v-if="post.audio.length" :songs="post.audio" class="post-audio" :id="post.id" :ref="el => {
202 + if (el) {
203 + // 确保不重复添加
204 + if (!audioPlayers?.includes(el)) {
205 + audioPlayers?.push(el);
206 + }
207 + }
208 + }" @play="(player) => handleAudioPlay(player, post)" />
209 + </div>
210 + </div>
211 + <div class="post-footer">
212 + <van-icon @click="handLike(post)" name="good-job" class="like-icon" :color="post.is_liked ? 'red' : ''" />
213 + <span class="like-count">{{ post.likes }}</span>
214 + </div>
215 + </div>
216 + </van-list>
138 </div> 217 </div>
139 218
140 <!-- 状态筛选弹窗 --> 219 <!-- 状态筛选弹窗 -->
...@@ -159,11 +238,20 @@ ...@@ -159,11 +238,20 @@
159 </template> 238 </template>
160 239
161 <script setup> 240 <script setup>
162 -import { ref, computed, onMounted } from 'vue' 241 +import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
163 import { useRouter, useRoute } from 'vue-router' 242 import { useRouter, useRoute } from 'vue-router'
243 +import { showConfirmDialog, showSuccessToast, showFailToast, showLoadingToast } from 'vant';
244 +import VideoPlayer from "@/components/ui/VideoPlayer.vue";
245 +import AudioPlayer from "@/components/ui/AudioPlayer.vue";
246 +import { useTitle } from '@vueuse/core';
247 +import dayjs from 'dayjs';
248 +
249 +import { getTaskDetailAPI, getUploadTaskListAPI, delUploadTaskInfoAPI, likeUploadTaskInfoAPI, dislikeUploadTaskInfoAPI } from "@/api/checkin";
250 +
164 251
165 const router = useRouter() 252 const router = useRouter()
166 const route = useRoute() 253 const route = useRoute()
254 +useTitle(route.meta.title);
167 255
168 // 学生信息 256 // 学生信息
169 const studentInfo = ref({ 257 const studentInfo = ref({
...@@ -185,7 +273,7 @@ const studentStats = ref({ ...@@ -185,7 +273,7 @@ const studentStats = ref({
185 }) 273 })
186 274
187 // 当前选中的标签页 275 // 当前选中的标签页
188 -const activeTab = ref('statistics') 276 +const activeTab = ref('homework')
189 277
190 // 状态筛选 278 // 状态筛选
191 const statusFilter = ref('按状态') 279 const statusFilter = ref('按状态')
...@@ -240,8 +328,8 @@ const records = ref([ ...@@ -240,8 +328,8 @@ const records = ref([
240 ]) 328 ])
241 329
242 // 列表加载状态 330 // 列表加载状态
243 -const loading = ref(false) 331 +const recordLoading = ref(false)
244 -const finished = ref(false) 332 +const recordFinished = ref(false)
245 333
246 /** 334 /**
247 * 过滤后的记录列表 335 * 过滤后的记录列表
...@@ -277,25 +365,33 @@ const onStatusSelect = (option) => { ...@@ -277,25 +365,33 @@ const onStatusSelect = (option) => {
277 } 365 }
278 366
279 /** 367 /**
280 - * 加载更多数据 368 + * 加载更多记录数据
281 */ 369 */
282 -const onLoad = () => { 370 +const onRecordLoad = () => {
283 setTimeout(() => { 371 setTimeout(() => {
284 - loading.value = false 372 + recordLoading.value = false
285 - finished.value = true 373 + recordFinished.value = true
286 }, 1000) 374 }, 1000)
287 } 375 }
288 376
289 /** 377 /**
290 * 组件挂载时初始化数据 378 * 组件挂载时初始化数据
291 */ 379 */
292 -onMounted(() => { 380 +const checkinDataList = ref([]);
381 +onMounted(async () => {
293 // 从路由参数获取学生ID 382 // 从路由参数获取学生ID
294 const studentId = route.params.id 383 const studentId = route.params.id
295 console.log('学生详情页面已加载,学生ID:', studentId) 384 console.log('学生详情页面已加载,学生ID:', studentId)
296 385
297 // 这里可以根据studentId调用API获取学生详细信息 386 // 这里可以根据studentId调用API获取学生详细信息
298 loadStudentData(studentId) 387 loadStudentData(studentId)
388 +
389 + const current_date = route.query.date;
390 + if (current_date) {
391 + onLoad(current_date);
392 + } else {
393 + onLoad(dayjs().format('YYYY-MM-DD'));
394 + }
299 }) 395 })
300 396
301 /** 397 /**
...@@ -306,9 +402,290 @@ const loadStudentData = (studentId) => { ...@@ -306,9 +402,290 @@ const loadStudentData = (studentId) => {
306 // 这里可以调用API获取实际数据 402 // 这里可以调用API获取实际数据
307 console.log('加载学生数据:', studentId) 403 console.log('加载学生数据:', studentId)
308 } 404 }
405 +
406 +// 处理标签页切换
407 +const handleTabChange = (name) => {
408 + // 先更新activeTab值
409 + activeTab.value = name;
410 +
411 + nextTick(() => {
412 + // 停止所有视频和音频播放
413 + if (videoPlayers.value) {
414 + videoPlayers.value.forEach(player => {
415 + console.warn(player);
416 + if (player && typeof player?.pause === 'function') {
417 + player?.pause();
418 + }
419 + });
420 + }
421 +
422 + stopAllAudio();
423 + })
424 +};
425 +
426 +// 存储所有视频播放器的引用
427 +const videoPlayers = ref([]);
428 +
429 +// 存储所有音频播放器的引用
430 +const audioPlayers = ref([]);
431 +
432 +// 组件卸载前清理播放器引用和事件监听器
433 +onBeforeUnmount(() => {
434 + // 停止所有视频和音频播放
435 + if (videoPlayers.value) {
436 + videoPlayers.value.forEach(player => {
437 + if (player && typeof player?.pause === 'function') {
438 + player?.pause();
439 + }
440 + });
441 + }
442 +
443 + stopAllAudio();
444 +
445 + // 清空引用数组
446 + if (videoPlayers.value) videoPlayers.value = [];
447 + if (audioPlayers.value) audioPlayers.value = [];
448 +});
449 +
450 +/**
451 + * 开始播放指定帖子的视频
452 + * @param {Object} post - 要播放视频的帖子对象
453 + */
454 +const startPlay = (post) => {
455 + // 确保checkinDataList.value是一个数组
456 + if (checkinDataList.value) {
457 + // 先暂停所有其他视频
458 + checkinDataList.value.forEach(p => {
459 + p.videoList.forEach(v => {
460 + if (v.id !== post.id) {
461 + v.isPlaying = false;
462 + }
463 + });
464 + });
465 + }
466 +
467 + // 设置当前视频为播放状态
468 + post.isPlaying = true;
469 +};
470 +
471 +/**
472 + * 处理视频播放事件
473 + * @param {Object} player - 视频播放器实例
474 + * @param {Object} post - 包含视频的帖子对象
475 + */
476 +const handleVideoPlay = (player, post) => {
477 + stopAllAudio();
478 +};
479 +
480 +/**
481 + * 处理视频暂停事件
482 + * @param {Object} post - 包含视频的帖子对象
483 + */
484 +const handleVideoPause = (post) => {
485 + // 视频暂停时不改变isPlaying状态,保持播放器可见
486 + // 这样用户可以继续从暂停处播放
487 +};
488 +
489 +/**
490 + * 停止除当前播放器外的所有其他视频
491 + * @param {Object} currentPlayer - 当前播放的视频播放器实例
492 + * @param {Object} currentPost - 当前播放的帖子对象
493 + */
494 +const stopOtherVideos = (currentPlayer, currentPost) => {
495 + // 确保videoPlayers.value是一个数组
496 + if (videoPlayers.value) {
497 + // 暂停其他视频播放器
498 + videoPlayers.value.forEach(player => {
499 + if (player !== currentPlayer && player.pause) {
500 + player.pause();
501 + }
502 + });
503 + }
504 +
505 + // 更新其他帖子的播放状态
506 + checkinDataList.value.forEach(p => {
507 + p.videoList.forEach(v => {
508 + if (v.id !== currentPost.id) {
509 + v.isPlaying = false;
510 + }
511 + });
512 + });
513 +};
514 +
515 +/**
516 + * 处理音频播放事件
517 + * @param {Object} player - 音频播放器实例
518 + * @param {Object} post - 包含音频的帖子对象
519 + */
520 +const handleAudioPlay = (player, post) => {
521 + // 停止其他音频播放
522 + stopOtherAudio(player, post);
523 +};
524 +
525 +const stopOtherAudio = (currentPlayer, currentPost) => {
526 + // 确保audioPlayers.value是一个数组
527 + if (audioPlayers.value) {
528 + // 暂停其他音频播放器
529 + audioPlayers.value.forEach(player => {
530 + if (player.id !== currentPost.id && player.pause) {
531 + player.pause();
532 + }
533 + });
534 + }
535 + // 更新其他帖子的播放状态
536 + checkinDataList.value.forEach(post => {
537 + if (post.id !== currentPost.id) {
538 + post.isPlaying = false;
539 + }
540 + });
541 + // 停止所有视频播放
542 + stopAllVideos();
543 +}
544 +
545 +const stopAllAudio = () => {
546 + // 确保audioPlayers.value是一个数组
547 + if (!audioPlayers.value) return;
548 + audioPlayers.value?.forEach(player => {
549 + // 使用组件暴露的pause方法
550 + if (typeof player.pause === 'function') {
551 + player.pause();
552 + }
553 + });
554 + // 更新所有帖子的播放状态
555 + checkinDataList.value.forEach(post => {
556 + if (post.audio.length) {
557 + post.isPlaying = false;
558 + }
559 + });
560 +}
561 +
562 +/**
563 + * 停止所有视频播放
564 + */
565 +const stopAllVideos = () => {
566 + // 确保videoPlayers.value是一个数组
567 + if (!videoPlayers.value) return;
568 +
569 + // 更新所有帖子的播放状态
570 + checkinDataList.value.forEach(p => {
571 + p.videoList.forEach(v => {
572 + v.isPlaying = false;
573 + });
574 + });
575 +};
576 +
577 +// 图片预览相关
578 +const showImagePreview = ref(false);
579 +const startPosition = ref(0);
580 +const currentPost = ref(null);
581 +
582 +// 打开图片预览
583 +const openImagePreview = (index, post) => {
584 + currentPost.value = post;
585 + startPosition.value = index;
586 + showImagePreview.value = true;
587 +}
588 +
589 +// 图片切换事件处理
590 +const onChange = (index) => {
591 + startPosition.value = index;
592 +}
593 +
594 +const handLike = async (post) => {
595 + if (!post.is_liked) {
596 + const { code, data } = await likeUploadTaskInfoAPI({ checkin_id: post.id, })
597 + if (code) {
598 + showSuccessToast('点赞成功')
599 + post.likes++;
600 + post.is_liked = true;
601 + }
602 + } else {
603 + const { code, data } = await dislikeUploadTaskInfoAPI({ checkin_id: post.id, })
604 + if (code) {
605 + showSuccessToast('取消点赞成功')
606 + post.likes--;
607 + post.is_liked = false;
608 + }
609 + }
610 +}
611 +
612 +const loading = ref(false)
613 +const finished = ref(false)
614 +const limit = ref(3)
615 +const page = ref(0)
616 +
617 +const onLoad = async (date) => {
618 + const nextPage = page.value;
619 + const current_date = date || route.query.date || dayjs().format('YYYY-MM-DD');
620 + //
621 + const res = await getUploadTaskListAPI({
622 + limit: limit.value,
623 + page: nextPage,
624 + task_id: route.query.id,
625 + date: current_date
626 + });
627 + if (res.code) {
628 + // 整理数据结构
629 + checkinDataList.value = [...checkinDataList.value, ...formatData(res.data)];
630 + finished.value = res.data.checkin_list.length < limit.value;
631 + page.value = nextPage + 1;
632 + }
633 + loading.value = false;
634 +};
635 +
636 +const formatData = (data) => {
637 + let formattedData = [];
638 + formattedData = data?.checkin_list.map((item, index) => {
639 + let images = [];
640 + let audio = [];
641 + let videoList = [];
642 + if (item.file_type === 'image') {
643 + images = item.files.map(file => {
644 + return file.value;
645 + });
646 + } else if (item.file_type === 'video') {
647 + videoList = item.files.map(file => {
648 + return {
649 + id: file.meta_id,
650 + video: file.value,
651 + videoCover: file.cover,
652 + isPlaying: false,
653 + }
654 + })
655 + } else if (item.file_type === 'audio') {
656 + audio = item.files.map(file => {
657 + return {
658 + title: file.name ? file.name : '打卡音频',
659 + artist: file.artist ? file.artist : '',
660 + url: file.value,
661 + cover: file.cover ? file.cover : '',
662 + }
663 + })
664 + }
665 + return {
666 + id: item.id,
667 + task_id: item.task_id,
668 + user: {
669 + name: item.username,
670 + avatar: item.avatar,
671 + time: item.created_time_desc,
672 + },
673 + content: item.note,
674 + images,
675 + videoList,
676 + audio,
677 + isPlaying: false,
678 + likes: item.like_count,
679 + is_liked: item.is_like,
680 + is_my: item.is_my,
681 + file_type: item.file_type,
682 + }
683 + })
684 + return formattedData;
685 +}
309 </script> 686 </script>
310 687
311 -<style scoped> 688 +<style scoped lang="less">
312 /* 自定义样式 */ 689 /* 自定义样式 */
313 .van-circle { 690 .van-circle {
314 font-size: 12px; 691 font-size: 12px;
...@@ -328,4 +705,75 @@ const loadStudentData = (studentId) => { ...@@ -328,4 +705,75 @@ const loadStudentData = (studentId) => {
328 .border-b-2 { 705 .border-b-2 {
329 border-bottom-width: 2px; 706 border-bottom-width: 2px;
330 } 707 }
708 +
709 +.post-card {
710 + // margin: 1rem 0;
711 + padding: 1rem;
712 + background-color: #FFF;
713 + border-radius: 5px;
714 +
715 + .post-header {
716 + margin-bottom: 1rem;
717 + }
718 +
719 + .user-info {
720 + margin-left: 0.5rem;
721 +
722 + .username {
723 + font-weight: 500;
724 + }
725 +
726 + .post-time {
727 + color: gray;
728 + font-size: 0.8rem;
729 + }
730 + }
731 +
732 + .post-menu {
733 + display: flex;
734 + justify-content: space-between;
735 + align-items: center;
736 + margin-bottom: 1rem;
737 + }
738 +
739 + .post-content {
740 + .post-text {
741 + color: #666;
742 + margin-bottom: 1rem;
743 + }
744 +
745 + .post-media {
746 + .post-images {
747 + display: flex;
748 + flex-wrap: wrap;
749 + gap: 0.5rem;
750 + }
751 +
752 + .post-video {
753 + margin: 1rem 0;
754 + width: 100%;
755 + border-radius: 8px;
756 + overflow: hidden;
757 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
758 + }
759 +
760 + .post-audio {
761 + margin: 1rem 0;
762 + }
763 + }
764 + }
765 +
766 + .post-footer {
767 + margin-top: 1rem;
768 + color: #666;
769 +
770 + .like-icon {
771 + margin-right: 0.25rem;
772 + }
773 +
774 + .like-count {
775 + font-size: 0.9rem;
776 + }
777 + }
778 +}
331 </style> 779 </style>
......