2026-02-28 17:51:15 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="course-detail-view">
|
|
|
|
|
|
<!-- 顶部导航 -->
|
|
|
|
|
|
<div class="detail-header">
|
|
|
|
|
|
<div class="header-left">
|
|
|
|
|
|
<a-button type="text" @click="router.back()">
|
|
|
|
|
|
<ArrowLeftOutlined />
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
<div class="course-title">
|
|
|
|
|
|
<h2>{{ course.name || '课程包详情' }}</h2>
|
|
|
|
|
|
<a-tag :style="getStatusStyle(course.status)" style="margin-left: 12px;">
|
|
|
|
|
|
{{ translateStatus(course.status) }}
|
|
|
|
|
|
</a-tag>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="header-actions">
|
|
|
|
|
|
<a-button @click="editCourse">
|
|
|
|
|
|
<EditOutlined /> 编辑
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
<a-button @click="viewStats">
|
|
|
|
|
|
<BarChartOutlined /> 数据
|
|
|
|
|
|
</a-button>
|
2026-03-03 17:38:29 +08:00
|
|
|
|
<a-popconfirm v-if="course.status === 'DRAFT' || course.status === 'ARCHIVED'" title="确定删除此课程包吗?"
|
|
|
|
|
|
@confirm="deleteCourse">
|
2026-02-28 17:51:15 +08:00
|
|
|
|
<a-button danger>
|
|
|
|
|
|
<DeleteOutlined /> 删除
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-popconfirm>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<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="course.theme" color="blue">{{ course.theme.name }}</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">
|
2026-03-03 17:38:29 +08:00
|
|
|
|
<div v-for="lesson in courseLessons" :key="lesson.id" class="lesson-card"
|
|
|
|
|
|
:class="'lesson-type-' + lesson.lessonType?.toLowerCase()">
|
2026-02-28 17:51:15 +08:00
|
|
|
|
<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">
|
2026-03-03 17:38:29 +08:00
|
|
|
|
<div v-if="lesson.videoPath" class="resource-item"
|
|
|
|
|
|
@click="previewFile(lesson.videoPath, lesson.videoName || '绘本动画')">
|
2026-02-28 17:51:15 +08:00
|
|
|
|
<VideoCameraOutlined class="resource-icon video" />
|
|
|
|
|
|
<span class="resource-name">{{ lesson.videoName || '绘本动画' }}</span>
|
|
|
|
|
|
<EyeOutlined class="resource-action" />
|
|
|
|
|
|
</div>
|
2026-03-03 17:38:29 +08:00
|
|
|
|
<div v-if="lesson.pptPath" class="resource-item"
|
|
|
|
|
|
@click="previewFile(lesson.pptPath, lesson.pptName || '教学课件')">
|
2026-02-28 17:51:15 +08:00
|
|
|
|
<FilePptOutlined class="resource-icon ppt" />
|
|
|
|
|
|
<span class="resource-name">{{ lesson.pptName || '教学课件' }}</span>
|
|
|
|
|
|
<EyeOutlined class="resource-action" />
|
|
|
|
|
|
</div>
|
2026-03-03 17:38:29 +08:00
|
|
|
|
<div v-if="lesson.pdfPath" class="resource-item"
|
|
|
|
|
|
@click="previewFile(lesson.pdfPath, lesson.pdfName || '电子绘本')">
|
2026-02-28 17:51:15 +08:00
|
|
|
|
<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">
|
2026-03-03 17:38:29 +08:00
|
|
|
|
<div v-for="(item, index) in allVideos" :key="'video-' + index" class="resource-item-card"
|
|
|
|
|
|
@click="previewFile(item.path, item.name)">
|
2026-02-28 17:51:15 +08:00
|
|
|
|
<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">
|
2026-03-03 17:38:29 +08:00
|
|
|
|
<div v-for="(item, index) in allAudios" :key="'audio-' + index" class="resource-item-card"
|
|
|
|
|
|
@click="previewFile(item.path, item.name)">
|
2026-02-28 17:51:15 +08:00
|
|
|
|
<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">
|
2026-03-03 17:38:29 +08:00
|
|
|
|
<div v-for="(item, index) in allDocuments" :key="'doc-' + index" class="resource-item-card"
|
|
|
|
|
|
@click="previewFile(item.path, item.name)">
|
2026-02-28 17:51:15 +08:00
|
|
|
|
<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">
|
2026-03-03 17:38:29 +08:00
|
|
|
|
<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))" />
|
2026-02-28 17:51:15 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 文件预览弹窗 -->
|
2026-03-03 17:38:29 +08:00
|
|
|
|
<FilePreviewModal v-model:open="previewModalVisible" :file-url="previewFileUrl" :file-name="previewFileName" />
|
2026-02-28 17:51:15 +08:00
|
|
|
|
</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 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('');
|
|
|
|
|
|
|
|
|
|
|
|
// 年级
|
|
|
|
|
|
const grades = computed(() => {
|
|
|
|
|
|
if (!course.value.gradeTags) return [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
const tags = JSON.parse(course.value.gradeTags);
|
|
|
|
|
|
return tags;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 领域标签
|
|
|
|
|
|
const domainTags = computed(() => {
|
|
|
|
|
|
if (!course.value.domainTags) return [];
|
|
|
|
|
|
try {
|
|
|
|
|
|
const tags = JSON.parse(course.value.domainTags);
|
|
|
|
|
|
return tags;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 是否有课程介绍内容
|
|
|
|
|
|
const hasIntroContent = computed(() => {
|
|
|
|
|
|
return course.value.introSummary || course.value.introHighlights ||
|
2026-03-03 17:38:29 +08:00
|
|
|
|
course.value.introGoals || course.value.introSchedule ||
|
|
|
|
|
|
course.value.introKeyPoints || course.value.introMethods ||
|
|
|
|
|
|
course.value.introEvaluation || course.value.introNotes;
|
2026-02-28 17:51:15 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 排课参考数据
|
|
|
|
|
|
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: '资源库' });
|
|
|
|
|
|
});
|
2026-03-03 17:38:29 +08:00
|
|
|
|
} catch { }
|
2026-02-28 17:51:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
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: '资源库' });
|
|
|
|
|
|
});
|
2026-03-03 17:38:29 +08:00
|
|
|
|
} catch { }
|
2026-02-28 17:51:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
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: '资源库' });
|
|
|
|
|
|
});
|
2026-03-03 17:38:29 +08:00
|
|
|
|
} catch { }
|
2026-02-28 17:51:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
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 || '挂图' });
|
|
|
|
|
|
});
|
2026-03-03 17:38:29 +08:00
|
|
|
|
} catch { }
|
2026-02-28 17:51:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
return images;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const hasAnyResources = computed(() => {
|
|
|
|
|
|
return allVideos.value.length > 0 ||
|
2026-03-03 17:38:29 +08:00
|
|
|
|
allAudios.value.length > 0 ||
|
|
|
|
|
|
allDocuments.value.length > 0 ||
|
|
|
|
|
|
allImages.value.length > 0;
|
2026-02-28 17:51:15 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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': '草稿',
|
|
|
|
|
|
'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/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 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;
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 16px 24px;
|
|
|
|
|
|
background: white;
|
|
|
|
|
|
border-bottom: 1px solid #f0f0f0;
|
|
|
|
|
|
position: sticky;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
z-index: 100;
|
|
|
|
|
|
|
|
|
|
|
|
.header-left {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
|
|
|
|
|
|
.course-title {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
|
|
|
|
|
|
h2 {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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;
|
|
|
|
|
|
|
2026-03-03 17:38:29 +08:00
|
|
|
|
&.video {
|
|
|
|
|
|
color: #722ed1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.ppt {
|
|
|
|
|
|
color: #fa8c16;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
&.pdf {
|
|
|
|
|
|
color: #f5222d;
|
|
|
|
|
|
}
|
2026-02-28 17:51:15 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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>
|