hookehuyr

feat(教师端): 新增学员作业记录页面及跳转功能

- 添加学员作业记录页面路由及视图组件
- 在作业主页学生卡片添加点击跳转功能
- 实现作业记录列表展示、媒体播放及点评功能
- 更新README文档说明新增功能
......@@ -12,3 +12,7 @@ https://oa-dev.onwall.cn/f/mlaj
- 统计:出勤率与任务完成率(参考 `myClassPage.vue` 统计样式,数据Mock)。
- 日历:使用 `van-calendar` 单选模式,选择日期后展示当日学生完成情况。
- 学生完成情况:参考图片2样式,勾选代表已完成,未勾选代表未完成(数据Mock)。
- 教师端新增学员作业记录页面:路径 `/teacher/student-record`,标题“学员作业记录”。
- 在作业主页的学生列表点击卡片可跳转至该页面(当前版本为固定示例页面)。
- 列表展示:作业帖子、图片/视频/音频、点赞与点评弹窗(与 `studentPage.vue` 的作业记录样式一致)。
- 接口参数固定:`user_id=817017``group_id=816653`(后续可替换为动态参数)。
......
......@@ -60,4 +60,13 @@ export default [
requiresAuth: true
},
},
{
path: '/teacher/student-record',
name: 'StudentRecord',
component: () => import('../views/teacher/studentRecordPage.vue'),
meta: {
title: '学员作业记录',
requiresAuth: true
},
},
]
......
<!--
* @Date: 2025-11-19 22:05:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-19 22:12:24
* @FilePath: /mlaj/src/views/teacher/studentRecordPage.vue
* @Description: 学生作业记录页面(仅作业记录与点评功能),固定 user_id 与 group_id
-->
<template>
<div class="bg-gradient-to-br from-green-50 via-green-100/30 to-blue-50/30 min-h-screen">
<!-- 作业记录列表 -->
<van-list v-model:loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad" class="space-y-4 p-4">
<div class="post-card shadow-md" 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="optimizeCdn(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="optimizeCdn(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="optimizeCdn(v.videoCover || 'https://cdn.ipadbiz.cn/mlaj/images/cover_video_2.png')" :alt="post.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 flex items-center justify-between">
<!-- 左侧:点赞 -->
<div class="flex items-center">
<van-icon @click="handLike(post)" name="good-job" class="like-icon" :color="post.is_liked ? 'red' : ''" />
<span class="like-count ml-1">{{ post.likes }}</span>
</div>
<!-- 右侧:点评 -->
<div class="flex items-center cursor-pointer" @click="openCommentPopup(post)">
<van-icon name="comment-o" :color="post?.is_feedback ? '#10b981' : '#999'" size="19" class="mr-1" style="margin-top: 0.2rem;" />
<span class="text-sm" :class="post?.is_feedback ? 'text-green-600' : 'text-gray-500'">
{{ post?.is_feedback ? '已点评' : '待点评' }}
</span>
</div>
</div>
</div>
</van-list>
<van-empty v-show="!checkinDataList.length" description="暂无数据" />
<!-- 点评弹窗 -->
<van-popup v-model:show="showCommentPopup" position="bottom" round class="comment-popup">
<div class="p-6 w-100">
<div class="text-center text-lg font-bold mb-4">作业点评</div>
<!-- 评分 -->
<div class="mb-4">
<div class="text-sm text-gray-600 mb-2">评分</div>
<van-rate v-model="commentForm.score" :size="24" color="#ffd21e" void-color="#eee" :readonly="currentCommentPost?.is_feedback" />
</div>
<!-- 点评内容 -->
<div class="mb-6">
<div class="text-sm text-gray-600 mb-2">点评内容</div>
<van-field v-model="commentForm.note" type="textarea" placeholder="请输入点评内容..." rows="4" maxlength="200" show-word-limit :border="false" class="bg-gray-50 rounded-lg" :readonly="currentCommentPost?.is_feedback" />
</div>
<!-- 操作按钮 -->
<div v-if="!currentCommentPost?.is_feedback" class="flex gap-3">
<van-button block type="default" @click="closeCommentPopup" class="flex-1">取消</van-button>
<van-button block type="primary" @click="submitComment" class="flex-1">提交</van-button>
</div>
<div v-else class="flex gap-3">
<van-button block type="default" @click="closeCommentPopup" class="flex-1">关闭</van-button>
</div>
</div>
</van-popup>
</div>
<van-back-top right="5vw" bottom="25vh" offset="600" />
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { showSuccessToast, showFailToast, showLoadingToast } from 'vant'
import VideoPlayer from '@/components/ui/VideoPlayer.vue'
import AudioPlayer from '@/components/ui/AudioPlayer.vue'
import { getStudentUploadListAPI, addCheckinFeedbackAPI } from '@/api/teacher'
import { likeUploadTaskInfoAPI, dislikeUploadTaskInfoAPI } from '@/api/checkin'
// 固定的用户与班级ID
const fixedUserId = 817017
const fixedGroupId = 816653
// 列表相关状态
const checkinDataList = ref([])
const loading = ref(false)
const finished = ref(false)
const limit = ref(10)
const page = ref(0)
// 图片预览相关
const showImagePreview = ref(false)
const startPosition = ref(0)
const currentPost = ref(null)
// 播放器引用
const videoPlayers = ref([])
const audioPlayers = ref([])
// 点评相关状态
const showCommentPopup = ref(false)
const currentCommentPost = ref(null)
const commentForm = ref({
checkin_id: '',
score: 0,
note: ''
})
/**
* CDN图片优化:为 cdn.ipadbiz.cn 域名添加压缩参数
* @param {string} url - 图片地址
* @returns {string} 处理后的图片地址
*/
function optimizeCdn(url) {
if (!url) return url
try {
const u = String(url)
if (u.includes('cdn.ipadbiz.cn')) {
const hasQuery = u.includes('?')
const param = 'imageMogr2/thumbnail/200x/strip/quality/70'
return hasQuery ? `${u}&${param}` : `${u}?${param}`
}
return u
} catch (e) {
return url
}
}
/**
* 打开图片预览
* @param {number} index - 起始索引
* @param {Object} post - 当前帖子
*/
function openImagePreview(index, post) {
currentPost.value = post
startPosition.value = index
showImagePreview.value = true
}
/**
* 图片切换事件
* @param {number} index - 当前索引
*/
function onChange(index) {
startPosition.value = index
}
/**
* 点赞/取消点赞
* @param {Object} post - 帖子对象
* @returns {Promise<void>}
*/
async function handLike(post) {
if (!post.is_liked) {
const { code } = await likeUploadTaskInfoAPI({ checkin_id: post.id })
if (code) {
showSuccessToast('点赞成功')
post.likes++
post.is_liked = true
}
} else {
const { code } = await dislikeUploadTaskInfoAPI({ checkin_id: post.id })
if (code) {
showSuccessToast('取消点赞成功')
post.likes--
post.is_liked = false
}
}
}
/**
* 打开点评弹窗
* @param {Object} post - 帖子对象
*/
function openCommentPopup(post) {
currentCommentPost.value = post
commentForm.value.checkin_id = post.id
if (post.feedback_id) {
commentForm.value.score = post.feedback_score || 0
commentForm.value.note = post.feedback || ''
} else {
commentForm.value.score = 0
commentForm.value.note = ''
}
showCommentPopup.value = true
}
/**
* 关闭点评弹窗
*/
function closeCommentPopup() {
if (currentCommentPost.value && currentCommentPost.value.is_feedback) {
showCommentPopup.value = false
return
}
showCommentPopup.value = false
currentCommentPost.value = null
commentForm.value.score = 0
commentForm.value.note = ''
}
/**
* 提交点评
* @returns {Promise<void>}
*/
async function submitComment() {
if (!commentForm.value.note.trim()) {
showFailToast('请输入点评内容')
return
}
if (commentForm.value.score === 0) {
showFailToast('请选择评分')
return
}
try {
showLoadingToast('提交中...')
const { code, data } = await addCheckinFeedbackAPI(commentForm.value)
if (code) {
commentForm.value.feedback_id = data.id
currentCommentPost.value.is_feedback = true
checkinDataList.value.forEach(item => {
if (item.id === currentCommentPost.value.id) {
item.feedback_id = commentForm.value.feedback_id
item.feedback_score = commentForm.value.score
item.feedback = commentForm.value.note
}
})
showSuccessToast('点评提交成功')
closeCommentPopup()
}
} catch (err) {
showFailToast('提交失败,请重试')
}
}
/**
* 开始播放视频
* @param {Object} post - 视频条目(含video、isPlaying)
*/
function startPlay(post) {
if (checkinDataList.value) {
checkinDataList.value.forEach(p => {
p.videoList.forEach(v => {
if (v.id !== post.id) {
v.isPlaying = false
}
})
})
}
post.isPlaying = true
}
/**
* 视频播放事件:同时停止音频
*/
function handleVideoPlay() {
stopAllAudio()
}
/**
* 视频暂停事件:保持播放器可见
*/
function handleVideoPause() {
// 暂不处理,保持播放器状态
}
/**
* 停止其他视频与音频
* @param {Object} currentPlayer - 当前播放器
* @param {Object} currentPost - 当前帖子
*/
function stopOtherVideos(currentPlayer, currentPost) {
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 - 当前帖子
*/
function handleAudioPlay(player, post) {
stopOtherAudio(player, post)
}
/**
* 停止其他音频
* @param {Object} currentPlayer - 当前播放器
* @param {Object} currentPost - 当前帖子
*/
function stopOtherAudio(currentPlayer, currentPost) {
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()
}
/**
* 停止所有音频
*/
function stopAllAudio() {
if (!audioPlayers.value) return
audioPlayers.value?.forEach(player => {
if (typeof player.pause === 'function') {
player?.pause()
}
})
checkinDataList.value.forEach(post => {
if (post.audio.length) {
post.isPlaying = false
}
})
}
/**
* 停止所有视频
*/
function stopAllVideos() {
if (!videoPlayers.value) return
checkinDataList.value.forEach(p => {
p.videoList.forEach(v => {
v.isPlaying = false
})
})
}
/**
* 拉取作业记录分页数据
* @returns {Promise<void>}
*/
async function onLoad() {
const nextPage = page.value
const res = await getStudentUploadListAPI({
limit: limit.value,
page: nextPage,
user_id: fixedUserId,
group_id: fixedGroupId,
})
if (res.code) {
checkinDataList.value = [...checkinDataList.value, ...formatData(res.data)]
finished.value = res.data.length < limit.value
page.value = nextPage + 1
}
loading.value = false
}
/**
* 规范化接口数据为页面所需结构
* @param {Array} data - 原始接口数据
* @returns {Array} 规范化后的列表
*/
function formatData(data) {
let formattedData = []
formattedData = data?.map(item => {
let images = []
let audio = []
let videoList = []
if (item.file_type === 'image') {
images = item.files.map(file => file.value)
} else if (item.file_type === 'video') {
videoList = item.files.map(file => ({
id: file.meta_id,
video: file.value,
videoCover: file.cover,
isPlaying: false,
}))
} else if (item.file_type === 'audio') {
audio = item.files.map(file => ({
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,
is_feedback: item.feedback_id || false,
feedback: item.feedback || '',
feedback_id: item.feedback_id || '',
feedback_score: item.feedback_score || 0,
comment: item.comment || null,
}
})
return formattedData
}
// 生命周期:卸载时清理播放器引用
onMounted(() => {})
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 = []
})
</script>
<style lang="less">
.post-card {
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-content {
.post-text {
color: #666;
margin-bottom: 1rem;
white-space: pre-wrap;
word-wrap: break-word;
}
.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;
}
}
}
.comment-popup {
.van-popup {
max-width: 90vw;
}
.van-rate {
display: flex;
justify-content: center;
}
.van-field {
padding: 12px;
border-radius: 8px;
}
.van-button {
height: 44px;
border-radius: 8px;
}
}
</style>
<!--
* @Date: 2025-11-19 21:00:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-19 21:43:15
* @LastEditTime: 2025-11-19 22:10:07
* @FilePath: /mlaj/src/views/teacher/taskHomePage.vue
* @Description: 教师端作业主页(头部介绍、统计、日历与学生完成情况;数据Mock)
-->
......@@ -66,7 +66,8 @@
<div class="grid grid-cols-5 gap-3 StudentsGrid">
<div v-for="(stu, idx) in students_status" :key="stu.id"
class="studentItem relative rounded-md h-16 flex flex-col items-center justify-center text-center border overflow-hidden"
:class="stu.completed ? 'bg-white border-green-500 text-green-600' : 'bg-gray-100 border-gray-300 text-gray-500'">
:class="stu.completed ? 'bg-white border-green-500 text-green-600' : 'bg-gray-100 border-gray-300 text-gray-500'"
@click="go_student_record(stu)">
<div class="text-sm font-semibold">{{ idx + 1 }}</div>
<div class="text-sm mt-1">{{ stu.name }}</div>
<img v-if="stu.completed" :src="checkCorner" alt="checked" class="cornerIcon" />
......@@ -78,12 +79,13 @@
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useTitle } from '@vueuse/core'
import TaskCalendar from '@/components/ui/TaskCalendar.vue'
import checkCorner from '@/assets/check_corner.svg'
const $route = useRoute()
const $router = useRouter()
useTitle('作业主页')
//
......@@ -193,6 +195,16 @@ const completed_count = computed(() => students_status.value.filter(s => s.compl
* @returns {string} 文本
*/
const current_date_text = computed(() => selected_date.value)
/**
* 跳转至学员作业记录页面(固定ID的示例页面)
* @param {{id:string,name:string,completed:boolean}} stu - 学员对象
* @returns {void}
*/
function go_student_record(stu) {
// 跳转到固定ID的作业记录页面,当前版本不使用传入ID
$router.push({ name: 'StudentRecord' })
}
</script>
<style lang="less" scoped>
......