kindergarten_java/reading-platform-frontend/src/views/admin/courses/CourseDetailView.vue

957 lines
35 KiB
Vue
Raw Normal View History

<template>
<div class="course-detail">
<a-page-header
:title="course.name || '课程包详情'"
@back="() => router.back()"
>
<template #extra>
<a-space>
<a-button @click="editCourse">
<EditOutlined /> 编辑
</a-button>
<a-button @click="viewStats">
<BarChartOutlined /> 数据统计
</a-button>
<a-dropdown v-if="course.status === 'published'">
<template #overlay>
<a-menu @click="handleMenuClick">
<a-menu-item key="unpublish">下架</a-menu-item>
<a-menu-item key="iterate">迭代版本</a-menu-item>
</a-menu>
</template>
<a-button>
更多 <DownOutlined />
</a-button>
</a-dropdown>
<a-popconfirm
v-else-if="course.status === 'draft' || course.status === 'archived'"
title="确定删除此课程包吗?"
@confirm="deleteCourse"
>
<a-button danger>
<DeleteOutlined /> 删除
</a-button>
</a-popconfirm>
</a-space>
</template>
</a-page-header>
<a-spin :spinning="loading">
<div style="margin-top: 16px;">
<a-row :gutter="16">
<!-- 左侧主要信息 -->
<a-col :span="16">
<!-- 基本信息 -->
<a-card title="基本信息" :bordered="false" style="margin-bottom: 16px;">
<!-- 课程封面 -->
<div v-if="course.coverImagePath" style="margin-bottom: 16px; text-align: center;">
<img
:src="getFileUrl(course.coverImagePath)"
alt="课程封面"
style="max-width: 100%; max-height: 300px; border-radius: 8px; object-fit: cover;"
/>
</div>
<a-descriptions :column="2" bordered>
<a-descriptions-item label="课程包名称" :span="2">
{{ course.name }}
</a-descriptions-item>
<a-descriptions-item label="适用年级" :span="2">
<a-tag v-for="grade in grades" :key="grade" :style="getGradeTagStyle(grade)">
{{ grade }}
</a-tag>
<span v-if="grades.length === 0">-</span>
</a-descriptions-item>
<a-descriptions-item label="关联绘本" :span="2">
{{ course.pictureBookName || '未关联' }}
</a-descriptions-item>
<a-descriptions-item label="课程时长" :span="2">
{{ course.duration || 25 }} 分钟
</a-descriptions-item>
<a-descriptions-item label="核心发展目标" :span="2">
<a-tag v-for="tag in domainTags" :key="tag" :style="getDomainTagStyle(tag)">
{{ tag }}
</a-tag>
<span v-if="domainTags.length === 0">-</span>
</a-descriptions-item>
<a-descriptions-item label="课程简介" :span="2">
{{ course.description || '暂无简介' }}
</a-descriptions-item>
<a-descriptions-item label="状态" :span="2">
<a-tag :style="getCourseStatusStyle(course.status)">
{{ translateCourseStatus(course.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="版本">
v{{ course.version }}
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatDate(course.createdAt) }}
</a-descriptions-item>
</a-descriptions>
</a-card>
<!-- 教学流程 -->
<a-card title="教学流程" :bordered="false" style="margin-bottom: 16px;">
<a-timeline v-if="scripts && scripts.length > 0">
<a-timeline-item v-for="script in scripts" :key="script.id">
<template #dot>
<ClockCircleOutlined style="font-size: 16px;" />
</template>
<div>
<div style="font-weight: 500; margin-bottom: 4px;">
{{ script.stepName }}
<a-tag size="small" :style="getStepTypeStyle(translateStepType(script.stepType))" style="margin-left: 8px;">
{{ translateStepType(script.stepType) }}
</a-tag>
<span style="color: #999; font-size: 12px; margin-left: 8px;">
{{ script.duration }} 分钟
</span>
</div>
<div v-if="script.objective" style="color: #666; margin-bottom: 4px;">
<strong>目标:</strong> {{ script.objective }}
</div>
<div v-if="script.teacherScript" style="color: #333; margin-bottom: 4px; background: #f9f9f9; padding: 8px 12px; border-radius: 6px; border-left: 3px solid #FF8C42;">
<strong style="color: #FF8C42;">教师讲稿:</strong>
<div style="margin-top: 4px; white-space: pre-wrap;">{{ script.teacherScript }}</div>
</div>
<!-- 关联资源展示 -->
<div v-if="getScriptResources(script).length > 0" style="margin-top: 8px; padding-top: 8px; border-top: 1px dashed #e8e8e8;">
<strong style="color: #666; font-size: 13px;">关联资源:</strong>
<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px;">
<a-tag
v-for="res in getScriptResources(script)"
:key="res.id"
style="margin: 0; cursor: pointer;"
@click="previewResource(res)"
>
<span style="margin-right: 4px;">{{ res.icon }}</span>
{{ res.name }}
<span style="color: #999; margin-left: 4px;">({{ res.typeName }})</span>
</a-tag>
</div>
</div>
<!-- 逐页脚本展示 -->
<div v-if="script.pages && script.pages.length > 0" style="margin-top: 12px; padding-top: 12px; border-top: 1px dashed #e8e8e8;">
<strong style="color: #666; font-size: 13px;">逐页脚本 ({{ script.pages.length }}):</strong>
<div style="margin-top: 8px;">
<a-collapse size="small" :activeKey="getExpandedPages(script)">
<a-collapse-panel v-for="(page, pIndex) in script.pages" :key="pIndex">
<template #header>
<span> {{ page.pageNumber }} </span>
<span v-if="hasPageContent(page)" style="color: #52c41a; margin-left: 8px; font-size: 12px;"></span>
</template>
<div class="script-page-detail">
<div v-if="page.questions" style="margin-bottom: 8px;">
<strong>教师话术</strong>
<div style="background: #f5f5f5; padding: 6px 10px; border-radius: 4px; margin-top: 4px; white-space: pre-wrap; font-size: 13px;">
{{ page.questions }}
</div>
</div>
<div v-if="page.teacherNotes" style="margin-bottom: 8px;">
<strong>教学备注</strong>
<span style="color: #666; font-size: 13px;">{{ page.teacherNotes }}</span>
</div>
<div v-if="getPageResources(script, page).length > 0">
<strong>关联资源</strong>
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;">
<a-tag
v-for="res in getPageResources(script, page)"
:key="res.id"
size="small"
style="cursor: pointer;"
@click="previewResource(res)"
>
<span style="margin-right: 2px;">{{ res.icon }}</span>
{{ res.name }}
</a-tag>
</div>
</div>
</div>
</a-collapse-panel>
</a-collapse>
</div>
</div>
</div>
</a-timeline-item>
</a-timeline>
<a-empty v-else description="暂无教学流程" />
</a-card>
<!-- 延伸活动 -->
<a-card title="延伸活动" :bordered="false" style="margin-bottom: 16px;">
<a-list v-if="activities && activities.length > 0" :data-source="activities" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<template #actions>
<span></span>
</template>
<a-list-item-meta>
<template #title>
{{ item.name }}
<a-tag size="small" :style="getActivityTypeStyle(translateActivityType(item.activityType))" style="margin-left: 8px;">
{{ translateActivityType(item.activityType) }}
</a-tag>
<span style="color: #999; font-size: 12px; margin-left: 8px;">
{{ item.duration }} 分钟
</span>
</template>
<template #description>
<div v-if="item.content" style="margin-bottom: 4px;">
<strong>活动内容</strong>{{ item.content }}
</div>
<div v-if="item.materials">
<strong>所需材料</strong>{{ item.materials }}
</div>
<div v-if="!item.content && !item.materials" style="color: #999;">
暂无详细说明
</div>
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
<a-empty v-else description="暂无延伸活动" />
</a-card>
<!-- 测评工具 -->
<a-card title="测评工具" :bordered="false" style="margin-bottom: 16px;" v-if="assessment">
<div v-if="assessment.enabled">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="测评状态" :span="2">
<a-tag color="success">已启用</a-tag>
</a-descriptions-item>
<a-descriptions-item label="测评类型" :span="2">
<a-tag v-for="type in assessment.types" :key="type" color="processing" style="margin-right: 4px;">
{{ translateAssessmentType(type) }}
</a-tag>
<span v-if="!assessment.types || assessment.types.length === 0">-</span>
</a-descriptions-item>
<a-descriptions-item label="评价指标" :span="2">
<div v-if="assessment.indicators && assessment.indicators.length > 0">
<a-tag v-for="indicator in assessment.indicators" :key="indicator.name" style="margin-right: 4px; margin-bottom: 4px;">
{{ indicator.name }} ({{ translateLevel(indicator.level) }})
</a-tag>
</div>
<span v-else>-</span>
</a-descriptions-item>
<a-descriptions-item label="评语模板" :span="2">
{{ assessment.commentTemplate || '暂无' }}
</a-descriptions-item>
<a-descriptions-item label="家长反馈" :span="2">
<a-tag :color="assessment.parentFeedback ? 'success' : 'default'">
{{ assessment.parentFeedback ? '需要' : '不需要' }}
</a-tag>
</a-descriptions-item>
</a-descriptions>
</div>
<div v-else>
<a-tag color="default">未启用测评</a-tag>
</div>
</a-card>
</a-col>
<!-- 右侧信息 -->
<a-col :span="8">
<!-- 使用统计 -->
<a-card title="使用统计" :bordered="false" style="margin-bottom: 16px;">
<a-statistic
title="使用次数"
:value="course.usageCount || 0"
style="margin-bottom: 16px;"
/>
<a-statistic
title="使用教师"
:value="course.teacherCount || 0"
style="margin-bottom: 16px;"
/>
<a-statistic
title="平均评分"
:value="course.avgRating || 0"
:precision="1"
suffix="/ 5"
/>
</a-card>
<!-- 数字资源 -->
<a-card title="数字资源" :bordered="false" style="margin-bottom: 16px;">
<!-- 电子绘本 -->
<div v-if="ebookPaths && ebookPaths.length > 0" style="margin-bottom: 12px;">
<div style="font-weight: 500; margin-bottom: 8px; color: #1890ff;">
<FileTextOutlined /> 电子绘本
</div>
<div v-for="(item, index) in ebookPaths" :key="index" class="resource-item" @click="previewFile(item.path, item.name)">
<FileTextOutlined class="resource-icon" style="color: #1890ff;" />
<span class="resource-name">{{ item.name || `电子绘本${index + 1}` }}</span>
<EyeOutlined class="resource-preview-icon" />
</div>
</div>
<!-- 音频 -->
<div v-if="audioPaths && audioPaths.length > 0" style="margin-bottom: 12px;">
<div style="font-weight: 500; margin-bottom: 8px; color: #52c41a;">
<AudioOutlined /> 音频资源
</div>
<div v-for="(item, index) in audioPaths" :key="index" class="resource-item" @click="previewFile(item.path, item.name)">
<AudioOutlined class="resource-icon" style="color: #52c41a;" />
<span class="resource-name">{{ item.name || `音频${index + 1}` }}</span>
<PlayCircleOutlined class="resource-preview-icon" />
</div>
</div>
<!-- 视频 -->
<div v-if="videoPaths && videoPaths.length > 0" style="margin-bottom: 12px;">
<div style="font-weight: 500; margin-bottom: 8px; color: #722ed1;">
<VideoCameraOutlined /> 视频资源
</div>
<div v-for="(item, index) in videoPaths" :key="index" class="resource-item" @click="previewFile(item.path, item.name)">
<VideoCameraOutlined class="resource-icon" style="color: #722ed1;" />
<span class="resource-name">{{ item.name || `视频${index + 1}` }}</span>
<PlayCircleOutlined class="resource-preview-icon" />
</div>
</div>
<!-- 其他素材 -->
<div v-if="otherResources && otherResources.length > 0" style="margin-bottom: 12px;">
<div style="font-weight: 500; margin-bottom: 8px; color: #fa8c16;">
<FileOutlined /> 其他素材
</div>
<div v-for="(item, index) in otherResources" :key="index" class="resource-item" @click="previewFile(item.path, item.name)">
<FileOutlined class="resource-icon" style="color: #fa8c16;" />
<span class="resource-name">{{ item.name || `素材${index + 1}` }}</span>
<EyeOutlined class="resource-preview-icon" />
</div>
</div>
<a-empty v-if="(!ebookPaths || ebookPaths.length === 0) && (!audioPaths || audioPaths.length === 0) && (!videoPaths || videoPaths.length === 0) && (!otherResources || otherResources.length === 0)" description="暂无数字资源" />
</a-card>
<!-- 教学材料 -->
<a-card title="教学材料" :bordered="false" style="margin-bottom: 16px;">
<!-- 教学PPT -->
<div v-if="course.pptPath" style="margin-bottom: 12px;">
<div style="font-weight: 500; margin-bottom: 8px; color: #1890ff;">
<FilePptOutlined /> 教学PPT
</div>
<div class="resource-item" @click="previewFile(course.pptPath, course.pptName || '教学PPT')">
<FilePptOutlined class="resource-icon" style="color: #1890ff;" />
<span class="resource-name">{{ course.pptName || '教学PPT' }}</span>
<EyeOutlined class="resource-preview-icon" />
</div>
</div>
<!-- 教学挂图 -->
<div v-if="posterPaths && posterPaths.length > 0" style="margin-bottom: 12px;">
<div style="font-weight: 500; margin-bottom: 8px; color: #52c41a;">
<PictureOutlined /> 教学挂图
</div>
<div class="poster-grid">
<img v-for="(item, index) in posterPaths" :key="index"
:src="getFileUrl(item.path)"
:alt="item.name || `挂图${index + 1}`"
class="poster-thumbnail"
@click="previewImage(getFileUrl(item.path))"
/>
</div>
</div>
<!-- 实体教具 -->
<div v-if="tools && tools.length > 0" style="margin-bottom: 12px;">
<div style="font-weight: 500; margin-bottom: 8px; color: #722ed1;">
<ToolOutlined /> 实体教具
</div>
<div v-for="(item, index) in tools" :key="index" style="padding: 4px 0; border-bottom: 1px solid #f0f0f0;">
{{ item.name }} x {{ item.quantity }}
</div>
</div>
<!-- 学生材料 -->
<div v-if="course.studentMaterials" style="margin-bottom: 12px;">
<div style="font-weight: 500; margin-bottom: 8px; color: #fa8c16;">
<FormOutlined /> 学生材料
</div>
<div style="padding: 4px 0; white-space: pre-wrap;">{{ course.studentMaterials }}</div>
</div>
<a-empty v-if="!course.pptPath && (!posterPaths || posterPaths.length === 0) && (!tools || tools.length === 0) && !course.studentMaterials" description="暂无教学材料" />
</a-card>
<!-- 版本记录 -->
<a-card title="版本记录" :bordered="false">
<a-timeline mode="left" size="small">
<a-timeline-item color="green">
<div>创建课程</div>
<div style="color: #999; font-size: 12px;">{{ formatDate(course.createdAt) }}</div>
</a-timeline-item>
<a-timeline-item v-if="course.publishedAt" color="blue">
<div>发布课程</div>
<div style="color: #999; font-size: 12px;">{{ formatDate(course.publishedAt) }}</div>
</a-timeline-item>
<a-timeline-item color="gray">
<div>最后更新</div>
<div style="color: #999; font-size: 12px;">{{ formatDate(course.updatedAt) }}</div>
</a-timeline-item>
</a-timeline>
</a-card>
</a-col>
</a-row>
</div>
</a-spin>
<!-- 图片预览 -->
<a-modal v-model:open="imagePreviewVisible" :footer="null" centered>
<img :src="previewImageUrl" style="width: 100%;" />
</a-modal>
<!-- 文件预览弹窗 -->
<FilePreviewModal
v-model:open="previewModalVisible"
:file-url="previewFileUrl"
:file-name="previewFileName"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import {
EditOutlined,
BarChartOutlined,
DeleteOutlined,
DownOutlined,
ClockCircleOutlined,
FileTextOutlined,
FilePptOutlined,
VideoCameraOutlined,
AudioOutlined,
FileOutlined,
PictureOutlined,
ToolOutlined,
FormOutlined,
EyeOutlined,
PlayCircleOutlined,
} from '@ant-design/icons-vue';
import * as courseApi from '@/api/course';
import FilePreviewModal from '@/components/FilePreviewModal.vue';
import {
translateGradeTag,
translateDomainTag,
getGradeTagStyle,
getDomainTagStyle,
translateActivityType,
getActivityTypeStyle,
translateStepType,
getStepTypeStyle,
translateCourseStatus,
getCourseStatusStyle,
} from '@/utils/tagMaps';
const router = useRouter();
const route = useRoute();
// 获取完整的文件 URL
const getFileUrl = (filePath: string | null | undefined): string => {
if (!filePath) return '';
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
return filePath;
}
const SERVER_BASE = import.meta.env.VITE_SERVER_BASE_URL || 'http://localhost:3000';
return `${SERVER_BASE}${filePath}`;
};
const loading = ref(false);
const course = ref<any>({
name: '',
status: 'DRAFT',
version: '1.0',
duration: 25,
gradeTags: '[]',
domainTags: '[]',
description: '',
pictureBookName: '',
usageCount: 0,
teacherCount: 0,
avgRating: 0,
createdAt: null,
updatedAt: null,
publishedAt: null,
pptPath: '',
pptName: '',
studentMaterials: '',
});
const scripts = ref<any[]>([]);
const activities = ref<any[]>([]);
const assessment = ref<any>(null);
// 数字资源
const ebookPaths = ref<any[]>([]);
const audioPaths = ref<any[]>([]);
const videoPaths = ref<any[]>([]);
const otherResources = ref<any[]>([]);
// 教学材料
const posterPaths = ref<any[]>([]);
const tools = ref<any[]>([]);
// 图片预览
const imagePreviewVisible = ref(false);
const previewImageUrl = ref('');
// 文件预览
const previewModalVisible = ref(false);
const previewFileUrl = ref('');
const previewFileName = ref('');
const grades = computed(() => {
if (!course.value.gradeTags) return [];
try {
const tags = JSON.parse(course.value.gradeTags);
return tags.map((tag: string) => translateGradeTag(tag));
} catch {
return [];
}
});
const domainTags = computed(() => {
if (!course.value.domainTags) return [];
try {
const tags = JSON.parse(course.value.domainTags);
return tags.map((tag: string) => translateDomainTag(tag));
} catch {
return [];
}
});
// 根据脚本的 resourceIds 获取资源详情列表
const getScriptResources = (script: any) => {
// 处理 resourceIds可能是字符串或数组
let resourceIds: string[] = [];
if (script.resourceIds) {
if (typeof script.resourceIds === 'string') {
try {
resourceIds = JSON.parse(script.resourceIds);
} catch {
resourceIds = [];
}
} else if (Array.isArray(script.resourceIds)) {
resourceIds = script.resourceIds;
}
}
if (!resourceIds.length) return [];
const resources: { id: string; name: string; typeName: string; icon: string }[] = [];
// 资源类型信息
const typeInfo: Record<string, { name: string; icon: string }> = {
ebook: { name: '电子绘本', icon: '📄' },
audio: { name: '音频', icon: '🎵' },
video: { name: '视频', icon: '📹' },
ppt: { name: 'PPT', icon: '📊' },
poster: { name: '挂图', icon: '🖼️' },
};
resourceIds.forEach((resId: string) => {
const parts = resId.split('-');
if (parts.length !== 2) return;
const type = parts[0];
const index = parseInt(parts[1]);
const info = typeInfo[type] || { name: '资源', icon: '📁' };
let name = '';
switch (type) {
case 'ebook':
name = ebookPaths.value[index]?.name || `电子绘本${index + 1}`;
break;
case 'audio':
name = audioPaths.value[index]?.name || `音频${index + 1}`;
break;
case 'video':
name = videoPaths.value[index]?.name || `视频${index + 1}`;
break;
case 'ppt':
name = course.value.pptName || '教学PPT';
break;
case 'poster':
name = posterPaths.value[index]?.name || `挂图${index + 1}`;
break;
}
if (name) {
resources.push({
id: resId,
name,
typeName: info.name,
icon: info.icon,
});
}
});
return resources;
};
// 获取逐页脚本的资源列表
const getPageResources = (script: any, page: any) => {
if (!page.resourceIds || page.resourceIds.length === 0) return [];
// 获取当前环节的所有资源
const scriptResources = getScriptResources(script);
const scriptResourceIds = scriptResources.map((r: any) => r.id);
// 过滤出页面关联的资源
let pageResourceIds: string[] = [];
if (typeof page.resourceIds === 'string') {
try {
pageResourceIds = JSON.parse(page.resourceIds);
} catch {
pageResourceIds = [];
}
} else if (Array.isArray(page.resourceIds)) {
pageResourceIds = page.resourceIds;
}
return scriptResources.filter((r: any) => pageResourceIds.includes(r.id));
};
// 检查页面是否有内容
const hasPageContent = (page: any) => {
return (page.questions && page.questions.trim()) ||
(page.teacherNotes && page.teacherNotes.trim()) ||
(page.resourceIds && page.resourceIds.length > 0);
};
// 获取有内容的页面索引(用于默认展开)
const getExpandedPages = (script: any) => {
if (!script.pages) return [];
return script.pages
.map((page: any, index: number) => hasPageContent(page) ? String(index) : null)
.filter((idx: string | null) => idx !== null);
};
// 预览资源(教学环节和逐页脚本中的资源)
const previewResource = (res: any) => {
// 获取资源的实际URL
const resId = res.id;
const parts = resId.split('-');
if (parts.length !== 2) return;
const type = parts[0];
const index = parseInt(parts[1]);
let path = '';
switch (type) {
case 'ebook':
path = ebookPaths.value[index]?.path || '';
break;
case 'audio':
path = audioPaths.value[index]?.path || '';
break;
case 'video':
path = videoPaths.value[index]?.path || '';
break;
case 'ppt':
path = course.value.pptPath || '';
break;
case 'poster':
path = posterPaths.value[index]?.path || '';
break;
}
if (path) {
previewFile(path, res.name);
}
};
onMounted(async () => {
await fetchCourseDetail();
});
const fetchCourseDetail = async () => {
loading.value = true;
try {
const courseId = +route.params.id;
const data = await courseApi.getCourse(courseId);
course.value = data;
// 处理教学流程数据,包含逐页脚本
scripts.value = (data.scripts || []).map((script: any) => ({
...script,
pages: (script.pages || []).map((page: any) => ({
...page,
resourceIds: typeof page.resourceIds === 'string' ? JSON.parse(page.resourceIds) : (page.resourceIds || []),
})),
}));
// 处理活动数据
activities.value = (data.activities || []).map((activity: any) => {
let content = '';
if (activity.onlineMaterials) {
if (typeof activity.onlineMaterials === 'object') {
content = activity.onlineMaterials.content || '';
} else if (typeof activity.onlineMaterials === 'string') {
try {
const parsed = JSON.parse(activity.onlineMaterials);
content = parsed.content || '';
} catch {
content = activity.onlineMaterials;
}
}
}
return {
...activity,
content: content,
materials: activity.offlineMaterials || '',
};
});
// 解析数字资源 - admin API返回原始JSON字符串
if (data.ebookPaths) {
if (typeof data.ebookPaths === 'string') {
try { ebookPaths.value = JSON.parse(data.ebookPaths); } catch { ebookPaths.value = []; }
} else if (Array.isArray(data.ebookPaths)) {
ebookPaths.value = data.ebookPaths;
}
}
if (data.audioPaths) {
if (typeof data.audioPaths === 'string') {
try { audioPaths.value = JSON.parse(data.audioPaths); } catch { audioPaths.value = []; }
} else if (Array.isArray(data.audioPaths)) {
audioPaths.value = data.audioPaths;
}
}
if (data.videoPaths) {
if (typeof data.videoPaths === 'string') {
try { videoPaths.value = JSON.parse(data.videoPaths); } catch { videoPaths.value = []; }
} else if (Array.isArray(data.videoPaths)) {
videoPaths.value = data.videoPaths;
}
}
if (data.otherResources) {
if (typeof data.otherResources === 'string') {
try { otherResources.value = JSON.parse(data.otherResources); } catch { otherResources.value = []; }
} else if (Array.isArray(data.otherResources)) {
otherResources.value = data.otherResources;
}
}
// 解析教学材料
if (data.posterPaths) {
if (typeof data.posterPaths === 'string') {
try { posterPaths.value = JSON.parse(data.posterPaths); } catch { posterPaths.value = []; }
} else if (Array.isArray(data.posterPaths)) {
posterPaths.value = data.posterPaths;
}
}
if (data.tools) {
if (typeof data.tools === 'string') {
try { tools.value = JSON.parse(data.tools); } catch { tools.value = []; }
} else if (Array.isArray(data.tools)) {
tools.value = data.tools;
}
}
// 解析测评工具
if (data.assessmentData) {
if (typeof data.assessmentData === 'string') {
try {
assessment.value = JSON.parse(data.assessmentData);
} catch {
assessment.value = { enabled: false };
}
} else if (typeof data.assessmentData === 'object') {
assessment.value = data.assessmentData;
} else {
assessment.value = { enabled: false };
}
} else {
assessment.value = { enabled: false };
}
} catch (error) {
console.error('获取课程详情失败:', error);
message.error('获取课程详情失败');
} finally {
loading.value = false;
}
};
// 翻译页面动作
const translateAction = (action: string): string => {
const actionMap: Record<string, string> = {
show: '展示页面',
read: '朗读文字',
discuss: '引导讨论',
interact: '互动提问',
};
return actionMap[action] || action;
};
// 翻译测评类型
const translateAssessmentType = (type: string): string => {
const typeMap: Record<string, string> = {
observation: '观察记录',
work: '作品评价',
participation: '参与度评价',
quiz: '问答测验',
};
return typeMap[type] || type;
};
// 翻译等级
const translateLevel = (level: string): string => {
const levelMap: Record<string, string> = {
high: '高',
medium: '中',
low: '低',
};
return levelMap[level] || level;
};
// 图片预览
const previewImage = (url: string) => {
previewImageUrl.value = url;
imagePreviewVisible.value = true;
};
// 文件预览
const previewFile = (filePath: string, fileName: string) => {
if (!filePath) {
message.warning('该资源暂无可预览的文件');
return;
}
const SERVER_BASE = import.meta.env.VITE_SERVER_BASE_URL || 'http://localhost:3000';
const fullUrl = filePath.startsWith('http') ? filePath : `${SERVER_BASE}${filePath}`;
previewFileUrl.value = fullUrl;
previewFileName.value = fileName || '资源预览';
previewModalVisible.value = true;
};
const editCourse = () => {
router.push(`/admin/courses/${route.params.id}/edit`);
};
const viewStats = () => {
router.push(`/admin/courses/${route.params.id}/stats`);
};
const deleteCourse = async () => {
try {
await courseApi.deleteCourse(+route.params.id);
message.success('删除成功');
router.push('/admin/courses');
} catch (error) {
message.error('删除失败');
}
};
const handleMenuClick = ({ key }: { key: string | number }) => {
handleMoreAction(String(key));
};
const handleMoreAction = (key: string) => {
if (key === 'unpublish') {
unpublishCourse();
} else if (key === 'iterate') {
iterateCourse();
}
};
const unpublishCourse = async () => {
Modal.confirm({
title: '确认下架',
content: '下架后教师端将无法看到此课程包,确认继续?',
onOk: async () => {
try {
await courseApi.unpublishCourse(+route.params.id);
message.success('下架成功');
await fetchCourseDetail();
} catch (error) {
message.error('下架失败');
}
},
});
};
const iterateCourse = () => {
router.push(`/admin/courses/${route.params.id}/iterate`);
};
const formatDate = (dateStr: string) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN');
};
</script>
<style scoped lang="scss">
.course-detail {
background: #f5f5f5;
min-height: calc(100vh - 64px);
:deep(.ant-page-header) {
background: white;
padding: 16px 24px;
}
:deep(.ant-card) {
margin-bottom: 16px;
}
:deep(.ant-descriptions-item-label) {
font-weight: 500;
}
.script-page-detail {
padding: 8px 0;
}
.poster-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
.poster-thumbnail {
width: 100%;
height: 80px;
object-fit: cover;
border-radius: 4px;
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: scale(1.05);
}
}
}
// 资源项样式
.resource-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.2s ease;
border-radius: 4px;
margin-bottom: 4px;
&:hover {
background: #f5f5f5;
.resource-preview-icon {
opacity: 1;
}
}
.resource-icon {
font-size: 16px;
margin-right: 8px;
}
.resource-name {
flex: 1;
color: #333;
}
.resource-preview-icon {
color: #1890ff;
opacity: 0;
transition: opacity 0.2s ease;
}
}
}
</style>