feat:添加小节预览功能;完善章节编辑功能

This commit is contained in:
yuk255 2025-09-23 16:47:14 +08:00
parent ce54a41f4a
commit 254fb72d0d
12 changed files with 3440 additions and 179 deletions

View File

@ -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,14 +754,14 @@ 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
@ -800,6 +828,9 @@ export class ClassApi {
return ApiRequest.post('/aiol/aiolClass/add', data);
}
/**
*
*/
static async queryClassList(params: { course_id: string|null }): Promise<ApiResponse<any>> {
return ApiRequest.get('/aiol/aiolClass/query_list', params);
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@ -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;
}

View File

@ -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 => {

View File

@ -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 = [
{
const classOptions = ref<Array<{ label: string; value: string }>>([{
label: '全部班级',
value: 'all'
},
{
label: '软件工程一班',
value: 'class1'
},
{
label: '软件工程二班',
value: 'class2'
},
{
label: '计算机科学一班',
value: 'class3'
},
{
label: '计算机科学二班',
value: 'class4'
},
{
label: '信息安全一班',
value: 'class5'
}
]
}])
//
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 {

View File

@ -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>