feat:添加小节预览功能;完善章节编辑功能
This commit is contained in:
parent
ce54a41f4a
commit
254fb72d0d
@ -562,6 +562,34 @@ export class TeachCourseApi {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询练习章节
|
||||
*/
|
||||
static async getSectionExercise(sectionId: string): Promise<ApiResponseWithResult<any>> {
|
||||
try {
|
||||
const response = await ApiRequest.get<{ result: any }>(`/aiol/aiolExam/getExamQuestionsByChapter/${sectionId}`)
|
||||
console.log('📑 查询小节绑定的练习响应:', response)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('❌ 查询小节绑定的练习失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询讨论章节
|
||||
*/
|
||||
static async getSectionDiscussion(courseId:string,sectionId: string): Promise<ApiResponseWithResult<any>> {
|
||||
try {
|
||||
const response = await ApiRequest.get<{ result: any }>(`/aiol/aiolCourse/${courseId}/section_discussion/${sectionId}`)
|
||||
console.log('📑 查询小节绑定的讨论响应:', response)
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('❌ 查询小节绑定的讨论失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 作业相关类型定义
|
||||
@ -726,15 +754,15 @@ export class HomeworkApi {
|
||||
/**
|
||||
* 查询作业提交情况
|
||||
*/
|
||||
static async getHomeworkSubmits(homeworkId: string): Promise<ApiResponseWithResult<HomeworkSubmit[]>> {
|
||||
static async getHomeworkSubmits(homeworkId: string): Promise<ApiResponseWithResult<any>> {
|
||||
try {
|
||||
console.log('🚀 发送查询作业提交情况请求:', {
|
||||
url: `/aiol/aiolHomeworkSubmit/homework/${homeworkId}/submits`,
|
||||
homeworkId
|
||||
})
|
||||
|
||||
const response = await ApiRequest.get<{ result: HomeworkSubmit[] }>(`/aiol/aiolHomeworkSubmit/homework/${homeworkId}/submits`)
|
||||
|
||||
|
||||
const response = await ApiRequest.get<any>(`/aiol/aiolHomeworkSubmit/homework/${homeworkId}/submits`)
|
||||
|
||||
console.log('📊 作业提交情况响应:', response)
|
||||
return response
|
||||
} catch (error) {
|
||||
@ -800,7 +828,10 @@ export class ClassApi {
|
||||
return ApiRequest.post('/aiol/aiolClass/add', data);
|
||||
}
|
||||
|
||||
static async queryClassList(params: { course_id: string|null }): Promise<ApiResponse<any>> {
|
||||
/**
|
||||
* 查询班级列表
|
||||
*/
|
||||
static async queryClassList(params: { course_id: string|null }): Promise<ApiResponse<any>> {
|
||||
return ApiRequest.get('/aiol/aiolClass/query_list', params);
|
||||
}
|
||||
|
||||
|
323
src/components/common/PreviewModal.vue
Normal file
323
src/components/common/PreviewModal.vue
Normal file
@ -0,0 +1,323 @@
|
||||
<template>
|
||||
<n-modal
|
||||
v-model:show="show"
|
||||
preset="card"
|
||||
style="width: 90vw; max-width: 1200px; max-height: 80vh;"
|
||||
:mask-closable="true"
|
||||
:closable="true"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #header>
|
||||
<div class="preview-header">
|
||||
<h3>{{ sectionData?.name || '小节预览' }}</h3>
|
||||
<span class="section-type-badge">{{ getTypeText(sectionData?.type) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="preview-content">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<n-spin size="large">
|
||||
<template #description>正在加载内容...</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-container">
|
||||
<n-result status="error" title="加载失败" :description="error">
|
||||
<template #footer>
|
||||
<n-button @click="loadSectionData">重试</n-button>
|
||||
</template>
|
||||
</n-result>
|
||||
</div>
|
||||
|
||||
<!-- 预览内容 -->
|
||||
<div v-else-if="sectionData" class="preview-body">
|
||||
<!-- 动态组件渲染 -->
|
||||
<component
|
||||
:is="currentPreviewComponent"
|
||||
:section-data="sectionData"
|
||||
:detail-data="detailData"
|
||||
:course-id="courseId"
|
||||
@error="handlePreviewError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 无数据状态 -->
|
||||
<div v-else class="empty-container">
|
||||
<n-empty description="暂无内容" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="preview-footer">
|
||||
<n-space justify="end">
|
||||
<n-button @click="handleClose">关闭</n-button>
|
||||
<!-- <n-button
|
||||
v-if="sectionData && detailData"
|
||||
type="primary"
|
||||
@click="handleEdit"
|
||||
>
|
||||
编辑
|
||||
</n-button> -->
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, defineAsyncComponent } from 'vue'
|
||||
import { NModal, NSpin, NResult, NButton, NSpace, NEmpty, useMessage } from 'naive-ui'
|
||||
import TeachCourseApi from '@/api/modules/teachCourse'
|
||||
|
||||
// 动态导入预览组件
|
||||
const VideoPreview = defineAsyncComponent(() => import('./preview/VideoPreview.vue'))
|
||||
const DocumentPreview = defineAsyncComponent(() => import('./preview/DocumentPreview.vue'))
|
||||
const ExamPreview = defineAsyncComponent(() => import('./preview/ExamPreview.vue'))
|
||||
const HomeworkPreview = defineAsyncComponent(() => import('./preview/HomeworkPreview.vue'))
|
||||
const PracticePreview = defineAsyncComponent(() => import('./preview/PracticePreview.vue'))
|
||||
const DiscussionPreview = defineAsyncComponent(() => import('./preview/DiscussionPreview.vue'))
|
||||
|
||||
// Props 定义
|
||||
interface SectionData {
|
||||
id: string
|
||||
name: string
|
||||
type: string | number
|
||||
courseId?: string
|
||||
parentId?: string
|
||||
level?: number
|
||||
createTime?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
sectionData: SectionData | null
|
||||
courseId: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
show: false,
|
||||
sectionData: null,
|
||||
courseId: ''
|
||||
})
|
||||
|
||||
// Emits 定义
|
||||
const emit = defineEmits<{
|
||||
'update:show': [value: boolean]
|
||||
'edit': [sectionData: SectionData, detailData: any]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 响应式状态
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const detailData = ref<any>(null)
|
||||
|
||||
// 计算属性
|
||||
const show = computed({
|
||||
get: () => props.show,
|
||||
set: (value) => emit('update:show', value)
|
||||
})
|
||||
|
||||
// 根据类型获取对应的预览组件
|
||||
const currentPreviewComponent = computed(() => {
|
||||
if (!props.sectionData) return null
|
||||
|
||||
const typeMap: Record<string, any> = {
|
||||
'0': VideoPreview, // 视频
|
||||
'1': DocumentPreview, // 资料
|
||||
'2': ExamPreview, // 考试
|
||||
'3': HomeworkPreview, // 作业
|
||||
'4': PracticePreview, // 练习
|
||||
'5': DiscussionPreview // 讨论
|
||||
}
|
||||
|
||||
return typeMap[props.sectionData.type.toString()] || null
|
||||
})
|
||||
|
||||
// 获取类型文本
|
||||
const getTypeText = (type: string | number | undefined) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
'0': '视频',
|
||||
'1': '资料',
|
||||
'2': '考试',
|
||||
'3': '作业',
|
||||
'4': '练习',
|
||||
'5': '讨论',
|
||||
}
|
||||
return typeMap[type?.toString() || ''] || '未知类型'
|
||||
}
|
||||
|
||||
// 根据小节类型调用对应的API获取详情数据
|
||||
const loadSectionData = async () => {
|
||||
if (!props.sectionData || !props.courseId) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
detailData.value = null
|
||||
|
||||
try {
|
||||
const sectionType = props.sectionData.type.toString()
|
||||
const sectionId = props.sectionData.id
|
||||
const courseId = props.courseId
|
||||
|
||||
let response
|
||||
|
||||
switch (sectionType) {
|
||||
case '0': // 视频
|
||||
response = await TeachCourseApi.getSectionVideo(courseId, sectionId)
|
||||
break
|
||||
case '1': // 资料/文档
|
||||
response = await TeachCourseApi.getSectionDocument(courseId, sectionId)
|
||||
break
|
||||
case '2': // 考试
|
||||
response = await TeachCourseApi.getSectionExam(courseId, sectionId)
|
||||
break
|
||||
case '3': // 作业
|
||||
response = await TeachCourseApi.getSectionHomeWork(courseId, sectionId)
|
||||
break
|
||||
case '4': // 练习
|
||||
response = await TeachCourseApi.getSectionExercise(sectionId)
|
||||
break
|
||||
case '5': // 讨论
|
||||
response = await TeachCourseApi.getSectionDiscussion(courseId, sectionId)
|
||||
break
|
||||
default:
|
||||
throw new Error(`不支持的小节类型: ${sectionType}`)
|
||||
}
|
||||
|
||||
if (response?.data?.result) {
|
||||
const data = response.data.result
|
||||
detailData.value = Array.isArray(data) && data.length > 0 ? data[0] : data
|
||||
console.log(detailData.value);
|
||||
|
||||
} else {
|
||||
throw new Error('数据格式错误或无数据')
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('加载小节详情失败:', err)
|
||||
error.value = err.message || '加载失败,请重试'
|
||||
message.error(error.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理预览组件错误
|
||||
const handlePreviewError = (errorMsg: string) => {
|
||||
error.value = errorMsg
|
||||
message.error(errorMsg)
|
||||
}
|
||||
|
||||
// 处理关闭事件
|
||||
const handleClose = () => {
|
||||
show.value = false
|
||||
// 清理状态
|
||||
setTimeout(() => {
|
||||
detailData.value = null
|
||||
error.value = ''
|
||||
loading.value = false
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 处理编辑事件
|
||||
// const handleEdit = () => {
|
||||
// if (props.sectionData && detailData.value) {
|
||||
// emit('edit', props.sectionData, detailData.value)
|
||||
// }
|
||||
// }
|
||||
|
||||
// 监听弹窗显示状态,自动加载数据
|
||||
watch(
|
||||
() => props.show,
|
||||
(newShow) => {
|
||||
if (newShow && props.sectionData) {
|
||||
loadSectionData()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听小节数据变化,重新加载
|
||||
watch(
|
||||
() => props.sectionData,
|
||||
(newData) => {
|
||||
if (newData && props.show) {
|
||||
loadSectionData()
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.preview-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-type-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
min-height: 300px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.preview-footer {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
padding-top: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.preview-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.preview-content::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.preview-content::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.preview-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
</style>
|
401
src/components/common/preview/DiscussionPreview.vue
Normal file
401
src/components/common/preview/DiscussionPreview.vue
Normal file
@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<div class="discussion-preview">
|
||||
<div v-if="!detailData" class="empty-state">
|
||||
<n-empty description="暂无讨论内容" />
|
||||
</div>
|
||||
|
||||
<div v-else class="discussion-content">
|
||||
<!-- 讨论信息 -->
|
||||
<div class="discussion-info">
|
||||
<h4>{{ detailData.title || '讨论主题' }}</h4>
|
||||
<div class="discussion-meta">
|
||||
<n-space>
|
||||
<n-tag type="info" size="small">
|
||||
<template #icon>
|
||||
<n-icon><ChatbubbleEllipsesOutline /></n-icon>
|
||||
</template>
|
||||
讨论
|
||||
</n-tag>
|
||||
<span v-if="detailData.replyCount || detailData.commentCount" class="reply-count">
|
||||
回复数: {{ detailData.replyCount || detailData.commentCount || 0 }}
|
||||
</span>
|
||||
<span v-if="detailData.status !== undefined" class="status">
|
||||
状态: {{ getStatusText(detailData.status) }}
|
||||
</span>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<!-- 作者信息 -->
|
||||
<div v-if="detailData.authorName || detailData.createTime" class="author-info">
|
||||
<n-space>
|
||||
<span v-if="detailData.authorName" class="author">
|
||||
发起人: {{ detailData.authorName }}
|
||||
</span>
|
||||
<span v-if="detailData.createTime" class="create-time">
|
||||
创建时间: {{ formatTime(detailData.createTime) }}
|
||||
</span>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 讨论描述/内容 -->
|
||||
<div class="discussion-description">
|
||||
<h5>讨论内容</h5>
|
||||
<div class="description-content">
|
||||
<div class="content-text" v-html="detailData.description">
|
||||
</div>
|
||||
|
||||
<!-- 讨论图片(如果有) -->
|
||||
<div v-if="detailData.images && detailData.images.length > 0" class="discussion-images">
|
||||
<div class="images-grid">
|
||||
<img
|
||||
v-for="(image, index) in detailData.images"
|
||||
:key="index"
|
||||
:src="image"
|
||||
:alt="`讨论图片${index + 1}`"
|
||||
class="discussion-image"
|
||||
@click="previewImage(image)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 讨论统计 -->
|
||||
<div v-if="hasStats" class="discussion-stats">
|
||||
<h5>讨论统计</h5>
|
||||
<n-card size="small">
|
||||
<n-descriptions :column="3" size="small">
|
||||
<n-descriptions-item label="参与人数">
|
||||
{{ detailData.participantCount || detailData.participants || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="回复数量">
|
||||
{{ detailData.replyCount || detailData.commentCount || 0 }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="点赞数">
|
||||
{{ detailData.likeCount || detailData.likes || 0 }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="浏览次数">
|
||||
{{ detailData.viewCount || detailData.views || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="最新回复">
|
||||
{{ formatTime(detailData.lastReplyTime) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="热度">
|
||||
{{ detailData.hotScore || '-' }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- 最新回复预览 -->
|
||||
<div v-if="detailData.recentReplies && detailData.recentReplies.length > 0" class="recent-replies">
|
||||
<h5>最新回复</h5>
|
||||
<div class="replies-list">
|
||||
<div
|
||||
v-for="(reply, index) in detailData.recentReplies.slice(0, 3)"
|
||||
:key="reply.id || index"
|
||||
class="reply-item"
|
||||
>
|
||||
<n-card size="small" style="margin-bottom: 8px;">
|
||||
<div class="reply-header">
|
||||
<span class="reply-author">{{ reply.authorName || reply.username || '匿名用户' }}</span>
|
||||
<span class="reply-time">{{ formatTime(reply.createTime || reply.time) }}</span>
|
||||
</div>
|
||||
<div class="reply-content">
|
||||
{{ reply.content || reply.text || '无内容' }}
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div v-if="detailData.recentReplies.length > 3" class="more-replies">
|
||||
<n-button text type="primary" @click="viewAllReplies">
|
||||
查看全部 {{ detailData.replyCount || detailData.recentReplies.length }} 条回复
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 讨论详细信息 -->
|
||||
<div class="discussion-details">
|
||||
<n-divider />
|
||||
<n-descriptions :column="2" bordered size="small">
|
||||
<n-descriptions-item label="讨论标题">
|
||||
{{ detailData.title || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">
|
||||
{{ formatTime(detailData.createTime) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="讨论ID">
|
||||
{{ detailData.id || '-' }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import {
|
||||
NEmpty,
|
||||
NSpace,
|
||||
NTag,
|
||||
NIcon,
|
||||
NCard,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NDivider,
|
||||
NButton
|
||||
} from 'naive-ui'
|
||||
import { ChatbubbleEllipsesOutline } from '@vicons/ionicons5'
|
||||
|
||||
interface Props {
|
||||
sectionData: any
|
||||
detailData: any
|
||||
courseId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
error: [message: string]
|
||||
edit: [sectionData: any, detailData: any]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 判断是否有统计信息
|
||||
const hasStats = computed(() => {
|
||||
const data = props.detailData
|
||||
return data && (
|
||||
data.participantCount !== undefined ||
|
||||
data.participants !== undefined ||
|
||||
data.viewCount !== undefined ||
|
||||
data.views !== undefined ||
|
||||
data.likeCount !== undefined ||
|
||||
data.likes !== undefined ||
|
||||
data.hotScore !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string | undefined) => {
|
||||
if (!time) return '-'
|
||||
try {
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return time
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: number | string | undefined) => {
|
||||
const statusMap: Record<string, string> = {
|
||||
'0': '草稿',
|
||||
'1': '进行中',
|
||||
'2': '已关闭',
|
||||
'3': '置顶'
|
||||
}
|
||||
return statusMap[status?.toString() || '1'] || '进行中'
|
||||
}
|
||||
|
||||
// 预览图片
|
||||
const previewImage = (imageUrl: string) => {
|
||||
// 这里可以使用图片预览组件
|
||||
window.open(imageUrl, '_blank')
|
||||
}
|
||||
|
||||
// 参与讨论
|
||||
// const joinDiscussion = () => {
|
||||
// message.info('跳转到讨论页面')
|
||||
// // 实际实现中可以使用 router.push() 跳转到讨论页面
|
||||
// }
|
||||
|
||||
// // 查看讨论详情
|
||||
// const viewDiscussion = () => {
|
||||
// message.info('查看讨论详情')
|
||||
// // 实际实现中可以跳转到讨论详情页面
|
||||
// }
|
||||
|
||||
// 查看全部回复
|
||||
const viewAllReplies = () => {
|
||||
message.info('查看全部回复')
|
||||
// 实际实现中可以跳转到回复列表页面
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.discussion-preview {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.discussion-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.discussion-info {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.discussion-info h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.discussion-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.author-info {
|
||||
font-size: 13px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.discussion-description,
|
||||
.discussion-stats,
|
||||
.recent-replies {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.discussion-description h5,
|
||||
.discussion-stats h5,
|
||||
.recent-replies h5 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
white-space: pre-wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.discussion-images {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.images-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.discussion-image {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.discussion-image:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.reply-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.reply-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.reply-author {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.reply-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.reply-content {
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.more-replies {
|
||||
text-align: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.discussion-details {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.discussion-actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.reply-count,
|
||||
.status,
|
||||
.author,
|
||||
.create-time {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.discussion-preview {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.discussion-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.author-info {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.images-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
}
|
||||
|
||||
.discussion-image {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.reply-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
451
src/components/common/preview/DocumentPreview.vue
Normal file
451
src/components/common/preview/DocumentPreview.vue
Normal file
@ -0,0 +1,451 @@
|
||||
<template>
|
||||
<div class="document-preview">
|
||||
<div v-if="!detailData" class="empty-state">
|
||||
<n-empty description="暂无文档内容" />
|
||||
</div>
|
||||
|
||||
<div v-else class="document-content">
|
||||
<!-- 文档信息 -->
|
||||
<div class="document-info">
|
||||
<h4>{{ detailData.name || '文档标题' }}</h4>
|
||||
<div class="document-meta">
|
||||
<n-space>
|
||||
<n-tag type="success" size="small">
|
||||
<template #icon>
|
||||
<n-icon><DocumentTextOutline /></n-icon>
|
||||
</template>
|
||||
文档
|
||||
</n-tag>
|
||||
<span v-if="detailData.fileSize" class="file-size">
|
||||
大小: {{ formatFileSize(detailData.fileSize) }}
|
||||
</span>
|
||||
<span v-if="getFileExtension(detailData.fileUrl || detailData.documentUrl)" class="file-type">
|
||||
格式: {{ getFileExtension(detailData.fileUrl || detailData.documentUrl) }}
|
||||
</span>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文档操作区域 -->
|
||||
<div class="document-actions">
|
||||
<div v-if="detailData.fileUrl || detailData.documentUrl" class="document-card">
|
||||
<!-- 图片直接显示 -->
|
||||
<div v-if="isImageFile(detailData.fileUrl || detailData.documentUrl)" class="image-preview">
|
||||
<img
|
||||
:src="detailData.fileUrl || detailData.documentUrl"
|
||||
:alt="detailData.name"
|
||||
class="preview-image"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div class="image-actions">
|
||||
<n-space justify="center">
|
||||
<n-button @click="downloadDocument">
|
||||
<template #icon>
|
||||
<n-icon><DownloadOutline /></n-icon>
|
||||
</template>
|
||||
下载图片
|
||||
</n-button>
|
||||
<n-button @click="openInNewTab">
|
||||
<template #icon>
|
||||
<n-icon><OpenOutline /></n-icon>
|
||||
</template>
|
||||
新窗口查看
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 其他文件类型的图标和下载方式 -->
|
||||
<div v-else class="file-preview">
|
||||
<!-- 文档图标和类型说明 -->
|
||||
<div class="document-icon-section">
|
||||
<div class="document-icon">
|
||||
<n-icon size="48" :color="getFileTypeColor(detailData.fileUrl || detailData.documentUrl)">
|
||||
<component :is="getFileTypeIcon(detailData.fileUrl || detailData.documentUrl)" />
|
||||
</n-icon>
|
||||
</div>
|
||||
<div class="document-type-info">
|
||||
<h4>{{ detailData.name }}</h4>
|
||||
<p class="file-description">{{ detailData.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="document-operations">
|
||||
<n-space>
|
||||
<n-button type="primary" size="large" @click="downloadDocument">
|
||||
<template #icon>
|
||||
<n-icon><DownloadOutline /></n-icon>
|
||||
</template>
|
||||
下载文档
|
||||
</n-button>
|
||||
<n-button @click="openInNewTab">
|
||||
<template #icon>
|
||||
<n-icon><OpenOutline /></n-icon>
|
||||
</template>
|
||||
新窗口打开
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-document">
|
||||
<n-result status="warning" title="文档文件不存在" description="该小节尚未关联文档文件">
|
||||
<template #footer>
|
||||
<n-button type="primary" @click="$emit('edit', sectionData, detailData)">
|
||||
去添加文档
|
||||
</n-button>
|
||||
</template>
|
||||
</n-result>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文档描述 -->
|
||||
<div v-if="detailData.description" class="document-description">
|
||||
<h5>文档简介</h5>
|
||||
<div class="description-content">
|
||||
{{ detailData.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文档详细信息 -->
|
||||
<div class="document-details">
|
||||
<n-divider />
|
||||
<n-descriptions :column="2" bordered size="small">
|
||||
<n-descriptions-item label="文档名称">
|
||||
{{ detailData.name || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="文件格式">
|
||||
{{ getFileExtension(detailData.fileUrl || detailData.documentUrl) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="文件大小">
|
||||
{{ detailData.fileSize ? formatFileSize(detailData.fileSize) : '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="detailData.pages" label="页数">
|
||||
{{ detailData.pages }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui'
|
||||
import {
|
||||
NEmpty,
|
||||
NSpace,
|
||||
NTag,
|
||||
NIcon,
|
||||
NResult,
|
||||
NButton,
|
||||
NDivider,
|
||||
NDescriptions,
|
||||
NDescriptionsItem
|
||||
} from 'naive-ui'
|
||||
import {
|
||||
DocumentTextOutline,
|
||||
DownloadOutline,
|
||||
OpenOutline,
|
||||
DocumentOutline,
|
||||
ImagesOutline,
|
||||
CodeOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
|
||||
interface Props {
|
||||
sectionData: any
|
||||
detailData: any
|
||||
courseId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
error: [message: string]
|
||||
edit: [sectionData: any, detailData: any]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
// 判断是否为图片文件
|
||||
const isImageFile = (url: string | undefined) => {
|
||||
if (!url) return false
|
||||
return /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(url)
|
||||
}
|
||||
|
||||
// 处理图片加载错误
|
||||
const handleImageError = () => {
|
||||
message.error('图片加载失败,请检查图片链接是否有效')
|
||||
}
|
||||
|
||||
// 根据文件类型获取图标
|
||||
const getFileTypeIcon = (url: string | undefined) => {
|
||||
if (!url) return DocumentOutline
|
||||
|
||||
const extension = getFileExtension(url).toLowerCase()
|
||||
|
||||
switch (extension) {
|
||||
case 'pdf':
|
||||
return DocumentTextOutline
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return DocumentOutline
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return DocumentTextOutline
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return DocumentTextOutline
|
||||
case 'txt':
|
||||
case 'md':
|
||||
return CodeOutline
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'bmp':
|
||||
case 'webp':
|
||||
return ImagesOutline
|
||||
default:
|
||||
return DocumentOutline
|
||||
}
|
||||
}
|
||||
|
||||
// 根据文件类型获取颜色
|
||||
const getFileTypeColor = (url: string | undefined) => {
|
||||
if (!url) return '#666'
|
||||
|
||||
const extension = getFileExtension(url).toLowerCase()
|
||||
|
||||
switch (extension) {
|
||||
case 'pdf':
|
||||
return '#e74c3c'
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return '#2980b9'
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return '#27ae60'
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return '#f39c12'
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'bmp':
|
||||
case 'webp':
|
||||
return '#9b59b6'
|
||||
default:
|
||||
return '#666'
|
||||
}
|
||||
}
|
||||
|
||||
// 获取文件扩展名
|
||||
const getFileExtension = (url: string | undefined) => {
|
||||
if (!url) return '-'
|
||||
const match = url.match(/\.([^.]+)$/)
|
||||
return match ? match[1].toUpperCase() : '-'
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number | string) => {
|
||||
const num = typeof bytes === 'string' ? parseInt(bytes) : bytes
|
||||
if (isNaN(num)) return '-'
|
||||
|
||||
if (num === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(num) / Math.log(k))
|
||||
return parseFloat((num / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 下载文档
|
||||
const downloadDocument = () => {
|
||||
const url = props.detailData.fileUrl || props.detailData.documentUrl
|
||||
if (url) {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = props.detailData.name || 'document'
|
||||
link.click()
|
||||
message.success('开始下载文档')
|
||||
}
|
||||
}
|
||||
|
||||
// 新窗口打开
|
||||
const openInNewTab = () => {
|
||||
const url = props.detailData.fileUrl || props.detailData.documentUrl
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.document-preview {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.document-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.document-info {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.document-info h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.document-actions {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
/* 保持原有的文件预览样式 */
|
||||
}
|
||||
|
||||
.document-icon-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.document-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
border: 2px solid #e9ecef;
|
||||
}
|
||||
|
||||
.document-type-info h4 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.file-description {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.document-operations {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.other-document {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-document {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.document-description {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.document-description h5 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.document-details {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.file-size,
|
||||
.file-type {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.document-preview {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.document-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.document-icon-section {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.document-operations {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
424
src/components/common/preview/ExamPreview.vue
Normal file
424
src/components/common/preview/ExamPreview.vue
Normal file
@ -0,0 +1,424 @@
|
||||
<template>
|
||||
<div class="exam-preview">
|
||||
|
||||
<div class="exam-content">
|
||||
<!-- 考试信息 -->
|
||||
<div class="exam-info">
|
||||
<h4>{{ detailData.title || detailData.name || '考试标题' }}</h4>
|
||||
<div class="exam-meta">
|
||||
<n-space>
|
||||
<n-tag type="error" size="small">
|
||||
<template #icon>
|
||||
<n-icon><ClipboardOutline /></n-icon>
|
||||
</template>
|
||||
考试
|
||||
</n-tag>
|
||||
<span v-if="detailData.questionCount || detailData.questions?.length" class="question-count">
|
||||
题目数量: {{ detailData.questionCount || detailData.questions?.length || 0 }}
|
||||
</span>
|
||||
<span v-if="detailData.duration" class="duration">
|
||||
考试时长: {{ formatDuration(detailData.duration) }}
|
||||
</span>
|
||||
<span v-if="detailData.totalScore" class="total-score">
|
||||
总分: {{ detailData.totalScore }}分
|
||||
</span>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 考试状态和时间 -->
|
||||
<div class="exam-status">
|
||||
<n-card size="small" style="margin-bottom: 16px;">
|
||||
<n-descriptions :column="2" size="small">
|
||||
<!-- <n-descriptions-item label="考试状态">
|
||||
<n-tag :type="getStatusType(detailData.status)" size="small">
|
||||
{{ getStatusText(detailData.status) }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="detailData.passScore" label="及格分数">
|
||||
{{ detailData.passScore }}分
|
||||
</n-descriptions-item> -->
|
||||
<n-descriptions-item v-if="detailData.startTime" label="开始时间">
|
||||
{{ formatTime(detailData.startTime) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="detailData.endTime" label="结束时间">
|
||||
{{ formatTime(detailData.endTime) }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- 考试说明 -->
|
||||
<div v-if="detailData.description || detailData.instruction" class="exam-description">
|
||||
<h5>考试说明</h5>
|
||||
<div class="description-content">
|
||||
{{ detailData.description || detailData.instruction || '暂无考试说明' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 题目预览 -->
|
||||
<!-- <div class="questions-preview">
|
||||
<h5>题目预览</h5>
|
||||
|
||||
<div v-if="detailData.questions && detailData.questions.length > 0" class="questions-list">
|
||||
<div
|
||||
v-for="(question, index) in displayQuestions"
|
||||
:key="question.id || index"
|
||||
class="question-item"
|
||||
>
|
||||
<n-card size="small" style="margin-bottom: 12px;">
|
||||
<div class="question-header">
|
||||
<span class="question-number">第{{ index + 1 }}题</span>
|
||||
<n-tag size="small" :type="getQuestionTypeColor(question.type)">
|
||||
{{ getQuestionTypeText(question.type) }}
|
||||
</n-tag>
|
||||
<span v-if="question.score" class="question-score">{{ question.score }}分</span>
|
||||
</div>
|
||||
|
||||
<div class="question-content">
|
||||
<div class="question-text">{{ question.title || question.content || question.question }}</div>
|
||||
|
||||
<div v-if="question.options && question.options.length > 0" class="question-options">
|
||||
<div
|
||||
v-for="(option, optionIndex) in question.options"
|
||||
:key="optionIndex"
|
||||
class="option-item"
|
||||
>
|
||||
<span class="option-label">{{ getOptionLabel(optionIndex) }}.</span>
|
||||
<span class="option-text">{{ option.content || option.text || option }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="question.answer || question.correctAnswer" class="question-answer">
|
||||
<span class="answer-label">参考答案:</span>
|
||||
<span class="answer-content">{{ question.answer || question.correctAnswer }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<div v-if="detailData.questions.length > maxDisplayQuestions" class="show-more">
|
||||
<n-button v-if="!showAllQuestions" @click="showAllQuestions = true">
|
||||
显示全部 {{ detailData.questions.length }} 道题目
|
||||
</n-button>
|
||||
<n-button v-else @click="showAllQuestions = false">
|
||||
收起题目
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-questions">
|
||||
<n-empty description="暂无题目" size="small" />
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- 考试详细信息 -->
|
||||
<div class="exam-details">
|
||||
<n-divider />
|
||||
<n-descriptions :column="2" bordered size="small">
|
||||
<n-descriptions-item label="考试名称">
|
||||
{{ detailData.title || detailData.name || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">
|
||||
{{ formatTime(detailData.createTime) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="更新时间">
|
||||
{{ formatTime(detailData.updateTime) }}
|
||||
</n-descriptions-item>
|
||||
<!-- <n-descriptions-item label="允许重考">
|
||||
{{ detailData.allowRetake ? '是' : '否' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="成绩公布">
|
||||
{{ detailData.showScore ? '立即公布' : '稍后公布' }}
|
||||
</n-descriptions-item> -->
|
||||
</n-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// import { ref, computed } from 'vue'
|
||||
import {
|
||||
NSpace,
|
||||
NTag,
|
||||
NIcon,
|
||||
NCard,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NDivider,
|
||||
} from 'naive-ui'
|
||||
import { ClipboardOutline } from '@vicons/ionicons5'
|
||||
|
||||
interface Props {
|
||||
sectionData: any
|
||||
detailData: any
|
||||
courseId: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
error: [message: string]
|
||||
edit: [sectionData: any, detailData: any]
|
||||
}>()
|
||||
|
||||
// const showAllQuestions = ref(false)
|
||||
// const maxDisplayQuestions = 5
|
||||
|
||||
// 显示的题目列表
|
||||
// const displayQuestions = computed(() => {
|
||||
// if (!props.detailData?.questions) return []
|
||||
|
||||
// return showAllQuestions.value
|
||||
// ? props.detailData.questions
|
||||
// : props.detailData.questions.slice(0, maxDisplayQuestions)
|
||||
// })
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (minutes: number | string) => {
|
||||
const num = typeof minutes === 'string' ? parseInt(minutes) : minutes
|
||||
if (isNaN(num)) return '-'
|
||||
|
||||
if (num < 60) return `${num}分钟`
|
||||
const hours = Math.floor(num / 60)
|
||||
const remainingMinutes = num % 60
|
||||
return `${hours}小时${remainingMinutes}分钟`
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string | undefined) => {
|
||||
if (!time) return '-'
|
||||
try {
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return time
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
// const getStatusText = (status: number | string | undefined) => {
|
||||
// const statusMap: Record<string, string> = {
|
||||
// '0': '未开始',
|
||||
// '1': '进行中',
|
||||
// '2': '已结束',
|
||||
// '3': '已暂停'
|
||||
// }
|
||||
// return statusMap[status?.toString() || '0'] || '未知状态'
|
||||
// }
|
||||
|
||||
// 获取状态类型
|
||||
// const getStatusType = (status: number | string | undefined): 'default' | 'success' | 'warning' | 'error' => {
|
||||
// const typeMap: Record<string, 'default' | 'success' | 'warning' | 'error'> = {
|
||||
// '0': 'default',
|
||||
// '1': 'success',
|
||||
// '2': 'warning',
|
||||
// '3': 'error'
|
||||
// }
|
||||
// return typeMap[status?.toString() || '0'] || 'default'
|
||||
// }
|
||||
|
||||
// 获取题目类型文本
|
||||
// const getQuestionTypeText = (type: number | string | undefined) => {
|
||||
// const typeMap: Record<string, string> = {
|
||||
// '1': '单选题',
|
||||
// '2': '多选题',
|
||||
// '3': '判断题',
|
||||
// '4': '填空题',
|
||||
// '5': '简答题',
|
||||
// '6': '论述题'
|
||||
// }
|
||||
// return typeMap[type?.toString() || '1'] || '单选题'
|
||||
// }
|
||||
|
||||
// 获取题目类型颜色
|
||||
// const getQuestionTypeColor = (type: number | string | undefined): 'default' | 'primary' | 'info' | 'success' | 'warning' | 'error' => {
|
||||
// const colorMap: Record<string, 'default' | 'primary' | 'info' | 'success' | 'warning' | 'error'> = {
|
||||
// '1': 'primary',
|
||||
// '2': 'info',
|
||||
// '3': 'success',
|
||||
// '4': 'warning',
|
||||
// '5': 'error',
|
||||
// '6': 'default'
|
||||
// }
|
||||
// return colorMap[type?.toString() || '1'] || 'primary'
|
||||
// }
|
||||
|
||||
// // 获取选项标签
|
||||
// const getOptionLabel = (index: number) => {
|
||||
// return String.fromCharCode(65 + index) // A, B, C, D...
|
||||
// }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.exam-preview {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.exam-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.exam-info {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.exam-info h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.exam-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.exam-description {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.exam-description h5 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.questions-preview h5 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.question-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.question-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.question-number {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.question-score {
|
||||
margin-left: auto;
|
||||
color: #ff6b35;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.question-content {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.question-options {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
margin-right: 8px;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.option-text {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.question-answer {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f0f9ff;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #1890ff;
|
||||
}
|
||||
|
||||
.answer-label {
|
||||
font-weight: 500;
|
||||
color: #1890ff;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.answer-content {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.show-more {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.no-questions {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exam-details {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.question-count,
|
||||
.duration,
|
||||
.total-score {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.exam-preview {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.exam-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.question-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
335
src/components/common/preview/HomeworkPreview.vue
Normal file
335
src/components/common/preview/HomeworkPreview.vue
Normal file
@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<div class="homework-preview">
|
||||
<div v-if="!detailData" class="empty-state">
|
||||
<n-empty description="暂无作业内容" />
|
||||
</div>
|
||||
|
||||
<div v-else class="homework-content">
|
||||
<!-- 作业信息 -->
|
||||
<div class="homework-info">
|
||||
<h4>{{ detailData.title || '作业标题' }}</h4>
|
||||
<div class="homework-meta">
|
||||
<n-space>
|
||||
<n-tag type="warning" size="small">
|
||||
<template #icon>
|
||||
<n-icon><BookOutline /></n-icon>
|
||||
</template>
|
||||
作业
|
||||
</n-tag>
|
||||
<span v-if="detailData.maxScore" class="max-score">
|
||||
满分: {{ detailData.maxScore }}分
|
||||
</span>
|
||||
<span v-if="detailData.passScore" class="pass-score">
|
||||
及格分: {{ detailData.passScore }}分
|
||||
</span>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 作业状态和时间 -->
|
||||
<!-- <div class="homework-status">
|
||||
<n-card size="small" style="margin-bottom: 16px;">
|
||||
<n-descriptions :column="2" size="small">
|
||||
<n-descriptions-item label="作业状态">
|
||||
<n-tag :type="getStatusType(detailData.status)" size="small">
|
||||
{{ getStatusText(detailData.status) }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="允许补交">
|
||||
<n-tag :type="detailData.allowMakeup === '1' ? 'success' : 'default'" size="small">
|
||||
{{ detailData.allowMakeup === '1' ? '允许' : '不允许' }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="detailData.startTime" label="开始时间">
|
||||
{{ formatTime(detailData.startTime) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="detailData.endTime" label="截止时间">
|
||||
{{ formatTime(detailData.endTime) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="detailData.makeupTime" label="补交截止">
|
||||
{{ formatTime(detailData.makeupTime) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item v-if="detailData.notifyTime" label="通知时间">
|
||||
{{ formatTime(detailData.notifyTime) }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</div> -->
|
||||
|
||||
<!-- 作业描述 -->
|
||||
<div class="homework-description">
|
||||
<h5>作业描述</h5>
|
||||
<div class="description-content">
|
||||
{{ detailData.description || '暂无作业要求说明' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- 提交统计(如果有的话) -->
|
||||
<div v-if="detailData.submitStats || detailData.submissionStats" class="homework-stats">
|
||||
<h5>提交统计</h5>
|
||||
<n-card size="small">
|
||||
<n-descriptions :column="3" size="small">
|
||||
<n-descriptions-item label="应交人数">
|
||||
{{ detailData.submitStats?.totalStudents || detailData.submissionStats?.total || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="已提交">
|
||||
{{ detailData.submitStats?.submitted || detailData.submissionStats?.submitted || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="未提交">
|
||||
{{ detailData.submitStats?.unsubmitted || detailData.submissionStats?.unsubmitted || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="已批改">
|
||||
{{ detailData.submitStats?.reviewed || detailData.submissionStats?.reviewed || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="待批改">
|
||||
{{ detailData.submitStats?.pending || detailData.submissionStats?.pending || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="平均分">
|
||||
{{ detailData.submitStats?.averageScore || detailData.submissionStats?.averageScore || '-' }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- 作业详细信息 -->
|
||||
<div class="homework-details">
|
||||
<n-divider />
|
||||
<n-descriptions :column="2" bordered size="small">
|
||||
<n-descriptions-item label="作业标题">
|
||||
{{ detailData.title || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">
|
||||
{{ formatTime(detailData.createTime) }}
|
||||
</n-descriptions-item>
|
||||
<!-- <n-descriptions-item label="更新时间">
|
||||
{{ formatTime(detailData.updateTime) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="关联班级">
|
||||
{{ detailData.className || detailData.classNames || '-' }}
|
||||
</n-descriptions-item> -->
|
||||
<n-descriptions-item label="作业ID">
|
||||
{{ detailData.id || '-' }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<!-- <div class="homework-actions">
|
||||
<n-divider />
|
||||
<n-space justify="center">
|
||||
<n-button type="primary" @click="viewSubmissions">
|
||||
查看提交情况
|
||||
</n-button>
|
||||
<n-button @click="$emit('edit', sectionData, detailData)">
|
||||
编辑作业
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// import { useMessage } from 'naive-ui'
|
||||
import {
|
||||
NEmpty,
|
||||
NSpace,
|
||||
NTag,
|
||||
NIcon,
|
||||
NCard,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NDivider,
|
||||
} from 'naive-ui'
|
||||
import { BookOutline } from '@vicons/ionicons5'
|
||||
|
||||
interface Props {
|
||||
sectionData: any
|
||||
detailData: any
|
||||
courseId: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
error: [message: string]
|
||||
edit: [sectionData: any, detailData: any]
|
||||
}>()
|
||||
|
||||
// const message = useMessage()
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string | number | undefined) => {
|
||||
if (!time) return '-'
|
||||
try {
|
||||
// 如果是时间戳(数字),转换为毫秒
|
||||
const date = typeof time === 'number' ? new Date(time > 1e10 ? time : time * 1000) : new Date(time)
|
||||
return date.toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return String(time)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
// const getStatusText = (status: number | string | undefined) => {
|
||||
// const statusMap: Record<string, string> = {
|
||||
// '0': '未发布',
|
||||
// '1': '进行中',
|
||||
// '2': '已截止',
|
||||
// '3': '已关闭'
|
||||
// }
|
||||
// return statusMap[status?.toString() || '0'] || '未知状态'
|
||||
// }
|
||||
|
||||
// // 获取状态类型
|
||||
// const getStatusType = (status: number | string | undefined): 'default' | 'success' | 'warning' | 'error' => {
|
||||
// const typeMap: Record<string, 'default' | 'success' | 'warning' | 'error'> = {
|
||||
// '0': 'default',
|
||||
// '1': 'success',
|
||||
// '2': 'warning',
|
||||
// '3': 'error'
|
||||
// }
|
||||
// return typeMap[status?.toString() || '0'] || 'default'
|
||||
// }
|
||||
|
||||
// 获取文件名
|
||||
// const getFileName = (url: string | undefined) => {
|
||||
// if (!url) return '附件'
|
||||
// const fileName = url.split('/').pop() || url
|
||||
// return decodeURIComponent(fileName)
|
||||
// }
|
||||
|
||||
// 下载附件
|
||||
// const downloadAttachment = () => {
|
||||
// if (props.detailData.attachment) {
|
||||
// const link = document.createElement('a')
|
||||
// link.href = props.detailData.attachment
|
||||
// link.download = getFileName(props.detailData.attachment)
|
||||
// link.click()
|
||||
// message.success('开始下载附件')
|
||||
// }
|
||||
// }
|
||||
|
||||
// // 预览附件
|
||||
// const previewAttachment = () => {
|
||||
// if (props.detailData.attachment) {
|
||||
// window.open(props.detailData.attachment, '_blank')
|
||||
// }
|
||||
// }
|
||||
|
||||
// 查看提交情况
|
||||
// const viewSubmissions = () => {
|
||||
// // 这里可以导航到作业提交管理页面
|
||||
// message.info('跳转到作业提交管理页面')
|
||||
// // 实际实现中可以使用 router.push() 跳转
|
||||
// }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.homework-preview {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.homework-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.homework-info {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.homework-info h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.homework-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.homework-description,
|
||||
.homework-attachment,
|
||||
.homework-stats {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.homework-description h5,
|
||||
.homework-attachment h5,
|
||||
.homework-stats h5 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
flex: 1;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.homework-details {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.homework-actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.max-score,
|
||||
.pass-score {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.homework-preview {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.homework-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
</style>
|
506
src/components/common/preview/PracticePreview.vue
Normal file
506
src/components/common/preview/PracticePreview.vue
Normal file
@ -0,0 +1,506 @@
|
||||
<template>
|
||||
<div class="practice-preview">
|
||||
<!-- <div v-if="!detailData || (!detailData.questions && !detailData.length)" class="empty-state">
|
||||
<n-empty description="暂无练习内容" />
|
||||
</div> -->
|
||||
|
||||
<div class="practice-content">
|
||||
<!-- 练习信息 -->
|
||||
<div class="practice-info">
|
||||
<h4>{{ sectionData?.name || '练习题目' }}</h4>
|
||||
<div class="practice-meta">
|
||||
<n-space>
|
||||
<n-tag type="primary" size="small">
|
||||
<template #icon>
|
||||
<n-icon><FlashOutline /></n-icon>
|
||||
</template>
|
||||
练习
|
||||
</n-tag>
|
||||
<span v-if="questionList.length" class="question-count">
|
||||
题目数量: {{ questionList.length }}
|
||||
</span>
|
||||
<span v-if="totalScore > 0" class="total-score">
|
||||
总分: {{ totalScore }}分
|
||||
</span>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 练习说明 -->
|
||||
<div v-if="detailData.description || detailData.instruction" class="practice-description">
|
||||
<h5>练习说明</h5>
|
||||
<div class="description-content">
|
||||
{{ detailData.description || detailData.instruction || '暂无练习说明' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 题目列表 -->
|
||||
<div class="questions-preview">
|
||||
<h5>练习题目</h5>
|
||||
|
||||
<div v-if="questionList.length > 0" class="questions-list">
|
||||
<div
|
||||
v-for="(question, index) in displayQuestions"
|
||||
:key="question.id || index"
|
||||
class="question-item"
|
||||
>
|
||||
<n-card size="small" style="margin-bottom: 12px;">
|
||||
<div class="question-header">
|
||||
<span class="question-number">第{{ index + 1 }}题</span>
|
||||
<n-tag size="small" :type="getQuestionTypeColor(question.type || question.questionType)">
|
||||
{{ getQuestionTypeText(question.type || question.questionType) }}
|
||||
</n-tag>
|
||||
<span v-if="question.score || question.points" class="question-score">
|
||||
{{ question.score || question.points }}分
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="question-content">
|
||||
<div class="question-text">
|
||||
{{ question.title || question.content || question.question || question.stem }}
|
||||
</div>
|
||||
|
||||
<!-- 选择题选项 -->
|
||||
<div
|
||||
v-if="(question.options && question.options.length > 0) || question.choices"
|
||||
class="question-options"
|
||||
>
|
||||
<div
|
||||
v-for="(option, optionIndex) in (question.options || question.choices || [])"
|
||||
:key="optionIndex"
|
||||
class="option-item"
|
||||
>
|
||||
<span class="option-label">{{ getOptionLabel(optionIndex) }}.</span>
|
||||
<span class="option-text">
|
||||
{{ typeof option === 'string' ? option : (option.content || option.text || option.value) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 正确答案(练习模式显示) -->
|
||||
<div
|
||||
v-if="question.answer || question.correctAnswer || question.standardAnswer"
|
||||
class="question-answer"
|
||||
>
|
||||
<span class="answer-label">参考答案:</span>
|
||||
<span class="answer-content">
|
||||
{{ question.answer || question.correctAnswer || question.standardAnswer }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 解析 -->
|
||||
<div v-if="question.analysis || question.explanation" class="question-analysis">
|
||||
<span class="analysis-label">题目解析:</span>
|
||||
<div class="analysis-content">
|
||||
{{ question.analysis || question.explanation }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
<!-- 显示更多按钮 -->
|
||||
<div v-if="questionList.length > maxDisplayQuestions" class="show-more">
|
||||
<n-button v-if="!showAllQuestions" @click="showAllQuestions = true">
|
||||
显示全部 {{ questionList.length }} 道题目
|
||||
</n-button>
|
||||
<n-button v-else @click="showAllQuestions = false">
|
||||
收起题目
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-questions">
|
||||
<n-empty description="暂无练习题目" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 练习统计信息 -->
|
||||
<!-- <div v-if="detailData.statistics || detailData.stats" class="practice-stats">
|
||||
<h5>练习统计</h5>
|
||||
<n-card size="small">
|
||||
<n-descriptions :column="3" size="small">
|
||||
<n-descriptions-item label="参与人数">
|
||||
{{ detailData.statistics?.participants || detailData.stats?.participants || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="完成人数">
|
||||
{{ detailData.statistics?.completed || detailData.stats?.completed || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="平均分">
|
||||
{{ detailData.statistics?.averageScore || detailData.stats?.averageScore || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="最高分">
|
||||
{{ detailData.statistics?.maxScore || detailData.stats?.maxScore || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="最低分">
|
||||
{{ detailData.statistics?.minScore || detailData.stats?.minScore || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="及格率">
|
||||
{{ detailData.statistics?.passRate || detailData.stats?.passRate || '-' }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
</div> -->
|
||||
|
||||
<!-- 练习详细信息 -->
|
||||
<div class="practice-details">
|
||||
<n-divider />
|
||||
<n-descriptions :column="2" bordered size="small">
|
||||
<n-descriptions-item label="练习名称">
|
||||
{{ sectionData?.name || detailData.title || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="练习类型">
|
||||
{{ detailData.practiceType || '章节练习' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="开始时间">
|
||||
{{ formatTime(detailData.startTime) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="结束时间">
|
||||
{{ formatTime(detailData.endTime) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="考试时间">
|
||||
{{ detailData.totalTime + '分钟' || '-' }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<!-- <div class="practice-actions">
|
||||
<n-divider />
|
||||
<n-space justify="center">
|
||||
<n-button type="primary" @click="startPractice">
|
||||
开始练习
|
||||
</n-button>
|
||||
<n-button @click="viewResults">
|
||||
查看结果
|
||||
</n-button>
|
||||
<n-button @click="$emit('edit', sectionData, detailData)">
|
||||
编辑练习
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
// import { useMessage } from 'naive-ui'
|
||||
import {
|
||||
NEmpty,
|
||||
NSpace,
|
||||
NTag,
|
||||
NIcon,
|
||||
NCard,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NDivider,
|
||||
NButton
|
||||
} from 'naive-ui'
|
||||
import { FlashOutline } from '@vicons/ionicons5'
|
||||
|
||||
interface Props {
|
||||
sectionData: any
|
||||
detailData: any
|
||||
courseId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
error: [message: string]
|
||||
edit: [sectionData: any, detailData: any]
|
||||
}>()
|
||||
|
||||
// const message = useMessage()
|
||||
const showAllQuestions = ref(false)
|
||||
const maxDisplayQuestions = 5
|
||||
|
||||
// 题目列表(处理不同的数据结构)
|
||||
const questionList = computed(() => {
|
||||
if (!props.detailData) return []
|
||||
|
||||
// 如果detailData本身是数组,直接返回
|
||||
if (Array.isArray(props.detailData)) {
|
||||
return props.detailData
|
||||
}
|
||||
|
||||
// 如果有questions字段
|
||||
if (props.detailData.questions && Array.isArray(props.detailData.questions)) {
|
||||
return props.detailData.questions
|
||||
}
|
||||
|
||||
// 如果有result字段且是数组
|
||||
if (props.detailData.result && Array.isArray(props.detailData.result)) {
|
||||
return props.detailData.result
|
||||
}
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
// 显示的题目列表
|
||||
const displayQuestions = computed(() => {
|
||||
return showAllQuestions.value
|
||||
? questionList.value
|
||||
: questionList.value.slice(0, maxDisplayQuestions)
|
||||
})
|
||||
|
||||
// 计算总分
|
||||
const totalScore = computed(() => {
|
||||
return questionList.value.reduce((total: number, question: any) => {
|
||||
const score = question.score || question.points || 0
|
||||
return total + (typeof score === 'number' ? score : parseInt(score) || 0)
|
||||
}, 0)
|
||||
})
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string | undefined) => {
|
||||
if (!time) return '-'
|
||||
try {
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
} catch {
|
||||
return time
|
||||
}
|
||||
}
|
||||
|
||||
// 获取题目类型文本
|
||||
const getQuestionTypeText = (type: number | string | undefined) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
'1': '单选题',
|
||||
'2': '多选题',
|
||||
'3': '判断题',
|
||||
'4': '填空题',
|
||||
'5': '简答题',
|
||||
'6': '论述题'
|
||||
}
|
||||
return typeMap[type?.toString() || '1'] || '单选题'
|
||||
}
|
||||
|
||||
// 获取题目类型颜色
|
||||
const getQuestionTypeColor = (type: number | string | undefined): 'default' | 'primary' | 'info' | 'success' | 'warning' | 'error' => {
|
||||
const colorMap: Record<string, 'default' | 'primary' | 'info' | 'success' | 'warning' | 'error'> = {
|
||||
'1': 'primary',
|
||||
'2': 'info',
|
||||
'3': 'success',
|
||||
'4': 'warning',
|
||||
'5': 'error',
|
||||
'6': 'default'
|
||||
}
|
||||
return colorMap[type?.toString() || '1'] || 'primary'
|
||||
}
|
||||
|
||||
// 获取选项标签
|
||||
const getOptionLabel = (index: number) => {
|
||||
return String.fromCharCode(65 + index) // A, B, C, D...
|
||||
}
|
||||
|
||||
// 开始练习
|
||||
// const startPractice = () => {
|
||||
// message.info('跳转到练习页面')
|
||||
// // 实际实现中可以使用 router.push() 跳转到练习页面
|
||||
// }
|
||||
|
||||
// // 查看结果
|
||||
// const viewResults = () => {
|
||||
// message.info('查看练习结果')
|
||||
// // 实际实现中可以显示练习结果或跳转到结果页面
|
||||
// }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.practice-preview {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.practice-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.practice-info {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.practice-info h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.practice-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.practice-description,
|
||||
.practice-stats {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.practice-description h5,
|
||||
.practice-stats h5 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.questions-preview h5 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.question-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.question-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.question-number {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.question-score {
|
||||
margin-left: auto;
|
||||
color: #ff6b35;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.question-content {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.question-options {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.option-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
margin-right: 8px;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.option-text {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.question-answer {
|
||||
margin-top: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f0f9ff;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #1890ff;
|
||||
}
|
||||
|
||||
.answer-label {
|
||||
font-weight: 500;
|
||||
color: #1890ff;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.answer-content {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.question-analysis {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f6ffed;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #52c41a;
|
||||
}
|
||||
|
||||
.analysis-label {
|
||||
font-weight: 500;
|
||||
color: #52c41a;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.analysis-content {
|
||||
color: #333;
|
||||
margin-top: 4px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.show-more {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.no-questions {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.practice-details {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.practice-actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.question-count,
|
||||
.total-score {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.practice-preview {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.practice-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.question-header {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
550
src/components/common/preview/VideoPreview.vue
Normal file
550
src/components/common/preview/VideoPreview.vue
Normal file
@ -0,0 +1,550 @@
|
||||
<template>
|
||||
<div class="video-preview">
|
||||
<div v-if="!detailData" class="empty-state">
|
||||
<n-empty description="暂无视频内容" />
|
||||
</div>
|
||||
|
||||
<div v-else class="video-content">
|
||||
<!-- 视频信息 -->
|
||||
<div class="video-info">
|
||||
<h4>{{ detailData.name || '视频标题' }}</h4>
|
||||
<div class="video-meta">
|
||||
<n-space>
|
||||
<n-tag type="info" size="small">
|
||||
<template #icon>
|
||||
<n-icon><PlayCircleOutline /></n-icon>
|
||||
</template>
|
||||
视频
|
||||
</n-tag>
|
||||
<span v-if="videoQualities.length > 1" class="quality-count">
|
||||
{{ videoQualities.length }}种画质
|
||||
</span>
|
||||
<!-- <span v-if="detailData.duration" class="duration">
|
||||
时长: {{ formatDuration(detailData.duration) }}
|
||||
</span> -->
|
||||
<span v-if="detailData.size || detailData.fileSize" class="file-size">
|
||||
大小: {{ formatFileSize(detailData.size || detailData.fileSize) }}
|
||||
</span>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DPlayer 视频播放器 -->
|
||||
<div class="video-player">
|
||||
<div v-if="videoQualities.length > 0" class="player-container">
|
||||
<div ref="dplayerContainer" class="dplayer-container"></div>
|
||||
|
||||
<!-- 视频加载状态 -->
|
||||
<div v-if="videoLoading" class="video-loading">
|
||||
<n-spin size="large">
|
||||
<template #description>视频加载中...</template>
|
||||
</n-spin>
|
||||
</div>
|
||||
|
||||
<!-- 画质选择器(仅在需要手动切换时显示) -->
|
||||
<div v-if="videoQualities.length > 1 && !dplayer?.switchQuality" class="quality-selector">
|
||||
<n-space>
|
||||
<span class="quality-label">画质:</span>
|
||||
<n-button-group>
|
||||
<n-button
|
||||
v-for="(quality, index) in videoQualities"
|
||||
:key="index"
|
||||
:type="currentQualityIndex === index ? 'primary' : 'default'"
|
||||
size="small"
|
||||
@click="switchQuality(index)"
|
||||
>
|
||||
{{ quality.name }}
|
||||
</n-button>
|
||||
</n-button-group>
|
||||
</n-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-video">
|
||||
<n-result status="warning" title="视频文件不存在" description="该小节尚未关联视频文件">
|
||||
</n-result>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频描述 -->
|
||||
<div v-if="detailData.description" class="video-description">
|
||||
<h5>视频简介</h5>
|
||||
<div class="description-content">
|
||||
{{ detailData.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频详细信息 -->
|
||||
<div class="video-details">
|
||||
<n-divider />
|
||||
<n-descriptions :column="2" bordered size="small">
|
||||
<n-descriptions-item label="视频名称">
|
||||
{{ detailData.name || '-' }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="文件大小">
|
||||
{{ detailData.fileSize || detailData.size ? formatFileSize(detailData.fileSize || detailData.size) : '-' }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
|
||||
import {
|
||||
NEmpty,
|
||||
NSpace,
|
||||
NTag,
|
||||
NIcon,
|
||||
NSpin,
|
||||
NResult,
|
||||
NButton,
|
||||
NDivider,
|
||||
NDescriptions,
|
||||
NDescriptionsItem,
|
||||
NButtonGroup,
|
||||
useMessage
|
||||
} from 'naive-ui'
|
||||
import { PlayCircleOutline } from '@vicons/ionicons5'
|
||||
import DPlayer from 'dplayer'
|
||||
|
||||
// DPlayer类型扩展
|
||||
declare module 'dplayer' {
|
||||
interface DPlayer {
|
||||
switchQuality?: (index: number) => void
|
||||
volume: (volume?: number) => number
|
||||
play: () => Promise<void>
|
||||
on: (event: string, callback: (info?: any) => void) => void
|
||||
destroy: () => void
|
||||
video: HTMLVideoElement
|
||||
}
|
||||
}
|
||||
|
||||
interface VideoQuality {
|
||||
name: string
|
||||
url: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sectionData: any
|
||||
detailData: any
|
||||
courseId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
error: [message: string]
|
||||
edit: [sectionData: any, detailData: any]
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const dplayerContainer = ref<HTMLElement>()
|
||||
const videoLoading = ref(false)
|
||||
const currentQualityIndex = ref(0)
|
||||
let dplayer: any
|
||||
|
||||
// 解析视频链接和画质
|
||||
const videoQualities = computed<VideoQuality[]>(() => {
|
||||
if (!props.detailData?.fileUrl) return []
|
||||
|
||||
const urls = props.detailData.fileUrl.split(',').map((url: string) => url.trim()).filter(Boolean)
|
||||
|
||||
// 按画质排序:480p -> 720p -> 1080p
|
||||
return urls.sort((a: string, b: string) => {
|
||||
const getQualityOrder = (url: string): number => {
|
||||
if (url.includes('1080p') || url.includes('1080')) return 3
|
||||
if (url.includes('720p') || url.includes('720')) return 2
|
||||
if (url.includes('480p') || url.includes('480')) return 1
|
||||
return 0
|
||||
}
|
||||
return getQualityOrder(a) - getQualityOrder(b)
|
||||
}).map((url: string, index: number) => ({
|
||||
name: getQualityNameFromUrl(url, index),
|
||||
url,
|
||||
type: 'auto'
|
||||
}))
|
||||
})
|
||||
|
||||
// 根据URL获取画质名称
|
||||
const getQualityNameFromUrl = (url: string, index: number): string => {
|
||||
if (url.includes('1080p') || url.includes('1080')) return '1080p'
|
||||
if (url.includes('720p') || url.includes('720')) return '720p'
|
||||
if (url.includes('480p') || url.includes('480')) return '480p'
|
||||
|
||||
const defaultQualities = ['480p', '720p', '1080p']
|
||||
return defaultQualities[index] || `画质${index + 1}`
|
||||
}
|
||||
|
||||
// 获取画质名称 - 根据链接内容判断画质
|
||||
const getQualityName = (index: number) => {
|
||||
const quality = videoQualities.value[index]
|
||||
return quality?.name || `画质${index + 1}`
|
||||
}
|
||||
|
||||
// 获取当前播放的视频源
|
||||
const currentVideoSource = computed(() => {
|
||||
if (videoQualities.value.length === 0) return null
|
||||
|
||||
const currentQuality = videoQualities.value[currentQualityIndex.value]
|
||||
return {
|
||||
url: currentQuality.url,
|
||||
pic: props.detailData?.thumbnailUrl || '',
|
||||
type: 'auto'
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化DPlayer
|
||||
const initDPlayer = async () => {
|
||||
if (!dplayerContainer.value || videoQualities.value.length === 0) return
|
||||
|
||||
try {
|
||||
videoLoading.value = true
|
||||
|
||||
// 销毁已存在的播放器
|
||||
if (dplayer) {
|
||||
dplayer.destroy()
|
||||
dplayer = null
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
// 清空容器内容
|
||||
dplayerContainer.value.innerHTML = ''
|
||||
|
||||
// 准备画质配置
|
||||
const qualityConfig = videoQualities.value.length > 1 ? {
|
||||
quality: videoQualities.value,
|
||||
defaultQuality: currentQualityIndex.value
|
||||
} : {}
|
||||
|
||||
// 创建新的DPlayer实例
|
||||
dplayer = new DPlayer({
|
||||
container: dplayerContainer.value,
|
||||
video: {
|
||||
url: currentVideoSource.value!.url,
|
||||
pic: currentVideoSource.value!.pic,
|
||||
type: 'auto',
|
||||
...qualityConfig,
|
||||
},
|
||||
subtitle: {
|
||||
url: '',
|
||||
type: 'webvtt',
|
||||
fontSize: '20px',
|
||||
bottom: '40px',
|
||||
color: '#fff'
|
||||
},
|
||||
contextmenu: [
|
||||
{
|
||||
text: '视频信息',
|
||||
click: () => {
|
||||
message.info(`视频: ${props.detailData?.name || '未知'}`)
|
||||
}
|
||||
}
|
||||
],
|
||||
theme: '#FADFA3',
|
||||
loop: false,
|
||||
lang: 'zh-cn',
|
||||
screenshot: false,
|
||||
hotkey: true,
|
||||
preload: 'metadata',
|
||||
volume: 0.7,
|
||||
mutex: true,
|
||||
pictureInPicture: false,
|
||||
airplay: false,
|
||||
chromecast: false
|
||||
})
|
||||
|
||||
// 监听播放器事件
|
||||
dplayer.on('loadstart', () => {
|
||||
videoLoading.value = true
|
||||
})
|
||||
|
||||
dplayer.on('loadedmetadata', () => {
|
||||
videoLoading.value = false
|
||||
})
|
||||
|
||||
dplayer.on('canplay', () => {
|
||||
videoLoading.value = false
|
||||
})
|
||||
|
||||
dplayer.on('error', (info?: any) => {
|
||||
videoLoading.value = false
|
||||
console.error('DPlayer播放错误:', info)
|
||||
emit('error', '视频播放失败,请检查视频链接是否有效')
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
videoLoading.value = false
|
||||
console.error('DPlayer初始化失败:', error)
|
||||
emit('error', '视频播放器初始化失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换画质
|
||||
const switchQuality = async (index: number) => {
|
||||
if (index === currentQualityIndex.value || !dplayer || videoQualities.value.length <= 1) return
|
||||
|
||||
try {
|
||||
// 如果DPlayer支持内置画质切换,使用switchQuality方法
|
||||
if (typeof dplayer.switchQuality === 'function') {
|
||||
dplayer.switchQuality(index)
|
||||
return
|
||||
}
|
||||
|
||||
// 否则手动切换(保存当前播放状态)
|
||||
const currentTime = dplayer.video.currentTime
|
||||
const isPaused = dplayer.video.paused
|
||||
const currentVolume = dplayer.volume()
|
||||
|
||||
currentQualityIndex.value = index
|
||||
|
||||
// 重新初始化播放器
|
||||
await initDPlayer()
|
||||
|
||||
// 恢复播放状态
|
||||
if (dplayer && dplayer.video) {
|
||||
const waitForLoad = () => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const checkLoad = () => {
|
||||
if (dplayer && dplayer.video.readyState >= 2) {
|
||||
try {
|
||||
dplayer.video.currentTime = currentTime
|
||||
dplayer.volume(currentVolume)
|
||||
if (!isPaused) {
|
||||
dplayer.play().catch(console.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('恢复播放状态时出错:', error)
|
||||
}
|
||||
resolve()
|
||||
} else {
|
||||
setTimeout(checkLoad, 100)
|
||||
}
|
||||
}
|
||||
checkLoad()
|
||||
})
|
||||
}
|
||||
|
||||
await waitForLoad()
|
||||
}
|
||||
|
||||
message.success(`已切换到${getQualityName(index)}`)
|
||||
} catch (error) {
|
||||
console.error('切换画质失败:', error)
|
||||
message.error('切换画质失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number | string) => {
|
||||
const num = typeof bytes === 'string' ? parseInt(bytes) : bytes;
|
||||
if (isNaN(num)) return '-';
|
||||
|
||||
if (num < 1024) return '0 KB';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['KB', 'MB', 'GB', 'TB']; // 从 KB 开始
|
||||
// 计算"KB 级"的对数下标
|
||||
const i = Math.floor(Math.log(num) / Math.log(k));
|
||||
return parseFloat((num / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i - 1]; // i-1 因为 sizes 从 KB 起
|
||||
};
|
||||
|
||||
// 监听详情数据变化,重新初始化播放器
|
||||
watch(
|
||||
() => props.detailData,
|
||||
() => {
|
||||
if (props.detailData && videoQualities.value.length > 0) {
|
||||
currentQualityIndex.value = 0
|
||||
nextTick(() => {
|
||||
initDPlayer()
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 组件挂载时初始化
|
||||
onMounted(() => {
|
||||
if (props.detailData && videoQualities.value.length > 0) {
|
||||
nextTick(() => {
|
||||
initDPlayer()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时销毁播放器
|
||||
onUnmounted(() => {
|
||||
if (dplayer) {
|
||||
dplayer.destroy()
|
||||
dplayer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-preview {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.video-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.video-info {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.video-info h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
margin-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player-container {
|
||||
position: relative;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dplayer-container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.video-loading {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.quality-selector {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.quality-label {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.no-video {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.video-description {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.video-description h5 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.video-details {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.quality-count,
|
||||
.duration,
|
||||
.file-size {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* DPlayer 样式覆盖 */
|
||||
:deep(.dplayer) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.dplayer-controller) {
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8), transparent);
|
||||
}
|
||||
|
||||
:deep(.dplayer-thumb) {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
:deep(.dplayer-played) {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.video-preview {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dplayer-container {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.quality-selector {
|
||||
position: static;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
margin-top: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.quality-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1915,7 +1915,6 @@ const getSelectedResources = async (sectionData: any) => {
|
||||
|
||||
if (videoResult.data?.result) {
|
||||
console.log('📹 视频资源查询成功:', videoResult.data.result);
|
||||
// TODO: 更新对应小节的视频资源显示
|
||||
data = videoResult.data.result;
|
||||
} else {
|
||||
console.log('📹 视频资源查询无数据');
|
||||
@ -1929,7 +1928,6 @@ const getSelectedResources = async (sectionData: any) => {
|
||||
const documentResult = await TeachCourseApi.getSectionDocument(courseId.value, sectionId);
|
||||
if (documentResult.data?.result) {
|
||||
console.log('📄 文档资源查询成功:', documentResult.data.result);
|
||||
// TODO: 更新对应小节的文档资源显示
|
||||
data = documentResult.data.result;
|
||||
|
||||
} else {
|
||||
@ -1944,8 +1942,6 @@ const getSelectedResources = async (sectionData: any) => {
|
||||
const examResult = await TeachCourseApi.getSectionExam(courseId.value, sectionId);
|
||||
if (examResult.data?.result) {
|
||||
console.log('📝 考试资源查询成功:', examResult.data.result);
|
||||
// TODO: 更新对应小节的考试资源显示
|
||||
|
||||
data = examResult.data.result;
|
||||
} else {
|
||||
console.log('📝 考试资源查询无数据');
|
||||
@ -1959,7 +1955,6 @@ const getSelectedResources = async (sectionData: any) => {
|
||||
const homeworkResult = await TeachCourseApi.getSectionHomeWork(courseId.value, sectionId);
|
||||
if (homeworkResult.data?.result) {
|
||||
console.log('📋 作业资源查询成功:', homeworkResult.data.result);
|
||||
// TODO: 更新对应小节的作业资源显示
|
||||
data = homeworkResult.data.result
|
||||
} else {
|
||||
console.log('📋 作业资源查询无数据');
|
||||
@ -1970,16 +1965,17 @@ const getSelectedResources = async (sectionData: any) => {
|
||||
case 4: {
|
||||
// 练习类型
|
||||
console.log('🏃 练习资源接口暂未实现');
|
||||
// TODO: 预留练习资源接口
|
||||
// const exerciseResult = await TeachCourseApi.getSectionExercise(courseId.value, sectionId);
|
||||
const exerciseResult = await TeachCourseApi.getSectionExercise(sectionId);
|
||||
console.log('🏃 练习资源查询结果:', exerciseResult);
|
||||
data = exerciseResult.data.result
|
||||
break;
|
||||
}
|
||||
|
||||
case 5: {
|
||||
// 讨论类型
|
||||
console.log('💬 讨论资源接口暂未实现');
|
||||
// TODO: 预留讨论资源接口
|
||||
// const discussionResult = await TeachCourseApi.getSectionDiscussion(courseId.value, sectionId);
|
||||
const discussionResult = await TeachCourseApi.getSectionDiscussion(courseId.value, sectionId);
|
||||
console.log('💬 讨论资源查询结果:', discussionResult);
|
||||
data = discussionResult.data.result;
|
||||
break;
|
||||
}
|
||||
|
||||
|
@ -41,9 +41,9 @@
|
||||
</span>
|
||||
|
||||
<span v-if="showRightEllipsis" class="page-number">...</span>
|
||||
<span v-if="totalPages > 1" class="page-number page-number-bordered" @click="goToPage(totalPages)">
|
||||
<!-- <span v-if="totalPages > 1" class="page-number page-number-bordered" @click="goToPage(totalPages)">
|
||||
{{ totalPages }}
|
||||
</span>
|
||||
</span> -->
|
||||
|
||||
<span class="page-number nav-button" :class="{ disabled: currentPage === totalPages }"
|
||||
@click="goToPage('next')">
|
||||
@ -59,6 +59,14 @@
|
||||
</div>
|
||||
<ImportModal v-model:show="showImportModal" template-name="custom_template.xlsx" import-type="custom"
|
||||
@success="handleImportSuccess" @template-download="handleTemplateDownload" />
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
<PreviewModal
|
||||
v-model:show="showPreviewModal"
|
||||
:section-data="previewSectionData"
|
||||
:course-id="courseId"
|
||||
@edit="handlePreviewEdit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -69,6 +77,7 @@ import type { DataTableColumns } from 'naive-ui'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ChevronForwardOutline } from '@vicons/ionicons5'
|
||||
import ImportModal from '@/components/common/ImportModal.vue'
|
||||
import PreviewModal from '@/components/common/PreviewModal.vue'
|
||||
import TeachCourseApi from '@/api/modules/teachCourse'
|
||||
|
||||
const router = useRouter()
|
||||
@ -97,6 +106,10 @@ interface Chapter {
|
||||
|
||||
const showImportModal = ref(false)
|
||||
|
||||
// 预览弹窗相关状态
|
||||
const showPreviewModal = ref(false)
|
||||
const previewSectionData = ref<any>(null)
|
||||
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
// const error = ref('')
|
||||
@ -486,7 +499,27 @@ const columns: DataTableColumns<Chapter> = [
|
||||
default: () => h(ChevronForwardOutline)
|
||||
}) : (isChapter ? h('span', {}) : null),
|
||||
h('span', {
|
||||
style: { color: '#062333', fontSize: '13px', fontWeight: isChapter ? '600' : '400' }
|
||||
style: {
|
||||
color: '#062333',
|
||||
fontSize: '13px',
|
||||
fontWeight: isChapter ? '600' : '400',
|
||||
cursor: !isChapter ? 'pointer' : 'default',
|
||||
textDecoration: !isChapter ? 'none' : 'none'
|
||||
},
|
||||
onClick: !isChapter ? (e: Event) => {
|
||||
e.stopPropagation()
|
||||
handleSectionPreview(row)
|
||||
} : undefined,
|
||||
onMouseenter: !isChapter ? (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
target.style.textDecoration = 'underline'
|
||||
target.style.color = '#1890ff'
|
||||
} : undefined,
|
||||
onMouseleave: !isChapter ? (e: Event) => {
|
||||
const target = e.target as HTMLElement
|
||||
target.style.textDecoration = 'none'
|
||||
target.style.color = '#062333'
|
||||
} : undefined
|
||||
}, row.name)
|
||||
])
|
||||
}
|
||||
@ -614,6 +647,40 @@ const getTypeText = (type: string) => {
|
||||
return typeMap[type] || '-'
|
||||
}
|
||||
|
||||
// 处理小节预览
|
||||
const handleSectionPreview = (section: Chapter) => {
|
||||
console.log('预览小节:', section)
|
||||
|
||||
// 检查小节是否有有效的类型
|
||||
if (!section.type || section.type === '-') {
|
||||
message.warning('该小节暂未设置类型,无法预览')
|
||||
return
|
||||
}
|
||||
|
||||
// 设置预览数据并显示弹窗
|
||||
previewSectionData.value = {
|
||||
id: section.id,
|
||||
name: section.name,
|
||||
type: section.type,
|
||||
courseId: courseId.value,
|
||||
parentId: section.parentId,
|
||||
level: section.level,
|
||||
createTime: section.createTime
|
||||
}
|
||||
|
||||
showPreviewModal.value = true
|
||||
}
|
||||
|
||||
// 处理预览弹窗的编辑事件
|
||||
const handlePreviewEdit = (sectionData: any, detailData: any) => {
|
||||
console.log('编辑小节:', sectionData, detailData)
|
||||
showPreviewModal.value = false
|
||||
|
||||
// 这里可以跳转到编辑页面或打开编辑弹窗
|
||||
message.info('跳转到小节编辑页面')
|
||||
// 实际实现中可以使用 router.push() 跳转
|
||||
}
|
||||
|
||||
const fetchCourseChapters = () => {
|
||||
loading.value = true
|
||||
TeachCourseApi.getCourseSections(courseId.value).then(res => {
|
||||
|
@ -22,6 +22,21 @@
|
||||
|
||||
<!-- 作业列表 -->
|
||||
<div class="homework-list">
|
||||
<!-- 空状态占位 -->
|
||||
<div v-if="filteredHomeworks.length === 0 && !loading" class="empty-state">
|
||||
<n-empty
|
||||
description="还没有作业"
|
||||
size="large"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="empty-icon">📝</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<span class="empty-extra">请先去创建作业吧~</span>
|
||||
</template>
|
||||
</n-empty>
|
||||
</div>
|
||||
|
||||
<!-- 已结束作业 -->
|
||||
<div v-for="homework in filteredHomeworks" :key="homework.id">
|
||||
|
||||
@ -37,7 +52,7 @@
|
||||
<div class="homework-info">
|
||||
<div class="info-item">
|
||||
<img class="icon" src="/images/teacher/发布人.png" alt="发布人" />
|
||||
<span class="text">发布人:{{ homework.publisher }}</span>
|
||||
<span class="text">班级:{{ homework.classNames?.join(', ') || '未绑定班级' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<img class="icon" src="/images/teacher/起点时间.png" alt="时间" />
|
||||
@ -68,11 +83,11 @@
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="content-left">
|
||||
<p class="homework-desc">{{ homework.content }}</p>
|
||||
<p class="homework-desc" v-html="homework.content"></p>
|
||||
<div class="homework-info">
|
||||
<div class="info-item">
|
||||
<img class="icon" src="/images/teacher/发布人.png" alt="发布人" />
|
||||
<span class="text">发布人:{{ homework.publisher }}</span>
|
||||
<span class="text">班级:{{ homework.classNames?.join(', ') || '未绑定班级' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<img class="icon" src="/images/teacher/起点时间.png" alt="时间" />
|
||||
@ -101,11 +116,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { NButton, NSelect } from 'naive-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NButton, NSelect, NEmpty, useMessage } from 'naive-ui'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { HomeworkApi, ClassApi } from '@/api/modules/teachCourse'
|
||||
|
||||
interface HomeworkItem {
|
||||
id: number
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
publisher: string
|
||||
@ -114,39 +130,24 @@
|
||||
pendingCount: number
|
||||
submittedCount: number
|
||||
unsubmittedCount: number
|
||||
start_time: string | null
|
||||
end_time: string | null
|
||||
classId: string
|
||||
courseId: string
|
||||
classNames?: string[] // 可选字段,存储班级名称
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
const activeTab = ref<'all' | 'publishing' | 'ended'>('all')
|
||||
|
||||
// 班级选择相关
|
||||
const selectedClass = ref<string | null>(null)
|
||||
const classOptions = [
|
||||
{
|
||||
label: '全部班级',
|
||||
value: 'all'
|
||||
},
|
||||
{
|
||||
label: '软件工程一班',
|
||||
value: 'class1'
|
||||
},
|
||||
{
|
||||
label: '软件工程二班',
|
||||
value: 'class2'
|
||||
},
|
||||
{
|
||||
label: '计算机科学一班',
|
||||
value: 'class3'
|
||||
},
|
||||
{
|
||||
label: '计算机科学二班',
|
||||
value: 'class4'
|
||||
},
|
||||
{
|
||||
label: '信息安全一班',
|
||||
value: 'class5'
|
||||
}
|
||||
]
|
||||
const classOptions = ref<Array<{ label: string; value: string }>>([{
|
||||
label: '全部班级',
|
||||
value: 'all'
|
||||
}])
|
||||
|
||||
// 屏幕宽度响应式数据
|
||||
const screenWidth = ref(window.innerWidth)
|
||||
@ -157,8 +158,10 @@
|
||||
}
|
||||
|
||||
// 在组件挂载时添加事件监听器
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
await loadClassOptions() // 加载班级选项
|
||||
await loadHomeworkList() // 加载作业列表
|
||||
})
|
||||
|
||||
// 在组件卸载时移除事件监听器
|
||||
@ -166,30 +169,122 @@
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
|
||||
const homeworks = ref<HomeworkItem[]>([
|
||||
{
|
||||
id: 1,
|
||||
title: '作业名称作业名称作业名称作业名称',
|
||||
content: '作业内容作业内容作业内容作业内容作业内容作业内容作业内容作业内容作业内容',
|
||||
publisher: '王建国',
|
||||
timeRange: '2025.8.18-2025.9.18',
|
||||
status: 'ended',
|
||||
pendingCount: 10,
|
||||
submittedCount: 0,
|
||||
unsubmittedCount: 0
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '作业名称作业名称作业名称作业名称',
|
||||
content: '作业内容作业内容作业内容作业内容作业内容作业内容作业内容作业内容作业内容',
|
||||
publisher: '王建国',
|
||||
timeRange: '2025.8.18-2025.9.18',
|
||||
status: 'publishing',
|
||||
pendingCount: 0,
|
||||
submittedCount: 0,
|
||||
unsubmittedCount: 0
|
||||
// 加载状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 加载班级选项
|
||||
const loadClassOptions = async () => {
|
||||
if (!courseId.value) {
|
||||
console.error('缺少课程ID')
|
||||
return
|
||||
}
|
||||
])
|
||||
|
||||
try {
|
||||
const response = await ClassApi.queryClassList({ course_id: courseId.value })
|
||||
|
||||
if (response.data.code === 200 && response.data.result) {
|
||||
// 重置班级选项,保留"全部班级"
|
||||
classOptions.value = [{
|
||||
label: '全部班级',
|
||||
value: 'all'
|
||||
}]
|
||||
|
||||
// 添加接口返回的班级数据
|
||||
const classItems = response.data.result.map((classInfo: any) => ({
|
||||
label: classInfo.name || '未命名班级',
|
||||
value: classInfo.id || ''
|
||||
}))
|
||||
|
||||
classOptions.value.push(...classItems)
|
||||
console.log('📚 班级选项加载成功:', classOptions.value)
|
||||
} else {
|
||||
console.warn('📋 班级数据格式异常:', response)
|
||||
message.warning('班级数据格式异常')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载班级选项失败:', error)
|
||||
message.error('加载班级选项失败,请重试')
|
||||
}
|
||||
}
|
||||
|
||||
// 作业列表数据
|
||||
const homeworks = ref<HomeworkItem[]>([])
|
||||
|
||||
// 获取课程ID
|
||||
const courseId = computed(() => route.params.id as string)
|
||||
|
||||
// 加载作业列表
|
||||
const loadHomeworkList = async () => {
|
||||
if (!courseId.value) {
|
||||
console.error('缺少课程ID')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await HomeworkApi.getTeacherHomeworkList(courseId.value)
|
||||
|
||||
if (response.data.code === 200 && response.data?.result) {
|
||||
// 转换API数据为组件需要的格式
|
||||
homeworks.value = response.data.result.map((homework: any) => {
|
||||
const now = new Date()
|
||||
const startTime = homework.startTime ? new Date(homework.startTime) : null
|
||||
const endTime = homework.endTime ? new Date(homework.endTime) : null
|
||||
|
||||
// 判断作业状态:根据当前时间与开始、结束时间的比较
|
||||
let status: 'publishing' | 'ended' = 'ended'
|
||||
if (startTime && endTime) {
|
||||
if (now >= startTime && now <= endTime) {
|
||||
status = 'publishing' // 发布中:在开始和结束时间范围内
|
||||
} else {
|
||||
status = 'ended' // 已结束:超过结束时间或未到开始时间
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: homework.id || '',
|
||||
title: homework.title || '未命名作业',
|
||||
content: homework.description || '',
|
||||
publisher: homework.createBy || '未知',
|
||||
timeRange: formatTimeRange(homework.startTime, homework.endTime),
|
||||
status,
|
||||
pendingCount: homework.pendingCount || 0,
|
||||
submittedCount: homework.submittedCount || 0,
|
||||
unsubmittedCount: homework.unsubmittedCount || 0,
|
||||
start_time: homework.startTime,
|
||||
end_time: homework.endTime,
|
||||
classId: homework.classId,
|
||||
courseId: homework.courseId || '',
|
||||
classNames: homework.classNames || []
|
||||
} as HomeworkItem
|
||||
})
|
||||
} else {
|
||||
console.warn('📋 API响应格式异常:', response)
|
||||
message.warning('作业数据格式异常')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载作业列表失败:', error)
|
||||
message.error('加载作业列表失败,请重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间范围显示
|
||||
const formatTimeRange = (startTime: string | null, endTime: string | null) => {
|
||||
if (!startTime || !endTime) return '-'
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).replace(/\//g, '.')
|
||||
}
|
||||
|
||||
return `${formatDate(startTime)}-${formatDate(endTime)}`
|
||||
}
|
||||
|
||||
const setActiveTab = (tab: 'all' | 'publishing' | 'ended') => {
|
||||
activeTab.value = tab
|
||||
@ -197,28 +292,58 @@
|
||||
|
||||
const handleClassChange = (value: string | null) => {
|
||||
console.log('选中的班级:', value)
|
||||
// 这里可以添加筛选逻辑
|
||||
// 筛选逻辑已在 filteredHomeworks computed 中实现
|
||||
// 如果选择了班级,重新加载作业列表以获取该班级的数据
|
||||
loadHomeworkList()
|
||||
}
|
||||
|
||||
const filteredHomeworks = computed(() => {
|
||||
if (activeTab.value === 'all') {
|
||||
return homeworks.value
|
||||
let filtered = homeworks.value
|
||||
|
||||
// 根据选中的班级筛选
|
||||
if (selectedClass.value && selectedClass.value !== 'all') {
|
||||
// 支持多个班级ID的情况(作业可能分配给多个班级)
|
||||
filtered = filtered.filter((homework: HomeworkItem) => {
|
||||
if (!homework.classId) return false
|
||||
// 如果 classId 是逗号分隔的字符串,检查是否包含选中的班级
|
||||
const classIds = homework.classId.split(',')
|
||||
return classIds.includes(selectedClass.value!)
|
||||
})
|
||||
}
|
||||
return homeworks.value.filter((homework: HomeworkItem) => homework.status === activeTab.value)
|
||||
|
||||
// 根据tab状态筛选
|
||||
if (activeTab.value === 'all') {
|
||||
return filtered
|
||||
}
|
||||
return filtered.filter((homework: HomeworkItem) => homework.status === activeTab.value)
|
||||
})
|
||||
|
||||
const reviewHomework = (id: number) => {
|
||||
const reviewHomework = (id: string) => {
|
||||
console.log('批阅作业:', id)
|
||||
// 跳转到批阅详情页面
|
||||
router.push(`/teacher/course-editor/1/homework/review/${id}`)
|
||||
router.push(`/teacher/course-editor/${courseId.value}/homework/review/${id}`)
|
||||
}
|
||||
|
||||
const viewHomework = (id: number) => {
|
||||
const viewHomework = (id: string) => {
|
||||
console.log('查看作业:', id)
|
||||
// 跳转到作业详情页面
|
||||
router.push(`/teacher/course-editor/${courseId.value}/homework/review/${id}`)
|
||||
}
|
||||
|
||||
const deleteHomework = (id: number) => {
|
||||
const deleteHomework = async (id: string) => {
|
||||
console.log('删除作业:', id)
|
||||
try {
|
||||
const response = await HomeworkApi.deleteHomework(id)
|
||||
if (response.code === 200 || response.data?.code === 200) {
|
||||
message.success('删除作业成功')
|
||||
await loadHomeworkList() // 重新加载列表
|
||||
} else {
|
||||
message.error(response.message || response.data?.message || '删除作业失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除作业失败:', error)
|
||||
message.error('删除作业失败,请重试')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -275,6 +400,27 @@
|
||||
/* 作业列表 */
|
||||
.homework-list {
|
||||
padding: 0;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
/* 空状态样式 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-extra {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.homework-card {
|
||||
|
@ -45,7 +45,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h } from 'vue'
|
||||
import { ref, computed, h, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ArrowBackOutline } from '@vicons/ionicons5'
|
||||
import {
|
||||
@ -57,16 +57,52 @@ import {
|
||||
NInputGroup,
|
||||
type DataTableColumns
|
||||
} from 'naive-ui'
|
||||
import { HomeworkApi } from '@/api/modules/teachCourse'
|
||||
|
||||
// 定义接口返回的数据类型
|
||||
interface ApiHomeworkSubmission {
|
||||
id: string
|
||||
homeworkId: string
|
||||
studentId: string
|
||||
content: string | null
|
||||
attachment: string | null
|
||||
score: number
|
||||
comment: string | null
|
||||
gradedTime: string | null
|
||||
status: number
|
||||
createBy: string | null
|
||||
createTime: string
|
||||
updateBy: string | null
|
||||
updateTime: string | null
|
||||
homeworkTitle: string
|
||||
homeworkDescription: string
|
||||
homeworkMaxScore: number
|
||||
homeworkPassScore: number
|
||||
homeworkStartTime: string
|
||||
homeworkEndTime: string
|
||||
homeworkStatus: number | null
|
||||
homeworkNotifyTime: number
|
||||
homeworkAllowMakeup: number
|
||||
homeworkMakeupTime: string
|
||||
studentUsername: string
|
||||
studentRealname: string
|
||||
studentAvatar: string
|
||||
classId: string
|
||||
className: string
|
||||
}
|
||||
|
||||
interface HomeworkSubmission {
|
||||
id: number
|
||||
id: string
|
||||
name: string
|
||||
studentId: string
|
||||
submitTime: string
|
||||
status: '已批阅' | '待批阅'
|
||||
status: '已批阅' | '待批阅' | '未提交'
|
||||
reviewer: string
|
||||
reviewTime: string
|
||||
selected: boolean
|
||||
score?: number
|
||||
comment?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
@ -80,89 +116,59 @@ const viewHomework = (row: HomeworkSubmission) => {
|
||||
router.push(`/teacher/course-editor/${route.params.id}/homework/review/${route.params.homeworkId}/view/${row.studentId}`)
|
||||
}
|
||||
|
||||
// 模拟数据 - 包含已批阅和待批阅的数据
|
||||
const homeworkData = ref<HomeworkSubmission[]>([
|
||||
{
|
||||
id: 1,
|
||||
name: '陈成',
|
||||
studentId: '1826685554',
|
||||
submitTime: '2025.07.25 09:20',
|
||||
status: '已批阅',
|
||||
reviewer: '王建国',
|
||||
reviewTime: '2025.07.25 09:20',
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '李华',
|
||||
studentId: '1826685555',
|
||||
submitTime: '2025.07.25 10:15',
|
||||
status: '待批阅',
|
||||
reviewer: '王建国',
|
||||
reviewTime: '2025.07.25 10:15',
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '张伟',
|
||||
studentId: '1826685556',
|
||||
submitTime: '2025.07.25 11:30',
|
||||
status: '已批阅',
|
||||
reviewer: '王建国',
|
||||
reviewTime: '2025.07.25 11:30',
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '王芳',
|
||||
studentId: '1826685557',
|
||||
submitTime: '2025.07.25 14:20',
|
||||
status: '待批阅',
|
||||
reviewer: '王建国',
|
||||
reviewTime: '2025.07.25 14:20',
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '刘强',
|
||||
studentId: '1826685558',
|
||||
submitTime: '2025.07.25 16:45',
|
||||
status: '已批阅',
|
||||
reviewer: '王建国',
|
||||
reviewTime: '2025.07.25 16:45',
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: '赵敏',
|
||||
studentId: '1826685559',
|
||||
submitTime: '2025.07.26 08:30',
|
||||
status: '待批阅',
|
||||
reviewer: '王建国',
|
||||
reviewTime: '2025.07.26 08:30',
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: '孙丽',
|
||||
studentId: '1826685560',
|
||||
submitTime: '2025.07.26 09:15',
|
||||
status: '已批阅',
|
||||
reviewer: '王建国',
|
||||
reviewTime: '2025.07.26 09:15',
|
||||
selected: false
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: '周杰',
|
||||
studentId: '1826685561',
|
||||
submitTime: '2025.07.26 10:45',
|
||||
status: '待批阅',
|
||||
reviewer: '王建国',
|
||||
reviewTime: '2025.07.26 10:45',
|
||||
selected: false
|
||||
}
|
||||
])
|
||||
// 批阅作业
|
||||
const reviewHomework = (row: HomeworkSubmission) => {
|
||||
// 跳转到批阅页面,可以传递学生ID等参数
|
||||
router.push(`/teacher/course-editor/${route.params.id}/homework/review/${route.params.homeworkId}/review/${row.studentId}`)
|
||||
}
|
||||
|
||||
// 数据映射函数
|
||||
const mapApiDataToHomeworkSubmission = (apiData: ApiHomeworkSubmission[]): any => {
|
||||
return apiData.map((item: ApiHomeworkSubmission) => {
|
||||
// 格式化时间
|
||||
const formatDateTime = (dateTime: string | null) => {
|
||||
if (!dateTime) return '-'
|
||||
const date = new Date(dateTime)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).replace(/\//g, '.').replace(',', '')
|
||||
}
|
||||
|
||||
// 根据状态判断批阅状态
|
||||
// status: 0-未提交, 1-已提交未批阅, 2-已批阅
|
||||
let submissionStatus: '已批阅' | '待批阅' | '未提交'
|
||||
if (item.status === 0) {
|
||||
submissionStatus = '未提交'
|
||||
} else if (item.status === 1) {
|
||||
submissionStatus = '待批阅'
|
||||
} else {
|
||||
submissionStatus = '已批阅'
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.studentRealname || item.studentUsername || '未知学生',
|
||||
studentId: item.studentUsername,
|
||||
submitTime: formatDateTime(item.createTime),
|
||||
status: submissionStatus,
|
||||
reviewer: item.createBy || '系统',
|
||||
reviewTime: formatDateTime(item.gradedTime || item.updateTime),
|
||||
selected: false,
|
||||
score: item.score,
|
||||
comment: item.comment,
|
||||
className: item.className
|
||||
}
|
||||
})
|
||||
}
|
||||
// 作业提交数据
|
||||
const homeworkData = ref<HomeworkSubmission[]>([])
|
||||
|
||||
// 加载中状态
|
||||
const loading = ref(false)
|
||||
|
||||
// 根据标签页和搜索过滤数据
|
||||
const filteredData = computed(() => {
|
||||
@ -172,13 +178,14 @@ const filteredData = computed(() => {
|
||||
if (activeTab.value === 'submitted') {
|
||||
filtered = filtered.filter((item: HomeworkSubmission) => item.status === '已批阅')
|
||||
} else if (activeTab.value === 'unsubmitted') {
|
||||
filtered = filtered.filter((item: HomeworkSubmission) => item.status === '待批阅')
|
||||
filtered = filtered.filter((item: HomeworkSubmission) => item.status === '待批阅' || item.status === '未提交')
|
||||
}
|
||||
|
||||
// 根据搜索文本过滤
|
||||
if (searchText.value.trim()) {
|
||||
filtered = filtered.filter((item: HomeworkSubmission) =>
|
||||
item.name.includes(searchText.value.trim())
|
||||
item.name.includes(searchText.value.trim()) ||
|
||||
item.studentId.includes(searchText.value.trim())
|
||||
)
|
||||
}
|
||||
|
||||
@ -236,8 +243,10 @@ const columns: DataTableColumns<HomeworkSubmission> = [
|
||||
render: (_row: HomeworkSubmission) => {
|
||||
return h('span', {
|
||||
style: {
|
||||
color: '#062333',
|
||||
fontSize: '14px'
|
||||
color: _row.status === '已批阅' ? '#4CAF50' :
|
||||
_row.status === '待批阅' ? '#FF9800' : '#999',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500'
|
||||
}
|
||||
}, _row.status)
|
||||
}
|
||||
@ -269,14 +278,9 @@ const columns: DataTableColumns<HomeworkSubmission> = [
|
||||
type: 'primary',
|
||||
size: 'small',
|
||||
ghost: true,
|
||||
class: 'operation-btn'
|
||||
class: 'operation-btn',
|
||||
onClick: () => reviewHomework(row)
|
||||
}, { default: () => '批阅' }) : null,
|
||||
h(NButton, {
|
||||
type: 'error',
|
||||
size: 'small',
|
||||
ghost: true,
|
||||
class: 'operation-btn'
|
||||
}, { default: () => '移除' })
|
||||
].filter(Boolean))
|
||||
}
|
||||
}
|
||||
@ -365,6 +369,33 @@ const unsubmittedColumns: DataTableColumns<HomeworkSubmission> = [
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
const getHomeWorkDetails = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await HomeworkApi.getHomeworkSubmits(route.params.homeworkId as string)
|
||||
|
||||
if (response.data.result) {
|
||||
console.log('作业详情原始数据:', response.data.result)
|
||||
|
||||
// 使用映射函数转换数据
|
||||
const mappedData = mapApiDataToHomeworkSubmission(response.data.result)
|
||||
homeworkData.value = mappedData
|
||||
|
||||
console.log('映射后的数据:', mappedData)
|
||||
} else {
|
||||
console.error('获取作业详情失败:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('请求作业详情时出错:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await getHomeWorkDetails()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
Loading…
x
Reference in New Issue
Block a user