kindergarten_java/reading-platform-frontend/src/views/teacher/lessons/LessonView.vue
2026-03-20 10:07:17 +08:00

1758 lines
45 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<template>
<div class="lesson-view">
<!-- 顶部工具栏 -->
<div class="lesson-toolbar">
<div class="toolbar-left">
<a-button class="exit-btn" @click="exitLesson">
<template #icon>
<ArrowLeftOutlined />
</template>
退出上课
</a-button>
<div class="course-info" v-if="course.name">
<BookOutlined class="course-icon" />
<span class="course-name">{{ course.name }}</span>
<a-tag color="orange" v-if="classInfo.name">{{ classInfo.name }}</a-tag>
</div>
</div>
<!-- 中间:计时器 -->
<div class="toolbar-center">
<div class="timer-display" @click="showTimer = true">
<ClockCircleOutlined class="timer-icon" />
<span class="timer-value">{{ formatTime(timerSeconds) }}</span>
</div>
</div>
<div class="toolbar-right">
<a-button-group class="nav-buttons">
<a-button @click="previousStep" :disabled="currentStepIndex === 0 && !hasPreviousLesson">
<StepBackwardOutlined /> 上一步
</a-button>
<a-button type="primary" class="next-btn" @click="nextStep" :disabled="isLastStepOfLastLesson">
下一步
<StepForwardOutlined />
</a-button>
</a-button-group>
<a-button type="primary" class="broadcast-btn" @click="openBroadcastMode">
<template #icon>
<ExpandOutlined />
</template>
展播模式
</a-button>
<a-button class="toolbar-icon-btn" @click="showNotesDrawer = true">
<EditOutlined />
</a-button>
<a-button type="primary" danger class="finish-btn" @click="finishLesson">
<CheckOutlined /> 结束课程
</a-button>
</div>
</div>
<!-- 课程进度条(多课程时显示) -->
<div v-if="lessons.length > 1" class="course-progress-bar">
<a-steps :current="currentLessonIndex" size="small" class="course-steps">
<a-step v-for="(lesson, index) in lessons" :key="lesson.id" :title="getLessonShortName(lesson)"
:status="getLessonStatus(index)" :disabled="index > currentLessonIndex" @click="handleLessonClick(index)"
class="clickable-step" />
</a-steps>
</div>
<!-- 环节进度条 -->
<div class="step-progress-bar">
<div class="progress-info">
<span class="current-step-label">
{{ currentLesson?.name }} - {{ currentStep?.name || '准备中' }}
</span>
<span class="step-count">
环节 {{ currentStepIndex + 1 }}/{{ currentLesson?.steps?.length || 0 }}
</span>
</div>
<a-progress :percent="stepProgressPercent" :show-info="false" stroke-color="#FF8C42" class="step-progress" />
</div>
<!-- 主内容区 -->
<div class="lesson-content">
<!-- 左侧:步骤导航 -->
<div class="step-nav">
<div class="nav-header">
<ReadOutlined />
<span>教学流程</span>
<span class="progress-text">{{ currentStepIndex + 1 }}/{{ currentLesson?.steps?.length || 0 }}</span>
</div>
<div class="step-list">
<div v-for="(step, index) in currentLesson?.steps || []" :key="step.id" class="step-item" :class="{
active: currentStepIndex === index,
completed: index < currentStepIndex
}" @click="handleStepClick(index)">
<div class="step-number">
<CheckOutlined v-if="index < currentStepIndex" />
<span v-else>{{ index + 1 }}</span>
</div>
<div class="step-info">
<span class="step-name">{{ step.name }}</span>
<span class="step-duration">{{ step.duration }} 分钟</span>
</div>
</div>
</div>
<!-- 进度条 -->
<div class="progress-bar">
<div class="progress-fill" :style="{ width: stepProgressPercent + '%' }"></div>
</div>
</div>
<!-- 中间:展示区域 -->
<div class="display-area">
<div v-if="currentStep" class="step-display">
<!-- 环节标题 -->
<div class="step-header">
<div class="header-left">
<div class="step-badge">第 {{ currentStepIndex + 1 }} 环节</div>
<h2 class="step-title">{{ currentStep.name }}</h2>
</div>
<div class="header-tags">
<a-tag class="duration-tag">
<ClockCircleOutlined /> {{ currentStep.duration }}分钟
</a-tag>
<a-tag v-if="currentStep.stepType" class="type-tag">
{{ getStepTypeName(currentStep.stepType) }}
</a-tag>
</div>
</div>
<!-- 教学目标 -->
<div v-if="currentStep.objective" class="objective-card">
<div class="card-label">
<AimOutlined /> 教学目标
</div>
<div class="card-content">{{ currentStep.objective }}</div>
</div>
<!-- 环节说明 -->
<div v-if="currentStep.description" class="description-card">
<div class="card-label">
<FileTextOutlined /> 环节说明
</div>
<div class="card-content rich-content" v-html="currentStep.description"></div>
</div>
<!-- 教师讲稿 -->
<div v-if="currentStep.script" class="script-card">
<div class="card-label script-label">
<SoundOutlined /> 教师讲稿
</div>
<div class="script-text rich-content" v-html="currentStep.script"></div>
</div>
<!-- 环节资源 -->
<div v-if="hasStepResources" class="resources-card">
<div class="card-label">
<FolderOutlined /> 环节资源
</div>
<div class="resources-grid">
<!-- 图片资源 -->
<div v-if="currentStep.images?.length" class="resource-group">
<span class="resource-type-label">图片</span>
<div class="resource-items">
<div v-for="(img, idx) in currentStep.images" :key="idx" class="resource-item"
@click="previewResource(img, 'image')">
<PictureOutlined /> {{ img.name || `图片${idx + 1}` }}
</div>
</div>
</div>
<!-- 视频资源 -->
<div v-if="currentStep.videos?.length" class="resource-group">
<span class="resource-type-label">视频</span>
<div class="resource-items">
<div v-for="(vid, idx) in currentStep.videos" :key="idx" class="resource-item"
@click="previewResource(vid, 'video')">
<VideoCameraOutlined /> {{ vid.name || `视频${idx + 1}` }}
</div>
</div>
</div>
<!-- 音频资源 -->
<div v-if="currentStep.audioList?.length" class="resource-group">
<span class="resource-type-label">音频</span>
<div class="resource-items">
<div v-for="(aud, idx) in currentStep.audioList" :key="idx" class="resource-item"
@click="previewResource(aud, 'audio')">
<AudioOutlined /> {{ aud.name || `音频${idx + 1}` }}
</div>
</div>
</div>
<!-- PPT资源 -->
<div v-if="currentStep.pptFiles?.length" class="resource-group">
<span class="resource-type-label">课件</span>
<div class="resource-items">
<div v-for="(ppt, idx) in currentStep.pptFiles" :key="idx" class="resource-item"
@click="previewResource(ppt, 'ppt')">
<FilePptOutlined /> {{ ppt.name || `课件${idx + 1}` }}
</div>
</div>
</div>
<!-- 文档资源 -->
<div v-if="currentStep.documents?.length" class="resource-group">
<span class="resource-type-label">文档</span>
<div class="resource-items">
<div v-for="(doc, idx) in currentStep.documents" :key="idx" class="resource-item"
@click="previewResource(doc, 'document')">
<FileTextOutlined /> {{ doc.name || `文档${idx + 1}` }}
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<InboxOutlined />
<p>请选择一个教学环节</p>
</div>
</div>
<!-- 右侧:工具面板 -->
<div class="tool-panel">
<!-- 课程核心资源 -->
<div v-if="hasCourseResources" class="panel-card materials-card">
<div class="panel-header">
<FolderOutlined />
<span>课程资源</span>
</div>
<div class="panel-body">
<div v-if="courseResources.length > 0" class="materials-list">
<div v-for="item in courseResources" :key="item.id" class="material-item"
@click="previewCourseResource(item)">
<div class="material-icon" :class="item.type">
<VideoCameraOutlined v-if="item.type === 'video'" />
<AudioOutlined v-else-if="item.type === 'audio'" />
<FilePptOutlined v-else-if="item.type === 'ppt'" />
<FileTextOutlined v-else-if="item.type === 'document'" />
<PictureOutlined v-else />
</div>
<div class="material-info">
<span class="material-name">{{ item.name }}</span>
<span class="material-type">{{ item.typeLabel }}</span>
</div>
<EyeOutlined class="material-preview-icon" />
</div>
</div>
<div v-else class="empty-materials">
<InboxOutlined />
<span>暂无课程资源</span>
</div>
</div>
</div>
<!-- 教学准备 -->
<div v-if="currentLesson?.preparation" class="panel-card preparation-card">
<div class="panel-header">
<ToolOutlined />
<span>教学准备</span>
</div>
<div class="panel-body preparation-body">
<div class="preparation-text" v-html="currentLesson.preparation"></div>
</div>
</div>
<!-- 教学延伸 -->
<div v-if="currentLesson?.extension" class="panel-card extension-card">
<div class="panel-header">
<BranchesOutlined />
<span>教学延伸</span>
</div>
<div class="panel-body extension-body">
<div class="extension-text" v-html="currentLesson.extension"></div>
</div>
</div>
<!-- 本环节材料 -->
<div class="panel-card materials-card">
<div class="panel-header">
<FolderOutlined />
<span>本环节材料</span>
</div>
<div class="panel-body">
<div v-if="stepMaterials.length > 0" class="materials-list">
<div v-for="item in stepMaterials" :key="item.id" class="material-item" @click="previewMaterial(item)">
<div class="material-icon" :class="item.type">
<PlayCircleOutlined v-if="item.type === '视频'" />
<SoundOutlined v-else-if="item.type === '音频'" />
<FilePdfOutlined v-else-if="item.type === '电子绘本'" />
<FilePptOutlined v-else-if="item.type === 'PPT课件'" />
<PictureOutlined v-else />
</div>
<div class="material-info">
<span class="material-name">{{ item.name }}</span>
<span class="material-type">{{ item.type }}</span>
</div>
<EyeOutlined class="material-preview-icon" />
</div>
</div>
<div v-else class="empty-materials">
<InboxOutlined />
<span>暂无关联材料</span>
</div>
</div>
</div>
<!-- 学生评价 -->
<div class="panel-card evaluation-card">
<div class="panel-header">
<StarOutlined />
<span>课堂评价</span>
</div>
<div class="panel-body">
<div class="rating-item">
<span class="rating-label">整体表现</span>
<a-rate v-model:value="studentEvaluation.overall" allow-half />
</div>
<div class="rating-item">
<span class="rating-label">参与度</span>
<a-rate v-model:value="studentEvaluation.participation" allow-half />
</div>
<div class="rating-item">
<span class="rating-label">兴趣度</span>
<a-rate v-model:value="studentEvaluation.interest" allow-half />
</div>
<div class="rating-item">
<span class="rating-label">理解程度</span>
<a-rate v-model:value="studentEvaluation.understanding" allow-half />
</div>
</div>
</div>
</div>
</div>
<!-- 计时器弹窗 -->
<a-modal v-model:open="showTimer" title="课程计时器" :footer="null" width="360px" centered>
<div class="timer-modal">
<div class="timer-large">{{ formatTime(timerSeconds) }}</div>
<div class="timer-buttons">
<a-button type="primary" size="large" @click="startTimer" :disabled="timerRunning">
<PlayCircleOutlined /> 开始
</a-button>
<a-button size="large" @click="pauseTimer" :disabled="!timerRunning">
<PauseCircleOutlined /> 暂停
</a-button>
<a-button size="large" @click="resetTimer">
<ReloadOutlined /> 重置
</a-button>
</div>
</div>
</a-modal>
<!-- 课堂记录抽屉 -->
<a-drawer v-model:open="showNotesDrawer" title="课堂记录" placement="right" width="400">
<a-form layout="vertical">
<a-form-item label="完成情况">
<a-radio-group v-model:value="lessonRecord.completion">
<a-radio value="完全完成">完全完成</a-radio>
<a-radio value="基本完成">基本完成</a-radio>
<a-radio value="未完成">未完成</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="整体评价">
<a-rate v-model:value="lessonRecord.overallRating" />
</a-form-item>
<a-form-item label="学生参与度">
<a-rate v-model:value="lessonRecord.participationRating" />
</a-form-item>
<a-form-item label="完成备注">
<a-textarea v-model:value="lessonRecord.completionNote" placeholder="记录课程完成情况、学生表现等..."
:auto-size="{ minRows: 6, maxRows: 10 }" />
</a-form-item>
<a-button type="primary" block size="large" @click="saveLessonRecord">
保存并结束
</a-button>
</a-form>
</a-drawer>
<!-- 文件预览弹窗 -->
<FilePreviewModal v-model:open="previewModalVisible" :file-url="previewFileUrl" :file-name="previewFileName" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import {
ArrowLeftOutlined,
StepBackwardOutlined,
StepForwardOutlined,
ClockCircleOutlined,
EditOutlined,
CheckOutlined,
BookOutlined,
ReadOutlined,
AimOutlined,
SoundOutlined,
FileTextOutlined,
FolderOutlined,
BranchesOutlined,
ToolOutlined,
InboxOutlined,
StarOutlined,
EyeOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
ReloadOutlined,
FilePdfOutlined,
FilePptOutlined,
PictureOutlined,
ExpandOutlined,
VideoCameraOutlined,
AudioOutlined,
} from '@ant-design/icons-vue';
import { message, Modal } from 'ant-design-vue';
import * as teacherApi from '@/api/teacher';
import FilePreviewModal from '@/components/FilePreviewModal.vue';
const router = useRouter();
const route = useRoute();
const loading = ref(false);
const showTimer = ref(false);
const showNotesDrawer = ref(false);
const currentLessonIndex = ref(0);
const currentStepIndex = ref(0);
const lessonId = ref<string>('');
// 文件预览相关
const previewModalVisible = ref(false);
const previewFileUrl = ref('');
const previewFileName = ref('');
const timerSeconds = ref(0);
const timerRunning = ref(false);
let timerInterval: number | null = null;
const course = ref<any>({});
const classInfo = ref<any>({});
const lessons = ref<any[]>([]);
const studentEvaluation = ref({
overall: 0,
participation: 0,
interest: 0,
understanding: 0,
});
const lessonRecord = ref({
completion: '基本完成',
overallRating: 0,
participationRating: 0,
completionNote: '',
});
// 当前课程
const currentLesson = computed(() => lessons.value[currentLessonIndex.value] || null);
// 当前环节
const currentStep = computed(() => {
if (!currentLesson.value?.steps) return null;
return currentLesson.value.steps[currentStepIndex.value] || null;
});
// 环节进度百分比
const stepProgressPercent = computed(() => {
const totalSteps = currentLesson.value?.steps?.length || 1;
return Math.round(((currentStepIndex.value + 1) / totalSteps) * 100);
});
// 是否有上一个课程
const hasPreviousLesson = computed(() => currentLessonIndex.value > 0);
// 是否是最后一个课程的最后一个环节
const isLastStepOfLastLesson = computed(() => {
if (currentLessonIndex.value < lessons.value.length - 1) return false;
const totalSteps = currentLesson.value?.steps?.length || 1;
return currentStepIndex.value >= totalSteps - 1;
});
// 当前环节是否有资源
const hasStepResources = computed(() => {
const step = currentStep.value;
if (!step) return false;
return (step.images?.length || 0) +
(step.videos?.length || 0) +
(step.audioList?.length || 0) +
(step.pptFiles?.length || 0) +
(step.documents?.length || 0) > 0;
});
// 本环节材料列表(从当前环节资源生成)
const stepMaterials = computed(() => {
const step = currentStep.value;
if (!step) return [];
const materials: any[] = [];
step.images?.forEach((img: any, idx: number) => {
materials.push({
id: `img-${idx}`,
name: img.name || `图片${idx + 1}`,
type: '图片',
url: img.path,
});
});
step.videos?.forEach((vid: any, idx: number) => {
materials.push({
id: `video-${idx}`,
name: vid.name || `视频${idx + 1}`,
type: '视频',
url: vid.path,
});
});
step.audioList?.forEach((aud: any, idx: number) => {
materials.push({
id: `audio-${idx}`,
name: aud.name || `音频${idx + 1}`,
type: '音频',
url: aud.path,
});
});
step.pptFiles?.forEach((ppt: any, idx: number) => {
materials.push({
id: `ppt-${idx}`,
name: ppt.name || `课件${idx + 1}`,
type: 'PPT课件',
url: ppt.path,
});
});
step.documents?.forEach((doc: any, idx: number) => {
materials.push({
id: `doc-${idx}`,
name: doc.name || `文档${idx + 1}`,
type: '文档',
url: doc.path,
});
});
return materials;
});
// 是否有课程级资源
const hasCourseResources = computed(() => {
const lesson = currentLesson.value;
if (!lesson) return false;
return (lesson.videos?.length || 0) +
(lesson.pptFiles?.length || 0) +
(lesson.documents?.length || 0) > 0;
});
// 课程资源列表
const courseResources = computed(() => {
const lesson = currentLesson.value;
if (!lesson) return [];
const resources: any[] = [];
lesson.videos?.forEach((vid: any, idx: number) => {
resources.push({
id: `course-video-${idx}`,
name: vid.name || `视频${idx + 1}`,
type: 'video',
typeLabel: '视频',
url: vid.path,
});
});
lesson.pptFiles?.forEach((ppt: any, idx: number) => {
resources.push({
id: `course-ppt-${idx}`,
name: ppt.name || `课件${idx + 1}`,
type: 'ppt',
typeLabel: '课件',
url: ppt.path,
});
});
lesson.documents?.forEach((doc: any, idx: number) => {
resources.push({
id: `course-doc-${idx}`,
name: doc.name || `文档${idx + 1}`,
type: 'document',
typeLabel: '文档',
url: doc.path,
});
});
return resources;
});
// 监听进度变化,自动保存
watch([currentLessonIndex, currentStepIndex], () => {
if (lessonId.value) {
saveProgress();
}
});
// 获取环节类型名称
const getStepTypeName = (type: string): string => {
const typeMap: Record<string, string> = {
'WARMUP': '热身导入',
'INTRODUCTION': '导入环节',
'DEVELOPMENT': '发展环节',
'PRACTICE': '练习环节',
'EXTENSION': '延伸环节',
'CONCLUSION': '总结环节',
'ASSESSMENT': '评估环节',
};
return typeMap[type] || type;
};
// 获取课程简称
const getLessonShortName = (lesson: any): string => {
const typeMap: Record<string, string> = {
'INTRO': '导入课',
'INTRODUCTION': '导入课',
'COLLECTIVE': '集体课',
'DOMAIN_LANGUAGE': '语言课',
'DOMAIN_HEALTH': '健康课',
'DOMAIN_SCIENCE': '科学课',
'DOMAIN_SOCIAL': '社会课',
'DOMAIN_ART': '艺术课',
// 兼容旧格式
'LANGUAGE': '语言课',
'HEALTH': '健康课',
'SCIENCE': '科学课',
'SOCIAL': '社会课',
'ART': '艺术课',
};
return typeMap[lesson.lessonType] || lesson.lessonType || '课程';
};
// 获取课程状态
const getLessonStatus = (index: number): string => {
if (index < currentLessonIndex.value) return 'finish';
if (index === currentLessonIndex.value) return 'process';
return 'wait';
};
const loadLessonData = async () => {
lessonId.value = (route.params.id as string) || '';
if (!lessonId.value) return;
loading.value = true;
try {
const data = await teacherApi.getLesson(lessonId.value);
course.value = data.course || {};
classInfo.value = data.class || {};
// 获取课程列表
// 如果授课记录包含多个课程,使用该列表;否则使用课程包的所有课程
if (data.lessonCourses && data.lessonCourses.length > 0) {
lessons.value = data.lessonCourses;
} else if (course.value.courseLessons) {
// 转换课程数据格式,与备课模式保持一致
lessons.value = (course.value.courseLessons || []).map((lesson: any) => {
// 将资源路径转换为数组格式
const videos = lesson.videoPath ? [{ path: lesson.videoPath, name: lesson.videoName || '视频' }] : [];
const pptFiles = lesson.pptPath ? [{ path: lesson.pptPath, name: lesson.pptName || '课件' }] : [];
const documents = lesson.pdfPath ? [{ path: lesson.pdfPath, name: lesson.pdfName || '文档' }] : [];
// 转换steps数据格式content -> description
const steps = (lesson.steps || []).map((step: any) => ({
...step,
description: step.content || step.description || '',
}));
return {
...lesson,
steps,
videos,
pptFiles,
documents,
images: [],
audioList: [],
};
});
} else {
// 兼容旧数据:使用 scripts
lessons.value = [{
id: course.value.id,
name: course.value.name,
lessonType: 'CUSTOM',
duration: course.value.duration || 30,
steps: (course.value.scripts || []).map((script: any) => ({
id: script.id,
name: script.stepName,
duration: script.duration || 5,
stepType: script.stepType,
objective: script.objective,
description: script.description,
script: script.teacherScript,
// 兼容旧的资源结构
images: [],
videos: [],
audioList: [],
pptFiles: course.value.pptPath ? [{ name: course.value.pptName || '教学PPT', path: course.value.pptPath }] : [],
documents: [],
})),
}];
}
// 尝试恢复进度
try {
const progress = await teacherApi.getLessonProgress(lessonId.value);
if (progress && (progress.currentLessonId || progress.currentStepId)) {
// 有保存的进度,询问用户是否恢复
Modal.confirm({
title: '检测到上次上课进度',
content: `上次上课到:${getProgressDescription(progress)},是否继续?`,
okText: '继续上课',
cancelText: '重新开始',
onOk: () => {
// 恢复进度
if (progress.currentLessonId !== undefined) {
const lessonIndex = lessons.value.findIndex((l) => l.id === progress.currentLessonId);
if (lessonIndex >= 0) {
currentLessonIndex.value = lessonIndex;
}
}
if (progress.currentStepId !== undefined) {
currentStepIndex.value = progress.currentStepId;
}
},
onCancel: () => {
// 清除进度,从头开始
clearProgress();
},
});
}
} catch (progressError) {
// 没有保存的进度或获取失败,忽略
console.log('No saved progress found');
}
// 如果URL指定了课程索引跳转到该课程优先级高于恢复的进度
const queryLessonIndex = route.query.lessonIndex ? parseInt(route.query.lessonIndex as string) : 0;
if (queryLessonIndex >= 0 && queryLessonIndex < lessons.value.length) {
currentLessonIndex.value = queryLessonIndex;
}
// 如果URL指定了环节索引跳转到该环节优先级高于恢复的进度
const queryStepIndex = route.query.stepIndex ? parseInt(route.query.stepIndex as string) : 0;
const totalSteps = lessons.value[currentLessonIndex.value]?.steps?.length || 0;
if (queryStepIndex >= 0 && queryStepIndex < totalSteps) {
currentStepIndex.value = queryStepIndex;
}
// 启动计时器
startTimer();
} catch (error: any) {
message.error(error.message || '获取课程数据失败');
} finally {
loading.value = false;
}
};
// 获取进度描述
const getProgressDescription = (progress: any): string => {
if (progress.currentLessonId !== undefined) {
const lesson = lessons.value.find((l) => l.id === progress.currentLessonId);
if (lesson) {
return `${lesson.name} - 第 ${progress.currentStepId + 1} 环节`;
}
}
return '未知';
};
// 保存进度
const saveProgress = async () => {
try {
await teacherApi.saveLessonProgress(lessonId.value, {
lessonIds: lessons.value.map((l) => l.id),
completedLessonIds: lessons.value.slice(0, currentLessonIndex.value).map((l) => l.id),
currentLessonId: lessons.value[currentLessonIndex.value]?.id,
currentStepId: currentStepIndex.value,
progressData: {
timerSeconds: timerSeconds.value,
savedAt: new Date().toISOString(),
},
});
} catch (error) {
console.error('Failed to save progress:', error);
}
};
// 清除进度
const clearProgress = async () => {
try {
await teacherApi.saveLessonProgress(lessonId.value, {
lessonIds: [],
completedLessonIds: [],
currentLessonId: undefined,
currentStepId: undefined,
progressData: null,
});
} catch (error) {
console.error('Failed to clear progress:', error);
}
};
const handleLessonClick = (index: number) => {
if (index <= currentLessonIndex.value) {
currentLessonIndex.value = index;
currentStepIndex.value = 0;
}
};
const handleStepClick = (index: number) => {
currentStepIndex.value = index;
};
const previousStep = () => {
if (currentStepIndex.value > 0) {
currentStepIndex.value--;
} else if (currentLessonIndex.value > 0) {
// 切换到上一个课程的最后一个环节
currentLessonIndex.value--;
const prevLesson = lessons.value[currentLessonIndex.value];
currentStepIndex.value = (prevLesson?.steps?.length || 1) - 1;
}
};
const nextStep = () => {
const totalSteps = currentLesson.value?.steps?.length || 0;
if (currentStepIndex.value < totalSteps - 1) {
currentStepIndex.value++;
} else if (currentLessonIndex.value < lessons.value.length - 1) {
// 切换到下一个课程的第一个环节
currentLessonIndex.value++;
currentStepIndex.value = 0;
}
};
// 打开展播模式(新标签页)
const openBroadcastMode = () => {
const broadcastUrl = `${window.location.origin}/teacher/broadcast/${lessonId.value}?lessonIndex=${currentLessonIndex.value}&stepIndex=${currentStepIndex.value}`;
window.open(broadcastUrl, '_blank', 'noopener,noreferrer');
};
const startTimer = () => {
timerRunning.value = true;
timerInterval = setInterval(() => {
timerSeconds.value++;
}, 1000) as unknown as number;
};
const pauseTimer = () => {
timerRunning.value = false;
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
};
const resetTimer = () => {
pauseTimer();
timerSeconds.value = 0;
};
const exitLesson = () => {
Modal.confirm({
title: '确认退出',
content: '确定要退出上课吗?课堂记录将不会保存。',
okText: '确认退出',
cancelText: '继续上课',
onOk: () => {
router.back();
},
});
};
const saveLessonRecord = async () => {
try {
await teacherApi.finishLesson(lessonId.value, {
overallRating: lessonRecord.value.overallRating > 0 ? String(lessonRecord.value.overallRating) : undefined,
participationRating: lessonRecord.value.participationRating > 0 ? String(lessonRecord.value.participationRating) : undefined,
completionNote: lessonRecord.value.completionNote,
actualDuration: timerSeconds.value > 0 ? Math.round(timerSeconds.value / 60) : undefined,
});
// 清除进度
await clearProgress();
message.success('课程记录已保存');
showNotesDrawer.value = false;
router.back();
} catch (error: any) {
message.error(error.message || '保存记录失败');
}
};
const finishLesson = () => {
pauseTimer();
Modal.confirm({
title: '结束课程',
content: `本次课程已进行 ${formatTime(timerSeconds.value)},确定要结束吗?`,
okText: '确认结束',
cancelText: '继续上课',
onOk: () => {
showNotesDrawer.value = true;
},
});
};
// 获取完整的文件 URL
const getFileUrl = (filePath: string | null | undefined): string => {
if (!filePath) return '';
if (filePath.startsWith('http://') || filePath.startsWith('https://')) {
return filePath;
}
const SERVER_BASE = import.meta.env.VITE_SERVER_BASE_URL || 'http://localhost:3000';
return `${SERVER_BASE}${filePath}`;
};
// 预览资源
const previewResource = (resource: any, type: string) => {
if (!resource.path) {
message.warning('该资源暂无可预览的文件');
return;
}
previewFileUrl.value = getFileUrl(resource.path);
previewFileName.value = resource.name || '教学资源';
previewModalVisible.value = true;
};
// 预览课程级资源
const previewCourseResource = (resource: any) => {
if (!resource.url) {
message.warning('该资源暂无可预览的文件');
return;
}
previewFileUrl.value = getFileUrl(resource.url);
previewFileName.value = resource.name || '教学资源';
previewModalVisible.value = true;
};
// 预览材料
const previewMaterial = (item: any) => {
if (!item.url && !item.path) {
message.warning('该材料暂无可预览的文件');
return;
}
previewFileUrl.value = getFileUrl(item.url || item.path);
previewFileName.value = item.name || '教学材料';
previewModalVisible.value = true;
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
};
onMounted(() => {
loadLessonData();
});
onUnmounted(() => {
if (timerInterval) {
clearInterval(timerInterval);
}
});
</script>
<style scoped lang="scss">
.lesson-view {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #FFF5EB 0%, #FFF9F5 50%, #F0F7FF 100%);
}
// 顶部工具栏
.lesson-toolbar {
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 12px rgba(255, 140, 66, 0.2);
position: sticky;
top: 0;
z-index: 100;
.toolbar-left {
display: flex;
align-items: center;
gap: 16px;
}
.toolbar-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
}
.exit-btn {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
&:hover {
background: rgba(255, 255, 255, 0.3);
color: white;
border-color: rgba(255, 255, 255, 0.5);
}
}
.course-info {
display: flex;
align-items: center;
gap: 8px;
color: white;
.course-icon {
font-size: 18px;
}
.course-name {
font-size: 16px;
font-weight: 500;
}
}
.timer-display {
display: flex;
align-items: center;
gap: 8px;
background: rgba(255, 255, 255, 0.2);
padding: 8px 20px;
border-radius: 24px;
color: white;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.3);
}
.timer-icon {
font-size: 18px;
}
.timer-value {
font-size: 20px;
font-weight: 600;
font-family: 'Courier New', monospace;
min-width: 60px;
}
}
.nav-buttons {
display: flex;
gap: 5px;
.ant-btn {
border-radius: 6px;
font-weight: 500;
}
.ant-btn:not(.next-btn) {
background: white;
border-color: white;
color: #666;
&:hover:not(:disabled) {
background: #FFF5EB;
border-color: #FFD4B8;
color: #FF6B35;
}
&:disabled {
background: rgba(255, 255, 255, 0.5);
border-color: rgba(255, 255, 255, 0.5);
color: rgba(0, 0, 0, 0.25);
cursor: not-allowed;
}
}
.next-btn {
background: white;
border-color: white;
color: #FF6B35;
font-weight: 600;
&:hover:not(:disabled) {
background: #FF6B35;
border-color: #FF6B35;
color: white;
}
&:disabled {
background: rgba(255, 255, 255, 0.5);
border-color: rgba(255, 255, 255, 0.5);
color: rgba(255, 107, 53, 0.4);
cursor: not-allowed;
}
}
}
.broadcast-btn {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
&:hover {
background: rgba(255, 255, 255, 0.3);
color: white;
border-color: rgba(255, 255, 255, 0.5);
}
}
.toolbar-icon-btn {
background: white;
border-color: white;
color: #FF6B35;
width: 36px;
height: 32px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
&:hover {
background: #FF6B35 !important;
border-color: #FF6B35 !important;
color: white !important;
}
:deep(.anticon) {
font-size: 16px;
}
}
.finish-btn {
background: #52c41a;
border-color: #52c41a;
color: white;
font-weight: 600;
&:hover {
background: #73d13d !important;
border-color: #73d13d !important;
color: white !important;
}
}
}
// 课程进度条
.course-progress-bar {
background: white;
padding: 12px 24px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
.course-steps {
:deep(.ant-steps-item-process),
:deep(.ant-steps-item-finish) {
cursor: pointer;
}
}
.clickable-step {
cursor: pointer;
}
}
// 环节进度条
.step-progress-bar {
background: white;
padding: 10px 24px;
border-bottom: 1px solid #f0f0f0;
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.current-step-label {
font-size: 14px;
font-weight: 500;
color: #333;
}
.step-count {
font-size: 12px;
color: #999;
}
.step-progress {
:deep(.ant-progress-bg) {
border-radius: 4px;
}
}
}
// 主内容区
.lesson-content {
flex: 1;
display: flex;
padding: 16px;
gap: 16px;
overflow: hidden;
}
// 左侧步骤导航
.step-nav {
width: 240px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
overflow: hidden;
.nav-header {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: #333;
font-size: 15px;
.progress-text {
margin-left: auto;
color: #FF8C42;
font-size: 14px;
}
}
.step-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.step-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 8px;
&:hover {
background: #FFF5EB;
}
&.active {
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
box-shadow: 0 4px 12px rgba(255, 140, 66, 0.3);
.step-number {
background: white;
color: #FF6B35;
}
.step-name {
color: white;
font-weight: 600;
}
.step-duration {
color: rgba(255, 255, 255, 0.8);
}
}
&.completed {
.step-number {
background: #52c41a;
color: white;
}
}
}
.step-number {
width: 28px;
height: 28px;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: #666;
flex-shrink: 0;
}
.step-info {
flex: 1;
min-width: 0;
}
.step-name {
display: block;
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.step-duration {
font-size: 12px;
color: #999;
}
.progress-bar {
height: 4px;
background: #f0f0f0;
margin: 0 16px 16px;
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #FF8C42, #FF6B35);
border-radius: 2px;
transition: width 0.3s ease;
}
}
}
// 中间展示区域
.display-area {
flex: 1;
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
overflow-y: auto;
padding: 24px;
}
.step-display {
.step-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.step-badge {
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.step-title {
margin: 0;
font-size: 24px;
color: #333;
}
.duration-tag,
.type-tag {
background: #FFF5EB;
color: #FF6B35;
border: none;
padding: 4px 12px;
border-radius: 12px;
font-size: 14px;
margin-left: 8px;
}
}
}
.objective-card,
.description-card,
.script-card,
.resources-card {
background: #fafafa;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 16px;
.card-label {
font-size: 13px;
color: #666;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.card-content {
font-size: 15px;
color: #333;
line-height: 1.6;
}
}
.objective-card {
background: #E6F7FF;
border-left: 4px solid #1890ff;
.card-label {
color: #1890ff;
}
}
.description-card {
background: #F6FFED;
border-left: 4px solid #52c41a;
.card-label {
color: #52c41a;
}
}
.script-card {
background: #FFF5EB;
border-left: 4px solid #FF8C42;
.card-label {
color: #FF8C42;
font-weight: 500;
}
.script-text {
font-size: 16px;
line-height: 1.8;
color: #333;
white-space: pre-wrap;
}
}
.resources-card {
background: #FFFBE6;
border-left: 4px solid #FA8C16;
.card-label {
color: #FA8C16;
}
.resources-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.resource-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.resource-type-label {
font-size: 12px;
font-weight: 600;
color: #999;
text-transform: uppercase;
}
.resource-items {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.resource-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: white;
border: 1px solid #f0f0f0;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #666;
transition: all 0.2s;
&:hover {
border-color: #FF8C42;
color: #FF8C42;
background: #FFF9F5;
}
}
}
.rich-content {
:deep(p) {
margin-bottom: 12px;
}
:deep(p:last-child) {
margin-bottom: 0;
}
:deep(ul),
:deep(ol) {
padding-left: 24px;
margin-bottom: 12px;
}
:deep(li) {
margin-bottom: 6px;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
.anticon {
font-size: 48px;
margin-bottom: 16px;
}
}
// 右侧工具面板
.tool-panel {
width: 280px;
display: flex;
flex-direction: column;
gap: 16px;
}
.panel-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
overflow: hidden;
.panel-header {
padding: 14px 16px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
font-weight: 600;
color: #333;
display: flex;
align-items: center;
gap: 8px;
}
.panel-body {
padding: 12px;
}
}
.materials-card {
.panel-header {
background: #FFF5EB;
color: #FF6B35;
}
}
.materials-list {
.material-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f5f5f5;
.material-preview-icon {
opacity: 1;
}
}
}
.material-icon {
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
&.视频 {
background: #F3E5F5;
color: #8E24AA;
}
&.音频 {
background: #E3F2FD;
color: #1976D2;
}
&.图片 {
background: #FFF3E0;
color: #FB8C00;
}
&.PPT课件,
&.课件 {
background: #E8F5E9;
color: #43A047;
}
&.文档 {
background: #FCE4EC;
color: #C2185B;
}
}
.material-info {
flex: 1;
min-width: 0;
}
.material-name {
display: block;
font-size: 13px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.material-type {
font-size: 11px;
color: #999;
}
.material-preview-icon {
color: #1890ff;
opacity: 0;
transition: opacity 0.2s;
}
}
.empty-materials {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px;
color: #999;
.anticon {
font-size: 32px;
margin-bottom: 8px;
}
}
.evaluation-card {
.panel-header {
background: #FFFBE6;
color: #FA8C16;
}
.rating-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
&:last-child {
border-bottom: none;
}
}
.rating-label {
font-size: 13px;
color: #666;
}
:deep(.ant-rate) {
font-size: 16px;
}
}
.preparation-card {
.panel-header {
background: #E3F2FD;
color: #1976D2;
}
.preparation-body {
padding: 12px;
max-height: 200px;
overflow-y: auto;
.preparation-text {
font-size: 13px;
line-height: 1.6;
color: #666;
white-space: pre-wrap;
}
}
}
.extension-card {
.panel-header {
background: #F3E5F5;
color: #7B1FA2;
}
.extension-body {
padding: 12px;
max-height: 200px;
overflow-y: auto;
.extension-text {
font-size: 13px;
line-height: 1.6;
color: #666;
white-space: pre-wrap;
}
}
}
// 计时器弹窗
.timer-modal {
text-align: center;
padding: 20px 0;
.timer-large {
font-size: 64px;
font-weight: 700;
color: #FF6B35;
font-family: 'Courier New', monospace;
margin-bottom: 24px;
text-shadow: 0 2px 4px rgba(255, 107, 53, 0.2);
}
.timer-buttons {
display: flex;
justify-content: center;
gap: 12px;
.ant-btn {
display: flex;
align-items: center;
gap: 6px;
}
}
}
</style>