2026-02-26 15:22:26 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="broadcast-view">
|
|
|
|
|
|
<!-- 加载中 -->
|
|
|
|
|
|
<div v-if="loading" class="loading-container">
|
|
|
|
|
|
<a-spin size="large" />
|
|
|
|
|
|
<p>正在加载展播内容...</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 错误状态 -->
|
|
|
|
|
|
<div v-else-if="error" class="error-container">
|
|
|
|
|
|
<ExclamationCircleOutlined style="font-size: 48px; color: #ff4d4f;" />
|
|
|
|
|
|
<p>{{ error }}</p>
|
|
|
|
|
|
<a-button type="primary" @click="loadData">重新加载</a-button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 展播内容 -->
|
|
|
|
|
|
<KidsMode
|
2026-03-12 14:33:44 +08:00
|
|
|
|
v-else-if="currentLesson && currentSteps.length > 0"
|
2026-02-26 15:22:26 +08:00
|
|
|
|
:course="course"
|
2026-03-12 14:33:44 +08:00
|
|
|
|
:current-lesson="currentLesson"
|
|
|
|
|
|
:steps="currentSteps"
|
2026-02-26 15:22:26 +08:00
|
|
|
|
:activities="activities"
|
|
|
|
|
|
:current-step-index="currentStepIndex"
|
|
|
|
|
|
:timer-seconds="0"
|
|
|
|
|
|
@exit="handleExit"
|
|
|
|
|
|
@step-change="handleStepChange"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 空状态 -->
|
|
|
|
|
|
<div v-else class="empty-container">
|
|
|
|
|
|
<InboxOutlined style="font-size: 48px; color: #ccc;" />
|
|
|
|
|
|
<p>暂无展播内容</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-03-12 14:33:44 +08:00
|
|
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
2026-02-26 15:22:26 +08:00
|
|
|
|
import { useRoute } from 'vue-router';
|
|
|
|
|
|
import { ExclamationCircleOutlined, InboxOutlined } from '@ant-design/icons-vue';
|
|
|
|
|
|
import { message } from 'ant-design-vue';
|
|
|
|
|
|
import KidsMode from './components/KidsMode.vue';
|
|
|
|
|
|
import * as teacherApi from '@/api/teacher';
|
|
|
|
|
|
|
|
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
|
|
|
|
|
|
// 状态
|
|
|
|
|
|
const loading = ref(true);
|
|
|
|
|
|
const error = ref('');
|
|
|
|
|
|
const course = ref<any>(null);
|
2026-03-12 14:33:44 +08:00
|
|
|
|
const lessons = ref<any[]>([]);
|
2026-02-26 15:22:26 +08:00
|
|
|
|
const activities = ref<any[]>([]);
|
2026-03-12 14:33:44 +08:00
|
|
|
|
|
|
|
|
|
|
// 当前课程和环节索引
|
|
|
|
|
|
const currentLessonIndex = ref(0);
|
2026-02-26 15:22:26 +08:00
|
|
|
|
const currentStepIndex = ref(0);
|
|
|
|
|
|
|
|
|
|
|
|
// 获取URL参数
|
|
|
|
|
|
const lessonId = route.params.id as string;
|
2026-03-12 14:33:44 +08:00
|
|
|
|
const initialLessonIndex = parseInt(route.query.lessonIndex as string) || 0;
|
|
|
|
|
|
const initialStep = parseInt(route.query.stepIndex as string) || parseInt(route.query.step as string) || 0;
|
2026-02-26 15:22:26 +08:00
|
|
|
|
|
|
|
|
|
|
// 步骤类型映射
|
|
|
|
|
|
const stepTypeMap: Record<string, string> = {
|
|
|
|
|
|
'READING': '共读',
|
|
|
|
|
|
'DISCUSSION': '讨论',
|
|
|
|
|
|
'ACTIVITY': '活动',
|
|
|
|
|
|
'CREATION': '创作',
|
|
|
|
|
|
'SUMMARY': '总结',
|
|
|
|
|
|
'CUSTOM': '导入',
|
2026-03-12 14:33:44 +08:00
|
|
|
|
'WARMUP': '热身',
|
|
|
|
|
|
'INTRODUCTION': '导入',
|
|
|
|
|
|
'DEVELOPMENT': '发展',
|
|
|
|
|
|
'PRACTICE': '练习',
|
|
|
|
|
|
'EXTENSION': '延伸',
|
|
|
|
|
|
'CONCLUSION': '总结',
|
|
|
|
|
|
'ASSESSMENT': '评估',
|
2026-02-26 15:22:26 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-12 14:33:44 +08:00
|
|
|
|
// 当前课程
|
|
|
|
|
|
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 [];
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-26 15:22:26 +08:00
|
|
|
|
// 加载数据
|
|
|
|
|
|
const loadData = async () => {
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await teacherApi.getLesson(parseInt(lessonId));
|
|
|
|
|
|
|
|
|
|
|
|
// 解析课程中的路径数组
|
|
|
|
|
|
const parsePathArray = (paths: any) => {
|
|
|
|
|
|
if (!paths) return [];
|
|
|
|
|
|
if (typeof paths === 'string') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
return JSON.parse(paths);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return Array.isArray(paths) ? paths : [];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
course.value = {
|
|
|
|
|
|
...data.course,
|
|
|
|
|
|
ebookPaths: parsePathArray(data.course?.ebookPaths),
|
|
|
|
|
|
audioPaths: parsePathArray(data.course?.audioPaths),
|
|
|
|
|
|
videoPaths: parsePathArray(data.course?.videoPaths),
|
|
|
|
|
|
posterPaths: parsePathArray(data.course?.posterPaths),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-12 14:33:44 +08:00
|
|
|
|
// 获取课程列表
|
|
|
|
|
|
// 后端返回的是 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)
|
|
|
|
|
|
? script.interactionPoints.join('、')
|
|
|
|
|
|
: script.interactionPoints,
|
|
|
|
|
|
resourceIds: typeof script.resourceIds === 'string' ? JSON.parse(script.resourceIds) : (script.resourceIds || []),
|
|
|
|
|
|
pages: (script.pages || []).map((page: any) => ({
|
|
|
|
|
|
...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));
|
2026-02-26 15:22:26 +08:00
|
|
|
|
|
|
|
|
|
|
// 解析活动数据
|
|
|
|
|
|
if (data.course?.lessonPlanData) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const lessonPlan = typeof data.course.lessonPlanData === 'string'
|
|
|
|
|
|
? JSON.parse(data.course.lessonPlanData)
|
|
|
|
|
|
: data.course.lessonPlanData;
|
|
|
|
|
|
|
|
|
|
|
|
if (lessonPlan.activities && Array.isArray(lessonPlan.activities)) {
|
|
|
|
|
|
activities.value = lessonPlan.activities.map((activity: any, index: number) => ({
|
|
|
|
|
|
id: `activity-${index}`,
|
|
|
|
|
|
name: activity.name,
|
|
|
|
|
|
activityType: activity.type || 'OTHER',
|
|
|
|
|
|
duration: activity.duration,
|
|
|
|
|
|
objectives: activity.objectives,
|
|
|
|
|
|
activityGuide: activity.guide,
|
|
|
|
|
|
resourceUrl: activity.resourceUrl,
|
|
|
|
|
|
resourceType: activity.resourceType,
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (parseErr) {
|
|
|
|
|
|
console.error('Failed to parse lesson plan:', parseErr);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
console.error('Failed to load broadcast data:', err);
|
|
|
|
|
|
error.value = err.response?.data?.message || '加载失败,请重试';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理退出
|
|
|
|
|
|
const handleExit = () => {
|
|
|
|
|
|
message.info('展播模式已关闭,您可以关闭此标签页');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 处理环节切换
|
|
|
|
|
|
const handleStepChange = (index: number) => {
|
|
|
|
|
|
currentStepIndex.value = index;
|
|
|
|
|
|
// 更新URL参数(不刷新页面)
|
2026-03-12 14:33:44 +08:00
|
|
|
|
const newUrl = `${window.location.pathname}?lessonIndex=${currentLessonIndex.value}&stepIndex=${index}`;
|
2026-02-26 15:22:26 +08:00
|
|
|
|
window.history.replaceState({}, '', newUrl);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 键盘快捷键
|
|
|
|
|
|
const handleKeydown = (e: KeyboardEvent) => {
|
|
|
|
|
|
switch (e.key) {
|
|
|
|
|
|
case 'Escape':
|
|
|
|
|
|
message.info('按 ESC 退出全屏,关闭标签页请使用 Ctrl+W');
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'F11':
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
toggleFullscreen();
|
|
|
|
|
|
break;
|
2026-03-12 14:33:44 +08:00
|
|
|
|
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;
|
2026-02-26 15:22:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 全屏切换
|
|
|
|
|
|
const toggleFullscreen = () => {
|
|
|
|
|
|
if (!document.fullscreenElement) {
|
|
|
|
|
|
document.documentElement.requestFullscreen();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
document.exitFullscreen();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 生命周期
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
loadData();
|
|
|
|
|
|
document.addEventListener('keydown', handleKeydown);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
document.removeEventListener('keydown', handleKeydown);
|
|
|
|
|
|
// 退出全屏
|
|
|
|
|
|
if (document.fullscreenElement) {
|
|
|
|
|
|
document.exitFullscreen().catch(() => {});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.broadcast-view {
|
|
|
|
|
|
width: 100vw;
|
|
|
|
|
|
height: 100vh;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
background: #1a1a2e;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.loading-container,
|
|
|
|
|
|
.error-container,
|
|
|
|
|
|
.empty-container {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
height: 100vh;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
gap: 20px;
|
|
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
|
opacity: 0.8;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.error-container {
|
|
|
|
|
|
p {
|
|
|
|
|
|
color: #ff4d4f;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|