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:
Claude Opus 4.6 2026-03-12 14:33:44 +08:00
parent de54ed112c
commit 4e13f186f3
33 changed files with 7609 additions and 3093 deletions

View File

@ -175,7 +175,7 @@ const loadClasses = async () => {
}));
} catch (error: any) {
console.error('Failed to load classes:', error);
message.error(error.response?.data?.message || '加载班级失败');
message.error(error.message || '加载班级失败');
} finally {
loading.value = false;
}

View File

@ -257,7 +257,7 @@ const loadStudents = async () => {
avgScore: Math.round(Math.random() * 40 + 60), //
}));
} catch (error: any) {
message.error(error.response?.data?.message || '加载失败');
message.error(error.message || '加载失败');
} finally {
loading.value = false;
}

View File

@ -289,7 +289,7 @@ const loadCourses = async () => {
}));
pagination.total = data.total || 0;
} catch (error: any) {
message.error(error.response?.data?.message || '获取课程列表失败');
message.error(error.message || '获取课程列表失败');
} finally {
loading.value = false;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,9 +15,10 @@
<!-- 展播内容 -->
<KidsMode
v-else-if="course && scripts.length > 0"
v-else-if="currentLesson && currentSteps.length > 0"
:course="course"
:scripts="scripts"
:current-lesson="currentLesson"
:steps="currentSteps"
:activities="activities"
:current-step-index="currentStepIndex"
:timer-seconds="0"
@ -34,7 +35,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';
import { ExclamationCircleOutlined, InboxOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
@ -47,13 +48,17 @@ const route = useRoute();
const loading = ref(true);
const error = ref('');
const course = ref<any>(null);
const scripts = ref<any[]>([]);
const lessons = ref<any[]>([]);
const activities = ref<any[]>([]);
//
const currentLessonIndex = ref(0);
const currentStepIndex = ref(0);
// URL
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> = {
@ -63,8 +68,115 @@ const stepTypeMap: Record<string, string> = {
'CREATION': '创作',
'SUMMARY': '总结',
'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 () => {
loading.value = true;
@ -94,7 +206,20 @@ const loadData = async () => {
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,
stepType: stepTypeMap[script.stepType] || script.stepType,
interactionPointsText: Array.isArray(script.interactionPoints)
@ -105,7 +230,16 @@ const loadData = async () => {
...page,
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) {
@ -131,9 +265,6 @@ const loadData = async () => {
}
}
//
currentStepIndex.value = Math.min(initialStep, Math.max(0, scripts.value.length - 1));
} catch (err: any) {
console.error('Failed to load broadcast data:', err);
error.value = err.response?.data?.message || '加载失败,请重试';
@ -151,7 +282,7 @@ const handleExit = () => {
const handleStepChange = (index: number) => {
currentStepIndex.value = index;
// URL
const newUrl = `${window.location.pathname}?step=${index}`;
const newUrl = `${window.location.pathname}?lessonIndex=${currentLessonIndex.value}&stepIndex=${index}`;
window.history.replaceState({}, '', newUrl);
};
@ -165,6 +296,18 @@ const handleKeydown = (e: KeyboardEvent) => {
e.preventDefault();
toggleFullscreen();
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(() => {
loadData();
document.addEventListener('keydown', handleKeydown);
//
setTimeout(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {
//
});
}
}, 1000);
});
onUnmounted(() => {

View File

@ -258,7 +258,7 @@ const loadLessons = async () => {
lessons.value = data.items || [];
total.value = data.total || 0;
} catch (error: any) {
message.error(error.response?.data?.message || '获取上课记录失败');
message.error(error.message || '获取上课记录失败');
} finally {
loading.value = false;
}
@ -331,7 +331,7 @@ const startPlannedLesson = async () => {
detailDrawerVisible.value = false;
router.push(`/teacher/lessons/${selectedLesson.value.id}`);
} catch (error: any) {
message.error(error.response?.data?.message || '开始上课失败');
message.error(error.message || '开始上课失败');
}
},
});
@ -354,7 +354,7 @@ const cancelLesson = () => {
detailDrawerVisible.value = false;
loadLessons();
} catch (error: any) {
message.error(error.response?.data?.message || '取消失败');
message.error(error.message || '取消失败');
}
},
});

