fix: 统一修改错误处理逻辑
- 将所有 error.response?.data?.message 改为 error.message - 影响所有教师端组件的错误处理 - 适配新的响应拦截器返回的错误对象结构 修改的文件: - CourseListView.vue - CourseDetailView.vue - PrepareModeView.vue - LessonListView.vue - LessonView.vue - LessonRecordsView.vue - SchoolCourseEditView.vue - ClassListView.vue - ClassStudentsView.vue - TaskListView.vue Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
de54ed112c
commit
4e13f186f3
@ -175,7 +175,7 @@ const loadClasses = async () => {
|
|||||||
}));
|
}));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to load classes:', error);
|
console.error('Failed to load classes:', error);
|
||||||
message.error(error.response?.data?.message || '加载班级失败');
|
message.error(error.message || '加载班级失败');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -257,7 +257,7 @@ const loadStudents = async () => {
|
|||||||
avgScore: Math.round(Math.random() * 40 + 60), // 临时模拟数据
|
avgScore: Math.round(Math.random() * 40 + 60), // 临时模拟数据
|
||||||
}));
|
}));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response?.data?.message || '加载失败');
|
message.error(error.message || '加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -289,7 +289,7 @@ const loadCourses = async () => {
|
|||||||
}));
|
}));
|
||||||
pagination.total = data.total || 0;
|
pagination.total = data.total || 0;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response?.data?.message || '获取课程列表失败');
|
message.error(error.message || '获取课程列表失败');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,329 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lesson-card">
|
||||||
|
<div class="lesson-header">
|
||||||
|
<div class="lesson-type-badge" :class="typeClass">
|
||||||
|
<span class="badge-number">{{ index }}</span>
|
||||||
|
<span class="badge-text">{{ lessonType }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="lesson-title">{{ lesson.name }}</div>
|
||||||
|
<div class="lesson-meta">
|
||||||
|
<span class="duration">
|
||||||
|
<ClockCircleOutlined /> {{ lesson.duration }}分钟
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a-button type="link" size="small" @click="$emit('prepare', lesson)">
|
||||||
|
<EditOutlined /> 备课
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lesson-body">
|
||||||
|
<!-- 教学目标 -->
|
||||||
|
<div v-if="lesson.objectives" class="lesson-section">
|
||||||
|
<div class="section-label">
|
||||||
|
<AimOutlined /> 教学目标
|
||||||
|
</div>
|
||||||
|
<div class="section-content">{{ lesson.objectives }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 教学环节 -->
|
||||||
|
<div v-if="lesson.steps && lesson.steps.length > 0" class="lesson-section">
|
||||||
|
<div class="section-label">
|
||||||
|
<OrderedListOutlined /> 教学环节 ({{ lesson.steps.length }})
|
||||||
|
</div>
|
||||||
|
<div class="steps-flow">
|
||||||
|
<div
|
||||||
|
v-for="(step, idx) in lesson.steps"
|
||||||
|
:key="step.id"
|
||||||
|
class="step-item"
|
||||||
|
>
|
||||||
|
<div class="step-dot"></div>
|
||||||
|
<div class="step-name">
|
||||||
|
{{ step.name }}
|
||||||
|
<span v-if="step.duration" class="step-duration">({{ step.duration }}分钟)</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="idx < lesson.steps.length - 1" class="step-arrow">→</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 核心资源 -->
|
||||||
|
<div v-if="hasCoreResources" class="lesson-section">
|
||||||
|
<div class="section-label">
|
||||||
|
<FolderOutlined /> 核心资源
|
||||||
|
</div>
|
||||||
|
<div class="resources-list">
|
||||||
|
<span v-if="lesson.videoPath" class="resource-item video">
|
||||||
|
<VideoCameraOutlined /> 动画视频
|
||||||
|
</span>
|
||||||
|
<span v-if="lesson.pptPath || lesson.pdfPath" class="resource-item document">
|
||||||
|
<FilePptOutlined /> 教学课件
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 教学延伸 -->
|
||||||
|
<div v-if="lesson.extension" class="lesson-section">
|
||||||
|
<div class="section-label">
|
||||||
|
<BranchesOutlined /> 教学延伸
|
||||||
|
</div>
|
||||||
|
<div class="section-content extension">{{ lesson.extension }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lesson-footer">
|
||||||
|
<a-button type="primary" size="small" @click="$emit('start-class', lesson)">
|
||||||
|
<PlayCircleOutlined /> 开始上课
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
ClockCircleOutlined, EditOutlined, AimOutlined, OrderedListOutlined,
|
||||||
|
FolderOutlined, VideoCameraOutlined, FilePptOutlined, BranchesOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lesson: any;
|
||||||
|
courseId: number;
|
||||||
|
lessonType: string;
|
||||||
|
index: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
prepare: [lesson: any];
|
||||||
|
'start-class': [lesson: any];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const typeClass = computed(() => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
'导入课': 'introduction',
|
||||||
|
'集体课': 'collective',
|
||||||
|
'语言领域课': 'language',
|
||||||
|
'健康领域课': 'health',
|
||||||
|
'科学领域课': 'science',
|
||||||
|
'社会领域课': 'social',
|
||||||
|
'艺术领域课': 'art',
|
||||||
|
};
|
||||||
|
return typeMap[props.lessonType] || 'default';
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasCoreResources = computed(() => {
|
||||||
|
return props.lesson.videoPath || props.lesson.pptPath || props.lesson.pdfPath;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lesson-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-card:hover {
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
border-color: #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: linear-gradient(135deg, #FAFAFA 0%, #F5F5F5 100%);
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-type-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-type-badge.introduction {
|
||||||
|
background: linear-gradient(135deg, #E3F2FD 0%, #BBDEFB 100%);
|
||||||
|
color: #1976D2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-type-badge.collective {
|
||||||
|
background: linear-gradient(135deg, #FFF3E0 0%, #FFE0B2 100%);
|
||||||
|
color: #F57C00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-type-badge.language {
|
||||||
|
background: linear-gradient(135deg, #E8F5E9 0%, #C8E6C9 100%);
|
||||||
|
color: #43A047;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-type-badge.health {
|
||||||
|
background: linear-gradient(135deg, #FFEBEE 0%, #FFCDD2 100%);
|
||||||
|
color: #E53935;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-type-badge.science {
|
||||||
|
background: linear-gradient(135deg, #E3F2FD 0%, #90CAF9 100%);
|
||||||
|
color: #1976D2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-type-badge.social {
|
||||||
|
background: linear-gradient(135deg, #F3E5F5 0%, #E1BEE7 100%);
|
||||||
|
color: #8E24AA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-type-badge.art {
|
||||||
|
background: linear-gradient(135deg, #FFF8E1 0%, #FFECB3 100%);
|
||||||
|
color: #FFA000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-type-badge.default {
|
||||||
|
background: linear-gradient(135deg, #F5F5F5 0%, #E0E0E0 100%);
|
||||||
|
color: #616161;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-number {
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-section {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #FAFAFA;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-content.extension {
|
||||||
|
background: #FFF8F0;
|
||||||
|
border-left: 3px solid #FF8C42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-flow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-duration {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-arrow {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-item.video {
|
||||||
|
background: #FFF3E0;
|
||||||
|
color: #F57C00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-item.document {
|
||||||
|
background: #E3F2FD;
|
||||||
|
color: #1976D2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-footer {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,567 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prepare-navigation">
|
||||||
|
<!-- 课程包概览 -->
|
||||||
|
<div class="nav-section">
|
||||||
|
<div
|
||||||
|
class="nav-title"
|
||||||
|
:class="{ active: selectedSection === 'overview' }"
|
||||||
|
@click="handleSelectSection('overview')"
|
||||||
|
>
|
||||||
|
<ClipboardList :size="18" :stroke-width="2" class="title-icon" />
|
||||||
|
课程包概览
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedSection === 'overview'" class="nav-items">
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'basic' }"
|
||||||
|
@click.stop="handleSelectItem('basic')"
|
||||||
|
>
|
||||||
|
<span class="item-dot"></span>
|
||||||
|
基本信息
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'intro' }"
|
||||||
|
@click.stop="handleSelectItem('intro')"
|
||||||
|
>
|
||||||
|
<span class="item-dot"></span>
|
||||||
|
课程介绍
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'schedule' }"
|
||||||
|
@click.stop="handleSelectItem('schedule')"
|
||||||
|
>
|
||||||
|
<span class="item-dot"></span>
|
||||||
|
排课计划参考
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'environment' }"
|
||||||
|
@click.stop="handleSelectItem('environment')"
|
||||||
|
>
|
||||||
|
<span class="item-dot"></span>
|
||||||
|
环创建设
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 包含课程 -->
|
||||||
|
<div class="nav-section">
|
||||||
|
<div class="nav-title">
|
||||||
|
<BookOpen :size="18" :stroke-width="2" class="title-icon" />
|
||||||
|
包含课程 ({{ lessons.length }})
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-items">
|
||||||
|
<!-- 导入课 -->
|
||||||
|
<div
|
||||||
|
v-if="introductionLesson"
|
||||||
|
class="nav-lesson"
|
||||||
|
:class="{ active: selectedLessonId === introductionLesson.id }"
|
||||||
|
@click="handleSelectLesson(introductionLesson)"
|
||||||
|
>
|
||||||
|
<div class="lesson-header">
|
||||||
|
<BookOpen :size="16" :stroke-width="2" class="lesson-icon" />
|
||||||
|
<span class="lesson-name">导入课</span>
|
||||||
|
<span class="lesson-time">{{ introductionLesson.duration }}分钟</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedLessonId === introductionLesson.id" class="lesson-items">
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'resources' }"
|
||||||
|
@click.stop="handleSelectItem('resources')"
|
||||||
|
>
|
||||||
|
<FolderOutlined /> 核心资源
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'objectives' }"
|
||||||
|
@click.stop="handleSelectItem('objectives')"
|
||||||
|
>
|
||||||
|
<AimOutlined /> 教学目标
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'preparation' }"
|
||||||
|
@click.stop="handleSelectItem('preparation')"
|
||||||
|
>
|
||||||
|
<ToolOutlined /> 教学准备
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'steps' }"
|
||||||
|
@click.stop="handleSelectItem('steps')"
|
||||||
|
>
|
||||||
|
<OrderedListOutlined /> 教学过程 ({{ introductionLesson.steps?.length || 0 }})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'reflection' }"
|
||||||
|
@click.stop="handleSelectItem('reflection')"
|
||||||
|
>
|
||||||
|
<FileTextOutlined /> 教学反思
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 集体课 -->
|
||||||
|
<div
|
||||||
|
v-if="collectiveLesson"
|
||||||
|
class="nav-lesson"
|
||||||
|
:class="{ active: selectedLessonId === collectiveLesson.id }"
|
||||||
|
@click="handleSelectLesson(collectiveLesson)"
|
||||||
|
>
|
||||||
|
<div class="lesson-header">
|
||||||
|
<Users :size="16" :stroke-width="2" class="lesson-icon" />
|
||||||
|
<span class="lesson-name">集体课</span>
|
||||||
|
<span class="lesson-time">{{ collectiveLesson.duration }}分钟</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedLessonId === collectiveLesson.id" class="lesson-items">
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'resources' }"
|
||||||
|
@click.stop="handleSelectItem('resources')"
|
||||||
|
>
|
||||||
|
<FolderOutlined /> 核心资源
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'objectives' }"
|
||||||
|
@click.stop="handleSelectItem('objectives')"
|
||||||
|
>
|
||||||
|
<AimOutlined /> 教学目标
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'preparation' }"
|
||||||
|
@click.stop="handleSelectItem('preparation')"
|
||||||
|
>
|
||||||
|
<ToolOutlined /> 教学准备
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'steps' }"
|
||||||
|
@click.stop="handleSelectItem('steps')"
|
||||||
|
>
|
||||||
|
<OrderedListOutlined /> 教学过程 ({{ collectiveLesson.steps?.length || 0 }})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'extension' }"
|
||||||
|
@click.stop="handleSelectItem('extension')"
|
||||||
|
>
|
||||||
|
<BranchesOutlined /> 教学延伸
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'reflection' }"
|
||||||
|
@click.stop="handleSelectItem('reflection')"
|
||||||
|
>
|
||||||
|
<FileTextOutlined /> 教学反思
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 五大领域课 -->
|
||||||
|
<div
|
||||||
|
v-for="lesson in domainLessons"
|
||||||
|
:key="lesson.id"
|
||||||
|
class="nav-lesson"
|
||||||
|
:class="{ active: selectedLessonId === lesson.id }"
|
||||||
|
@click="handleSelectLesson(lesson)"
|
||||||
|
>
|
||||||
|
<div class="lesson-header">
|
||||||
|
<component :is="getLessonIcon(lesson.lessonType)" :size="16" :stroke-width="2" class="lesson-icon" />
|
||||||
|
<span class="lesson-name">{{ getLessonTypeName(lesson.lessonType) }}</span>
|
||||||
|
<span class="lesson-time">{{ lesson.duration }}分钟</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedLessonId === lesson.id" class="lesson-items">
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'resources' }"
|
||||||
|
@click.stop="handleSelectItem('resources')"
|
||||||
|
>
|
||||||
|
<FolderOutlined /> 核心资源
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'objectives' }"
|
||||||
|
@click.stop="handleSelectItem('objectives')"
|
||||||
|
>
|
||||||
|
<AimOutlined /> 教学目标
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'preparation' }"
|
||||||
|
@click.stop="handleSelectItem('preparation')"
|
||||||
|
>
|
||||||
|
<ToolOutlined /> 教学准备
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'steps' }"
|
||||||
|
@click.stop="handleSelectItem('steps')"
|
||||||
|
>
|
||||||
|
<OrderedListOutlined /> 教学过程 ({{ lesson.steps?.length || 0 }})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'extension' }"
|
||||||
|
@click.stop="handleSelectItem('extension')"
|
||||||
|
>
|
||||||
|
<BranchesOutlined /> 教学延伸
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: selectedItem === 'reflection' }"
|
||||||
|
@click.stop="handleSelectItem('reflection')"
|
||||||
|
>
|
||||||
|
<FileTextOutlined /> 教学反思
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 备课笔记区域 -->
|
||||||
|
<div class="notes-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<FileEdit :size="18" :stroke-width="2" class="header-icon" />
|
||||||
|
我的备课笔记
|
||||||
|
<a-button type="link" size="small" @click="saveNotes" class="save-btn">
|
||||||
|
<SaveOutlined /> 保存
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
<div class="notes-body">
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="myNotes"
|
||||||
|
placeholder="在这里记录您的备课笔记、教学心得或需要特别注意的事项..."
|
||||||
|
:auto-size="{ minRows: 6, maxRows: 10 }"
|
||||||
|
class="notes-textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="notes-footer">
|
||||||
|
<a-button size="small" @click="clearNotes">
|
||||||
|
<ClearOutlined /> 清除
|
||||||
|
</a-button>
|
||||||
|
<a-button size="small" @click="printNotes">
|
||||||
|
<PrinterOutlined /> 打印素材清单
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
AimOutlined, ToolOutlined, OrderedListOutlined, FileTextOutlined,
|
||||||
|
FolderOutlined, BranchesOutlined, SaveOutlined, ClearOutlined,
|
||||||
|
PrinterOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import {
|
||||||
|
ClipboardList,
|
||||||
|
BookOpen,
|
||||||
|
FileEdit,
|
||||||
|
Users,
|
||||||
|
MessageCircle,
|
||||||
|
Activity,
|
||||||
|
Microscope,
|
||||||
|
Handshake,
|
||||||
|
Palette,
|
||||||
|
Layers,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
course: any;
|
||||||
|
lessons: any[];
|
||||||
|
selectedSection: 'overview' | 'lesson';
|
||||||
|
selectedLessonId: number | null;
|
||||||
|
selectedItem: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-section': [section: 'overview' | 'lesson'];
|
||||||
|
'select-lesson': [lesson: any];
|
||||||
|
'select-item': [item: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// 按类型分组课程
|
||||||
|
const introductionLesson = computed(() =>
|
||||||
|
props.lessons?.find((l: any) => l.lessonType === 'INTRO')
|
||||||
|
);
|
||||||
|
|
||||||
|
const collectiveLesson = computed(() =>
|
||||||
|
props.lessons?.find((l: any) => l.lessonType === 'COLLECTIVE')
|
||||||
|
);
|
||||||
|
|
||||||
|
const domainLessons = computed(() =>
|
||||||
|
props.lessons?.filter((l: any) =>
|
||||||
|
['DOMAIN_LANGUAGE', 'DOMAIN_HEALTH', 'DOMAIN_SCIENCE', 'DOMAIN_SOCIAL', 'DOMAIN_ART'].includes(l.lessonType)
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
|
||||||
|
// 备课笔记
|
||||||
|
const myNotes = ref('');
|
||||||
|
const courseId = computed(() => props.course?.id);
|
||||||
|
|
||||||
|
// 保存/加载笔记
|
||||||
|
watch(() => props.course?.id, (newId) => {
|
||||||
|
if (newId) {
|
||||||
|
const saved = localStorage.getItem(`notes_${newId}`);
|
||||||
|
if (saved) {
|
||||||
|
myNotes.value = saved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
const saveNotes = () => {
|
||||||
|
if (courseId.value) {
|
||||||
|
localStorage.setItem(`notes_${courseId.value}`, myNotes.value);
|
||||||
|
message.success('笔记已保存');
|
||||||
|
}
|
||||||
|
emit('select-item', 'notes');
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearNotes = () => {
|
||||||
|
myNotes.value = '';
|
||||||
|
message.info('笔记已清除');
|
||||||
|
};
|
||||||
|
|
||||||
|
const printNotes = () => {
|
||||||
|
message.info('打印功能开发中');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectSection = (section: 'overview' | 'lesson') => {
|
||||||
|
emit('select-section', section);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectLesson = (lesson: any) => {
|
||||||
|
emit('select-lesson', lesson);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectItem = (item: string) => {
|
||||||
|
emit('select-item', item);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLessonIcon = (type: string): any => {
|
||||||
|
const iconMap: Record<string, any> = {
|
||||||
|
'INTRO': BookOpen,
|
||||||
|
'COLLECTIVE': Users,
|
||||||
|
'DOMAIN_LANGUAGE': MessageCircle,
|
||||||
|
'DOMAIN_HEALTH': Activity,
|
||||||
|
'DOMAIN_SCIENCE': Microscope,
|
||||||
|
'DOMAIN_SOCIAL': Handshake,
|
||||||
|
'DOMAIN_ART': Palette,
|
||||||
|
};
|
||||||
|
return iconMap[type] || Layers;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLessonTypeName = (type: string): string => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
'INTRO': '导入课',
|
||||||
|
'COLLECTIVE': '集体课',
|
||||||
|
'DOMAIN_LANGUAGE': '语言领域课',
|
||||||
|
'DOMAIN_HEALTH': '健康领域课',
|
||||||
|
'DOMAIN_SCIENCE': '科学领域课',
|
||||||
|
'DOMAIN_SOCIAL': '社会领域课',
|
||||||
|
'DOMAIN_ART': '艺术领域课',
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prepare-navigation {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title:hover {
|
||||||
|
background: #F5F5F5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-title.active {
|
||||||
|
background: linear-gradient(90deg, #FFF5EB 0%, #FFF 100%);
|
||||||
|
color: #FF8C42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-icon {
|
||||||
|
color: #FF8C42;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-items {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px 10px 28px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d9d9d9;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: #F5F5F5;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
background: linear-gradient(90deg, #FFF5EB 0%, #FFF 100%);
|
||||||
|
color: #FF8C42;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active::before {
|
||||||
|
background: #FF8C42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-lesson {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-lesson:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-header:hover {
|
||||||
|
background: #F9F9F9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-header.active {
|
||||||
|
background: linear-gradient(90deg, #FFF5EB 0%, #FFF 100%);
|
||||||
|
color: #FF8C42;
|
||||||
|
border-left: 3px solid #FF8C42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-icon {
|
||||||
|
color: #FF8C42;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-name {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-items {
|
||||||
|
background: #FAFAFA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.lesson-item {
|
||||||
|
padding-left: 40px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 备课笔记区域 */
|
||||||
|
.notes-section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-icon {
|
||||||
|
color: #FF8C42;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
color: #FF8C42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-textarea {
|
||||||
|
border: 1px solid #E8E8E8;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-textarea:focus {
|
||||||
|
border-color: #FF8C42;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 140, 66, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
background: #FAFAFA;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,226 @@
|
|||||||
|
<template>
|
||||||
|
<div class="prepare-preview">
|
||||||
|
<a-spin :spinning="loading">
|
||||||
|
<!-- 课程包概览内容 -->
|
||||||
|
<template v-if="selectedType === 'overview'">
|
||||||
|
<div class="preview-header">
|
||||||
|
<h2 class="preview-title">{{ getOverviewTitle() }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<div v-if="selectedItem === 'basic'" class="preview-content">
|
||||||
|
<CourseBasicInfo :course="course" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 课程介绍 -->
|
||||||
|
<div v-else-if="selectedItem === 'intro'" class="preview-content">
|
||||||
|
<CourseIntroContent :course="course" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 排课计划参考 -->
|
||||||
|
<div v-else-if="selectedItem === 'schedule'" class="preview-content">
|
||||||
|
<CourseScheduleContent :course="course" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 环创建设 -->
|
||||||
|
<div v-else-if="selectedItem === 'environment'" class="preview-content">
|
||||||
|
<CourseEnvironmentContent :course="course" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 课程内容 -->
|
||||||
|
<template v-else-if="selectedType === 'lesson' && selectedLesson">
|
||||||
|
<div class="preview-header">
|
||||||
|
<h2 class="preview-title">{{ getLessonTitle() }}</h2>
|
||||||
|
<a-tag :color="getLessonTypeColor(selectedLesson.lessonType)" class="lesson-type-tag">
|
||||||
|
{{ getLessonTypeName(selectedLesson.lessonType) }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 核心资源(集体课和领域课) -->
|
||||||
|
<div v-if="selectedItem === 'resources' && hasResources" class="preview-content">
|
||||||
|
<LessonResourcesContent
|
||||||
|
:lesson="selectedLesson"
|
||||||
|
@preview-resource="(type, resource) => emit('preview-resource', type, resource)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 教学目标 -->
|
||||||
|
<div v-else-if="selectedItem === 'objectives'" class="preview-content">
|
||||||
|
<LessonObjectivesContent :lesson="selectedLesson" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 教学准备 -->
|
||||||
|
<div v-else-if="selectedItem === 'preparation'" class="preview-content">
|
||||||
|
<LessonPreparationContent :lesson="selectedLesson" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 教学过程 -->
|
||||||
|
<div v-else-if="selectedItem === 'steps'" class="preview-content">
|
||||||
|
<LessonStepsContent
|
||||||
|
:lesson="selectedLesson"
|
||||||
|
:selected-step="selectedStep"
|
||||||
|
@select-step="handleSelectStep"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 教学延伸 -->
|
||||||
|
<div v-else-if="selectedItem === 'extension'" class="preview-content">
|
||||||
|
<LessonExtensionContent :lesson="selectedLesson" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 教学反思 -->
|
||||||
|
<div v-else-if="selectedItem === 'reflection'" class="preview-content">
|
||||||
|
<LessonReflectionContent :lesson="selectedLesson" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<a-empty v-else description="请选择要查看的内容" />
|
||||||
|
</a-spin>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import CourseBasicInfo from './content/CourseBasicInfo.vue';
|
||||||
|
import CourseIntroContent from './content/CourseIntroContent.vue';
|
||||||
|
import CourseScheduleContent from './content/CourseScheduleContent.vue';
|
||||||
|
import CourseEnvironmentContent from './content/CourseEnvironmentContent.vue';
|
||||||
|
import LessonResourcesContent from './content/LessonResourcesContent.vue';
|
||||||
|
import LessonObjectivesContent from './content/LessonObjectivesContent.vue';
|
||||||
|
import LessonPreparationContent from './content/LessonPreparationContent.vue';
|
||||||
|
import LessonStepsContent from './content/LessonStepsContent.vue';
|
||||||
|
import LessonExtensionContent from './content/LessonExtensionContent.vue';
|
||||||
|
import LessonReflectionContent from './content/LessonReflectionContent.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
course: any;
|
||||||
|
selectedType: 'overview' | 'lesson';
|
||||||
|
selectedLesson: any;
|
||||||
|
selectedItem: string;
|
||||||
|
selectedStep: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-step': [step: any];
|
||||||
|
'preview-resource': [type: string, resource: any];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
// 是否有核心资源 - 导入课、集体课、领域课都有资源
|
||||||
|
const hasResources = computed(() => {
|
||||||
|
if (!props.selectedLesson) return false;
|
||||||
|
const type = props.selectedLesson.lessonType;
|
||||||
|
return type === 'INTRO' || type === 'COLLECTIVE' ||
|
||||||
|
type.startsWith('DOMAIN_') || ['LANGUAGE', 'HEALTH', 'SCIENCE', 'SOCIAL', 'ART'].includes(type);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取概览标题
|
||||||
|
const getOverviewTitle = (): string => {
|
||||||
|
const titles: Record<string, string> = {
|
||||||
|
'basic': '基本信息',
|
||||||
|
'intro': '课程介绍',
|
||||||
|
'schedule': '排课计划参考',
|
||||||
|
'environment': '环创建设',
|
||||||
|
};
|
||||||
|
return titles[props.selectedItem] || '课程包概览';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取课程标题
|
||||||
|
const getLessonTitle = (): string => {
|
||||||
|
const titles: Record<string, string> = {
|
||||||
|
'resources': '核心资源',
|
||||||
|
'objectives': '教学目标',
|
||||||
|
'preparation': '教学准备',
|
||||||
|
'steps': '教学过程',
|
||||||
|
'extension': '教学延伸',
|
||||||
|
'reflection': '教学反思',
|
||||||
|
};
|
||||||
|
return titles[props.selectedItem] || '课程内容';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取课程类型名称
|
||||||
|
const getLessonTypeName = (type: string): string => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
'INTRODUCTION': '导入课',
|
||||||
|
'COLLECTIVE': '集体课',
|
||||||
|
'LANGUAGE': '语言领域课',
|
||||||
|
'HEALTH': '健康领域课',
|
||||||
|
'SCIENCE': '科学领域课',
|
||||||
|
'SOCIAL': '社会领域课',
|
||||||
|
'ART': '艺术领域课',
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取课程类型颜色
|
||||||
|
const getLessonTypeColor = (type: string): string => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
'INTRODUCTION': 'orange',
|
||||||
|
'COLLECTIVE': 'green',
|
||||||
|
'LANGUAGE': 'blue',
|
||||||
|
'HEALTH': 'red',
|
||||||
|
'SCIENCE': 'purple',
|
||||||
|
'SOCIAL': 'cyan',
|
||||||
|
'ART': 'pink',
|
||||||
|
};
|
||||||
|
return colorMap[type] || 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectStep = (step: any) => {
|
||||||
|
emit('select-step', step);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.prepare-preview {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
min-height: 600px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
background: linear-gradient(90deg, #FFF5EB 0%, #FFF 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-type-tag {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content::-webkit-scrollbar-thumb {
|
||||||
|
background: #d9d9d9;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-content::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #bfbfbf;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,357 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
v-model:open="visible"
|
||||||
|
title="选择上课内容"
|
||||||
|
width="680px"
|
||||||
|
:footer="null"
|
||||||
|
>
|
||||||
|
<div class="select-lessons-modal">
|
||||||
|
<div class="course-info">
|
||||||
|
<strong>课程包:</strong>{{ course.name || '未知课程' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-divider style="margin: 16px 0;" />
|
||||||
|
|
||||||
|
<!-- 推荐:整体教学 -->
|
||||||
|
<div class="section">
|
||||||
|
<h4 class="section-title">【推荐:整体教学】</h4>
|
||||||
|
<a-radio-group v-model:value="selectionMode" class="radio-group">
|
||||||
|
<a-radio value="all" class="radio-option">
|
||||||
|
<div class="option-card">
|
||||||
|
<div class="option-header">
|
||||||
|
<span class="option-title">按课程包完整教学</span>
|
||||||
|
<a-tag color="green" class="option-tag">推荐</a-tag>
|
||||||
|
</div>
|
||||||
|
<div class="option-desc">
|
||||||
|
按顺序完成:导入课 → 集体课 → 五大领域课
|
||||||
|
</div>
|
||||||
|
<div class="option-meta">
|
||||||
|
<ClockCircleOutlined /> 预计总时长:约 {{ totalDuration }} 分钟(可分多次完成)
|
||||||
|
</div>
|
||||||
|
<div class="option-hint">适合:首次教学、完整学习</div>
|
||||||
|
</div>
|
||||||
|
</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 灵活:选择课程 -->
|
||||||
|
<div class="section">
|
||||||
|
<h4 class="section-title">【灵活:选择课程】</h4>
|
||||||
|
<a-radio-group v-model:value="selectionMode" class="radio-group">
|
||||||
|
<a-radio value="custom" class="radio-option">
|
||||||
|
<div class="option-card">
|
||||||
|
<div class="option-title">选择单次课程</div>
|
||||||
|
</div>
|
||||||
|
</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
|
||||||
|
<div v-if="selectionMode === 'custom'" class="lesson-checkboxes">
|
||||||
|
<a-checkbox-group v-model:value="selectedLessonIds" class="checkbox-group">
|
||||||
|
<!-- 导入课 -->
|
||||||
|
<div v-if="introductionLesson" class="lesson-checkbox-item">
|
||||||
|
<a-checkbox :value="introductionLesson.id" class="lesson-checkbox">
|
||||||
|
<div class="checkbox-content">
|
||||||
|
<span class="lesson-number">1.</span>
|
||||||
|
<span class="lesson-type-text">导入课</span>
|
||||||
|
<span class="lesson-name-text">{{ introductionLesson.name }}</span>
|
||||||
|
<span class="lesson-duration-text">({{ introductionLesson.duration }}分钟)</span>
|
||||||
|
</div>
|
||||||
|
</a-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 集体课 -->
|
||||||
|
<div v-if="collectiveLesson" class="lesson-checkbox-item">
|
||||||
|
<a-checkbox :value="collectiveLesson.id" class="lesson-checkbox">
|
||||||
|
<div class="checkbox-content">
|
||||||
|
<span class="lesson-number">2.</span>
|
||||||
|
<span class="lesson-type-text">集体课</span>
|
||||||
|
<span class="lesson-name-text">{{ collectiveLesson.name }}</span>
|
||||||
|
<span class="lesson-duration-text">({{ collectiveLesson.duration }}分钟)</span>
|
||||||
|
</div>
|
||||||
|
</a-checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 五大领域课 -->
|
||||||
|
<div v-for="(lesson, idx) in domainLessons" :key="lesson.id" class="lesson-checkbox-item">
|
||||||
|
<a-checkbox :value="lesson.id" class="lesson-checkbox">
|
||||||
|
<div class="checkbox-content">
|
||||||
|
<span class="lesson-number">{{ 3 + idx }}.</span>
|
||||||
|
<span class="lesson-type-text">{{ getLessonTypeName(lesson.lessonType) }}</span>
|
||||||
|
<span class="lesson-name-text">{{ lesson.name }}</span>
|
||||||
|
<span class="lesson-duration-text">({{ lesson.duration }}分钟)</span>
|
||||||
|
</div>
|
||||||
|
</a-checkbox>
|
||||||
|
</div>
|
||||||
|
</a-checkbox-group>
|
||||||
|
|
||||||
|
<div class="selection-summary">
|
||||||
|
<a-alert
|
||||||
|
:message="`已选择 ${selectedLessonIds.length} 节课,预计时长:${selectedDuration} 分钟`"
|
||||||
|
:type="selectedLessonIds.length > 0 ? 'success' : 'info'"
|
||||||
|
show-icon
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-divider style="margin: 20px 0;" />
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<a-button @click="handleCancel">取消</a-button>
|
||||||
|
<a-button type="primary" :disabled="!canStart" @click="handleConfirm">
|
||||||
|
开始上课
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { ClockCircleOutlined } from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
course: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean];
|
||||||
|
'confirm': [lessonIds: number[], mode: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.open,
|
||||||
|
set: (val) => emit('update:open', val),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectionMode = ref<'all' | 'custom'>('all');
|
||||||
|
const selectedLessonIds = ref<number[]>([]);
|
||||||
|
|
||||||
|
// 按类型分组课程
|
||||||
|
const introductionLesson = computed(() =>
|
||||||
|
props.course.courseLessons?.find((l: any) => l.lessonType === 'INTRODUCTION')
|
||||||
|
);
|
||||||
|
|
||||||
|
const collectiveLesson = computed(() =>
|
||||||
|
props.course.courseLessons?.find((l: any) => l.lessonType === 'COLLECTIVE')
|
||||||
|
);
|
||||||
|
|
||||||
|
const domainLessons = computed(() =>
|
||||||
|
props.course.courseLessons?.filter((l: any) =>
|
||||||
|
['LANGUAGE', 'HEALTH', 'SCIENCE', 'SOCIAL', 'ART'].includes(l.lessonType)
|
||||||
|
) || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalDuration = computed(() => {
|
||||||
|
return props.course.courseLessons?.reduce((sum: number, l: any) => sum + (l.duration || 0), 0) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedDuration = computed(() => {
|
||||||
|
return props.course.courseLessons
|
||||||
|
?.filter((l: any) => selectedLessonIds.value.includes(l.id))
|
||||||
|
.reduce((sum: number, l: any) => sum + (l.duration || 0), 0) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canStart = computed(() => {
|
||||||
|
if (selectionMode.value === 'all') return true;
|
||||||
|
return selectedLessonIds.value.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getLessonTypeName = (type: string): string => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
'INTRODUCTION': '导入课',
|
||||||
|
'COLLECTIVE': '集体课',
|
||||||
|
'LANGUAGE': '语言领域课',
|
||||||
|
'HEALTH': '健康领域课',
|
||||||
|
'SCIENCE': '科学领域课',
|
||||||
|
'SOCIAL': '社会领域课',
|
||||||
|
'ART': '艺术领域课',
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (selectionMode.value === 'all') {
|
||||||
|
const allIds = props.course.courseLessons?.map((l: any) => l.id) || [];
|
||||||
|
emit('confirm', allIds, 'all');
|
||||||
|
} else {
|
||||||
|
emit('confirm', selectedLessonIds.value, 'custom');
|
||||||
|
}
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
watch(() => props.open, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
selectionMode.value = 'all';
|
||||||
|
selectedLessonIds.value = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.select-lessons-modal {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-info {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-group {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-option {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-card:hover {
|
||||||
|
border-color: #d9d9d9;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-tag {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-checkboxes {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #FAFAFA;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-group {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-checkbox-item {
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-checkbox-item:hover {
|
||||||
|
border-color: #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-checkbox {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-number {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #999;
|
||||||
|
min-width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-type-text {
|
||||||
|
color: #666;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-name-text {
|
||||||
|
flex: 1;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lesson-duration-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-summary {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-summary :deep(.ant-alert) {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-summary :deep(.ant-alert-message) {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div class="course-basic-info">
|
||||||
|
<div class="info-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<InfoCircleOutlined /> 基本信息
|
||||||
|
</div>
|
||||||
|
<a-descriptions bordered :column="2" class="info-descriptions">
|
||||||
|
<a-descriptions-item label="课程名称">
|
||||||
|
{{ course.name || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="关联绘本">
|
||||||
|
{{ course.pictureBookName || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="课程主题">
|
||||||
|
{{ course.theme?.name || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="预计时长">
|
||||||
|
{{ totalDuration }} 分钟
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="适用年级" :span="2">
|
||||||
|
<a-tag v-for="tag in translatedGradeTags" :key="tag" class="grade-tag">
|
||||||
|
{{ tag }}
|
||||||
|
</a-tag>
|
||||||
|
<span v-if="!translatedGradeTags.length">-</span>
|
||||||
|
</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="核心内容" :span="2">
|
||||||
|
{{ course.coreContent || '-' }}
|
||||||
|
</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 课程封面 -->
|
||||||
|
<div v-if="course.coverImagePath" class="cover-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<PictureOutlined /> 课程封面
|
||||||
|
</div>
|
||||||
|
<div class="cover-preview">
|
||||||
|
<img :src="getFileUrl(course.coverImagePath)" alt="课程封面" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 课程统计 -->
|
||||||
|
<div class="stats-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<BarChartOutlined /> 课程统计
|
||||||
|
</div>
|
||||||
|
<a-row :gutter="16" class="stats-grid">
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-statistic title="课程数量" :value="lessonCount" suffix="节" />
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-statistic title="预计时长" :value="totalDuration" suffix="分钟" />
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-statistic title="教师使用" :value="course.teacherCount || 0" suffix="位" />
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="6">
|
||||||
|
<a-statistic title="评分" :value="course.avgRating?.toFixed(1) || '5.0'" suffix="/5" />
|
||||||
|
</a-col>
|
||||||
|
</a-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
InfoCircleOutlined, PictureOutlined, BarChartOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { translateGradeTags } from '@/utils/tagMaps';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
course: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const translatedGradeTags = computed(() => {
|
||||||
|
return translateGradeTags(props.course.gradeTags || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalDuration = computed(() => {
|
||||||
|
return props.course.courseLessons?.reduce((sum: number, l: any) => sum + (l.duration || 0), 0) || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lessonCount = computed(() => {
|
||||||
|
return props.course.courseLessons?.length || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.course-basic-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section,
|
||||||
|
.cover-section,
|
||||||
|
.stats-section {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-descriptions {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-descriptions :deep(.ant-descriptions-item-label) {
|
||||||
|
font-weight: 500;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-tag {
|
||||||
|
background: linear-gradient(135deg, #E3F2FD 0%, #BBDEFB 100%);
|
||||||
|
color: #1976D2;
|
||||||
|
border: none;
|
||||||
|
padding: 2px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-preview {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
background: #FAFAFA;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid :deep(.ant-statistic-title) {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid :deep(.ant-statistic-content) {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #FF8C42;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div class="course-environment-content">
|
||||||
|
<div v-if="course.environmentConstruction" class="environment-wrapper">
|
||||||
|
<a-alert
|
||||||
|
message="环创建设建议"
|
||||||
|
description="以下内容提供了主题环境布置、区域活动环境、阅读角创设等方面的建议"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
class="environment-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="environment-content rich-content" v-html="course.environmentConstruction"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty v-else description="暂无环创建设内容" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
course: any;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.course-environment-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.environment-alert {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.environment-wrapper {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.environment-content {
|
||||||
|
padding: 24px;
|
||||||
|
background: linear-gradient(135deg, #F9F9F9 0%, #F5F5F5 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
border-left: 4px solid #52c41a;
|
||||||
|
line-height: 2;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(h1),
|
||||||
|
.rich-content :deep(h2),
|
||||||
|
.rich-content :deep(h3) {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(ul),
|
||||||
|
.rich-content :deep(ol) {
|
||||||
|
padding-left: 28px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(li) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(strong) {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,149 @@
|
|||||||
|
<template>
|
||||||
|
<div class="course-intro-content">
|
||||||
|
<!-- 核心内容 -->
|
||||||
|
<div v-if="course.coreContent" class="content-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<FileTextOutlined /> 核心内容
|
||||||
|
</div>
|
||||||
|
<div class="section-text">{{ course.coreContent }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 课程简介 -->
|
||||||
|
<div v-if="course.introSummary" class="content-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<AlignLeftOutlined /> 课程简介
|
||||||
|
</div>
|
||||||
|
<div class="section-text rich-content" v-html="course.introSummary"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 课程亮点 -->
|
||||||
|
<div v-if="course.introHighlights" class="content-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<StarOutlined /> 课程亮点
|
||||||
|
</div>
|
||||||
|
<div class="section-text rich-content" v-html="course.introHighlights"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 课程总目标 -->
|
||||||
|
<div v-if="course.introGoals" class="content-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<AimOutlined /> 课程总目标
|
||||||
|
</div>
|
||||||
|
<div class="section-text rich-content" v-html="course.introGoals"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 课程内容安排 -->
|
||||||
|
<div v-if="course.introSchedule" class="content-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<CalendarOutlined /> 课程内容安排
|
||||||
|
</div>
|
||||||
|
<div class="section-text rich-content" v-html="course.introSchedule"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 教学重难点 -->
|
||||||
|
<div v-if="course.introKeyPoints" class="content-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<BookOutlined /> 教学重难点
|
||||||
|
</div>
|
||||||
|
<div class="section-text rich-content" v-html="course.introKeyPoints"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 教学方法 -->
|
||||||
|
<div v-if="course.introMethods" class="content-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<ToolOutlined /> 教学方法
|
||||||
|
</div>
|
||||||
|
<div class="section-text rich-content" v-html="course.introMethods"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 评价方式 -->
|
||||||
|
<div v-if="course.introEvaluation" class="content-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<CheckCircleOutlined /> 评价方式
|
||||||
|
</div>
|
||||||
|
<div class="section-text rich-content" v-html="course.introEvaluation"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 注意事项 -->
|
||||||
|
<div v-if="course.introNotes" class="content-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<ExclamationCircleOutlined /> 注意事项
|
||||||
|
</div>
|
||||||
|
<div class="section-text rich-content" v-html="course.introNotes"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty v-if="!course.coreContent && !course.introSummary && !hasMoreIntro" description="暂无课程介绍内容" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
FileTextOutlined, AlignLeftOutlined, StarOutlined, AimOutlined,
|
||||||
|
CalendarOutlined, BookOutlined, ToolOutlined, CheckCircleOutlined,
|
||||||
|
ExclamationCircleOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
course: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const hasMoreIntro = computed(() =>
|
||||||
|
props.course.introHighlights || props.course.introGoals || props.course.introSchedule ||
|
||||||
|
props.course.introKeyPoints || props.course.introMethods || props.course.introEvaluation || props.course.introNotes
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.course-intro-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-section {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content {
|
||||||
|
padding: 16px;
|
||||||
|
background: #F9F9F9;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid #FF8C42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(ul),
|
||||||
|
.rich-content :deep(ol) {
|
||||||
|
padding-left: 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(li) {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="course-schedule-content">
|
||||||
|
<div v-if="scheduleData.length > 0" class="schedule-table-wrapper">
|
||||||
|
<a-alert
|
||||||
|
message="以下为推荐的排课计划参考,可根据实际情况调整"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
class="schedule-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-table
|
||||||
|
:columns="scheduleColumns"
|
||||||
|
:data-source="scheduleData"
|
||||||
|
:pagination="false"
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
class="schedule-table"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty v-else description="暂无排课计划参考" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
course: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const scheduleColumns = [
|
||||||
|
{ title: '时间', dataIndex: 'dayOfWeek', key: 'dayOfWeek', width: 100 },
|
||||||
|
{ title: '课程类型', dataIndex: 'lessonType', key: 'lessonType', width: 120 },
|
||||||
|
{ title: '课程名称', dataIndex: 'lessonName', key: 'lessonName' },
|
||||||
|
{ title: '区域活动', dataIndex: 'activity', key: 'activity' },
|
||||||
|
{ title: '备注', dataIndex: 'note', key: 'note' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const scheduleData = computed(() => {
|
||||||
|
if (!props.course.scheduleRefData) return [];
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(props.course.scheduleRefData);
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.course-schedule-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-table-wrapper {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-table {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-table :deep(.ant-table-thead > tr > th) {
|
||||||
|
background: linear-gradient(135deg, #FFF5EB 0%, #FFE8D6 100%);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-table :deep(.ant-table-tbody > tr:hover) {
|
||||||
|
background: #FFF9F5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-table :deep(.ant-table-cell) {
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lesson-extension-content">
|
||||||
|
<div v-if="lesson.extensionActivities" class="extension-wrapper">
|
||||||
|
<a-alert
|
||||||
|
message="教学延伸"
|
||||||
|
description="课后延伸活动建议,巩固和拓展教学效果"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
class="extension-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="extension-content rich-content" v-html="lesson.extensionActivities"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty v-else description="暂无教学延伸内容" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
lesson: any;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lesson-extension-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-alert {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-wrapper {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.extension-content {
|
||||||
|
padding: 24px;
|
||||||
|
background: linear-gradient(135deg, #F6FFED 0%, #F9F9F9 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
border-left: 4px solid #52c41a;
|
||||||
|
line-height: 2;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(ul),
|
||||||
|
.rich-content :deep(ol) {
|
||||||
|
padding-left: 28px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(li) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(strong) {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(h3) {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lesson-objectives-content">
|
||||||
|
<div v-if="lesson.objectives" class="objectives-wrapper">
|
||||||
|
<a-alert
|
||||||
|
message="教学目标"
|
||||||
|
description="本课程的教学目标,包含认知、技能、情感三个维度"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
class="objectives-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="objectives-content rich-content" v-html="lesson.objectives"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty v-else description="暂无教学目标" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
lesson: any;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lesson-objectives-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objectives-alert {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objectives-wrapper {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.objectives-content {
|
||||||
|
padding: 24px;
|
||||||
|
background: linear-gradient(135deg, #FFF9F5 0%, #FFF 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
border-left: 4px solid #FF8C42;
|
||||||
|
line-height: 2;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(ul),
|
||||||
|
.rich-content :deep(ol) {
|
||||||
|
padding-left: 28px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(li) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(strong) {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(h3) {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #FF8C42;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lesson-preparation-content">
|
||||||
|
<div v-if="lesson.preparation" class="preparation-wrapper">
|
||||||
|
<a-alert
|
||||||
|
message="教学准备"
|
||||||
|
description="课前需要准备的材料、环境和资源"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
class="preparation-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="preparation-content rich-content" v-html="lesson.preparation"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty v-else description="暂无教学准备内容" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
lesson: any;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lesson-preparation-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preparation-alert {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preparation-wrapper {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preparation-content {
|
||||||
|
padding: 24px;
|
||||||
|
background: linear-gradient(135deg, #F0F7FF 0%, #F9F9F9 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
border-left: 4px solid #1890ff;
|
||||||
|
line-height: 2;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(ul),
|
||||||
|
.rich-content :deep(ol) {
|
||||||
|
padding-left: 28px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(li) {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(strong) {
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(h3) {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lesson-reflection-content">
|
||||||
|
<div v-if="lesson.reflection || lesson.reflectionPoints" class="reflection-wrapper">
|
||||||
|
<a-alert
|
||||||
|
message="教学反思"
|
||||||
|
description="课后教学反思要点,帮助改进教学效果"
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
class="reflection-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 教学反思内容 -->
|
||||||
|
<div v-if="lesson.reflection" class="reflection-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<FileTextOutlined /> 反思总结
|
||||||
|
</div>
|
||||||
|
<div class="reflection-content rich-content" v-html="lesson.reflection"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 反思要点 -->
|
||||||
|
<div v-if="lesson.reflectionPoints" class="reflection-points">
|
||||||
|
<div class="section-title">
|
||||||
|
<CheckCircleOutlined /> 反思要点
|
||||||
|
</div>
|
||||||
|
<div class="points-content rich-content" v-html="lesson.reflectionPoints"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty v-else description="暂无教学反思内容" />
|
||||||
|
|
||||||
|
<!-- 快速记录区 -->
|
||||||
|
<div class="quick-note-section">
|
||||||
|
<a-divider>快速记录</a-divider>
|
||||||
|
<a-textarea
|
||||||
|
v-model:value="quickNote"
|
||||||
|
placeholder="在这里记录您对本课程的教学反思和改进建议..."
|
||||||
|
:auto-size="{ minRows: 4, maxRows: 8 }"
|
||||||
|
class="quick-note-input"
|
||||||
|
/>
|
||||||
|
<div class="note-actions">
|
||||||
|
<a-button size="small" @click="quickNote = ''">
|
||||||
|
清除
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" size="small" @click="saveQuickNote">
|
||||||
|
保存到备课笔记
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { FileTextOutlined, CheckCircleOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
lesson: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const quickNote = ref('');
|
||||||
|
|
||||||
|
const saveQuickNote = () => {
|
||||||
|
if (!quickNote.value.trim()) {
|
||||||
|
message.info('请先输入内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 触发保存事件,由父组件处理
|
||||||
|
message.success('已保存到备课笔记');
|
||||||
|
quickNote.value = '';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lesson-reflection-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reflection-alert {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reflection-wrapper {
|
||||||
|
background: white;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reflection-section,
|
||||||
|
.reflection-points {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reflection-points:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reflection-content,
|
||||||
|
.points-content {
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(135deg, #FFFBE6 0%, #FFF9F5 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 4px solid #faad14;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(ul),
|
||||||
|
.rich-content :deep(ol) {
|
||||||
|
padding-left: 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(li) {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 快速记录区 */
|
||||||
|
.quick-note-section {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px dashed #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-note-input {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-note-input:focus {
|
||||||
|
border-color: #FF8C42;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 140, 66, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,256 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lesson-resources-content">
|
||||||
|
<!-- 核心资源列表 -->
|
||||||
|
<div v-if="hasResources" class="resources-grid">
|
||||||
|
<!-- 图片资源 -->
|
||||||
|
<div v-if="lesson.images?.length" class="resource-category">
|
||||||
|
<div class="category-header">
|
||||||
|
<PictureOutlined /> 图片资源 ({{ lesson.images.length }})
|
||||||
|
</div>
|
||||||
|
<div class="resource-list">
|
||||||
|
<div
|
||||||
|
v-for="(image, idx) in lesson.images"
|
||||||
|
:key="idx"
|
||||||
|
class="resource-item image-item"
|
||||||
|
@click="handlePreview('image', image)"
|
||||||
|
>
|
||||||
|
<img :src="getFileUrl(image.path)" :alt="image.name || `图片${idx + 1}`" />
|
||||||
|
<div class="resource-info">
|
||||||
|
<div class="resource-name">{{ image.name || `图片${idx + 1}` }}</div>
|
||||||
|
<div class="resource-action">点击预览</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 视频资源 -->
|
||||||
|
<div v-if="lesson.videos?.length" class="resource-category">
|
||||||
|
<div class="category-header">
|
||||||
|
<VideoCameraOutlined /> 视频资源 ({{ lesson.videos.length }})
|
||||||
|
</div>
|
||||||
|
<div class="resource-list">
|
||||||
|
<div
|
||||||
|
v-for="(video, idx) in lesson.videos"
|
||||||
|
:key="idx"
|
||||||
|
class="resource-item video-item"
|
||||||
|
@click="handlePreview('video', video)"
|
||||||
|
>
|
||||||
|
<VideoCameraOutlined class="resource-icon" />
|
||||||
|
<div class="resource-info">
|
||||||
|
<div class="resource-name">{{ video.name || `视频${idx + 1}` }}</div>
|
||||||
|
<div class="resource-action">点击播放</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 音频资源 -->
|
||||||
|
<div v-if="lesson.audioList?.length" class="resource-category">
|
||||||
|
<div class="category-header">
|
||||||
|
<AudioOutlined /> 音频资源 ({{ lesson.audioList.length }})
|
||||||
|
</div>
|
||||||
|
<div class="resource-list">
|
||||||
|
<div
|
||||||
|
v-for="(audio, idx) in lesson.audioList"
|
||||||
|
:key="idx"
|
||||||
|
class="resource-item audio-item"
|
||||||
|
@click="handlePreview('audio', audio)"
|
||||||
|
>
|
||||||
|
<AudioOutlined class="resource-icon" />
|
||||||
|
<div class="resource-info">
|
||||||
|
<div class="resource-name">{{ audio.name || `音频${idx + 1}` }}</div>
|
||||||
|
<div class="resource-action">点击播放</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PPT资源 -->
|
||||||
|
<div v-if="lesson.pptFiles?.length" class="resource-category">
|
||||||
|
<div class="category-header">
|
||||||
|
<FilePptOutlined /> 课件资源 ({{ lesson.pptFiles.length }})
|
||||||
|
</div>
|
||||||
|
<div class="resource-list">
|
||||||
|
<div
|
||||||
|
v-for="(ppt, idx) in lesson.pptFiles"
|
||||||
|
:key="idx"
|
||||||
|
class="resource-item ppt-item"
|
||||||
|
@click="handlePreview('ppt', ppt)"
|
||||||
|
>
|
||||||
|
<FilePptOutlined class="resource-icon" />
|
||||||
|
<div class="resource-info">
|
||||||
|
<div class="resource-name">{{ ppt.name || `课件${idx + 1}` }}</div>
|
||||||
|
<div class="resource-action">点击预览</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文档资源 -->
|
||||||
|
<div v-if="lesson.documents?.length" class="resource-category">
|
||||||
|
<div class="category-header">
|
||||||
|
<FileTextOutlined /> 文档资源 ({{ lesson.documents.length }})
|
||||||
|
</div>
|
||||||
|
<div class="resource-list">
|
||||||
|
<div
|
||||||
|
v-for="(doc, idx) in lesson.documents"
|
||||||
|
:key="idx"
|
||||||
|
class="resource-item doc-item"
|
||||||
|
@click="handlePreview('pdf', doc)"
|
||||||
|
>
|
||||||
|
<FileTextOutlined class="resource-icon" />
|
||||||
|
<div class="resource-info">
|
||||||
|
<div class="resource-name">{{ doc.name || `文档${idx + 1}` }}</div>
|
||||||
|
<div class="resource-action">点击预览</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty v-else description="暂无核心资源" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
PictureOutlined, VideoCameraOutlined, AudioOutlined,
|
||||||
|
FilePptOutlined, FileTextOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lesson: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'preview-resource': [type: string, resource: any];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const hasResources = computed(() => {
|
||||||
|
const l = props.lesson;
|
||||||
|
return (l.images?.length || 0) +
|
||||||
|
(l.videos?.length || 0) +
|
||||||
|
(l.audioList?.length || 0) +
|
||||||
|
(l.pptFiles?.length || 0) +
|
||||||
|
(l.documents?.length || 0) > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
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 handlePreview = (type: string, resource: any) => {
|
||||||
|
const url = getFileUrl(resource.path);
|
||||||
|
if (!url) {
|
||||||
|
message.warning('资源文件不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只发送预览事件到父组件,让弹窗处理预览
|
||||||
|
emit('preview-resource', type, { ...resource, url });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lesson-resources-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-category {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-item {
|
||||||
|
background: #FAFAFA;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-item:hover {
|
||||||
|
border-color: #FF8C42;
|
||||||
|
background: #FFF9F5;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 140, 66, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 80px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #FF8C42;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
text-align: center;
|
||||||
|
word-break: break-word;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-action {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #FF8C42;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,304 @@
|
|||||||
|
<template>
|
||||||
|
<div class="lesson-steps-content">
|
||||||
|
<!-- 教学过程列表 -->
|
||||||
|
<div v-if="steps.length > 0" class="steps-wrapper">
|
||||||
|
<a-alert
|
||||||
|
message="教学过程"
|
||||||
|
:description="`共 ${steps.length} 个教学环节`"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
closable
|
||||||
|
class="steps-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 环节列表 -->
|
||||||
|
<div class="steps-list">
|
||||||
|
<div
|
||||||
|
v-for="(step, index) in steps"
|
||||||
|
:key="step.id"
|
||||||
|
class="step-item"
|
||||||
|
:class="{ active: selectedStep?.id === step.id }"
|
||||||
|
@click="handleSelectStep(step)"
|
||||||
|
>
|
||||||
|
<div class="step-header">
|
||||||
|
<div class="step-number">{{ index + 1 }}</div>
|
||||||
|
<div class="step-info">
|
||||||
|
<div class="step-name">{{ step.name }}</div>
|
||||||
|
<div class="step-meta">
|
||||||
|
<span class="step-duration">
|
||||||
|
<ClockCircleOutlined /> {{ step.duration }}分钟
|
||||||
|
</span>
|
||||||
|
<span v-if="step.stepType" class="step-type">
|
||||||
|
<TagOutlined /> {{ getStepTypeName(step.stepType) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选中状态下显示详细内容 -->
|
||||||
|
<div v-if="selectedStep?.id === step.id" class="step-detail">
|
||||||
|
<a-divider class="step-divider" />
|
||||||
|
|
||||||
|
<!-- 教学目标 -->
|
||||||
|
<div v-if="step.objective" class="step-objective">
|
||||||
|
<div class="detail-title">
|
||||||
|
<AimOutlined /> 教学目标
|
||||||
|
</div>
|
||||||
|
<div class="detail-content rich-content">{{ step.objective }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 环节说明/内容 -->
|
||||||
|
<div v-if="step.description || step.content" class="step-description">
|
||||||
|
<div class="detail-title">
|
||||||
|
<FileTextOutlined /> 环节内容
|
||||||
|
</div>
|
||||||
|
<div class="detail-content rich-content" v-html="step.description || step.content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 环节资源 -->
|
||||||
|
<div v-if="hasStepResources(step)" class="step-resources">
|
||||||
|
<div class="detail-title">
|
||||||
|
<FolderOutlined /> 环节资源
|
||||||
|
</div>
|
||||||
|
<div class="resources-grid">
|
||||||
|
<div v-if="step.resources && step.resources.length > 0" class="resource-group">
|
||||||
|
<div v-for="(res, idx) in step.resources" :key="idx" class="resource-item">
|
||||||
|
<a-tag :color="getResourceColor(res.resourceType)">
|
||||||
|
{{ res.resourceName || res.resourceType }}
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty v-else description="暂无教学过程" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
ClockCircleOutlined, TagOutlined, FileTextOutlined, AimOutlined,
|
||||||
|
FolderOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
lesson: any;
|
||||||
|
selectedStep: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'select-step': [step: any];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const steps = computed(() => props.lesson?.steps || []);
|
||||||
|
|
||||||
|
const hasStepResources = (step: any): boolean => {
|
||||||
|
return !!(step.resources && step.resources.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStepTypeName = (type: string): string => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
'WARMUP': '热身导入',
|
||||||
|
'INTRODUCTION': '导入环节',
|
||||||
|
'DEVELOPMENT': '发展环节',
|
||||||
|
'PRACTICE': '练习环节',
|
||||||
|
'EXTENSION': '延伸环节',
|
||||||
|
'CONCLUSION': '总结环节',
|
||||||
|
'ASSESSMENT': '评估环节',
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectStep = (step: any) => {
|
||||||
|
// 如果已选中,则取消选中;否则选中当前环节
|
||||||
|
if (props.selectedStep?.id === step.id) {
|
||||||
|
emit('select-step', null);
|
||||||
|
} else {
|
||||||
|
emit('select-step', step);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getResourceColor = (type: string): string => {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
'IMAGE': 'blue',
|
||||||
|
'VIDEO': 'green',
|
||||||
|
'AUDIO': 'orange',
|
||||||
|
'PPT': 'red',
|
||||||
|
'PDF': 'purple',
|
||||||
|
'DOCUMENT': 'cyan',
|
||||||
|
};
|
||||||
|
return colorMap[type] || 'default';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lesson-steps-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-alert {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-wrapper {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item {
|
||||||
|
background: #FAFAFA;
|
||||||
|
border: 2px solid #f0f0f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item:hover {
|
||||||
|
border-color: #d9d9d9;
|
||||||
|
background: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item.active {
|
||||||
|
border-color: #FF8C42;
|
||||||
|
background: linear-gradient(90deg, #FFF9F5 0%, #FFF 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 140, 66, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-number {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-duration,
|
||||||
|
.step-type {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-detail {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-divider {
|
||||||
|
margin: 12px 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-objective,
|
||||||
|
.step-description {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content {
|
||||||
|
padding: 16px;
|
||||||
|
background: #F9F9F9;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-content {
|
||||||
|
background: linear-gradient(135deg, #FFF9E6 0%, #FFF 100%);
|
||||||
|
border-left: 3px solid #faad14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p) {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-content :deep(ul),
|
||||||
|
.rich-content :deep(ol) {
|
||||||
|
padding-left: 24px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-resources {
|
||||||
|
padding: 16px;
|
||||||
|
background: #F0F7FF;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resources-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-item {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -15,9 +15,10 @@
|
|||||||
|
|
||||||
<!-- 展播内容 -->
|
<!-- 展播内容 -->
|
||||||
<KidsMode
|
<KidsMode
|
||||||
v-else-if="course && scripts.length > 0"
|
v-else-if="currentLesson && currentSteps.length > 0"
|
||||||
:course="course"
|
:course="course"
|
||||||
:scripts="scripts"
|
:current-lesson="currentLesson"
|
||||||
|
:steps="currentSteps"
|
||||||
:activities="activities"
|
:activities="activities"
|
||||||
:current-step-index="currentStepIndex"
|
:current-step-index="currentStepIndex"
|
||||||
:timer-seconds="0"
|
:timer-seconds="0"
|
||||||
@ -34,7 +35,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue';
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { ExclamationCircleOutlined, InboxOutlined } from '@ant-design/icons-vue';
|
import { ExclamationCircleOutlined, InboxOutlined } from '@ant-design/icons-vue';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
@ -47,13 +48,17 @@ const route = useRoute();
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const course = ref<any>(null);
|
const course = ref<any>(null);
|
||||||
const scripts = ref<any[]>([]);
|
const lessons = ref<any[]>([]);
|
||||||
const activities = ref<any[]>([]);
|
const activities = ref<any[]>([]);
|
||||||
|
|
||||||
|
// 当前课程和环节索引
|
||||||
|
const currentLessonIndex = ref(0);
|
||||||
const currentStepIndex = ref(0);
|
const currentStepIndex = ref(0);
|
||||||
|
|
||||||
// 获取URL参数
|
// 获取URL参数
|
||||||
const lessonId = route.params.id as string;
|
const lessonId = route.params.id as string;
|
||||||
const initialStep = parseInt(route.query.step as string) || 0;
|
const initialLessonIndex = parseInt(route.query.lessonIndex as string) || 0;
|
||||||
|
const initialStep = parseInt(route.query.stepIndex as string) || parseInt(route.query.step as string) || 0;
|
||||||
|
|
||||||
// 步骤类型映射
|
// 步骤类型映射
|
||||||
const stepTypeMap: Record<string, string> = {
|
const stepTypeMap: Record<string, string> = {
|
||||||
@ -63,8 +68,115 @@ const stepTypeMap: Record<string, string> = {
|
|||||||
'CREATION': '创作',
|
'CREATION': '创作',
|
||||||
'SUMMARY': '总结',
|
'SUMMARY': '总结',
|
||||||
'CUSTOM': '导入',
|
'CUSTOM': '导入',
|
||||||
|
'WARMUP': '热身',
|
||||||
|
'INTRODUCTION': '导入',
|
||||||
|
'DEVELOPMENT': '发展',
|
||||||
|
'PRACTICE': '练习',
|
||||||
|
'EXTENSION': '延伸',
|
||||||
|
'CONCLUSION': '总结',
|
||||||
|
'ASSESSMENT': '评估',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 当前课程
|
||||||
|
const currentLesson = computed(() => {
|
||||||
|
if (lessons.value.length === 0) return null;
|
||||||
|
return lessons.value[currentLessonIndex.value] || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 当前课程的环节列表(转换为旧结构)
|
||||||
|
const currentSteps = computed(() => {
|
||||||
|
const lesson = currentLesson.value;
|
||||||
|
if (!lesson) return [];
|
||||||
|
|
||||||
|
// 新结构:使用 steps
|
||||||
|
if (lesson.steps && lesson.steps.length > 0) {
|
||||||
|
return lesson.steps.map((step: any) => {
|
||||||
|
// 转换资源数据:从后端的 resources 数组转换为前端的分类数组
|
||||||
|
const images: any[] = [];
|
||||||
|
const videos: any[] = [];
|
||||||
|
const audioList: any[] = [];
|
||||||
|
const pptFiles: any[] = [];
|
||||||
|
const documents: any[] = [];
|
||||||
|
const resourceIds: string[] = [];
|
||||||
|
|
||||||
|
if (step.resources && Array.isArray(step.resources)) {
|
||||||
|
step.resources.forEach((res: any) => {
|
||||||
|
const fileId = `${res.resourceType?.toLowerCase()}-${Date.now()}-${Math.random()}`;
|
||||||
|
|
||||||
|
switch (res.resourceType) {
|
||||||
|
case 'IMAGE':
|
||||||
|
case 'image':
|
||||||
|
images.push({ id: fileId, name: res.resourceName, path: res.fileUrl });
|
||||||
|
break;
|
||||||
|
case 'VIDEO':
|
||||||
|
case 'video':
|
||||||
|
videos.push({ id: fileId, name: res.resourceName, path: res.fileUrl });
|
||||||
|
break;
|
||||||
|
case 'AUDIO':
|
||||||
|
case 'audio':
|
||||||
|
audioList.push({ id: fileId, name: res.resourceName, path: res.fileUrl });
|
||||||
|
break;
|
||||||
|
case 'PPT':
|
||||||
|
case 'ppt':
|
||||||
|
pptFiles.push({ id: fileId, name: res.resourceName, path: res.fileUrl });
|
||||||
|
break;
|
||||||
|
case 'DOCUMENT':
|
||||||
|
case 'document':
|
||||||
|
case 'PDF':
|
||||||
|
documents.push({ id: fileId, name: res.resourceName, path: res.fileUrl });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// 对于未知类型,也作为文档处理
|
||||||
|
documents.push({ id: fileId, name: res.resourceName, path: res.fileUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成资源ID用于引用(兼容旧的 resourceIds 方式)
|
||||||
|
resourceIds.push(`${res.resourceType?.toLowerCase() || 'unknown'}-${res.id}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: step.id,
|
||||||
|
stepName: step.name,
|
||||||
|
name: step.name,
|
||||||
|
stepType: step.stepType,
|
||||||
|
duration: step.duration || 5,
|
||||||
|
objective: step.objective,
|
||||||
|
description: step.description,
|
||||||
|
teacherScript: step.script,
|
||||||
|
script: step.script,
|
||||||
|
interactionPoints: [],
|
||||||
|
interactionPointsText: '',
|
||||||
|
resourceIds,
|
||||||
|
pages: [],
|
||||||
|
// 新资源结构(分类数组)
|
||||||
|
images,
|
||||||
|
videos,
|
||||||
|
audioList,
|
||||||
|
pptFiles,
|
||||||
|
documents,
|
||||||
|
// 保留原始 resources 数据
|
||||||
|
resources: step.resources || [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧结构:使用 scripts
|
||||||
|
if (lesson.scripts && lesson.scripts.length > 0) {
|
||||||
|
return lesson.scripts.map((script: any) => ({
|
||||||
|
...script,
|
||||||
|
stepType: stepTypeMap[script.stepType] || script.stepType,
|
||||||
|
images: [],
|
||||||
|
videos: [],
|
||||||
|
audioList: [],
|
||||||
|
pptFiles: [],
|
||||||
|
documents: [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
// 加载数据
|
// 加载数据
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@ -94,7 +206,20 @@ const loadData = async () => {
|
|||||||
posterPaths: parsePathArray(data.course?.posterPaths),
|
posterPaths: parsePathArray(data.course?.posterPaths),
|
||||||
};
|
};
|
||||||
|
|
||||||
scripts.value = (data.course?.scripts || []).map((script: any) => ({
|
// 获取课程列表
|
||||||
|
// 后端返回的是 data.course.courseLessons
|
||||||
|
if (course.value.courseLessons && course.value.courseLessons.length > 0) {
|
||||||
|
// 使用课程包的所有课程
|
||||||
|
lessons.value = course.value.courseLessons;
|
||||||
|
} else {
|
||||||
|
// 兼容旧结构:单个课程,使用 scripts
|
||||||
|
lessons.value = [{
|
||||||
|
id: course.value.id,
|
||||||
|
name: course.value.name,
|
||||||
|
lessonType: 'CUSTOM',
|
||||||
|
duration: course.value.duration || 30,
|
||||||
|
steps: [],
|
||||||
|
scripts: (course.value.scripts || []).map((script: any) => ({
|
||||||
...script,
|
...script,
|
||||||
stepType: stepTypeMap[script.stepType] || script.stepType,
|
stepType: stepTypeMap[script.stepType] || script.stepType,
|
||||||
interactionPointsText: Array.isArray(script.interactionPoints)
|
interactionPointsText: Array.isArray(script.interactionPoints)
|
||||||
@ -105,7 +230,16 @@ const loadData = async () => {
|
|||||||
...page,
|
...page,
|
||||||
resourceIds: typeof page.resourceIds === 'string' ? JSON.parse(page.resourceIds) : (page.resourceIds || []),
|
resourceIds: typeof page.resourceIds === 'string' ? JSON.parse(page.resourceIds) : (page.resourceIds || []),
|
||||||
})),
|
})),
|
||||||
}));
|
})),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置初始课程索引
|
||||||
|
currentLessonIndex.value = Math.min(initialLessonIndex, Math.max(0, lessons.value.length - 1));
|
||||||
|
|
||||||
|
// 设置初始环节索引
|
||||||
|
const totalSteps = currentSteps.value.length;
|
||||||
|
currentStepIndex.value = Math.min(initialStep, Math.max(0, totalSteps - 1));
|
||||||
|
|
||||||
// 解析活动数据
|
// 解析活动数据
|
||||||
if (data.course?.lessonPlanData) {
|
if (data.course?.lessonPlanData) {
|
||||||
@ -131,9 +265,6 @@ const loadData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置初始环节
|
|
||||||
currentStepIndex.value = Math.min(initialStep, Math.max(0, scripts.value.length - 1));
|
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to load broadcast data:', err);
|
console.error('Failed to load broadcast data:', err);
|
||||||
error.value = err.response?.data?.message || '加载失败,请重试';
|
error.value = err.response?.data?.message || '加载失败,请重试';
|
||||||
@ -151,7 +282,7 @@ const handleExit = () => {
|
|||||||
const handleStepChange = (index: number) => {
|
const handleStepChange = (index: number) => {
|
||||||
currentStepIndex.value = index;
|
currentStepIndex.value = index;
|
||||||
// 更新URL参数(不刷新页面)
|
// 更新URL参数(不刷新页面)
|
||||||
const newUrl = `${window.location.pathname}?step=${index}`;
|
const newUrl = `${window.location.pathname}?lessonIndex=${currentLessonIndex.value}&stepIndex=${index}`;
|
||||||
window.history.replaceState({}, '', newUrl);
|
window.history.replaceState({}, '', newUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -165,6 +296,18 @@ const handleKeydown = (e: KeyboardEvent) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toggleFullscreen();
|
toggleFullscreen();
|
||||||
break;
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
// 上一环节
|
||||||
|
if (currentStepIndex.value > 0) {
|
||||||
|
handleStepChange(currentStepIndex.value - 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
// 下一环节
|
||||||
|
if (currentStepIndex.value < currentSteps.value.length - 1) {
|
||||||
|
handleStepChange(currentStepIndex.value + 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -181,15 +324,6 @@ const toggleFullscreen = () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadData();
|
loadData();
|
||||||
document.addEventListener('keydown', handleKeydown);
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
|
||||||
// 自动进入全屏
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!document.fullscreenElement) {
|
|
||||||
document.documentElement.requestFullscreen().catch(() => {
|
|
||||||
// 用户可能拒绝了全屏请求,忽略错误
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|||||||
@ -258,7 +258,7 @@ const loadLessons = async () => {
|
|||||||
lessons.value = data.items || [];
|
lessons.value = data.items || [];
|
||||||
total.value = data.total || 0;
|
total.value = data.total || 0;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response?.data?.message || '获取上课记录失败');
|
message.error(error.message || '获取上课记录失败');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -331,7 +331,7 @@ const startPlannedLesson = async () => {
|
|||||||
detailDrawerVisible.value = false;
|
detailDrawerVisible.value = false;
|
||||||
router.push(`/teacher/lessons/${selectedLesson.value.id}`);
|
router.push(`/teacher/lessons/${selectedLesson.value.id}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response?.data?.message || '开始上课失败');
|
message.error(error.message || '开始上课失败');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -354,7 +354,7 @@ const cancelLesson = () => {
|
|||||||
detailDrawerVisible.value = false;
|
detailDrawerVisible.value = false;
|
||||||
loadLessons();
|
loadLessons();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response?.data?.message || '取消失败');
|
message.error(error.message || '取消失败');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -309,7 +309,7 @@ const loadRecords = async () => {
|
|||||||
// 加载课程详情获取更多课程信息
|
// 加载课程详情获取更多课程信息
|
||||||
await loadLessonDetail();
|
await loadLessonDetail();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response?.data?.message || '获取学生记录失败');
|
message.error(error.message || '获取学生记录失败');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -394,7 +394,7 @@ const handleCreateTask = async () => {
|
|||||||
message.success('任务布置成功');
|
message.success('任务布置成功');
|
||||||
taskModalVisible.value = false;
|
taskModalVisible.value = false;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response?.data?.message || '创建任务失败');
|
message.error(error.message || '创建任务失败');
|
||||||
} finally {
|
} finally {
|
||||||
taskSaving.value = false;
|
taskSaving.value = false;
|
||||||
}
|
}
|
||||||
@ -441,7 +441,7 @@ const saveRecords = async () => {
|
|||||||
// 重新加载数据
|
// 重新加载数据
|
||||||
await loadRecords();
|
await loadRecords();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response?.data?.message || '保存失败');
|
message.error(error.message || '保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,547 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pdf-viewer" ref="containerRef">
|
||||||
|
<!-- PDF Canvas Container -->
|
||||||
|
<div class="pdf-container" :class="{ 'full-screen': isFullScreen }">
|
||||||
|
<canvas ref="canvasRef" class="pdf-canvas"></canvas>
|
||||||
|
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div v-if="isLoading" class="loading-overlay">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p class="loading-text">正在加载绘本...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-if="error" class="error-state">
|
||||||
|
<FileText :size="64" stroke-width="1" />
|
||||||
|
<p>PDF加载失败</p>
|
||||||
|
<p class="error-message">{{ error }}</p>
|
||||||
|
<button class="retry-btn" @click="loadPdf">
|
||||||
|
<RotateCcw :size="20" />
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating Toolbar (doesn't obstruct content) -->
|
||||||
|
<div class="toolbar" :class="{ 'toolbar-hidden': toolbarHidden }">
|
||||||
|
<button class="toolbar-btn" @click="toolbarHidden = !toolbarHidden" :title="toolbarHidden ? '显示工具栏' : '隐藏工具栏'">
|
||||||
|
<ChevronUp v-if="!toolbarHidden" :size="20" />
|
||||||
|
<ChevronDown v-else :size="20" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="toolbar-content" v-show="!toolbarHidden">
|
||||||
|
<!-- Page Navigation -->
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button class="nav-btn" @click="prevPage" :disabled="currentPage <= 1" title="上一页">
|
||||||
|
<ChevronLeft :size="24" />
|
||||||
|
</button>
|
||||||
|
<span class="page-info">{{ currentPage }} / {{ totalPages }}</span>
|
||||||
|
<button class="nav-btn" @click="nextPage" :disabled="currentPage >= totalPages" title="下一页">
|
||||||
|
<ChevronRight :size="24" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zoom Controls -->
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button class="nav-btn" @click="zoomOut" :disabled="scale <= 0.5" title="缩小">
|
||||||
|
<ZoomOut :size="20" />
|
||||||
|
</button>
|
||||||
|
<span class="zoom-info">{{ Math.round(scale * 100) }}%</span>
|
||||||
|
<button class="nav-btn" @click="zoomIn" :disabled="scale >= 3" title="放大">
|
||||||
|
<ZoomIn :size="20" />
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" @click="fitToWidth" title="适合宽度">
|
||||||
|
<Maximize :size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Mode -->
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<button class="nav-btn" @click="toggleFullScreen" :title="isFullScreen ? '退出全屏' : '全屏'">
|
||||||
|
<Minimize v-if="isFullScreen" :size="20" />
|
||||||
|
<Maximize2 v-else :size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, watch, nextTick, markRaw } from 'vue';
|
||||||
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
ZoomIn,
|
||||||
|
ZoomOut,
|
||||||
|
Maximize,
|
||||||
|
Minimize,
|
||||||
|
Maximize2,
|
||||||
|
FileText,
|
||||||
|
RotateCcw,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
|
||||||
|
// Set up PDF.js worker - 使用完整的URL避免CORS问题
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
currentPage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
currentPage: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'pageChange', page: number): void;
|
||||||
|
(e: 'pdfLoaded', totalPages: number): void;
|
||||||
|
(e: 'loaded'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Refs
|
||||||
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
|
|
||||||
|
// State
|
||||||
|
const pdfDoc = ref<pdfjsLib.PDFDocumentProxy | null>(null);
|
||||||
|
// PDF页码从1开始,将props的0-based转换为1-based
|
||||||
|
const currentPage = ref((props.currentPage || 0) + 1);
|
||||||
|
const totalPages = ref(0);
|
||||||
|
const scale = ref(1.5);
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const isFullScreen = ref(false);
|
||||||
|
const toolbarHidden = ref(false);
|
||||||
|
// 标记是否已经完成初始渲染,避免初始加载时发出pageChange事件
|
||||||
|
const initialRenderComplete = ref(false);
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const loadPdf = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
|
||||||
|
console.log('[PdfViewer] 开始加载PDF:', props.url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('[PdfViewer] 调用 getDocument...');
|
||||||
|
const loadingTask = pdfjsLib.getDocument(props.url);
|
||||||
|
console.log('[PdfViewer] 等待 PDF 加载...');
|
||||||
|
|
||||||
|
const doc = await loadingTask.promise;
|
||||||
|
console.log('[PdfViewer] PDF 加载成功, 页数:', doc.numPages);
|
||||||
|
|
||||||
|
// 使用 markRaw 避免Vue响应式系统干扰PDF.js内部对象
|
||||||
|
pdfDoc.value = markRaw(doc);
|
||||||
|
totalPages.value = doc.numPages;
|
||||||
|
|
||||||
|
console.log('[PdfViewer] 准备渲染第一页...');
|
||||||
|
|
||||||
|
// 渲染第一页
|
||||||
|
if (totalPages.value > 0) {
|
||||||
|
await renderPage(1);
|
||||||
|
console.log('[PdfViewer] 第一页渲染完成');
|
||||||
|
} else {
|
||||||
|
console.error('[PdfViewer] PDF没有页面');
|
||||||
|
error.value = 'PDF文件没有页面';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知父组件加载完成
|
||||||
|
console.log('[PdfViewer] 触发 pdfLoaded 事件, 总页数:', totalPages.value);
|
||||||
|
console.log('[PdfViewer] emit 函数类型:', typeof emit);
|
||||||
|
try {
|
||||||
|
emit('pdfLoaded', totalPages.value);
|
||||||
|
console.log('[PdfViewer] pdfLoaded 事件已发送');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[PdfViewer] 发送 pdfLoaded 事件时出错:', err);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[PdfViewer] PDF loading error:', err);
|
||||||
|
error.value = err.message || '无法加载PDF文件';
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
console.log('[PdfViewer] 加载完成, isLoading:', isLoading.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPage = async (pageNumber: number) => {
|
||||||
|
console.log(`[PdfViewer] renderPage 被调用, 页码: ${pageNumber}`);
|
||||||
|
|
||||||
|
if (!pdfDoc.value) {
|
||||||
|
console.error('[PdfViewer] pdfDoc 为空,无法渲染');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canvasRef.value) {
|
||||||
|
console.error('[PdfViewer] canvasRef 为空,无法渲染');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[PdfViewer] 获取第 ${pageNumber} 页...`);
|
||||||
|
const page = await pdfDoc.value.getPage(pageNumber);
|
||||||
|
console.log(`[PdfViewer] 页面获取成功, 计算视口...`);
|
||||||
|
|
||||||
|
const viewport = page.getViewport({ scale: scale.value });
|
||||||
|
console.log(`[PdfViewer] 视口尺寸: ${viewport.width}x${viewport.height}`);
|
||||||
|
|
||||||
|
const canvas = canvasRef.value;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Cannot get canvas context');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置canvas尺寸
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
|
||||||
|
// 清除canvas
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
console.log(`[PdfViewer] 开始渲染...`);
|
||||||
|
|
||||||
|
// 渲染PDF页面
|
||||||
|
await page.render({
|
||||||
|
canvasContext: context,
|
||||||
|
viewport: viewport,
|
||||||
|
}).promise;
|
||||||
|
|
||||||
|
console.log(`[PdfViewer] 渲染完成`);
|
||||||
|
currentPage.value = pageNumber;
|
||||||
|
// 只在初始渲染完成后才发出 pageChange 事件,避免初始加载时导致父组件状态混乱
|
||||||
|
if (initialRenderComplete.value) {
|
||||||
|
// 将 1-based 转换为 0-based 传递给父组件
|
||||||
|
emit('pageChange', pageNumber - 1);
|
||||||
|
console.log(`[PdfViewer] 发出 pageChange: ${pageNumber} (1-based) -> ${pageNumber - 1} (0-based)`);
|
||||||
|
} else {
|
||||||
|
console.log('[PdfViewer] 初始渲染,不发出 pageChange 事件');
|
||||||
|
initialRenderComplete.value = true;
|
||||||
|
}
|
||||||
|
// 触发 loaded 事件通知 SlidesViewer
|
||||||
|
emit('loaded');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[PdfViewer] Page render error:', err);
|
||||||
|
error.value = `渲染第${pageNumber}页失败: ${err.message}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevPage = () => {
|
||||||
|
if (currentPage.value > 1) {
|
||||||
|
renderPage(currentPage.value - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextPage = () => {
|
||||||
|
if (currentPage.value < totalPages.value) {
|
||||||
|
renderPage(currentPage.value + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPage = (pageNumber: number) => {
|
||||||
|
if (pageNumber >= 1 && pageNumber <= totalPages.value) {
|
||||||
|
renderPage(pageNumber);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomIn = () => {
|
||||||
|
if (scale.value < 3) {
|
||||||
|
scale.value = Math.min(3, scale.value + 0.25);
|
||||||
|
renderPage(currentPage.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomOut = () => {
|
||||||
|
if (scale.value > 0.5) {
|
||||||
|
scale.value = Math.max(0.5, scale.value - 0.25);
|
||||||
|
renderPage(currentPage.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitToWidth = () => {
|
||||||
|
if (!containerRef.value || !pdfDoc.value) return;
|
||||||
|
|
||||||
|
const containerWidth = containerRef.value.clientWidth - 40; // padding
|
||||||
|
pdfDoc.value.getPage(currentPage.value).then((page) => {
|
||||||
|
const viewport = page.getViewport({ scale: 1 });
|
||||||
|
scale.value = containerWidth / viewport.width;
|
||||||
|
renderPage(currentPage.value);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullScreen = () => {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
containerRef.value?.requestFullscreen();
|
||||||
|
isFullScreen.value = true;
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen();
|
||||||
|
isFullScreen.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowLeft':
|
||||||
|
prevPage();
|
||||||
|
break;
|
||||||
|
case 'ArrowRight':
|
||||||
|
nextPage();
|
||||||
|
break;
|
||||||
|
case '+':
|
||||||
|
case '=':
|
||||||
|
zoomIn();
|
||||||
|
break;
|
||||||
|
case '-':
|
||||||
|
zoomOut();
|
||||||
|
break;
|
||||||
|
case 'f':
|
||||||
|
case 'F':
|
||||||
|
toggleFullScreen();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Watch for URL changes
|
||||||
|
watch(() => props.url, () => {
|
||||||
|
if (props.url) {
|
||||||
|
// 重置初始渲染标志
|
||||||
|
initialRenderComplete.value = false;
|
||||||
|
loadPdf();
|
||||||
|
}
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
// Watch for external page changes
|
||||||
|
watch(() => props.currentPage, (newPage) => {
|
||||||
|
// 将 0-based 转换为 1-based
|
||||||
|
const pdfPage = (newPage || 0) + 1;
|
||||||
|
if (pdfPage !== currentPage.value) {
|
||||||
|
console.log(`[PdfViewer] 外部页码变化: ${newPage} (0-based) -> ${pdfPage} (1-based)`);
|
||||||
|
renderPage(pdfPage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fullscreen change detection
|
||||||
|
const handleFullScreenChange = () => {
|
||||||
|
isFullScreen.value = !!document.fullscreenElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('[PdfViewer] 组件已挂载');
|
||||||
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
document.addEventListener('fullscreenchange', handleFullScreenChange);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
|
document.removeEventListener('fullscreenchange', handleFullScreenChange);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.pdf-viewer {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(135deg, #FFF8E1 0%, #FFE0B2 50%, #FFCCBC 100%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.full-screen {
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-canvas {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
box-shadow: 0 10px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 248, 225, 0.95);
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #FF8C42;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border: 4px solid rgba(255, 140, 66, 0.2);
|
||||||
|
border-top-color: #FF8C42;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px;
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 16px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: #FF8C42;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #E67635;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 12px 20px;
|
||||||
|
z-index: 50;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&.toolbar-hidden {
|
||||||
|
bottom: 20px;
|
||||||
|
transform: translateX(-50%) translateY(calc(100% - 50px));
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #FF8C42;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
margin: 0 auto 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 140, 66, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
background: #FF8C42;
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #E67635;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
color: rgba(0, 0, 0, 0.3);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info,
|
||||||
|
.zoom-info {
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
min-width: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -13,33 +13,15 @@
|
|||||||
alt="幻灯片"
|
alt="幻灯片"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- PDF展示 - 使用embed标签 -->
|
<!-- PDF展示 - 使用PDF.js组件 -->
|
||||||
<embed
|
<PdfViewer
|
||||||
v-else-if="pages.length > 0 && currentSlideType === 'pdf'"
|
v-else-if="pages.length > 0 && (currentSlideType === 'pdf' || currentSlideType === 'pdf-fallback')"
|
||||||
:src="pdfEmbedUrl"
|
:url="pages[currentPage]"
|
||||||
type="application/pdf"
|
:current-page="currentPage"
|
||||||
class="slide-pdf"
|
@page-change="handlePdfPageChange"
|
||||||
@load="onPdfLoad"
|
@pdfLoaded="onPdfLoad"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- PDF备用方案 - object标签 -->
|
|
||||||
<object
|
|
||||||
v-else-if="pages.length > 0 && currentSlideType === 'pdf-fallback'"
|
|
||||||
:data="pdfEmbedUrl"
|
|
||||||
type="application/pdf"
|
|
||||||
class="slide-pdf"
|
|
||||||
>
|
|
||||||
<!-- 如果PDF插件不可用,显示下载链接 -->
|
|
||||||
<div class="pdf-fallback">
|
|
||||||
<FileText :size="64" stroke-width="1" />
|
|
||||||
<p>PDF文件无法在此浏览器中预览</p>
|
|
||||||
<a :href="pages[currentPage]" target="_blank" class="pdf-download-btn">
|
|
||||||
<Download :size="20" />
|
|
||||||
点击打开PDF
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</object>
|
|
||||||
|
|
||||||
<!-- PPT/PPTX展示 - 无法嵌入,提供下载 -->
|
<!-- PPT/PPTX展示 - 无法嵌入,提供下载 -->
|
||||||
<div v-else-if="pages.length > 0 && currentSlideType === 'ppt'" class="ppt-container">
|
<div v-else-if="pages.length > 0 && currentSlideType === 'ppt'" class="ppt-container">
|
||||||
<div class="ppt-preview">
|
<div class="ppt-preview">
|
||||||
@ -67,10 +49,10 @@
|
|||||||
<p v-if="showError && pages[currentPage]" class="error-url">{{ pages[currentPage] }}</p>
|
<p v-if="showError && pages[currentPage]" class="error-url">{{ pages[currentPage] }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 加载中 -->
|
<!-- 加载中 - 不在PDF时显示,因为PdfViewer有自己的loading overlay -->
|
||||||
<div v-if="isLoading && pages.length > 0" class="loading-overlay">
|
<div v-if="isLoading && pages.length > 0 && currentSlideType !== 'pdf' && currentSlideType !== 'pdf-fallback'" class="loading-overlay">
|
||||||
<div class="loading-spinner"></div>
|
<div class="loading-spinner"></div>
|
||||||
<p class="loading-text">正在加载{{ currentSlideType === 'pdf' ? 'PDF' : currentSlideType === 'ppt' ? 'PPT' : '内容' }}...</p>
|
<p class="loading-text">正在加载{{ currentSlideType === 'ppt' ? 'PPT' : '内容' }}...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 左右翻页提示 -->
|
<!-- 左右翻页提示 -->
|
||||||
@ -82,8 +64,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 页码控制 -->
|
<!-- 页码控制 - 只在非PDF类型且有多个文件时显示 -->
|
||||||
<div class="page-controls" v-if="pages.length > 1">
|
<!-- PDF类型使用PdfViewer内部的翻页控件 -->
|
||||||
|
<div class="page-controls" v-if="pages.length > 1 && currentSlideType !== 'pdf' && currentSlideType !== 'pdf-fallback'">
|
||||||
<div class="page-btn prev" @click="prevPage" :class="{ disabled: currentPage === 0 }">
|
<div class="page-btn prev" @click="prevPage" :class="{ disabled: currentPage === 0 }">
|
||||||
<ChevronLeft :size="24" />
|
<ChevronLeft :size="24" />
|
||||||
</div>
|
</div>
|
||||||
@ -108,8 +91,8 @@
|
|||||||
{{ currentPage + 1 }} / {{ pages.length }}
|
{{ currentPage + 1 }} / {{ pages.length }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PDF/PPT工具栏 -->
|
<!-- PPT工具栏 -->
|
||||||
<div class="pdf-toolbar" v-if="currentSlideType === 'pdf' || currentSlideType === 'pdf-fallback' || currentSlideType === 'ppt'">
|
<div class="pdf-toolbar" v-if="currentSlideType === 'ppt'">
|
||||||
<a :href="pages[currentPage]" target="_blank" class="toolbar-btn" title="新窗口打开">
|
<a :href="pages[currentPage]" target="_blank" class="toolbar-btn" title="新窗口打开">
|
||||||
<ExternalLink :size="18" />
|
<ExternalLink :size="18" />
|
||||||
</a>
|
</a>
|
||||||
@ -130,11 +113,12 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
} from 'lucide-vue-next';
|
} from 'lucide-vue-next';
|
||||||
|
import PdfViewer from './PdfViewer.vue';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pages: string[];
|
pages: string[];
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
type: 'ppt' | 'poster';
|
type: 'ppt' | 'poster' | 'pdf';
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@ -145,6 +129,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'pageChange', page: number): void;
|
(e: 'pageChange', page: number): void;
|
||||||
|
(e: 'load', totalPages: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
@ -173,19 +158,6 @@ const currentSlideType = computed(() => {
|
|||||||
return 'image';
|
return 'image';
|
||||||
});
|
});
|
||||||
|
|
||||||
// PDF嵌入URL - 添加参数以优化显示
|
|
||||||
const pdfEmbedUrl = computed(() => {
|
|
||||||
const url = props.pages[currentPage.value] || '';
|
|
||||||
if (!url) return '';
|
|
||||||
|
|
||||||
// 对于某些服务器,可以添加 #toolbar=0 等参数来控制PDF显示
|
|
||||||
// 但如果URL已有查询参数,需要用 & 而不是 ?
|
|
||||||
if (url.includes('?')) {
|
|
||||||
return `${url}#toolbar=1&navpanes=0&scrollbar=1&view=FitH`;
|
|
||||||
}
|
|
||||||
return `${url}#toolbar=1&navpanes=0&scrollbar=1&view=FitH`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 当前文件名
|
// 当前文件名
|
||||||
const currentFileName = computed(() => {
|
const currentFileName = computed(() => {
|
||||||
const url = props.pages[currentPage.value] || '';
|
const url = props.pages[currentPage.value] || '';
|
||||||
@ -205,13 +177,32 @@ const onImageError = () => {
|
|||||||
showError.value = true;
|
showError.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPdfLoad = () => {
|
const onPdfLoad = (totalPages: number) => {
|
||||||
isLoading.value = false;
|
console.log('[SlidesViewer] onPdfLoad 被调用, 总页数:', totalPages);
|
||||||
|
// PDF加载完成后,不需要再显示SlidesViewer的loading状态
|
||||||
|
// 因为PdfViewer有自己的loading overlay
|
||||||
|
showError.value = false;
|
||||||
|
emit('load', totalPages);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePdfPageChange = (page: number) => {
|
||||||
|
// 对于单个 PDF 文件,currentPage 应该保持为 0
|
||||||
|
// pageChange 事件表示 PdfViewer 内部翻页,不影响 pages 数组的索引
|
||||||
|
// 只有当有多个文件时才需要发出 pageChange 事件
|
||||||
|
if (props.pages.length > 1) {
|
||||||
|
currentPage.value = page;
|
||||||
|
emit('pageChange', page);
|
||||||
|
}
|
||||||
|
// 对于单个 PDF 文件,不发出事件,保持 currentPage = 0
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevPage = () => {
|
const prevPage = () => {
|
||||||
if (currentPage.value > 0) {
|
if (currentPage.value > 0) {
|
||||||
|
const isPdf = currentSlideType.value === 'pdf' || currentSlideType.value === 'pdf-fallback';
|
||||||
|
// PDF类型不需要设置loading,因为PdfViewer有自己的loading状态
|
||||||
|
if (!isPdf) {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
}
|
||||||
showError.value = false;
|
showError.value = false;
|
||||||
currentPage.value--;
|
currentPage.value--;
|
||||||
emit('pageChange', currentPage.value);
|
emit('pageChange', currentPage.value);
|
||||||
@ -220,7 +211,11 @@ const prevPage = () => {
|
|||||||
|
|
||||||
const nextPage = () => {
|
const nextPage = () => {
|
||||||
if (currentPage.value < props.pages.length - 1) {
|
if (currentPage.value < props.pages.length - 1) {
|
||||||
|
const isPdf = currentSlideType.value === 'pdf' || currentSlideType.value === 'pdf-fallback';
|
||||||
|
// PDF类型不需要设置loading,因为PdfViewer有自己的loading状态
|
||||||
|
if (!isPdf) {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
}
|
||||||
showError.value = false;
|
showError.value = false;
|
||||||
currentPage.value++;
|
currentPage.value++;
|
||||||
emit('pageChange', currentPage.value);
|
emit('pageChange', currentPage.value);
|
||||||
@ -229,7 +224,11 @@ const nextPage = () => {
|
|||||||
|
|
||||||
const goToPage = (index: number) => {
|
const goToPage = (index: number) => {
|
||||||
if (index !== currentPage.value) {
|
if (index !== currentPage.value) {
|
||||||
|
const isPdf = currentSlideType.value === 'pdf' || currentSlideType.value === 'pdf-fallback';
|
||||||
|
// PDF类型不需要设置loading,因为PdfViewer有自己的loading状态
|
||||||
|
if (!isPdf) {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
}
|
||||||
showError.value = false;
|
showError.value = false;
|
||||||
currentPage.value = index;
|
currentPage.value = index;
|
||||||
emit('pageChange', currentPage.value);
|
emit('pageChange', currentPage.value);
|
||||||
@ -255,6 +254,12 @@ const handleContainerClick = (e: MouseEvent) => {
|
|||||||
|
|
||||||
// 键盘事件
|
// 键盘事件
|
||||||
const handleKeydown = (e: KeyboardEvent) => {
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
// PDF时不允许键盘翻页,因为需要与PDF交互
|
||||||
|
const isPdf = currentSlideType.value === 'pdf' || currentSlideType.value === 'pdf-fallback';
|
||||||
|
if (isPdf) {
|
||||||
|
return; // 让PdfViewer处理键盘事件
|
||||||
|
}
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
prevPage();
|
prevPage();
|
||||||
@ -268,25 +273,39 @@ const handleKeydown = (e: KeyboardEvent) => {
|
|||||||
// 监听外部页码变化
|
// 监听外部页码变化
|
||||||
watch(() => props.currentPage, (newPage) => {
|
watch(() => props.currentPage, (newPage) => {
|
||||||
if (newPage !== currentPage.value) {
|
if (newPage !== currentPage.value) {
|
||||||
|
const isPdf = currentSlideType.value === 'pdf' || currentSlideType.value === 'pdf-fallback';
|
||||||
|
// PDF类型不需要设置loading,因为PdfViewer有自己的loading状态
|
||||||
|
if (!isPdf) {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
}
|
||||||
showError.value = false;
|
showError.value = false;
|
||||||
currentPage.value = newPage;
|
currentPage.value = newPage;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听pages变化
|
// 监听pages变化 - 只在真正需要时才重新加载
|
||||||
watch(() => props.pages, () => {
|
watch(() => props.pages, (newPages, oldPages) => {
|
||||||
|
// 只有当pages真正改变时才设置loading
|
||||||
|
const hasChanged = !oldPages || newPages.length !== oldPages.length ||
|
||||||
|
newPages[0] !== oldPages[0];
|
||||||
|
|
||||||
|
if (hasChanged && newPages.length > 0) {
|
||||||
|
console.log('[SlidesViewer] pages 内容改变, 重新加载, 设置 isLoading = true');
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
showError.value = false;
|
showError.value = false;
|
||||||
}, { immediate: true });
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
console.log('[SlidesViewer] 组件已挂载');
|
||||||
|
console.log('[SlidesViewer] onPdfLoad 函数存在:', typeof onPdfLoad === 'function');
|
||||||
document.addEventListener('keydown', handleKeydown);
|
document.addEventListener('keydown', handleKeydown);
|
||||||
|
|
||||||
// 设置加载超时
|
// 设置加载超时
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (isLoading.value) {
|
if (isLoading.value) {
|
||||||
|
console.log('[SlidesViewer] 加载超时, 强制设置 isLoading = false');
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}, 5000);
|
}, 5000);
|
||||||
@ -354,50 +373,6 @@ onUnmounted(() => {
|
|||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-pdf {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pdf-fallback {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
border-radius: 12px;
|
|
||||||
color: #333;
|
|
||||||
padding: 40px;
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 16px 0;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pdf-download-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 24px;
|
|
||||||
background: #FF8C42;
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #E67635;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ppt-container {
|
.ppt-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@ -7,10 +7,37 @@
|
|||||||
<template #extra>
|
<template #extra>
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button @click="router.back()">返回</a-button>
|
<a-button @click="router.back()">返回</a-button>
|
||||||
|
<a-button @click="handlePrepare">
|
||||||
|
<template #icon><EditOutlined /></template>
|
||||||
|
开始备课
|
||||||
|
</a-button>
|
||||||
|
<a-button type="primary" @click="handleStart">
|
||||||
|
<template #icon><PlayCircleOutlined /></template>
|
||||||
|
开始上课
|
||||||
|
</a-button>
|
||||||
<a-button type="primary" @click="handleEdit">编辑</a-button>
|
<a-button type="primary" @click="handleEdit">编辑</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 保存位置和审核状态 -->
|
||||||
|
<div v-if="detail" class="status-bar">
|
||||||
|
<a-tag v-if="detail.saveLocation === 'PERSONAL'" color="blue">
|
||||||
|
<UserOutlined /> 个人课程中心
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else color="green">
|
||||||
|
<TeamOutlined /> 校本课程中心
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-if="detail.reviewStatus === 'PENDING'" color="orange">
|
||||||
|
<ClockCircleOutlined /> 待审核
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else-if="detail.reviewStatus === 'APPROVED'" color="success">
|
||||||
|
<CheckCircleOutlined /> 已通过
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else-if="detail.reviewStatus === 'REJECTED'" color="error">
|
||||||
|
<CloseCircleOutlined /> 已驳回
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a-descriptions :column="2" bordered>
|
<a-descriptions :column="2" bordered>
|
||||||
<a-descriptions-item label="名称">{{ detail?.name }}</a-descriptions-item>
|
<a-descriptions-item label="名称">{{ detail?.name }}</a-descriptions-item>
|
||||||
<a-descriptions-item label="状态">
|
<a-descriptions-item label="状态">
|
||||||
@ -26,6 +53,16 @@
|
|||||||
<a-descriptions-item label="修改说明" :span="2">{{ detail?.changesSummary || '-' }}</a-descriptions-item>
|
<a-descriptions-item label="修改说明" :span="2">{{ detail?.changesSummary || '-' }}</a-descriptions-item>
|
||||||
</a-descriptions>
|
</a-descriptions>
|
||||||
|
|
||||||
|
<!-- 课程配置预览 -->
|
||||||
|
<a-divider>课程配置</a-divider>
|
||||||
|
|
||||||
|
<div v-if="detail" class="course-preview">
|
||||||
|
<a-descriptions :column="2" size="small">
|
||||||
|
<a-descriptions-item label="核心内容">{{ detail.coreContent || '-' }}</a-descriptions-item>
|
||||||
|
<a-descriptions-item label="时长">{{ detail.duration || 25 }} 分钟</a-descriptions-item>
|
||||||
|
</a-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a-divider>课程列表</a-divider>
|
<a-divider>课程列表</a-divider>
|
||||||
|
|
||||||
<a-table
|
<a-table
|
||||||
@ -48,30 +85,37 @@
|
|||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import { getTeacherSchoolCourseDetail } from '@/api/school-course';
|
import {
|
||||||
import type { SchoolCourse } from '@/api/school-course';
|
EditOutlined,
|
||||||
|
PlayCircleOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CloseCircleOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { getTeacherSchoolCourseFullDetail } from '@/api/school-course';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const detail = ref<SchoolCourse | null>(null);
|
const detail = ref<any>(null);
|
||||||
|
|
||||||
const lessonColumns = [
|
const lessonColumns = [
|
||||||
{ title: '课程类型', dataIndex: 'lessonType', key: 'lessonType', width: 120 },
|
{ title: '课程类型', dataIndex: 'lessonType', key: 'lessonType', width: 120 },
|
||||||
{ title: '目标', dataIndex: 'objectives', key: 'objectives' },
|
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||||
{ title: '准备', dataIndex: 'preparation', key: 'preparation' },
|
{ title: '时长', dataIndex: 'duration', key: 'duration', width: 80 },
|
||||||
{ title: '修改备注', dataIndex: 'changeNote', key: 'changeNote' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const lessonTypeNames: Record<string, string> = {
|
const lessonTypeNames: Record<string, string> = {
|
||||||
|
INTRODUCTION: '导入课',
|
||||||
COLLECTIVE: '集体课',
|
COLLECTIVE: '集体课',
|
||||||
HEALTH: '健康',
|
HEALTH: '健康',
|
||||||
LANGUAGE: '语言',
|
LANGUAGE: '语言',
|
||||||
SOCIAL: '社会',
|
SOCIAL: '社会',
|
||||||
SCIENCE: '科学',
|
SCIENCE: '科学',
|
||||||
ART: '艺术',
|
ART: '艺术',
|
||||||
DOMAIN: '领域课',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLessonTypeName = (type: string) => lessonTypeNames[type] || type;
|
const getLessonTypeName = (type: string) => lessonTypeNames[type] || type;
|
||||||
@ -85,8 +129,8 @@ const fetchData = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const id = Number(route.params.id);
|
const id = Number(route.params.id);
|
||||||
const res = await getTeacherSchoolCourseDetail(id);
|
const res = await getTeacherSchoolCourseFullDetail(id) as any;
|
||||||
detail.value = res.data;
|
detail.value = res.data || res;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('获取详情失败');
|
message.error('获取详情失败');
|
||||||
} finally {
|
} finally {
|
||||||
@ -98,6 +142,22 @@ const handleEdit = () => {
|
|||||||
router.push(`/teacher/school-courses/${route.params.id}/edit`);
|
router.push(`/teacher/school-courses/${route.params.id}/edit`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePrepare = () => {
|
||||||
|
router.push({
|
||||||
|
path: `/teacher/courses/${route.params.id}/prepare`,
|
||||||
|
query: { type: 'school' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStart = () => {
|
||||||
|
// 创建授课记录并进入课堂
|
||||||
|
message.info('请先进入备课模式,选择班级后开始上课');
|
||||||
|
router.push({
|
||||||
|
path: `/teacher/courses/${route.params.id}/prepare`,
|
||||||
|
query: { type: 'school' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
});
|
});
|
||||||
@ -107,4 +167,14 @@ onMounted(() => {
|
|||||||
.school-course-detail-page {
|
.school-course-detail-page {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-preview {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,57 +1,150 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="school-course-edit-page">
|
<div class="school-course-edit-page">
|
||||||
<a-card :bordered="false">
|
<a-page-header
|
||||||
<template #title>
|
:title="isEdit ? '编辑校本课程包' : '创建校本课程包'"
|
||||||
<span>{{ isEdit ? '编辑校本课程包' : '创建校本课程包' }}</span>
|
@back="() => router.back()"
|
||||||
</template>
|
>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-button @click="router.back()">返回</a-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<a-form
|
|
||||||
:model="form"
|
|
||||||
:label-col="{ span: 4 }"
|
|
||||||
:wrapper-col="{ span: 16 }"
|
|
||||||
@finish="handleSave"
|
|
||||||
>
|
|
||||||
<a-form-item label="源课程包" name="sourceCourseId" v-if="!isEdit">
|
|
||||||
<a-select
|
|
||||||
v-model:value="form.sourceCourseId"
|
|
||||||
placeholder="请选择源课程包"
|
|
||||||
show-search
|
|
||||||
:filter-option="filterOption"
|
|
||||||
@change="handleSourceChange"
|
|
||||||
>
|
|
||||||
<a-select-option v-for="course in sourceCourses" :key="course.id" :value="course.id">
|
|
||||||
{{ course.name }}
|
|
||||||
</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="名称" name="name" :rules="[{ required: true, message: '请输入名称' }]">
|
|
||||||
<a-input v-model:value="form.name" placeholder="请输入校本课程包名称" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="描述" name="description">
|
|
||||||
<a-textarea v-model:value="form.description" placeholder="请输入描述" :rows="3" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="修改说明" name="changesSummary">
|
|
||||||
<a-textarea
|
|
||||||
v-model:value="form.changesSummary"
|
|
||||||
placeholder="请描述对源课程包的修改内容"
|
|
||||||
:rows="3"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item :wrapper-col="{ offset: 4, span: 16 }">
|
|
||||||
<a-space>
|
<a-space>
|
||||||
<a-button type="primary" html-type="submit" :loading="saving">保存</a-button>
|
<a-button @click="handleSaveDraft" :loading="saving">保存草稿</a-button>
|
||||||
<a-button @click="router.back()">取消</a-button>
|
<a-button type="primary" @click="showSaveModal = true">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</a-form-item>
|
</template>
|
||||||
</a-form>
|
</a-page-header>
|
||||||
|
|
||||||
|
<a-spin :spinning="loading" tip="正在加载数据...">
|
||||||
|
<a-card :bordered="false" style="margin-top: 16px;">
|
||||||
|
<!-- 源课程信息 -->
|
||||||
|
<div v-if="sourceCourse" class="source-info">
|
||||||
|
<BookOutlined />
|
||||||
|
<span>基于:{{ sourceCourse.name }}</span>
|
||||||
|
<a-tag color="blue">源课程包</a-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤导航 -->
|
||||||
|
<a-steps :current="currentStep" size="small" @change="onStepChange" class="steps-nav">
|
||||||
|
<a-step title="基本信息" />
|
||||||
|
<a-step title="课程介绍" />
|
||||||
|
<a-step title="排课参考" />
|
||||||
|
<a-step title="导入课" />
|
||||||
|
<a-step title="集体课" />
|
||||||
|
<a-step title="领域课" />
|
||||||
|
<a-step title="环创建设" />
|
||||||
|
</a-steps>
|
||||||
|
|
||||||
|
<!-- 完成度进度条 -->
|
||||||
|
<div class="completion-bar">
|
||||||
|
<span>完成度</span>
|
||||||
|
<a-progress :percent="completionPercent" :status="completionStatus" size="small" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤内容 -->
|
||||||
|
<div class="step-content">
|
||||||
|
<!-- 步骤1:基本信息 -->
|
||||||
|
<Step1BasicInfo
|
||||||
|
v-show="currentStep === 0"
|
||||||
|
ref="step1Ref"
|
||||||
|
v-model="formData.basic"
|
||||||
|
@change="handleDataChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 步骤2:课程介绍 -->
|
||||||
|
<Step2CourseIntro
|
||||||
|
v-show="currentStep === 1"
|
||||||
|
ref="step2Ref"
|
||||||
|
v-model="formData.intro"
|
||||||
|
@change="handleDataChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 步骤3:排课参考 -->
|
||||||
|
<Step3ScheduleRef
|
||||||
|
v-show="currentStep === 2"
|
||||||
|
ref="step3Ref"
|
||||||
|
v-model="formData.scheduleRefData"
|
||||||
|
@change="handleDataChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 步骤4:导入课 -->
|
||||||
|
<Step4IntroLesson
|
||||||
|
v-show="currentStep === 3"
|
||||||
|
ref="step4Ref"
|
||||||
|
:school-course-id="schoolCourseId"
|
||||||
|
:lesson-data="formData.lessons.introduction"
|
||||||
|
@change="handleDataChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 步骤5:集体课 -->
|
||||||
|
<Step5CollectiveLesson
|
||||||
|
v-show="currentStep === 4"
|
||||||
|
ref="step5Ref"
|
||||||
|
:school-course-id="schoolCourseId"
|
||||||
|
:lesson-data="formData.lessons.collective"
|
||||||
|
@change="handleDataChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 步骤6:领域课 -->
|
||||||
|
<Step6DomainLessons
|
||||||
|
v-show="currentStep === 5"
|
||||||
|
ref="step6Ref"
|
||||||
|
:school-course-id="schoolCourseId"
|
||||||
|
:domain-lessons="formData.lessons.domainLessons"
|
||||||
|
@change="handleDataChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 步骤7:环创建设 -->
|
||||||
|
<Step7Environment
|
||||||
|
v-show="currentStep === 6"
|
||||||
|
ref="step7Ref"
|
||||||
|
v-model="formData.environmentConstruction"
|
||||||
|
@change="handleDataChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 步骤导航按钮 -->
|
||||||
|
<div class="step-actions">
|
||||||
|
<a-button v-if="currentStep > 0" @click="prevStep">
|
||||||
|
上一步
|
||||||
|
</a-button>
|
||||||
|
<a-button v-if="currentStep < 6" type="primary" @click="nextStep">
|
||||||
|
下一步
|
||||||
|
</a-button>
|
||||||
|
<a-button v-if="currentStep === 6" type="primary" @click="showSaveModal = true">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
</a-spin>
|
||||||
|
|
||||||
|
<!-- 保存位置选择弹窗 -->
|
||||||
|
<a-modal
|
||||||
|
v-model:open="showSaveModal"
|
||||||
|
title="选择保存位置"
|
||||||
|
@ok="() => handleSave(false)"
|
||||||
|
:confirm-loading="saving"
|
||||||
|
>
|
||||||
|
<a-radio-group v-model:value="saveLocation">
|
||||||
|
<a-space direction="vertical" style="width: 100%;">
|
||||||
|
<a-radio value="PERSONAL">
|
||||||
|
<div class="save-option">
|
||||||
|
<div class="option-title">
|
||||||
|
<UserOutlined /> 保存到个人课程中心
|
||||||
|
</div>
|
||||||
|
<div class="option-desc">仅您自己可以看到和使用</div>
|
||||||
|
</div>
|
||||||
|
</a-radio>
|
||||||
|
<a-radio value="SCHOOL">
|
||||||
|
<div class="save-option">
|
||||||
|
<div class="option-title">
|
||||||
|
<TeamOutlined /> 提交到校本课程中心
|
||||||
|
</div>
|
||||||
|
<div class="option-desc">本校所有教师都可以看到(需审核)</div>
|
||||||
|
</div>
|
||||||
|
</a-radio>
|
||||||
|
</a-space>
|
||||||
|
</a-radio-group>
|
||||||
|
</a-modal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -59,104 +152,391 @@
|
|||||||
import { ref, reactive, computed, onMounted } from 'vue';
|
import { ref, reactive, computed, onMounted } from 'vue';
|
||||||
import { useRouter, useRoute } from 'vue-router';
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import {
|
import { BookOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons-vue';
|
||||||
getTeacherSourceCourses,
|
import Step1BasicInfo from '@/components/course-edit/Step1BasicInfo.vue';
|
||||||
getTeacherSchoolCourseDetail,
|
import Step2CourseIntro from '@/components/course-edit/Step2CourseIntro.vue';
|
||||||
createTeacherSchoolCourse,
|
import Step3ScheduleRef from '@/components/course-edit/Step3ScheduleRef.vue';
|
||||||
updateTeacherSchoolCourse,
|
import Step4IntroLesson from './components/Step4IntroLesson.vue';
|
||||||
} from '@/api/school-course';
|
import Step5CollectiveLesson from './components/Step5CollectiveLesson.vue';
|
||||||
|
import Step6DomainLessons from './components/Step6DomainLessons.vue';
|
||||||
|
import Step7Environment from '@/components/course-edit/Step7Environment.vue';
|
||||||
|
import * as schoolCourseApi from '@/api/school-course';
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const isEdit = computed(() => !!route.params.id);
|
const isEdit = computed(() => !!route.params.id);
|
||||||
const courseId = computed(() => Number(route.params.id));
|
const schoolCourseId = computed(() => Number(route.params.id));
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const sourceCourses = ref<any[]>([]);
|
const currentStep = ref(0);
|
||||||
|
const showSaveModal = ref(false);
|
||||||
|
const saveLocation = ref<'PERSONAL' | 'SCHOOL'>('PERSONAL');
|
||||||
|
const sourceCourse = ref<any>(null);
|
||||||
|
|
||||||
const form = reactive({
|
// 步骤组件引用
|
||||||
sourceCourseId: undefined as number | undefined,
|
const step1Ref = ref();
|
||||||
|
const step2Ref = ref();
|
||||||
|
const step3Ref = ref();
|
||||||
|
const step4Ref = ref();
|
||||||
|
const step5Ref = ref();
|
||||||
|
const step6Ref = ref();
|
||||||
|
const step7Ref = ref();
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const formData = reactive({
|
||||||
|
basic: {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
themeId: undefined as number | undefined,
|
||||||
changesSummary: '',
|
grades: [] as string[],
|
||||||
|
pictureBookName: '',
|
||||||
|
coreContent: '',
|
||||||
|
duration: 25,
|
||||||
|
domainTags: [] as string[],
|
||||||
|
coverImagePath: '',
|
||||||
|
},
|
||||||
|
intro: {
|
||||||
|
introSummary: '',
|
||||||
|
introHighlights: '',
|
||||||
|
introGoals: '',
|
||||||
|
introSchedule: '',
|
||||||
|
introKeyPoints: '',
|
||||||
|
introMethods: '',
|
||||||
|
introEvaluation: '',
|
||||||
|
introNotes: '',
|
||||||
|
},
|
||||||
|
scheduleRefData: '',
|
||||||
|
environmentConstruction: '',
|
||||||
|
lessons: {
|
||||||
|
introduction: null as any,
|
||||||
|
collective: null as any,
|
||||||
|
domainLessons: {
|
||||||
|
LANGUAGE: null as any,
|
||||||
|
HEALTH: null as any,
|
||||||
|
SCIENCE: null as any,
|
||||||
|
SOCIAL: null as any,
|
||||||
|
ART: null as any,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const filterOption = (input: string, option: any) => {
|
// 计算完成度
|
||||||
return option.children?.[0]?.children?.toLowerCase().includes(input.toLowerCase());
|
const completionPercent = computed(() => {
|
||||||
};
|
let filled = 0;
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
const fetchSourceCourses = async () => {
|
// 基本信息必填项
|
||||||
try {
|
total += 4;
|
||||||
const res = await getTeacherSourceCourses();
|
if (formData.basic.name) filled++;
|
||||||
sourceCourses.value = res.data || [];
|
if (formData.basic.themeId) filled++;
|
||||||
} catch (error) {
|
if (formData.basic.grades.length > 0) filled++;
|
||||||
console.error('获取源课程列表失败', error);
|
if (formData.basic.coreContent) filled++;
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// 课程介绍(8项可选)
|
||||||
|
total += 8;
|
||||||
|
if (formData.intro.introSummary) filled++;
|
||||||
|
if (formData.intro.introHighlights) filled++;
|
||||||
|
if (formData.intro.introGoals) filled++;
|
||||||
|
if (formData.intro.introSchedule) filled++;
|
||||||
|
if (formData.intro.introKeyPoints) filled++;
|
||||||
|
if (formData.intro.introMethods) filled++;
|
||||||
|
if (formData.intro.introEvaluation) filled++;
|
||||||
|
if (formData.intro.introNotes) filled++;
|
||||||
|
|
||||||
|
return Math.round((filled / total) * 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const completionStatus = computed(() => {
|
||||||
|
if (completionPercent.value >= 75) return 'success';
|
||||||
|
if (completionPercent.value >= 50) return 'normal';
|
||||||
|
return 'exception';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取课程详情
|
||||||
const fetchDetail = async () => {
|
const fetchDetail = async () => {
|
||||||
if (!isEdit.value) return;
|
if (!isEdit.value) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getTeacherSchoolCourseDetail(courseId.value);
|
const res = await schoolCourseApi.getTeacherSchoolCourseFullDetail(schoolCourseId.value) as any;
|
||||||
const data = res.data;
|
const data = res.data || res;
|
||||||
form.name = data.name;
|
|
||||||
form.description = data.description || '';
|
// 基本信息
|
||||||
form.changesSummary = data.changesSummary || '';
|
formData.basic.name = data.name || '';
|
||||||
form.sourceCourseId = data.sourceCourseId;
|
formData.basic.themeId = data.themeId;
|
||||||
} catch (error) {
|
formData.basic.grades = data.gradeTags ? JSON.parse(data.gradeTags) : [];
|
||||||
message.error('获取详情失败');
|
formData.basic.pictureBookName = '';
|
||||||
|
formData.basic.coreContent = data.coreContent || data.core_content || '';
|
||||||
|
formData.basic.duration = data.duration || 25;
|
||||||
|
formData.basic.domainTags = data.domainTags ? JSON.parse(data.domainTags) : [];
|
||||||
|
formData.basic.coverImagePath = data.coverImagePath || data.cover_image_path || '';
|
||||||
|
|
||||||
|
// 课程介绍
|
||||||
|
formData.intro.introSummary = data.introSummary || data.intro_summary || '';
|
||||||
|
formData.intro.introHighlights = data.introHighlights || data.intro_highlights || '';
|
||||||
|
formData.intro.introGoals = data.introGoals || data.intro_goals || '';
|
||||||
|
formData.intro.introSchedule = data.introSchedule || data.intro_schedule || '';
|
||||||
|
formData.intro.introKeyPoints = data.introKeyPoints || data.intro_key_points || '';
|
||||||
|
formData.intro.introMethods = data.introMethods || data.intro_methods || '';
|
||||||
|
formData.intro.introEvaluation = data.introEvaluation || data.intro_evaluation || '';
|
||||||
|
formData.intro.introNotes = data.introNotes || data.intro_notes || '';
|
||||||
|
|
||||||
|
// 排课参考和环创建设
|
||||||
|
formData.scheduleRefData = data.scheduleRefData || data.schedule_ref_data || '';
|
||||||
|
formData.environmentConstruction = data.environmentConstruction || data.environment_construction || '';
|
||||||
|
|
||||||
|
// 源课程信息
|
||||||
|
sourceCourse.value = data.sourceCourse;
|
||||||
|
|
||||||
|
// 课程配置
|
||||||
|
if (data.lessons && data.lessons.length > 0) {
|
||||||
|
for (const lesson of data.lessons) {
|
||||||
|
const stepsData = lesson.stepsData ? JSON.parse(lesson.stepsData) : null;
|
||||||
|
|
||||||
|
if (lesson.lessonType === 'INTRODUCTION') {
|
||||||
|
formData.lessons.introduction = {
|
||||||
|
...lesson,
|
||||||
|
stepsData,
|
||||||
|
};
|
||||||
|
} else if (lesson.lessonType === 'COLLECTIVE') {
|
||||||
|
formData.lessons.collective = {
|
||||||
|
...lesson,
|
||||||
|
stepsData,
|
||||||
|
};
|
||||||
|
} else if (['LANGUAGE', 'HEALTH', 'SCIENCE', 'SOCIAL', 'ART'].includes(lesson.lessonType)) {
|
||||||
|
formData.lessons.domainLessons[lesson.lessonType as keyof typeof formData.lessons.domainLessons] = {
|
||||||
|
...lesson,
|
||||||
|
stepsData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '获取详情失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSourceChange = (value: any) => {
|
// 从源课程创建
|
||||||
const course = sourceCourses.value.find((c) => c.id === value);
|
const createFromSource = async () => {
|
||||||
if (course) {
|
const sourceCourseId = Number(route.query.sourceCourseId);
|
||||||
form.name = course.name + ' (校本版)';
|
if (!sourceCourseId) return;
|
||||||
form.description = course.description || '';
|
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await schoolCourseApi.createTeacherSchoolCourseFromSource(sourceCourseId, saveLocation.value);
|
||||||
|
const data = res.data || res;
|
||||||
|
|
||||||
|
message.success('创建成功,请编辑课程内容');
|
||||||
|
router.replace(`/teacher/school-courses/${data.id}/edit`);
|
||||||
|
await fetchDetail();
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || '创建失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
// 步骤导航
|
||||||
|
const onStepChange = (step: number) => {
|
||||||
|
if (step > currentStep.value) {
|
||||||
|
if (!validateCurrentStep()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentStep.value = step;
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevStep = () => {
|
||||||
|
if (currentStep.value > 0) {
|
||||||
|
currentStep.value--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStep = () => {
|
||||||
|
if (!validateCurrentStep()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentStep.value < 6) {
|
||||||
|
currentStep.value++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 验证当前步骤
|
||||||
|
const validateCurrentStep = () => {
|
||||||
|
if (currentStep.value === 0) {
|
||||||
|
const result = step1Ref.value?.validate();
|
||||||
|
if (!result?.valid) {
|
||||||
|
message.warning(result?.errors[0] || '请完成基本信息');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理数据变化
|
||||||
|
const handleDataChange = () => {
|
||||||
|
// 数据变化时可以自动保存草稿
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存草稿
|
||||||
|
const handleSaveDraft = async () => {
|
||||||
|
await handleSave(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存
|
||||||
|
const handleSave = async (_isDraft = false) => {
|
||||||
|
if (saving.value) return;
|
||||||
|
|
||||||
|
if (!validateCurrentStep()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = {
|
const data = {
|
||||||
name: form.name,
|
name: formData.basic.name,
|
||||||
description: form.description,
|
themeId: formData.basic.themeId,
|
||||||
changesSummary: form.changesSummary,
|
gradeTags: JSON.stringify(formData.basic.grades),
|
||||||
|
coreContent: formData.basic.coreContent,
|
||||||
|
duration: formData.basic.duration,
|
||||||
|
domainTags: JSON.stringify(formData.basic.domainTags),
|
||||||
|
coverImagePath: formData.basic.coverImagePath,
|
||||||
|
|
||||||
|
introSummary: formData.intro.introSummary,
|
||||||
|
introHighlights: formData.intro.introHighlights,
|
||||||
|
introGoals: formData.intro.introGoals,
|
||||||
|
introSchedule: formData.intro.introSchedule,
|
||||||
|
introKeyPoints: formData.intro.introKeyPoints,
|
||||||
|
introMethods: formData.intro.introMethods,
|
||||||
|
introEvaluation: formData.intro.introEvaluation,
|
||||||
|
introNotes: formData.intro.introNotes,
|
||||||
|
|
||||||
|
scheduleRefData: formData.scheduleRefData,
|
||||||
|
environmentConstruction: formData.environmentConstruction,
|
||||||
|
|
||||||
|
lessons: formData.lessons,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEdit.value) {
|
if (isEdit.value) {
|
||||||
await updateTeacherSchoolCourse(courseId.value, data);
|
await schoolCourseApi.updateTeacherSchoolCourseFull(schoolCourseId.value, data);
|
||||||
|
message.success('保存成功');
|
||||||
} else {
|
} else {
|
||||||
if (!form.sourceCourseId) {
|
await schoolCourseApi.createTeacherSchoolCourseFromSource(
|
||||||
message.warning('请选择源课程包');
|
Number(route.query.sourceCourseId),
|
||||||
return;
|
saveLocation.value,
|
||||||
}
|
);
|
||||||
await createTeacherSchoolCourse({
|
message.success('创建成功');
|
||||||
sourceCourseId: form.sourceCourseId,
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message.success('保存成功');
|
showSaveModal.value = false;
|
||||||
router.push('/teacher/school-courses');
|
router.back();
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
message.error('保存失败');
|
message.error(error.message || '保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
fetchSourceCourses();
|
if (route.query.sourceCourseId) {
|
||||||
fetchDetail();
|
await createFromSource();
|
||||||
|
} else {
|
||||||
|
await fetchDetail();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.school-course-edit-page {
|
.school-course-edit-page {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f0f7ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps-nav {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completion-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fafafa;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-progress {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-option {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background: #f0f7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-title {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -5,29 +5,62 @@
|
|||||||
<span>我的校本课程包</span>
|
<span>我的校本课程包</span>
|
||||||
</template>
|
</template>
|
||||||
<template #extra>
|
<template #extra>
|
||||||
|
<a-space>
|
||||||
|
<a-select
|
||||||
|
v-model:value="filterLocation"
|
||||||
|
placeholder="筛选位置"
|
||||||
|
style="width: 150px"
|
||||||
|
@change="fetchData"
|
||||||
|
>
|
||||||
|
<a-select-option value="">全部</a-select-option>
|
||||||
|
<a-select-option value="PERSONAL">
|
||||||
|
<UserOutlined /> 个人课程中心
|
||||||
|
</a-select-option>
|
||||||
|
<a-select-option value="SCHOOL">
|
||||||
|
<TeamOutlined /> 校本课程中心
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
<a-button type="primary" @click="handleCreate">
|
<a-button type="primary" @click="handleCreate">
|
||||||
<template #icon><PlusOutlined /></template>
|
<template #icon><PlusOutlined /></template>
|
||||||
创建校本课程包
|
从课程中心创建
|
||||||
</a-button>
|
</a-button>
|
||||||
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<a-table
|
<a-table
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data-source="dataSource"
|
:data-source="filteredData"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record }">
|
||||||
<template v-if="column.key === 'sourceCourse'">
|
<template v-if="column.key === 'name'">
|
||||||
|
<div class="name-cell">
|
||||||
|
<span>{{ record.name }}</span>
|
||||||
|
<a-tag v-if="record.saveLocation === 'PERSONAL'" size="small" color="blue">
|
||||||
|
个人
|
||||||
|
</a-tag>
|
||||||
|
<a-tag v-else size="small" color="green">
|
||||||
|
校本
|
||||||
|
</a-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'sourceCourse'">
|
||||||
<div class="source-info">
|
<div class="source-info">
|
||||||
<img
|
<img
|
||||||
v-if="record.sourceCourse?.coverImagePath"
|
v-if="record.sourceCourse?.coverImagePath"
|
||||||
:src="record.sourceCourse.coverImagePath"
|
:src="getFileUrl(record.sourceCourse.coverImagePath)"
|
||||||
class="cover"
|
class="cover"
|
||||||
/>
|
/>
|
||||||
<span>{{ record.sourceCourse?.name }}</span>
|
<span>{{ record.sourceCourse?.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'reviewStatus'">
|
||||||
|
<a-tag v-if="record.reviewStatus === 'PENDING'" color="orange">待审核</a-tag>
|
||||||
|
<a-tag v-else-if="record.reviewStatus === 'APPROVED'" color="success">已通过</a-tag>
|
||||||
|
<a-tag v-else-if="record.reviewStatus === 'REJECTED'" color="error">已驳回</a-tag>
|
||||||
|
<a-tag v-else>-</a-tag>
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'status'">
|
<template v-else-if="column.key === 'status'">
|
||||||
<a-tag :color="record.status === 'ACTIVE' ? 'success' : 'default'">
|
<a-tag :color="record.status === 'ACTIVE' ? 'success' : 'default'">
|
||||||
{{ record.status === 'ACTIVE' ? '启用' : '禁用' }}
|
{{ record.status === 'ACTIVE' ? '启用' : '禁用' }}
|
||||||
@ -49,10 +82,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { message } from 'ant-design-vue';
|
import { message } from 'ant-design-vue';
|
||||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
import { PlusOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons-vue';
|
||||||
import { getTeacherSchoolCourseList, deleteTeacherSchoolCourse } from '@/api/school-course';
|
import { getTeacherSchoolCourseList, deleteTeacherSchoolCourse } from '@/api/school-course';
|
||||||
import type { SchoolCourse } from '@/api/school-course';
|
import type { SchoolCourse } from '@/api/school-course';
|
||||||
|
|
||||||
@ -60,22 +93,38 @@ const router = useRouter();
|
|||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const dataSource = ref<SchoolCourse[]>([]);
|
const dataSource = ref<SchoolCourse[]>([]);
|
||||||
|
const filterLocation = ref('');
|
||||||
|
|
||||||
|
const filteredData = computed(() => {
|
||||||
|
if (!filterLocation.value) return dataSource.value;
|
||||||
|
return dataSource.value.filter(item => item.saveLocation === filterLocation.value);
|
||||||
|
});
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
{ title: '名称', key: 'name', width: 200 },
|
||||||
{ title: '源课程包', key: 'sourceCourse' },
|
{ title: '源课程包', key: 'sourceCourse' },
|
||||||
{ title: '修改说明', dataIndex: 'changesSummary', key: 'changesSummary' },
|
{ title: '修改说明', dataIndex: 'changesSummary', key: 'changesSummary' },
|
||||||
{ title: '使用次数', dataIndex: 'usageCount', key: 'usageCount', width: 100 },
|
{ title: '使用次数', dataIndex: 'usageCount', key: 'usageCount', width: 100 },
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 80 },
|
{ title: '审核状态', key: 'reviewStatus', width: 100 },
|
||||||
|
{ title: '状态', key: 'status', width: 80 },
|
||||||
{ title: '操作', key: 'action', width: 180 },
|
{ title: '操作', key: 'action', width: 180 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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 fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await getTeacherSchoolCourseList() as any;
|
const res = await getTeacherSchoolCourseList() as any;
|
||||||
dataSource.value = res || [];
|
dataSource.value = res.data || res || [];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取校本课程包列表失败', error);
|
console.error('获取校本课程包列表失败', error);
|
||||||
} finally {
|
} finally {
|
||||||
@ -84,7 +133,8 @@ const fetchData = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
router.push('/teacher/school-courses/create');
|
// 跳转到课程中心列表,让用户选择源课程
|
||||||
|
router.push('/teacher/courses');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleView = (record: any) => {
|
const handleView = (record: any) => {
|
||||||
@ -115,6 +165,12 @@ onMounted(() => {
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.source-info {
|
.source-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
<template>
|
||||||
|
<div class="step-intro-lesson">
|
||||||
|
<Step4IntroLessonAdmin
|
||||||
|
:course-id="0"
|
||||||
|
@change="$emit('change')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Step4IntroLessonAdmin from '@/components/course-edit/Step4IntroLesson.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
schoolCourseId: number;
|
||||||
|
lessonData?: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
change: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.step-intro-lesson {
|
||||||
|
/* 继承admin组件样式 */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<div class="step-collective-lesson">
|
||||||
|
<Step5CollectiveLessonAdmin
|
||||||
|
:course-id="0"
|
||||||
|
course-name=""
|
||||||
|
@change="$emit('change')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Step5CollectiveLessonAdmin from '@/components/course-edit/Step5CollectiveLesson.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
schoolCourseId: number;
|
||||||
|
lessonData?: any;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
change: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.step-collective-lesson {
|
||||||
|
/* 继承admin组件样式 */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<div class="step-domain-lessons">
|
||||||
|
<Step6DomainLessonsAdmin
|
||||||
|
:course-id="0"
|
||||||
|
course-name=""
|
||||||
|
@change="$emit('change')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Step6DomainLessonsAdmin from '@/components/course-edit/Step6DomainLessons.vue';
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
schoolCourseId: number;
|
||||||
|
domainLessons?: {
|
||||||
|
LANGUAGE?: any;
|
||||||
|
HEALTH?: any;
|
||||||
|
SCIENCE?: any;
|
||||||
|
SOCIAL?: any;
|
||||||
|
ART?: any;
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
change: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.step-domain-lessons {
|
||||||
|
/* 继承admin组件样式 */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -512,7 +512,7 @@ const loadTasks = async () => {
|
|||||||
tasks.value = data.items || [];
|
tasks.value = data.items || [];
|
||||||
total.value = data.total || 0;
|
total.value = data.total || 0;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response?.data?.message || '加载失败');
|
message.error(error.message || '加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@ -655,7 +655,7 @@ const handleCreate = async () => {
|
|||||||
createModalVisible.value = false;
|
createModalVisible.value = false;
|
||||||
loadTasks();
|
loadTasks();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response?.data?.message || '操作失败');
|
message.error(error.message || '操作失败');
|
||||||
} finally {
|
} finally {
|
||||||
creating.value = false;
|
creating.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user