kindergarten_java/lesingle-edu-reading-platform-frontend/src/views/teacher/lessons/LessonView.vue
2026-03-31 11:07:53 +08:00

1847 lines
48 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" :disabled="!hasCourseResources" @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="displayLessons.length > 1" class="course-progress-bar">
<a-steps :current="currentLessonIndex" size="small" class="course-steps">
<a-step v-for="(lesson, index) in displayLessons" :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 break-all">
{{ currentLesson?.name }} - {{ currentStep?.name || '准备中' }}
</span>
<span class="step-count flex-shrink-0">
环节 {{ 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 break-all">{{ 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 flex-shrink-0">第 {{ currentStepIndex + 1 }} 环节</div>
<h2 class="step-title break-all whitespace-pre-wrap">{{ 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 break-all whitespace-pre-wrap">{{ currentStep.objective }}</div>
</div>
<!-- 环节说明 -->
<div v-if="currentStep.description" class="description-card">
<div class="card-label">
<FileTextOutlined /> 环节说明
</div>
<div class="card-content rich-content break-all whitespace-pre-wrap" 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 break-all whitespace-pre-wrap" 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="currentLesson?.lessonType" class="panel-card lesson-type-card">
<div class="panel-header">
<BookOutlined />
<span>课程类型</span>
</div>
<div class="panel-body">
<a-tag size="large" class="lesson-type-tag" :style="getLessonTagStyle(currentLesson.lessonType)">
{{ getLessonTypeName(currentLesson.lessonType) }}
</a-tag>
</div>
</div>
<!-- 课程核心资源 -->
<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 break-all whitespace-pre-wrap">{{ 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 break-all whitespace-pre-wrap">{{ 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';
import { getLessonTypeName, getLessonTagStyle } from '@/utils/tagMaps';
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 scheduleLessonType = ref<string | undefined>(undefined);
const studentEvaluation = ref({
overall: 0,
participation: 0,
interest: 0,
understanding: 0,
});
const lessonRecord = ref({
completion: '基本完成',
overallRating: 0,
participationRating: 0,
completionNote: '',
});
/** 判断排课 lessonType 与课程 lessonType 是否匹配(兼容 INTRODUCTION/INTRO、LANGUAGE/DOMAIN_LANGUAGE 等变体) */
const lessonTypeMatches = (scheduleType: string, lessonType: string): boolean => {
if (!scheduleType || !lessonType) return false;
const s = scheduleType.toUpperCase();
const l = lessonType.toUpperCase();
if (s === l) return true;
const pairs: [string, string][] = [
['INTRODUCTION', 'INTRO'],
['LANGUAGE', 'DOMAIN_LANGUAGE'],
['HEALTH', 'DOMAIN_HEALTH'],
['SCIENCE', 'DOMAIN_SCIENCE'],
['SOCIAL', 'DOMAIN_SOCIAL'],
['SOCIETY', 'DOMAIN_SOCIAL'],
['ART', 'DOMAIN_ART'],
];
for (const [a, b] of pairs) {
if ((s === a || s === b) && (l === a || l === b)) return true;
}
return false;
};
/** 展示的课程列表:子课程模式时仅包含排课选中的子课程,否则为全部 */
const displayLessons = computed(() => {
const type = scheduleLessonType.value;
if (!type || lessons.value.length === 0) return lessons.value;
const matched = lessons.value.filter((l) => lessonTypeMatches(type, l.lessonType || ''));
return matched.length > 0 ? matched : lessons.value;
});
// 当前课程
const currentLesson = computed(() => displayLessons.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 < displayLessons.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 || {};
// 排课选择的课程类型(子课程模式:直接进入该子课程,子课程结束即上课结束)
scheduleLessonType.value = data.lessonType || undefined;
// 获取课程列表
// 如果授课记录包含多个课程,使用该列表;否则使用课程包的所有课程
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: [],
})),
}];
}
// 子课程模式:根据排课 lessonType 直接进入对应子课程(优先于进度恢复和 URL 参数)
const matchedLessons = scheduleLessonType.value
? lessons.value.filter((l) => lessonTypeMatches(scheduleLessonType.value!, l.lessonType || ''))
: [];
if (matchedLessons.length > 0) {
currentLessonIndex.value = 0;
currentStepIndex.value = 0;
}
// 尝试恢复进度
try {
const progress = await teacherApi.getLessonProgress(lessonId.value);
if (progress && (progress.currentLessonId !== undefined || progress.currentStepId !== undefined)) {
const isSub = matchedLessons.length > 0;
const matchedLesson = matchedLessons[0];
const progressIsForMatched = isSub && progress.currentLessonId !== undefined
&& matchedLesson && progress.currentLessonId === matchedLesson.id;
Modal.confirm({
title: '检测到上次上课进度',
content: `上次上课到:${getProgressDescription(progress)},是否继续?`,
okText: '继续上课',
cancelText: '重新开始',
onOk: () => {
if (isSub && progressIsForMatched && progress.currentStepId !== undefined) {
// 子课程模式:仅恢复环节进度
currentLessonIndex.value = 0;
currentStepIndex.value = progress.currentStepId;
} else if (!isSub) {
// 非子课程模式:恢复课程和环节进度
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 参数可覆盖
if (matchedLessons.length === 0) {
const queryLessonIndex = route.query.lessonIndex ? parseInt(route.query.lessonIndex as string) : 0;
if (queryLessonIndex >= 0 && queryLessonIndex < lessons.value.length) {
currentLessonIndex.value = queryLessonIndex;
}
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 {
const list = displayLessons.value;
await teacherApi.saveLessonProgress(lessonId.value, {
lessonIds: list.map((l) => l.id),
completedLessonIds: list.slice(0, currentLessonIndex.value).map((l) => l.id),
currentLessonId: list[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 = () => {
if (!hasCourseResources.value) {
message.warning('暂无课程资源,无法进入展播模式');
return;
}
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: async () => {
if (!lessonId.value) {
router.back();
return;
}
try {
await teacherApi.abandonLesson(lessonId.value);
pauseTimer();
message.success('已退出上课');
router.back();
} catch (error: any) {
message.error(error?.message || error?.response?.data?.message || '退出失败');
throw error;
}
},
});
};
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;
}
}
.lesson-type-card {
.panel-header {
background: #F5F5F5;
color: #666;
}
.lesson-type-tag {
font-size: 14px;
padding: 6px 14px;
border-radius: 8px;
}
}
.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>