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

1225 lines
36 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="course-detail-view">
<!-- 顶部导航 -->
<a-page-header title="课程包详情" :sub-title="course.name || ''" @back="() => router.back()">
<template #extra>
<div class="header-actions">
<a-button v-if="canEdit" @click="editCourse">
<EditOutlined /> 编辑
</a-button>
<a-button @click="viewStats">
<BarChartOutlined /> 数据
</a-button>
<a-popconfirm v-if="course.status === 'DRAFT' || course.status === 'UNSUBMITTED' || course.status === 'ARCHIVED'" title="确定删除此课程包吗?"
@confirm="deleteCourse">
<a-button danger>
<DeleteOutlined /> 删除
</a-button>
</a-popconfirm>
</div>
</template>
<template #tags>
<a-tag :style="getStatusStyle(course.status)">{{ translateStatus(course.status) }}</a-tag>
</template>
</a-page-header>
<a-spin :spinning="loading">
<div class="detail-content">
<!-- 封面和基本信息 -->
<div class="cover-section" v-if="course.coverImagePath">
<img :src="getFileUrl(course.coverImagePath)" alt="课程封面" class="cover-image" />
</div>
<!-- 信息卡片网格 -->
<div class="info-grid">
<!-- 基本信息 -->
<div class="info-card basic-info">
<div class="card-header">
<span class="card-title">
<InfoCircleOutlined /> 基本信息
</span>
</div>
<div class="card-body">
<div class="info-row">
<span class="info-label">关联主题</span>
<span class="info-value">
<a-tag v-if="themeDisplayName" :style="themeTagStyle">{{ themeDisplayName }}</a-tag>
<span v-else class="empty-text">未设置</span>
</span>
</div>
<div class="info-row">
<span class="info-label">适用年级</span>
<span class="info-value">
<template v-if="grades.length > 0">
<a-tag v-for="grade in grades" :key="grade" :color="getGradeColor(grade)">
{{ grade }}
</a-tag>
</template>
<span v-else class="empty-text">未设置</span>
</span>
</div>
<div class="info-row">
<span class="info-label">关联绘本</span>
<span class="info-value">{{ course.pictureBookName || '未关联' }}</span>
</div>
<div class="info-row">
<span class="info-label">课程时长</span>
<span class="info-value">{{ course.duration || 25 }} 分钟</span>
</div>
<div class="info-row">
<span class="info-label">核心发展目标</span>
<span class="info-value">
<template v-if="domainTags.length > 0">
<a-tag v-for="tag in domainTags" :key="tag" color="purple" style="margin: 2px;">
{{ tag }}
</a-tag>
</template>
<span v-else class="empty-text">未设置</span>
</span>
</div>
<div class="info-row" v-if="course.coreContent">
<span class="info-label">核心内容</span>
<span class="info-value">{{ course.coreContent }}</span>
</div>
</div>
</div>
<!-- 统计数据 -->
<div class="info-card stats-card">
<div class="card-header">
<span class="card-title">
<BarChartOutlined /> 使用统计
</span>
</div>
<div class="card-body stats-body">
<div class="stat-item">
<div class="stat-value">{{ course.usageCount || 0 }}</div>
<div class="stat-label">使用次数</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ course.teacherCount || 0 }}</div>
<div class="stat-label">使用教师</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ course.avgRating?.toFixed(1) || '-' }}</div>
<div class="stat-label">平均评分</div>
</div>
</div>
</div>
<!-- 版本信息 -->
<div class="info-card version-card">
<div class="card-header">
<span class="card-title">
<ClockCircleOutlined /> 版本记录
</span>
</div>
<div class="card-body">
<div class="version-item">
<span class="version-label">当前版本</span>
<span class="version-value">v{{ course.version || '1.0' }}</span>
</div>
<div class="version-item">
<span class="version-label">创建时间</span>
<span class="version-value">{{ formatDate(course.createdAt) }}</span>
</div>
<div class="version-item">
<span class="version-label">更新时间</span>
<span class="version-value">{{ formatDate(course.updatedAt) }}</span>
</div>
<div class="version-item" v-if="course.publishedAt">
<span class="version-label">发布时间</span>
<span class="version-value">{{ formatDate(course.publishedAt) }}</span>
</div>
</div>
</div>
</div>
<!-- 课程介绍 -->
<div class="section-card" v-if="hasIntroContent">
<div class="section-header">
<span class="section-title">
<BookOutlined /> 课程介绍
</span>
</div>
<div class="section-body">
<a-tabs v-model:activeKey="activeIntroTab" type="card">
<a-tab-pane key="summary" tab="课程简介" v-if="course.introSummary">
<div class="intro-content">{{ course.introSummary }}</div>
</a-tab-pane>
<a-tab-pane key="highlights" tab="课程亮点" v-if="course.introHighlights">
<div class="intro-content">{{ course.introHighlights }}</div>
</a-tab-pane>
<a-tab-pane key="goals" tab="课程总目标" v-if="course.introGoals">
<div class="intro-content">{{ course.introGoals }}</div>
</a-tab-pane>
<a-tab-pane key="schedule" tab="内容安排" v-if="course.introSchedule">
<div class="intro-content">{{ course.introSchedule }}</div>
</a-tab-pane>
<a-tab-pane key="keyPoints" tab="重难点" v-if="course.introKeyPoints">
<div class="intro-content">{{ course.introKeyPoints }}</div>
</a-tab-pane>
<a-tab-pane key="methods" tab="教学方法" v-if="course.introMethods">
<div class="intro-content">{{ course.introMethods }}</div>
</a-tab-pane>
<a-tab-pane key="evaluation" tab="评价方式" v-if="course.introEvaluation">
<div class="intro-content">{{ course.introEvaluation }}</div>
</a-tab-pane>
<a-tab-pane key="notes" tab="注意事项" v-if="course.introNotes">
<div class="intro-content">{{ course.introNotes }}</div>
</a-tab-pane>
</a-tabs>
</div>
</div>
<!-- 排课参考 -->
<div class="section-card" v-if="scheduleRef.length > 0">
<div class="section-header">
<span class="section-title">
<CalendarOutlined /> 排课计划参考
</span>
</div>
<div class="section-body">
<a-table :dataSource="scheduleRef" :columns="scheduleColumns" :pagination="false" size="small" bordered>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'lessonType'">
<a-tag :color="getLessonTypeColor(record.lessonType)">
{{ translateLessonType(record.lessonType) }}
</a-tag>
</template>
</template>
</a-table>
</div>
</div>
<!-- 环创建设 -->
<div class="section-card" v-if="course.environmentConstruction">
<div class="section-header">
<span class="section-title">
<EnvironmentOutlined /> 环创建设
</span>
</div>
<div class="section-body">
<div class="intro-content">{{ course.environmentConstruction }}</div>
</div>
</div>
<!-- 课程配置 -->
<div class="section-card" v-if="courseLessons.length > 0">
<div class="section-header">
<span class="section-title">
<AppstoreOutlined /> 课程配置
</span>
<a-tag>{{ courseLessons.length }} 个课程</a-tag>
</div>
<div class="section-body">
<div class="lesson-cards">
<div v-for="lesson in courseLessons" :key="lesson.id" class="lesson-card"
:class="'lesson-type-' + lesson.lessonType?.toLowerCase()">
<div class="lesson-header">
<div class="lesson-type-badge" :style="{ background: getLessonTypeBgColor(lesson.lessonType) }">
{{ translateLessonType(lesson.lessonType) }}
</div>
<div class="lesson-name">{{ lesson.name }}</div>
<div class="lesson-duration">{{ lesson.duration }}分钟</div>
</div>
<div class="lesson-body">
<!-- 教学目标 -->
<div class="lesson-section" v-if="lesson.objectives">
<div class="lesson-section-title">教学目标</div>
<div class="lesson-section-content">{{ lesson.objectives }}</div>
</div>
<!-- 教学准备 -->
<div class="lesson-section" v-if="lesson.preparation">
<div class="lesson-section-title">教学准备</div>
<div class="lesson-section-content">{{ lesson.preparation }}</div>
</div>
<!-- 核心资源 -->
<div class="lesson-section" v-if="hasLessonResources(lesson)">
<div class="lesson-section-title">核心资源</div>
<div class="resource-grid">
<div v-if="lesson.videoPath" class="resource-item"
@click="previewFile(lesson.videoPath, lesson.videoName || '绘本动画')">
<VideoCameraOutlined class="resource-icon video" />
<span class="resource-name">{{ lesson.videoName || '绘本动画' }}</span>
<EyeOutlined class="resource-action" />
</div>
<div v-if="lesson.pptPath" class="resource-item"
@click="previewFile(lesson.pptPath, lesson.pptName || '教学课件')">
<FilePptOutlined class="resource-icon ppt" />
<span class="resource-name">{{ lesson.pptName || '教学课件' }}</span>
<EyeOutlined class="resource-action" />
</div>
<div v-if="lesson.pdfPath" class="resource-item"
@click="previewFile(lesson.pdfPath, lesson.pdfName || '电子绘本')">
<FilePdfOutlined class="resource-icon pdf" />
<span class="resource-name">{{ lesson.pdfName || '电子绘本' }}</span>
<EyeOutlined class="resource-action" />
</div>
</div>
</div>
<!-- 教学环节 -->
<div class="lesson-section" v-if="lesson.steps && lesson.steps.length > 0">
<div class="lesson-section-title">教学环节 ({{ lesson.steps.length }}个)</div>
<div class="steps-timeline">
<div v-for="(step, index) in lesson.steps" :key="step.id || index" class="step-item">
<div class="step-dot">{{ index + 1 }}</div>
<div class="step-content">
<div class="step-name">{{ step.name }}</div>
<div class="step-duration">{{ step.duration }}分钟</div>
<div class="step-info" v-if="step.objective">目标:{{ step.objective }}</div>
<div class="step-info" v-if="step.content">{{ step.content }}</div>
</div>
</div>
</div>
</div>
<!-- 教学延伸 -->
<div class="lesson-section" v-if="lesson.extension">
<div class="lesson-section-title">教学延伸</div>
<div class="lesson-section-content">{{ lesson.extension }}</div>
</div>
<!-- 教学反思 -->
<div class="lesson-section" v-if="lesson.reflection">
<div class="lesson-section-title">教学反思</div>
<div class="lesson-section-content">{{ lesson.reflection }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 数字资源汇总 -->
<div class="section-card" v-if="hasAnyResources">
<div class="section-header">
<span class="section-title">
<FolderOutlined /> 数字资源
</span>
<a-tag>{{ totalResourcesCount }} 个文件</a-tag>
</div>
<div class="section-body">
<div class="resources-grid">
<!-- 视频 -->
<div class="resource-group" v-if="allVideos.length > 0">
<div class="resource-group-title">
<VideoCameraOutlined style="color: #722ed1;" /> 视频资源
</div>
<div class="resource-list">
<div v-for="(item, index) in allVideos" :key="'video-' + index" class="resource-item-card"
@click="previewFile(item.path, item.name)">
<VideoCameraOutlined class="item-icon" style="color: #722ed1;" />
<span class="item-name">{{ item.name }}</span>
<PlayCircleOutlined class="item-action" />
</div>
</div>
</div>
<!-- 音频 -->
<div class="resource-group" v-if="allAudios.length > 0">
<div class="resource-group-title">
<AudioOutlined style="color: #52c41a;" /> 音频资源
</div>
<div class="resource-list">
<div v-for="(item, index) in allAudios" :key="'audio-' + index" class="resource-item-card"
@click="previewFile(item.path, item.name)">
<AudioOutlined class="item-icon" style="color: #52c41a;" />
<span class="item-name">{{ item.name }}</span>
<PlayCircleOutlined class="item-action" />
</div>
</div>
</div>
<!-- 文档 -->
<div class="resource-group" v-if="allDocuments.length > 0">
<div class="resource-group-title">
<FileTextOutlined style="color: #1890ff;" /> 文档资源
</div>
<div class="resource-list">
<div v-for="(item, index) in allDocuments" :key="'doc-' + index" class="resource-item-card"
@click="previewFile(item.path, item.name)">
<FilePdfOutlined v-if="item.type === 'pdf'" class="item-icon" style="color: #f5222d;" />
<FilePptOutlined v-else-if="item.type === 'ppt'" class="item-icon" style="color: #fa8c16;" />
<FileTextOutlined v-else class="item-icon" style="color: #1890ff;" />
<span class="item-name">{{ item.name }}</span>
<EyeOutlined class="item-action" />
</div>
</div>
</div>
<!-- 图片 -->
<div class="resource-group" v-if="allImages.length > 0">
<div class="resource-group-title">
<PictureOutlined style="color: #13c2c2;" /> 图片资源
</div>
<div class="image-grid">
<img v-for="(item, index) in allImages" :key="'img-' + index" :src="getFileUrl(item.path)"
:alt="item.name" class="image-thumbnail" @click="previewImage(getFileUrl(item.path))" />
</div>
</div>
</div>
</div>
</div>
</div>
</a-spin>
<!-- 图片预览 -->
<a-modal v-model:open="imagePreviewVisible" :footer="null" centered width="80%">
<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, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message, Modal } from 'ant-design-vue';
import {
ArrowLeftOutlined,
EditOutlined,
BarChartOutlined,
DeleteOutlined,
InfoCircleOutlined,
ClockCircleOutlined,
BookOutlined,
CalendarOutlined,
AppstoreOutlined,
FolderOutlined,
VideoCameraOutlined,
AudioOutlined,
FileTextOutlined,
FilePptOutlined,
FilePdfOutlined,
PictureOutlined,
EyeOutlined,
PlayCircleOutlined,
EnvironmentOutlined,
} from '@ant-design/icons-vue';
import * as courseApi from '@/api/course';
import { translateDomainTags, getThemeTagStyle } from '@/utils/tagMaps';
import FilePreviewModal from '@/components/FilePreviewModal.vue';
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;
}
if (filePath.startsWith('/uploads')) {
const SERVER_BASE = import.meta.env.VITE_SERVER_BASE_URL || 'http://localhost:3000';
return `${SERVER_BASE}${filePath}`;
}
if (filePath.includes('/uploads/')) {
const SERVER_BASE = import.meta.env.VITE_SERVER_BASE_URL || 'http://localhost:3000';
return `${SERVER_BASE}${filePath}`;
}
const SERVER_BASE = import.meta.env.VITE_SERVER_BASE_URL || 'http://localhost:3000';
return `${SERVER_BASE}/uploads/${filePath}`;
};
const loading = ref(false);
const course = ref<any>({
name: '',
status: 'DRAFT',
version: '1.0',
duration: 25,
gradeTags: '[]',
domainTags: '[]',
coreContent: '',
pictureBookName: '',
theme: null,
// 课程介绍
introSummary: '',
introHighlights: '',
introGoals: '',
introSchedule: '',
introKeyPoints: '',
introMethods: '',
introEvaluation: '',
introNotes: '',
// 排课参考
scheduleRefData: '',
// 环创建设
environmentConstruction: '',
// 统计
usageCount: 0,
teacherCount: 0,
avgRating: 0,
createdAt: null,
updatedAt: null,
publishedAt: null,
});
const courseLessons = ref<any[]>([]);
const activeIntroTab = ref('summary');
// 图片预览
const imagePreviewVisible = ref(false);
const previewImageUrl = ref('');
// 文件预览
const previewModalVisible = ref(false);
const previewFileUrl = ref('');
const previewFileName = ref('');
// 年级gradeTags 规范为 String[],与套餐管理对齐)
const grades = computed(() => {
const val = course.value.gradeTags;
if (!val) return [];
if (Array.isArray(val)) return val;
try {
const tags = JSON.parse(val);
return Array.isArray(tags) ? tags : [];
} catch {
return [];
}
});
// 领域标签domainTags 规范为 String[]
const domainTags = computed(() => {
const val = course.value.domainTags;
if (!val) return [];
if (Array.isArray(val)) return translateDomainTags(val);
try {
const tags = JSON.parse(val);
return translateDomainTags(Array.isArray(tags) ? tags : []);
} catch {
return [];
}
});
const themeDisplayName = computed(() =>
(course.value.theme?.name || course.value.themeName || '').trim()
);
const themeTagStyle = computed(() =>
getThemeTagStyle(course.value.theme?.color || course.value.themeColor)
);
// 审核中(待审核)不可编辑,仅草稿/已驳回/已下架可编辑
const canEdit = computed(() => {
const s = course.value.status;
return s === 'DRAFT' || s === 'UNSUBMITTED' || s === 'REJECTED' || s === 'ARCHIVED';
});
// 是否有课程介绍内容
const hasIntroContent = computed(() => {
return course.value.introSummary || course.value.introHighlights ||
course.value.introGoals || course.value.introSchedule ||
course.value.introKeyPoints || course.value.introMethods ||
course.value.introEvaluation || course.value.introNotes;
});
// 排课参考数据
const scheduleRef = computed(() => {
if (!course.value.scheduleRefData) return [];
try {
return JSON.parse(course.value.scheduleRefData);
} catch {
return [];
}
});
// 排课表格列定义
const scheduleColumns = [
{ title: '时间', dataIndex: 'dayOfWeek', key: 'dayOfWeek', width: 80 },
{ title: '课程类型', dataIndex: 'lessonType', key: 'lessonType', width: 100 },
{ title: '课程名称', dataIndex: 'lessonName', key: 'lessonName' },
{ title: '区域活动', dataIndex: 'activity', key: 'activity' },
{ title: '备注', dataIndex: 'note', key: 'note' },
];
// 汇总所有资源
const allVideos = computed(() => {
const videos: any[] = [];
// 从课程中获取
courseLessons.value.forEach(lesson => {
if (lesson.videoPath) {
videos.push({ path: lesson.videoPath, name: lesson.videoName || '视频', source: lesson.name });
}
});
// 从旧字段获取
if (course.value.videoPaths) {
try {
const paths = typeof course.value.videoPaths === 'string'
? JSON.parse(course.value.videoPaths) : course.value.videoPaths;
paths.forEach((item: any) => {
videos.push({ path: item.path, name: item.name || '视频', source: '资源库' });
});
} catch { }
}
return videos;
});
const allAudios = computed(() => {
const audios: any[] = [];
if (course.value.audioPaths) {
try {
const paths = typeof course.value.audioPaths === 'string'
? JSON.parse(course.value.audioPaths) : course.value.audioPaths;
paths.forEach((item: any) => {
audios.push({ path: item.path, name: item.name || '音频', source: '资源库' });
});
} catch { }
}
return audios;
});
const allDocuments = computed(() => {
const docs: any[] = [];
// 从课程中获取
courseLessons.value.forEach(lesson => {
if (lesson.pptPath) {
docs.push({ path: lesson.pptPath, name: lesson.pptName || '课件', type: 'ppt', source: lesson.name });
}
if (lesson.pdfPath) {
docs.push({ path: lesson.pdfPath, name: lesson.pdfName || '电子绘本', type: 'pdf', source: lesson.name });
}
});
// 从旧字段获取
if (course.value.pptPath) {
docs.push({ path: course.value.pptPath, name: course.value.pptName || '教学PPT', type: 'ppt', source: '课程' });
}
if (course.value.ebookPaths) {
try {
const paths = typeof course.value.ebookPaths === 'string'
? JSON.parse(course.value.ebookPaths) : course.value.ebookPaths;
paths.forEach((item: any) => {
docs.push({ path: item.path, name: item.name || '电子绘本', type: 'pdf', source: '资源库' });
});
} catch { }
}
return docs;
});
const allImages = computed(() => {
const images: any[] = [];
if (course.value.posterPaths) {
try {
const paths = typeof course.value.posterPaths === 'string'
? JSON.parse(course.value.posterPaths) : course.value.posterPaths;
paths.forEach((item: any) => {
images.push({ path: item.path, name: item.name || '挂图' });
});
} catch { }
}
return images;
});
const hasAnyResources = computed(() => {
return allVideos.value.length > 0 ||
allAudios.value.length > 0 ||
allDocuments.value.length > 0 ||
allImages.value.length > 0;
});
const totalResourcesCount = computed(() => {
return allVideos.value.length + allAudios.value.length + allDocuments.value.length + allImages.value.length;
});
// 检查课程是否有资源
const hasLessonResources = (lesson: any) => {
return lesson.videoPath || lesson.pptPath || lesson.pdfPath;
};
// 获取年级颜色
const getGradeColor = (grade: string) => {
const colors: Record<string, string> = {
'小班': 'green',
'中班': 'blue',
'大班': 'orange',
};
return colors[grade] || 'default';
};
// 获取状态样式
const getStatusStyle = (status: string) => {
const styles: Record<string, any> = {
'DRAFT': { background: '#f0f0f0', color: '#666' },
'PENDING': { background: '#e6f7ff', color: '#1890ff' },
'PUBLISHED': { background: '#f6ffed', color: '#52c41a' },
'ARCHIVED': { background: '#fff7e6', color: '#fa8c16' },
'REJECTED': { background: '#fff1f0', color: '#f5222d' },
};
return styles[status] || { background: '#f0f0f0', color: '#666' };
};
// 翻译状态
const translateStatus = (status: string) => {
const map: Record<string, string> = {
'DRAFT': '草稿',
'UNSUBMITTED': '未提交',
'PENDING': '待审核',
'PUBLISHED': '已发布',
'ARCHIVED': '已下架',
'REJECTED': '已驳回',
};
return map[status] || status;
};
// 获取课程类型颜色
const getLessonTypeColor = (type: string) => {
const colors: Record<string, string> = {
'INTRO': 'cyan',
'COLLECTIVE': 'green',
'DOMAIN_HEALTH': 'red',
'DOMAIN_LANGUAGE': 'orange',
'DOMAIN_SOCIAL': 'purple',
'DOMAIN_SCIENCE': 'geekblue',
'DOMAIN_ART': 'magenta',
};
return colors[type] || 'default';
};
const getLessonTypeBgColor = (type: string) => {
const colors: Record<string, string> = {
'INTRO': '#13c2c2',
'COLLECTIVE': '#52c41a',
'DOMAIN_HEALTH': '#f5222d',
'DOMAIN_LANGUAGE': '#fa8c16',
'DOMAIN_SOCIAL': '#722ed1',
'DOMAIN_SCIENCE': '#2f54eb',
'DOMAIN_ART': '#eb2f96',
};
return colors[type] || '#1890ff';
};
// 翻译课程类型
const translateLessonType = (type: string) => {
const map: Record<string, string> = {
'INTRO': '导入课',
'COLLECTIVE': '集体课',
'DOMAIN_HEALTH': '健康领域',
'DOMAIN_LANGUAGE': '语言领域',
'DOMAIN_SOCIAL': '社会领域',
'DOMAIN_SCIENCE': '科学领域',
'DOMAIN_ART': '艺术领域',
};
return map[type] || type;
};
// 图片预览
const previewImage = (url: string) => {
previewImageUrl.value = url;
imagePreviewVisible.value = true;
};
// 文件预览
const previewFile = (filePath: string, fileName: string) => {
if (!filePath) {
message.warning('该资源暂无可预览的文件');
return;
}
previewFileUrl.value = getFileUrl(filePath);
previewFileName.value = fileName || '资源预览';
previewModalVisible.value = true;
};
const editCourse = () => {
router.push(`/admin/packages/${route.params.id}/edit`);
};
const viewStats = () => {
router.push(`/admin/packages/${route.params.id}/stats`);
};
const deleteCourse = async () => {
try {
await courseApi.deleteCourse(route.params.id as string);
message.success('删除成功');
router.push('/admin/packages');
} catch (error) {
message.error('删除失败');
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('zh-CN');
};
onMounted(async () => {
await fetchCourseDetail();
});
const fetchCourseDetail = async () => {
loading.value = true;
try {
const courseId = route.params.id as string;
const data = await courseApi.getCourse(courseId);
course.value = data;
// 获取课程列表
if (data.courseLessons && data.courseLessons.length > 0) {
courseLessons.value = data.courseLessons.map((lesson: any) => ({
...lesson,
steps: lesson.steps || [],
}));
}
// 设置第一个有内容的tab为活动
if (course.value.introSummary) activeIntroTab.value = 'summary';
else if (course.value.introHighlights) activeIntroTab.value = 'highlights';
else if (course.value.introGoals) activeIntroTab.value = 'goals';
else if (course.value.introSchedule) activeIntroTab.value = 'schedule';
else if (course.value.introKeyPoints) activeIntroTab.value = 'keyPoints';
else if (course.value.introMethods) activeIntroTab.value = 'methods';
else if (course.value.introEvaluation) activeIntroTab.value = 'evaluation';
else if (course.value.introNotes) activeIntroTab.value = 'notes';
} catch (error) {
console.error('获取课程详情失败:', error);
message.error('获取课程详情失败');
} finally {
loading.value = false;
}
};
</script>
<style scoped lang="scss">
.course-detail-view {
min-height: 100vh;
background: #f0f2f5;
}
.header-actions {
display: flex;
gap: 8px;
}
.detail-content {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.cover-section {
margin-bottom: 24px;
text-align: center;
.cover-image {
max-width: 100%;
max-height: 400px;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
object-fit: cover;
}
}
.info-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
@media (max-width: 1200px) {
grid-template-columns: 1fr 1fr;
}
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.info-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
.card-header {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
.card-title {
font-size: 15px;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
gap: 8px;
}
}
.card-body {
padding: 16px 20px;
}
}
.info-row {
display: flex;
padding: 8px 0;
border-bottom: 1px dashed #f0f0f0;
&:last-child {
border-bottom: none;
}
.info-label {
width: 100px;
flex-shrink: 0;
color: #666;
font-size: 13px;
}
.info-value {
flex: 1;
color: #333;
font-size: 13px;
}
}
.empty-text {
color: #999;
}
.stats-card {
.stats-body {
display: flex;
justify-content: space-around;
padding: 24px 16px;
}
.stat-item {
text-align: center;
.stat-value {
font-size: 28px;
font-weight: 600;
color: #1890ff;
line-height: 1.2;
}
.stat-label {
font-size: 13px;
color: #666;
margin-top: 4px;
}
}
}
.version-card {
.version-item {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 13px;
.version-label {
color: #666;
}
.version-value {
color: #333;
}
}
}
.section-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
margin-bottom: 24px;
.section-header {
padding: 16px 24px;
border-bottom: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
.section-title {
font-size: 16px;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
gap: 8px;
}
}
.section-body {
padding: 20px 24px;
}
}
.intro-content {
line-height: 1.8;
color: #333;
white-space: pre-wrap;
font-size: 14px;
}
.lesson-cards {
display: grid;
gap: 16px;
}
.lesson-card {
border: 1px solid #e8e8e8;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.lesson-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
.lesson-type-badge {
padding: 4px 12px;
border-radius: 20px;
color: white;
font-size: 12px;
font-weight: 500;
}
.lesson-name {
flex: 1;
font-weight: 600;
font-size: 15px;
}
.lesson-duration {
color: #666;
font-size: 13px;
}
}
.lesson-body {
padding: 16px 20px;
}
.lesson-section {
margin-bottom: 16px;
&:last-child {
margin-bottom: 0;
}
.lesson-section-title {
font-size: 13px;
font-weight: 600;
color: #666;
margin-bottom: 8px;
padding-left: 8px;
border-left: 3px solid #1890ff;
}
.lesson-section-content {
font-size: 13px;
color: #333;
line-height: 1.6;
white-space: pre-wrap;
}
}
}
.resource-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
.resource-item {
display: flex;
align-items: center;
padding: 10px 12px;
background: #f9f9f9;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f0f0f0;
.resource-action {
opacity: 1;
}
}
.resource-icon {
font-size: 18px;
margin-right: 8px;
&.video {
color: #722ed1;
}
&.ppt {
color: #fa8c16;
}
&.pdf {
color: #f5222d;
}
}
.resource-name {
flex: 1;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-action {
color: #1890ff;
opacity: 0;
transition: opacity 0.2s ease;
}
}
}
.steps-timeline {
.step-item {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px dashed #f0f0f0;
&:last-child {
border-bottom: none;
}
.step-dot {
width: 24px;
height: 24px;
border-radius: 50%;
background: #1890ff;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.step-content {
flex: 1;
.step-name {
font-weight: 500;
margin-bottom: 4px;
}
.step-duration {
display: inline-block;
font-size: 12px;
color: #999;
margin-left: 8px;
}
.step-info {
font-size: 12px;
color: #666;
margin-top: 4px;
line-height: 1.5;
}
}
}
}
.resources-grid {
display: grid;
gap: 20px;
.resource-group {
.resource-group-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.resource-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
}
.resource-item-card {
display: flex;
align-items: center;
padding: 10px 14px;
background: #fafafa;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: #f0f0f0;
.item-action {
opacity: 1;
}
}
.item-icon {
font-size: 18px;
margin-right: 10px;
}
.item-name {
flex: 1;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-action {
color: #1890ff;
opacity: 0;
transition: opacity 0.2s ease;
}
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
.image-thumbnail {
width: 100%;
height: 100px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
}
}
}
}
}
</style>