View File

@ -309,7 +309,7 @@ const loadRecords = async () => {
//
await loadLessonDetail();
} catch (error: any) {
message.error(error.response?.data?.message || '获取学生记录失败');
message.error(error.message || '获取学生记录失败');
} finally {
loading.value = false;
}
@ -394,7 +394,7 @@ const handleCreateTask = async () => {
message.success('任务布置成功');
taskModalVisible.value = false;
} catch (error: any) {
message.error(error.response?.data?.message || '创建任务失败');
message.error(error.message || '创建任务失败');
} finally {
taskSaving.value = false;
}
@ -441,7 +441,7 @@ const saveRecords = async () => {
//
await loadRecords();
} catch (error: any) {
message.error(error.response?.data?.message || '保存失败');
message.error(error.message || '保存失败');
} finally {
saving.value = false;
}

View File

@ -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 - 使URLCORS
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);
// PDF1props0-based1-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 VuePDF.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>

View File

@ -13,33 +13,15 @@
alt="幻灯片"
/>
<!-- PDF展示 - 使用embed标签 -->
<embed
v-else-if="pages.length > 0 && currentSlideType === 'pdf'"
:src="pdfEmbedUrl"
type="application/pdf"
class="slide-pdf"
@load="onPdfLoad"
<!-- PDF展示 - 使用PDF.js组件 -->
<PdfViewer
v-else-if="pages.length > 0 && (currentSlideType === 'pdf' || currentSlideType === 'pdf-fallback')"
:url="pages[currentPage]"
:current-page="currentPage"
@page-change="handlePdfPageChange"
@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展示 - 无法嵌入提供下载 -->
<div v-else-if="pages.length > 0 && currentSlideType === 'ppt'" class="ppt-container">
<div class="ppt-preview">
@ -67,10 +49,10 @@
<p v-if="showError && pages[currentPage]" class="error-url">{{ pages[currentPage] }}</p>
</div>
<!-- 加载中 -->
<div v-if="isLoading && pages.length > 0" class="loading-overlay">
<!-- 加载中 - 不在PDF时显示因为PdfViewer有自己的loading overlay -->
<div v-if="isLoading && pages.length > 0 && currentSlideType !== 'pdf' && currentSlideType !== 'pdf-fallback'" class="loading-overlay">
<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>
<!-- 左右翻页提示 -->
@ -82,8 +64,9 @@
</div>
</div>
<!-- 页码控制 -->
<div class="page-controls" v-if="pages.length > 1">
<!-- 页码控制 - 只在非PDF类型且有多个文件时显示 -->
<!-- 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 }">
<ChevronLeft :size="24" />
</div>
@ -108,8 +91,8 @@
{{ currentPage + 1 }} / {{ pages.length }}
</div>
<!-- PDF/PPT工具栏 -->
<div class="pdf-toolbar" v-if="currentSlideType === 'pdf' || currentSlideType === 'pdf-fallback' || currentSlideType === 'ppt'">
<!-- PPT工具栏 -->
<div class="pdf-toolbar" v-if="currentSlideType === 'ppt'">
<a :href="pages[currentPage]" target="_blank" class="toolbar-btn" title="新窗口打开">
<ExternalLink :size="18" />
</a>
@ -130,11 +113,12 @@ import {
Download,
ExternalLink,
} from 'lucide-vue-next';
import PdfViewer from './PdfViewer.vue';
interface Props {
pages: string[];
currentPage: number;
type: 'ppt' | 'poster';
type: 'ppt' | 'poster' | 'pdf';
}
const props = withDefaults(defineProps<Props>(), {
@ -145,6 +129,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
(e: 'pageChange', page: number): void;
(e: 'load', totalPages: number): void;
}>();
//
@ -173,19 +158,6 @@ const currentSlideType = computed(() => {
return 'image';
});
// PDFURL -
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 url = props.pages[currentPage.value] || '';
@ -205,13 +177,32 @@ const onImageError = () => {
showError.value = true;
};
const onPdfLoad = () => {
isLoading.value = false;
const onPdfLoad = (totalPages: number) => {
console.log('[SlidesViewer] onPdfLoad 被调用, 总页数:', totalPages);
// PDFSlidesViewerloading
// PdfViewerloading 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 = () => {
if (currentPage.value > 0) {
const isPdf = currentSlideType.value === 'pdf' || currentSlideType.value === 'pdf-fallback';
// PDFloadingPdfViewerloading
if (!isPdf) {
isLoading.value = true;
}
showError.value = false;
currentPage.value--;
emit('pageChange', currentPage.value);
@ -220,7 +211,11 @@ const prevPage = () => {
const nextPage = () => {
if (currentPage.value < props.pages.length - 1) {
const isPdf = currentSlideType.value === 'pdf' || currentSlideType.value === 'pdf-fallback';
// PDFloadingPdfViewerloading
if (!isPdf) {
isLoading.value = true;
}
showError.value = false;
currentPage.value++;
emit('pageChange', currentPage.value);
@ -229,7 +224,11 @@ const nextPage = () => {
const goToPage = (index: number) => {
if (index !== currentPage.value) {
const isPdf = currentSlideType.value === 'pdf' || currentSlideType.value === 'pdf-fallback';
// PDFloadingPdfViewerloading
if (!isPdf) {
isLoading.value = true;
}
showError.value = false;
currentPage.value = index;
emit('pageChange', currentPage.value);
@ -255,6 +254,12 @@ const handleContainerClick = (e: MouseEvent) => {
//
const handleKeydown = (e: KeyboardEvent) => {
// PDFPDF
const isPdf = currentSlideType.value === 'pdf' || currentSlideType.value === 'pdf-fallback';
if (isPdf) {
return; // PdfViewer
}
switch (e.key) {
case 'ArrowLeft':
prevPage();
@ -268,25 +273,39 @@ const handleKeydown = (e: KeyboardEvent) => {
//
watch(() => props.currentPage, (newPage) => {
if (newPage !== currentPage.value) {
const isPdf = currentSlideType.value === 'pdf' || currentSlideType.value === 'pdf-fallback';
// PDFloadingPdfViewerloading
if (!isPdf) {
isLoading.value = true;
}
showError.value = false;
currentPage.value = newPage;
}
});
// pages
watch(() => props.pages, () => {
// pages -
watch(() => props.pages, (newPages, oldPages) => {
// pagesloading
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;
showError.value = false;
}, { immediate: true });
}
});
//
onMounted(() => {
console.log('[SlidesViewer] 组件已挂载');
console.log('[SlidesViewer] onPdfLoad 函数存在:', typeof onPdfLoad === 'function');
document.addEventListener('keydown', handleKeydown);
//
setTimeout(() => {
if (isLoading.value) {
console.log('[SlidesViewer] 加载超时, 强制设置 isLoading = false');
isLoading.value = false;
}
}, 5000);
@ -354,50 +373,6 @@ onUnmounted(() => {
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 {
width: 100%;
height: 100%;

View File

@ -7,10 +7,37 @@
<template #extra>
<a-space>
<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-space>
</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-item label="名称">{{ detail?.name }}</a-descriptions-item>
<a-descriptions-item label="状态">
@ -26,6 +53,16 @@
<a-descriptions-item label="修改说明" :span="2">{{ detail?.changesSummary || '-' }}</a-descriptions-item>
</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-table
@ -48,30 +85,37 @@
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import { getTeacherSchoolCourseDetail } from '@/api/school-course';
import type { SchoolCourse } from '@/api/school-course';
import {
EditOutlined,
PlayCircleOutlined,
UserOutlined,
TeamOutlined,
ClockCircleOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from '@ant-design/icons-vue';
import { getTeacherSchoolCourseFullDetail } from '@/api/school-course';
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const detail = ref<SchoolCourse | null>(null);
const detail = ref<any>(null);
const lessonColumns = [
{ title: '课程类型', dataIndex: 'lessonType', key: 'lessonType', width: 120 },
{ title: '目标', dataIndex: 'objectives', key: 'objectives' },
{ title: '准备', dataIndex: 'preparation', key: 'preparation' },
{ title: '修改备注', dataIndex: 'changeNote', key: 'changeNote' },
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '时长', dataIndex: 'duration', key: 'duration', width: 80 },
];
const lessonTypeNames: Record<string, string> = {
INTRODUCTION: '导入课',
COLLECTIVE: '集体课',
HEALTH: '健康',
LANGUAGE: '语言',
SOCIAL: '社会',
SCIENCE: '科学',
ART: '艺术',
DOMAIN: '领域课',
};
const getLessonTypeName = (type: string) => lessonTypeNames[type] || type;
@ -85,8 +129,8 @@ const fetchData = async () => {
loading.value = true;
try {
const id = Number(route.params.id);
const res = await getTeacherSchoolCourseDetail(id);
detail.value = res.data;
const res = await getTeacherSchoolCourseFullDetail(id) as any;
detail.value = res.data || res;
} catch (error) {
message.error('获取详情失败');
} finally {
@ -98,6 +142,22 @@ const handleEdit = () => {
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(() => {
fetchData();
});
@ -107,4 +167,14 @@ onMounted(() => {
.school-course-detail-page {
padding: 24px;
}
.status-bar {
margin-bottom: 16px;
display: flex;
gap: 8px;
}
.course-preview {
margin-bottom: 16px;
}
</style>

View File

@ -1,57 +1,150 @@
<template>
<div class="school-course-edit-page">
<a-card :bordered="false">
<template #title>
<span>{{ isEdit ? '编辑校本课程包' : '创建校本课程包' }}</span>
</template>
<a-page-header
:title="isEdit ? '编辑校本课程包' : '创建校本课程包'"
@back="() => router.back()"
>
<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-button type="primary" html-type="submit" :loading="saving">保存</a-button>
<a-button @click="router.back()">取消</a-button>
<a-button @click="handleSaveDraft" :loading="saving">保存草稿</a-button>
<a-button type="primary" @click="showSaveModal = true">
{{ isEdit ? '保存' : '创建' }}
</a-button>
</a-space>
</a-form-item>
</a-form>
</template>
</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-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>
</template>
@ -59,104 +152,391 @@
import { ref, reactive, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { message } from 'ant-design-vue';
import {
getTeacherSourceCourses,
getTeacherSchoolCourseDetail,
createTeacherSchoolCourse,
updateTeacherSchoolCourse,
} from '@/api/school-course';
import { BookOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons-vue';
import Step1BasicInfo from '@/components/course-edit/Step1BasicInfo.vue';
import Step2CourseIntro from '@/components/course-edit/Step2CourseIntro.vue';
import Step3ScheduleRef from '@/components/course-edit/Step3ScheduleRef.vue';
import Step4IntroLesson from './components/Step4IntroLesson.vue';
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 route = useRoute();
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 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: '',
description: '',
changesSummary: '',
themeId: undefined as number | undefined,
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 {
const res = await getTeacherSourceCourses();
sourceCourses.value = res.data || [];
} catch (error) {
console.error('获取源课程列表失败', error);
}
};
//
total += 4;
if (formData.basic.name) filled++;
if (formData.basic.themeId) filled++;
if (formData.basic.grades.length > 0) filled++;
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 () => {
if (!isEdit.value) return;
loading.value = true;
try {
const res = await getTeacherSchoolCourseDetail(courseId.value);
const data = res.data;
form.name = data.name;
form.description = data.description || '';
form.changesSummary = data.changesSummary || '';
form.sourceCourseId = data.sourceCourseId;
} catch (error) {
message.error('获取详情失败');
const res = await schoolCourseApi.getTeacherSchoolCourseFullDetail(schoolCourseId.value) as any;
const data = res.data || res;
//
formData.basic.name = data.name || '';
formData.basic.themeId = data.themeId;
formData.basic.grades = data.gradeTags ? JSON.parse(data.gradeTags) : [];
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);
if (course) {
form.name = course.name + ' (校本版)';
form.description = course.description || '';
//
const createFromSource = async () => {
const sourceCourseId = Number(route.query.sourceCourseId);
if (!sourceCourseId) return;
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;
try {
const data = {
name: form.name,
description: form.description,
changesSummary: form.changesSummary,
name: formData.basic.name,
themeId: formData.basic.themeId,
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) {
await updateTeacherSchoolCourse(courseId.value, data);
await schoolCourseApi.updateTeacherSchoolCourseFull(schoolCourseId.value, data);
message.success('保存成功');
} else {
if (!form.sourceCourseId) {
message.warning('请选择源课程包');
return;
}
await createTeacherSchoolCourse({
sourceCourseId: form.sourceCourseId,
...data,
});
await schoolCourseApi.createTeacherSchoolCourseFromSource(
Number(route.query.sourceCourseId),
saveLocation.value,
);
message.success('创建成功');
}
message.success('保存成功');
router.push('/teacher/school-courses');
} catch (error) {
message.error('保存失败');
showSaveModal.value = false;
router.back();
} catch (error: any) {
message.error(error.message || '保存失败');
} finally {
saving.value = false;
}
};
onMounted(() => {
fetchSourceCourses();
fetchDetail();
onMounted(async () => {
if (route.query.sourceCourseId) {
await createFromSource();
} else {
await fetchDetail();
}
});
</script>
<style scoped>
<style scoped lang="scss">
.school-course-edit-page {
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>

View File

@ -5,29 +5,62 @@
<span>我的校本课程包</span>
</template>
<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">
<template #icon><PlusOutlined /></template>
创建校本课程包
从课程中心创建
</a-button>
</a-space>
</template>
<a-table
:columns="columns"
:data-source="dataSource"
:data-source="filteredData"
:loading="loading"
row-key="id"
>
<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">
<img
v-if="record.sourceCourse?.coverImagePath"
:src="record.sourceCourse.coverImagePath"
:src="getFileUrl(record.sourceCourse.coverImagePath)"
class="cover"
/>
<span>{{ record.sourceCourse?.name }}</span>
</div>
</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'">
<a-tag :color="record.status === 'ACTIVE' ? 'success' : 'default'">
{{ record.status === 'ACTIVE' ? '启用' : '禁用' }}
@ -49,10 +82,10 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
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 type { SchoolCourse } from '@/api/school-course';
@ -60,22 +93,38 @@ const router = useRouter();
const loading = ref(false);
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 = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '名称', key: 'name', width: 200 },
{ title: '源课程包', key: 'sourceCourse' },
{ title: '修改说明', dataIndex: 'changesSummary', key: 'changesSummary' },
{ 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 },
];
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 () => {
loading.value = true;
try {
const res = await getTeacherSchoolCourseList() as any;
dataSource.value = res || [];
dataSource.value = res.data || res || [];
} catch (error) {
console.error('获取校本课程包列表失败', error);
} finally {
@ -84,7 +133,8 @@ const fetchData = async () => {
};
const handleCreate = () => {
router.push('/teacher/school-courses/create');
//
router.push('/teacher/courses');
};
const handleView = (record: any) => {
@ -115,6 +165,12 @@ onMounted(() => {
padding: 24px;
}
.name-cell {
display: flex;
align-items: center;
gap: 8px;
}
.source-info {
display: flex;
align-items: center;

View File

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

View File

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

View File

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

View File

@ -512,7 +512,7 @@ const loadTasks = async () => {
tasks.value = data.items || [];
total.value = data.total || 0;
} catch (error: any) {
message.error(error.response?.data?.message || '加载失败');
message.error(error.message || '加载失败');
} finally {
loading.value = false;
}
@ -655,7 +655,7 @@ const handleCreate = async () => {
createModalVisible.value = false;
loadTasks();
} catch (error: any) {
message.error(error.response?.data?.message || '操作失败');
message.error(error.message || '操作失败');
} finally {
creating.value = false;
}