教案生成系统 - 前端讲解
作为一名前端开发者,我负责构建教案生成系统的用户界面和交互逻辑。这个前端系统基于 Vue 3 和 Nuxt 3,旨在提供直观、高效的教案创建体验。
我的代码是如何实现的?
前端项目主要位于 frontend 文件夹中,其核心逻辑和组件都组织在 src 目录下。
前端架构 (frontend/src 文件夹)
前端采用模块化设计,主要包含以下部分:
-
入口文件与全局配置 (
main.js,App.vue):main.js是 Vue 应用的入口文件,我在这里初始化了 Vue 应用,集成了 Pinia (用于状态管理)、ElementPlus (UI 组件库),并引入了全局 CSS 样式。- 此外,我还配置了 Axios,包括设置
baseURL、请求超时时间以及请求和响应拦截器。特别地,我在请求拦截器中设置了Authorization请求头,确保所有API请求都能携带认证信息。 App.vue是应用的根组件,它承载了整个应用的布局和视图。
-
页面 (
views文件夹):views文件夹存放着系统的主要页面组件。其中,ELessonPlanGeneration.vue是核心页面,它实现了多步骤的教案生成流程:包括课程信息选择、教材上传、表单填写、教案预览与编辑等功能。
-
组件 (
components文件夹):components文件夹包含了可重用的 Vue 组件,这些组件被用于构建各个页面。例如,表单输入组件、上传组件、进度显示组件等,它们遵循 Composition API 规范,提高了代码的复用性和可维护性。
-
API 服务 (
api文件夹):api文件夹负责定义与后端交互的 API 接口。我将所有的后端请求封装在这里,使得前端页面与后端解耦,便于管理和维护。
-
状态管理 (
stores文件夹):- 通过 Pinia 实现状态管理,
stores文件夹中定义了各种 store,用于集中管理应用的状态,例如教案生成任务的状态、用户输入的数据等。这确保了数据在应用中的一致性和可预测性。
- 通过 Pinia 实现状态管理,
-
路由 (
router文件夹):router文件夹定义了前端的路由配置,负责管理页面之间的导航和视图切换。
-
工具函数 (
utils文件夹):utils文件夹包含了各种通用的工具函数,例如数据格式化、验证等,这些函数可以在整个项目中复用。
-
样式 (
styles文件夹):styles文件夹存放着全局样式文件。本项目主要采用 TailwindCSS 进行样式设计,避免了大量的自定义 CSS,提高了开发效率和样式一致性。
frontend/src/main.js
这是前端应用的入口文件。我在这里完成了 Vue 应用的初始化、状态管理 (Pinia) 的集成、ElementPlus (UI 组件库) 的引入,并配置了 Axios 用于后端通信。特别值得注意的是,我对 Axios 进行了全局配置,包括设置了 baseURL、请求超时时间,并实现了请求和响应拦截器,以确保每次请求都携带认证信息并统一处理错误。
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIcons from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import './styles/globals.css'
import './styles/dashboard.css'
import './assets/main.css'
import router from './router'
import App from './App.vue'
import axios from 'axios'
// 配置axios默认值
axios.defaults.baseURL = 'http://localhost:9090'
axios.defaults.timeout = 120000
// 添加请求拦截器
axios.interceptors.request.use(
config => {
// 在这里可以添加token等认证信息
return config;
},
error => {
return Promise.reject(error);
}
);
// 添加响应拦截器
axios.interceptors.response.use(
response => {
return response;
},
error => {
// 统一处理错误
console.error('请求错误:', error);
return Promise.reject(error);
}
);
const app = createApp(App)
const pinia = createPinia()
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIcons)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus)
// 初始化 axios 请求头
const token = localStorage.getItem('token')
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
}
app.mount('#app')
注意:main.js 文件内容较长,此处仅为部分代码片段。完整代码请查阅 frontend/src/main.js 源文件。
frontend/src/App.vue
App.vue 是整个 Vue 应用的根组件,负责定义整体布局和视图的呈现。我在这里实现了两种主要布局:一种是针对登录注册等无需侧边栏的页面,另一种是带有可拖拽调整大小的侧边栏布局,用于主要的应用内容。它还处理了侧边栏的展开/收起逻辑以及路由视图的动态渲染,并通过 keep-alive 优化了组件的性能。
<template>
<div class="app">
<!-- 登录注册页面等不需要侧边栏的页面 -->
<template v-if="!showLayoutWithSidebar">
<router-view v-slot="{ Component }">
<keep-alive include="GradingView">
<component :is="Component" />
</keep-alive>
</router-view>
</template>
<!-- 带有侧边栏的主要布局 -->
<template v-else>
<div class="main-layout">
<!-- 可拖拽调整大小的左侧边栏 -->
<div ref="sidebarRef" class="resizable-sidebar" :class="{ 'collapsed': isSidebarCollapsed }"
:style="{ width: currentSidebarWidth + 'px' }">
<ResizableSidebar :sidebar-width="currentSidebarWidth" :current-route="currentRoute"
:is-collapsed="isSidebarCollapsed" @new-chat="handleNewChat" @return-home="handleReturnHome"
@select-chat="handleSelectChat" @toggle-sidebar="toggleSidebar"
@knowledge-chat-created="handleKnowledgeChatCreated"
@knowledge-chat-selected="handleKnowledgeChatSelected"
@knowledge-chat-deleted="handleKnowledgeChatDeleted" />
<!-- 拖拽手柄 -->
<div v-if="!isSidebarCollapsed" class="resize-handle" @mousedown="startResize"></div>
</div>
<!-- 右侧主内容区 -->
<div class="main-content" :style="{ marginLeft: currentSidebarWidth + 'px' }">
<!-- 顶部导航栏 -->
<!-- 路由视图 -->
<div class="content-area" :class="{ 'with-nav': showNav }">
<router-view v-slot="{ Component }">
<keep-alive include="GradingView">
<component :is="Component" v-if="Component" />
</keep-alive>
</router-view>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, provide } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import NavBar from './components/NavBar.vue';
import ResizableSidebar from './components/ResizableSidebar.vue';
const route = useRoute();
const router = useRouter();
// 侧边栏宽度
const sidebarWidth = ref(300);
const minSidebarWidth = 200;
const maxSidebarWidth = 500;
const collapsedSidebarWidth = 60; // 收起时的宽度
// 拖拽相关
const isResizing = ref(false);
const sidebarRef = ref(null);
// 侧边栏收起状态
const isSidebarCollapsed = ref(false);
const savedSidebarWidth = ref(300); // 保存收起前的宽度
// 全局状态管理
const globalActions = ref({
createNewSession: null,
setCurrentSession: null
});
// 提供给子组件
provide('globalActions', globalActions);
// 计算属性
const currentRoute = computed(() => route.path);
const showLayoutWithSidebar = computed(() => {
// 不显示侧边栏的页面
const excludeRoutes = ['/login', '/register', '/reset-password'];
return !excludeRoutes.includes(route.path);
});
const showNav = computed(() => {
// 首页不显示顶部导航栏,其他页面显示
if (route.path === '/' || route.path === '/home') {
return false;
}
return route.meta.showNav !== false && showLayoutWithSidebar.value;
});
const currentSidebarWidth = computed(() => {
return isSidebarCollapsed.value ? collapsedSidebarWidth : sidebarWidth.value;
});
// 拖拽调整侧边栏大小
const startResize = (e) => {
isResizing.value = true;
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
e.preventDefault();
};
const handleResize = (e) => {
if (!isResizing.value) return;
const newWidth = e.clientX;
if (newWidth >= minSidebarWidth && newWidth <= maxSidebarWidth) {
sidebarWidth.value = newWidth;
}
};
const stopResize = () => {
isResizing.value = false;
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
};
// 侧边栏切换功能
const toggleSidebar = () => {
if (isSidebarCollapsed.value) {
// 展开侧边栏
sidebarWidth.value = savedSidebarWidth.value;
isSidebarCollapsed.value = false;
} else {
// 收起侧边栏
savedSidebarWidth.value = sidebarWidth.value;
isSidebarCollapsed.value = true;
}
};
// 事件处理
const handleNewChat = async () => {
// 如果当前不在首页,先跳转到首页
if (route.path !== '/') {
await router.push('/');
// 等待路由跳转完成
await new Promise(resolve => setTimeout(resolve, 200));
}
// 使用全局状态调用创建新对话
if (globalActions.value.createNewSession) {
try {
await globalActions.value.createNewSession();
} catch (error) {
console.error('创建新对话失败:', error);
}
}
};
const handleReturnHome = () => {
router.push('/');
};
// 处理侧边栏的聊天选择事件
const handleSelectChat = async (chatId) => {
// 如果当前不在首页,先跳转到首页
if (route.path !== '/') {
await router.push('/');
// 等待路由跳转完成
await new Promise(resolve => setTimeout(resolve, 200));
}
// 使用全局状态调用切换对话
if (globalActions.value.setCurrentSession) {
await globalActions.value.setCurrentSession(chatId);
}
};
// 知识图谱对话事件处理
const handleKnowledgeChatCreated = (chatId) => {
// 通知知识图谱页面创建了新对话
if (globalActions.value.createKnowledgeChat) {
globalActions.value.createKnowledgeChat(chatId);
}
};
const handleKnowledgeChatSelected = (chatId) => {
// 通知知识图谱页面选择了对话
if (globalActions.value.selectKnowledgeChat) {
globalActions.value.selectKnowledgeChat(chatId);
}
};
const handleKnowledgeChatDeleted = (chatId) => {
// 通知知识图谱页面删除了对话
if (globalActions.value.deleteKnowledgeChat) {
globalActions.value.deleteKnowledgeChat(chatId);
}
};
// 生命周期
onMounted(() => {
// 从localStorage恢复侧边栏宽度和状态
const savedWidth = localStorage.getItem('sidebarWidth');
const savedCollapsed = localStorage.getItem('sidebarCollapsed');
if (savedWidth) {
sidebarWidth.value = parseInt(savedWidth);
}
if (savedCollapsed) {
isSidebarCollapsed.value = savedCollapsed === 'true';
}
});
onUnmounted(() => {
// 保存侧边栏宽度和状态到localStorage
localStorage.setItem('sidebarWidth', sidebarWidth.value.toString());
localStorage.setItem('sidebarCollapsed', isSidebarCollapsed.value.toString());
});
</script>
<style scoped>
.app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
.main-layout {
display: flex;
height: 100vh;
}
.resizable-sidebar {
position: relative;
height: 100%;
background-color: #f0f2f5;
transition: width 0.3s ease;
overflow: hidden;
}
.resizable-sidebar.collapsed {
width: 60px !important;
}
.resize-handle {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 10px;
cursor: ew-resize;
background-color: #ccc;
opacity: 0;
transition: opacity 0.3s ease;
}
.resizable-sidebar:hover .resize-handle {
opacity: 1;
}
.main-content {
flex-grow: 1;
overflow-y: auto;
padding: 20px;
background-color: #f9f9f9;
}
.content-area {
max-width: 800px;
margin: 0 auto;
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.content-area.with-nav {
margin-top: 60px;
}
</style>
**注意**:`App.vue` 文件内容较长,此处仅为部分代码片段。完整代码请查阅 `frontend/src/App.vue` 源文件。
### `frontend/src/views/ELessonPlanGeneration.vue`
`ELessonPlanGeneration.vue` 是教案生成系统的核心页面,它为用户提供了多步骤的教案创建流程。该页面集成了班级选择、教材上传、详细信息填写、实时预览与编辑以及最终下载等功能,通过直观的 UI 和异步通信,为用户提供了流畅且高效的教案生成体验。
```vue
<template>
<div class="lesson-plan-view max-w-4xl mx-auto p-4 md:p-8">
<transition name="fade-in-up" appear>
<el-card shadow="hover" class="mb-8 main-title-card" key="main-title">
<template #header>
<div class="main-title-row">
<el-icon class="main-title-icon"><Document /></el-icon>
<span class="main-title-text select-none">教案生成中心</span>
</div>
</template>
<div class="text-gray-500 text-base mt-2">智能生成个性化教案,提升教学效率</div>
</el-card>
</transition>
<transition name="fade-in-up" appear>
<el-card shadow="never" class="mb-8 step-card" key="step-bar">
<el-steps :active="currentStep" finish-status="success" align-center>
<el-step title="选择/创建班级" :icon="FolderOpened" />
<el-step title="填写教案信息" :icon="Notebook" />
<el-step title="预览与确认" :icon="View" />
</el-steps>
</el-card>
</transition>
<!-- 步骤卡片区域:合并为同一个transition,避免v-else-if报错 -->
<transition name="fade-in-up" appear>
<el-card v-if="currentStep === 0" shadow="hover" class="mb-8 step-section-card" key="step-0">
<template #header>
<div class="section-title-row">
<el-icon class="section-title-icon"><School /></el-icon>
<span class="section-title-text">选择班级</span>
</div>
</template>
<div class="subsection-title">我的班级</div>
<el-form-item label-width="0">
<el-row type="flex" justify="space-between" align="middle" class="w-full">
<el-col :span="19" class="pr-3">
<el-select
v-model="selectedClassId"
placeholder="请选择班级"
style="width: 100%"
@change="handleClassChange"
tabindex="0"
aria-label="选择班级"
>
<el-option
v-for="cls in classList"
:key="cls.id"
:label="`${cls.name} (${cls.grade})`"
:value="cls.id"
/>
</el-select>
</el-col>
<el-col :span="5" class="text-right pl-2">
<el-button
type="success"
size="mini"
@click="handleOpenCreateClassDialog"
tabindex="0"
aria-label="新增班级"
style="border-radius:0"
>
<el-icon><Plus /></el-icon> 新增班级
</el-button>
</el-col>
</el-row>
</el-form-item>
<el-form-item label-width="0" class="w-full">
<el-table :data="visibleClassStudents" border stripe class="rounded-lg mt-4" style="width: 100%">
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column prop="gender" label="性别" width="80" />
<el-table-column prop="age" label="年龄" width="80" />
<el-table-column prop="student_number" label="学号" width="150" />
<el-table-column prop="academic_level" label="学业水平" width="120" />
<el-table-column label="操作" width="140">
<template #default="scope">
<el-button size="small" type="info" @click="handleOpenStudentDialog(scope.row)" tabindex="0" aria-label="编辑学生" class="table-btn-xs">
<el-icon><Edit /></el-icon>
</el-button>
<el-button size="small" type="danger" @click="handleDeleteStudent(scope.row._id)" tabindex="0" aria-label="删除学生" class="table-btn-xs">
<el-icon><Delete /></el-icon>
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="selectedClassStudents.length === 0" class="text-center text-gray-400 mt-6">请选择班级或添加学生</div>
<div v-if="selectedClassStudents.length > 5 || true" class="flex justify-between items-center mt-2">
<el-button v-if="selectedClassStudents.length > 5" type="default" size="mini" class="btn-xs" @click="showAllStudents = !showAllStudents">
{{ showAllStudents ? '收起' : '显示全部' }}
</el-button>
<span></span>
<el-button type="default" size="mini" class="btn-xs ml-3" @click="handleOpenStudentDialog()">
<el-icon><Plus /></el-icon> 新增学生
</el-button>
</div>
</el-form-item>
<!-- 教材资源上传区域 -->
<div class="section-title-row mb-4 mt-8">
<el-icon class="section-title-icon"><Folder /></el-icon>
<span class="section-title-text">教材资源</span>
</div>
<el-form-item>
<div class="upload-card teaching-upload-card">
<div class="upload-area teaching-upload" @click="triggerTeachingFileInput" :class="{ 'has-file': formData.teachingMaterialId }">
<input ref="teachingFileInput" type="file" @change="handleTeachingFileChange" accept=".pdf,.doc,.docx" style="display: none;" />
<div v-if="!formData.teachingMaterialId" class="upload-placeholder">
<div class="upload-icon">
<el-icon :size="32"><Folder /></el-icon>
</div>
<div class="upload-text">
<p class="main-text">点击上传教材/教辅文件</p>
<p class="sub-text">支持PDF、Word格式</p>
</div>
</div>
<div v-else class="file-info">
<el-icon class="text-green-400"><Document /></el-icon>
<span class="file-name">{{ getTeachingMaterialName(formData.teachingMaterialId) }}</span>
<el-button type="danger" size="small" circle @click.stop="clearFile">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
<!-- 教辅文件预览列表 -->
<div v-if="teachingMaterials.length > 0" class="mt-4">
<div v-for="mat in teachingMaterials" :key="mat._id || mat.id" class="flex items-center gap-4 mb-2">
<span class="font-medium text-blue-600">{{ mat.fileName }}</span>
<span class="text-gray-400 text-xs">上传: {{ formatDate(mat.uploadTime) }}</span>
<span class="text-gray-400 text-xs">页数: {{ mat.pageCount }}</span>
<el-button size="mini" @click="handlePreviewMaterial(mat)">预览</el-button>
</div>
</div>
</el-form-item>
</el-card>
<el-card v-else-if="currentStep === 1" shadow="hover" class="mb-8 step-section-card" key="step-1">
<template #header>
<div class="section-title-row">
<el-icon class="section-title-icon"><Notebook /></el-icon>
<span class="section-title-text">教案填写</span>
</div>
</template>
<el-form :model="formData" label-width="120px" ref="lessonPlanForm">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<el-form-item label="科目" required>
<el-input v-model="formData.subject" placeholder="请选择班级后自动填充" readonly tabindex="0" aria-label="科目"></el-input>
</el-form-item>
<el-form-item label="课题名称" required>
<el-input v-model="formData.courseTitle" placeholder="请输入课题名称" tabindex="0" aria-label="课题名称"></el-input>
</el-form-item>
<el-form-item label="课型" required>
<el-select v-model="formData.courseType" placeholder="请选择课型" class="w-full" tabindex="0" aria-label="课型">
<el-option v-for="(desc, type) in courseTypeOptions" :key="type" :label="`${type} - ${desc}`" :value="type" />
<el-option label="自定义" value="custom" />
</el-select>
<el-input v-if="formData.courseType === 'custom'" v-model="formData.customCourseType" placeholder="请输入自定义课型" class="mt-2" tabindex="0" aria-label="自定义课型" />
</el-form-item>
<el-form-item label="课时安排" required>
<el-select v-model="formData.classDuration" placeholder="请选择课时" class="w-full" tabindex="0" aria-label="课时安排">
<el-option v-for="(desc, duration) in classDurationOptions" :key="duration" :label="`${duration} - ${desc}`" :value="duration" />
<el-option label="自定义" value="custom" />
</el-select>
<el-input v-if="formData.classDuration === 'custom'" v-model="formData.customClassDuration" placeholder="请输入自定义课时" class="mt-2" tabindex="0" aria-label="自定义课时" />
</el-form-item>
</div>
<div class="section-title-row mt-8 mb-4">
<el-icon class="section-title-icon"><CollectionTag /></el-icon>
<span class="section-title-text">教辅资源</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<el-form-item label="教辅文件">
<el-select v-model="formData.teachingMaterialId" placeholder="请选择教辅文件" class="w-full" tabindex="0" aria-label="教辅文件">
<el-option v-for="mat in teachingMaterials" :key="mat.id || mat._id" :label="mat.fileName || mat.name" :value="mat.id || mat._id" />
</el-select>
</el-form-item>
<el-form-item label="参考页码">
<el-input v-model="formData.referencePages" placeholder="如10-15或12,14,16" tabindex="0" aria-label="参考页码"></el-input>
</el-form-item>
</div>
<!-- 教学目标部分 -->
<div class="section-title-row mt-8 mb-4">
<el-icon class="section-title-icon"><Star /></el-icon>
<span class="section-title-text">教学目标</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<el-form-item label="知识与技能" required>
<el-input type="textarea" v-model="formData.knowledgeObjective"
placeholder="请输入知识与技能目标" tabindex="0" aria-label="知识与技能目标"></el-input>
</el-form-item>
<el-form-item label="过程与方法">
<el-input type="textarea" v-model="formData.methodObjective"
placeholder="请输入过程与方法目标" tabindex="0" aria-label="过程与方法目标"></el-input>
</el-form-item>
<el-form-item label="情感态度">
<el-input type="textarea" v-model="formData.emotionalObjective"
placeholder="请输入情感态度与价值观目标" tabindex="0" aria-label="情感态度与价值观目标"></el-input>
</el-form-item>
</div>
<!-- 教学对象部分 -->
<div class="section-title-row mt-8 mb-4">
<el-icon class="section-title-icon"><User /></el-icon>
<span class="section-title-text">教学对象</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<el-form-item label="学生年级" required>
<el-input v-model="formData.studentGrade" placeholder="如小学、初中、高中等" tabindex="0" aria-label="学生年级"></el-input>
</el-form-item>
<el-form-item label="知识储备">
<el-input type="textarea" v-model="formData.studentBackground"
placeholder="请输入学生已有的知识储备" tabindex="0" aria-label="知识储备"></el-input>
</el-form-item>
<el-form-item label="学生特点">
<el-input type="textarea" v-model="formData.studentCharacteristics"
placeholder="如兴趣点、学习习惯等" tabindex="0" aria-label="学生特点"></el-input>
</el-form-item>
</div>
</el-form>
</el-card>
<el-card v-else-if="currentStep === 2" shadow="hover" class="mb-8 step-section-card" key="step-2">
<template #header>
<div class="section-title-row">
<el-icon class="section-title-icon"><View /></el-icon>
<span class="section-title-text">教案预览</span>
</div>
</template>
<div class="panel-card">
<div class="card-header flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-bold">
<el-icon><View /></el-icon>
教案预览
</h3>
<div class="flex gap-2" v-if="previewData">
<el-button class="action-btn" @click="saveLessonPlan" v-if="isEditing" tabindex="0" aria-label="保存修改">
<el-icon><Document /></el-icon> 保存
</el-button>
<el-button class="action-btn" @click="toggleEdit" tabindex="0" aria-label="切换编辑/预览">
<el-icon><Edit v-if="!isEditing" /><View v-else /></el-icon>
{{ isEditing ? '预览模式' : '编辑模式' }}
</el-button>
<el-button class="action-btn" @click="downloadLessonPlan" tabindex="0" aria-label="下载文档">
<el-icon><Download /></el-icon> 下载
</el-button>
</div>
</div>
<div class="card-content preview-content">
<div v-if="previewData" class="lesson-plan-display">
<h2 v-if="!isEditing" class="text-2xl font-bold mb-4 text-center">{{ previewData.title }}</h2>
<el-input v-else v-model="editingData.title" class="text-2xl font-bold mb-4 text-center" />
<div v-if="previewData.teaching_objectives" class="mb-4">
<h3 class="text-xl font-semibold mb-2">教学目标</h3>
</div>
</div>
</div>
</div>
</el-card>
</transition>
<!-- 底部导航按钮 -->
<div class="flex justify-between mt-8">
<el-button v-if="currentStep > 0" @click="handlePreviousStep" tabindex="0" aria-label="上一步">上一步</el-button>
<el-button v-if="currentStep < 2" type="primary" @click="handleNextStep" tabindex="0" aria-label="下一步">下一步</el-button>
<el-button v-else type="success" @click="handleGenerateLessonPlan" :loading="isGenerating" tabindex="0" aria-label="生成教案">
<el-icon v-if="isGenerating" class="is-loading"><Loading /></el-icon>
{{ isGenerating ? '生成中...' : '生成教案' }}
</el-button>
</div>
<!-- 创建班级对话框 -->
<el-dialog
v-model="createClassDialogVisible"
title="新增班级"
width="50%"
:before-close="handleCloseCreateClassDialog"
>
<el-form :model="newClassForm" ref="newClassFormRef" label-width="80px">
<el-form-item label="班级名称" prop="name" required>
<el-input v-model="newClassForm.name" autocomplete="off" tabindex="0" aria-label="班级名称"></el-input>
</el-form-item>
<el-form-item label="年级" prop="grade" required>
<el-input v-model="newClassForm.grade" autocomplete="off" tabindex="0" aria-label="年级"></el-input>
</el-form-item>
<el-form-item label="学科" prop="subject" required>
<el-input v-model="newClassForm.subject" autocomplete="off" tabindex="0" aria-label="学科"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleCloseCreateClassDialog" tabindex="0" aria-label="取消">取消</el-button>
<el-button type="primary" @click="handleCreateClass" tabindex="0" aria-label="确定">确定</el-button>
</span>
</template>
</el-dialog>
<!-- 学生信息对话框 -->
<el-dialog
v-model="studentDialogVisible"
:title="currentStudent.id ? '编辑学生信息' : '新增学生信息'"
width="50%"
:before-close="handleCloseStudentDialog"
>
<el-form :model="currentStudent" ref="studentFormRef" label-width="80px">
<el-form-item label="姓名" prop="name" required>
<el-input v-model="currentStudent.name" autocomplete="off" tabindex="0" aria-label="学生姓名"></el-input>
</el-form-item>
<el-form-item label="性别" prop="gender" required>
<el-select v-model="currentStudent.gender" placeholder="请选择性别" class="w-full" tabindex="0" aria-label="学生性别">
<el-option label="男" value="男"></el-option>
<el-option label="女" value="女"></el-option>
</el-select>
</el-form-item>
<el-form-item label="年龄" prop="age" required>
<el-input-number v-model="currentStudent.age" :min="1" :max="100" tabindex="0" aria-label="学生年龄"></el-input-number>
</el-form-item>
<el-form-item label="学号" prop="student_number" required>
<el-input v-model="currentStudent.student_number" autocomplete="off" tabindex="0" aria-label="学生学号"></el-input>
</el-form-item>
<el-form-item label="学业水平" prop="academic_level" required>
<el-input v-model="currentStudent.academic_level" autocomplete="off" tabindex="0" aria-label="学业水平"></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleCloseStudentDialog" tabindex="0" aria-label="取消">取消</el-button>
<el-button type="primary" @click="handleSaveStudent" tabindex="0" aria-label="确定">确定</el-button>
</span>
</template>
</el-dialog>
<!-- 教材预览对话框 -->
<el-dialog
v-model="materialPreviewDialogVisible"
:title="currentMaterialPreview.fileName"
width="80%"
fullscreen
:before-close="handleCloseMaterialPreviewDialog"
>
<div v-if="currentMaterialPreview.previewUrl" class="h-full">
<iframe :src="currentMaterialPreview.previewUrl" width="100%" height="100%" frameborder="0"></iframe>
</div>
<div v-else class="text-center py-10">
<el-empty description="无法预览该文件"></el-empty>
</div>
</el-dialog>
<!-- 任务进度对话框 -->
<el-dialog
v-model="taskProgressDialogVisible"
title="教案生成进度"
width="60%"
:before-close="handleCloseTaskProgressDialog"
:close-on-click-modal="false"
:close-on-press-escape="false"
show-close
>
<div class="task-progress-content">
<el-timeline>
<el-timeline-item
v-for="(activity, index) in taskProgressEvents"
:key="index"
:timestamp="activity.timestamp"
:color="activity.status === 'error' ? '#F56C6C' : (activity.status === 'success' ? '#67C23A' : '#409EFF')"
placement="top"
>
<el-card>
<h4 class="font-bold">{{ activity.message }}</h4>
<p v-if="activity.detail" class="text-sm text-gray-500 mt-1">{{ activity.detail }}</p>
<el-progress v-if="activity.progress !== undefined" :percentage="activity.progress" :status="activity.status"></el-progress>
</el-card>
</el-timeline-item>
</el-timeline>
<div v-if="taskProgressEvents.length === 0 && isGenerating" class="text-center text-gray-500">
正在连接服务器,请稍候...
</div>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleCloseTaskProgressDialog" :disabled="isGenerating">关闭</el-button>
<el-button type="primary" @click="handleDownloadGeneratedDoc" :disabled="isGenerating || !generatedDocUrl">
下载教案
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, provide } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore } from 'pinia';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
FolderOpened,
Notebook,
View,
Document,
Edit,
Download,
Delete,
Plus,
School,
CollectionTag,
Star,
User,
Folder,
Loading,
} from '@element-plus/icons-vue';
import axios from 'axios';
const route = useRoute();
const router = useRouter();
const store = useStore();
// 步骤管理
const currentStep = ref(0);
// 表单数据
const formData = ref({
subject: '',
courseTitle: '',
courseType: '',
customCourseType: '', // 自定义课型
classDuration: '',
customClassDuration: '', // 自定义课时
teachingMaterialId: '', // 教材ID
referencePages: '', // 参考页码
knowledgeObjective: '',
methodObjective: '',
emotionalObjective: '',
studentGrade: '',
studentBackground: '',
studentCharacteristics: '',
});
// 预览和编辑相关
const previewData = ref(null);
const isEditing = ref(false);
const editingData = ref({
title: '',
basic_info: {},
objectives: {},
key_points: {},
process: {},
board_design: '',
reflection: '',
});
// 班级和学生管理
const classList = ref([]);
const selectedClassId = ref(null);
const selectedClassStudents = ref([]);
const showAllStudents = ref(false);
const visibleClassStudents = computed(() =>
showAllStudents.value ? selectedClassStudents.value : selectedClassStudents.value.slice(0, 5)
);
// 教材管理
const teachingMaterials = ref([]);
const teachingFileInput = ref(null);
const materialPreviewDialogVisible = ref(false);
const currentMaterialPreview = ref({});
// 任务生成和进度
const isGenerating = ref(false);
const taskProgressDialogVisible = ref(false);
const taskProgressEvents = ref([]);
const currentTaskId = ref(null);
const eventSource = ref(null);
const generatedDocUrl = ref(null);
// 对话框表单
const createClassDialogVisible = ref(false);
const newClassForm = ref({
name: '',
grade: '',
subject: '',
});
const newClassFormRef = ref(null); // 用于表单验证
const studentDialogVisible = ref(false);
const currentStudent = ref({
id: null,
name: '',
gender: '',
age: null,
student_number: '',
academic_level: '',
});
const studentFormRef = ref(null); // 用于表单验证
// 选项数据
const courseTypeOptions = {
'讲授型': '以讲授为主,通过老师的讲解和解答来传授知识',
'探究型': '以学生的探究和实验为主,通过实验和实践来学习',
'讨论型': '以讨论和交流为主,通过讨论和互动来学习',
'实践型': '以实践为主,通过实践和操作来学习',
};
const classDurationOptions = {
'1小时': '一次课程时长为1小时',
'1.5小时': '一次课程时长为1.5小时',
'2小时': '一次课程时长为2小时',
'2.5小时': '一次课程时长为2.5小时',
'3小时': '一次课程时长为3小时',
};
// --- 方法 Methods ---
// 步骤导航
const handleNextStep = async () => {
if (currentStep.value === 0) {
if (!selectedClassId.value) {
ElMessage.warning('请先选择班级或创建新班级!');
return;
}
} else if (currentStep.value === 1) {
// 验证表单
try {
await lessonPlanForm.value.validate();
} catch (error) {
ElMessage.warning('请填写所有必填项!');
return;
}
}
currentStep.value++;
};
const handlePreviousStep = () => {
currentStep.value--;
};
// 班级和学生相关操作
const fetchClassList = async () => {
try {
const response = await axios.get('/api/classes');
classList.value = response.data.classes;
if (classList.value.length > 0 && !selectedClassId.value) {
selectedClassId.value = classList.value[0].id;
handleClassChange(selectedClassId.value);
}
} catch (error) {
console.error('获取班级列表失败:', error);
ElMessage.error('获取班级列表失败!');
}
};
const handleClassChange = (classId) => {
const selectedClass = classList.value.find((cls) => cls.id === classId);
if (selectedClass) {
formData.value.subject = selectedClass.subject;
formData.value.studentGrade = selectedClass.grade; // 自动填充年级
selectedClassStudents.value = selectedClass.students || [];
}
};
const handleOpenCreateClassDialog = () => {
createClassDialogVisible.value = true;
if (newClassFormRef.value) {
newClassFormRef.value.resetFields();
}
newClassForm.value = { name: '', grade: '', subject: '' };
};
const handleCloseCreateClassDialog = () => {
createClassDialogVisible.value = false;
};
const handleCreateClass = async () => {
try {
await newClassFormRef.value.validate();
const response = await axios.post('/api/classes', newClassForm.value);
ElMessage.success('班级创建成功!');
createClassDialogVisible.value = false;
fetchClassList(); // 刷新班级列表
selectedClassId.value = response.data.class.id; // 选中新创建的班级
handleClassChange(selectedClassId.value);
} catch (error) {
console.error('创建班级失败:', error);
ElMessage.error('创建班级失败!');
}
};
const handleOpenStudentDialog = (student = {}) => {
studentDialogVisible.value = true;
currentStudent.value = { ...student, age: student.age || null };
if (studentFormRef.value) {
studentFormRef.value.resetFields();
}
};
const handleCloseStudentDialog = () => {
studentDialogVisible.value = false;
};
const handleSaveStudent = async () => {
try {
await studentFormRef.value.validate();
if (!selectedClassId.value) {
ElMessage.warning('请先选择班级!');
return;
}
if (currentStudent.value.id) {
// 编辑学生
await axios.put(`/api/classes/${selectedClassId.value}/students/${currentStudent.value.id}`, currentStudent.value);
ElMessage.success('学生信息更新成功!');
} else {
// 新增学生
await axios.post(`/api/classes/${selectedClassId.value}/students`, currentStudent.value);
ElMessage.success('学生添加成功!');
}
studentDialogVisible.value = false;
fetchClassList(); // 刷新班级和学生列表
} catch (error) {
console.error('保存学生信息失败:', error);
ElMessage.error('保存学生信息失败!');
}
};
const handleDeleteStudent = async (studentId) => {
try {
await ElMessageBox.confirm('确定要删除该学生吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await axios.delete(`/api/classes/${selectedClassId.value}/students/${studentId}`);
ElMessage.success('学生删除成功!');
fetchClassList(); // 刷新班级和学生列表
} catch (error) {
if (error !== 'cancel') { // 用户取消不报错
console.error('删除学生失败:', error);
ElMessage.error('删除学生失败!');
}
}
};
// 教材相关操作
const triggerTeachingFileInput = () => {
teachingFileInput.value.click();
};
const handleTeachingFileChange = async (event) => {
const file = event.target.files[0];
if (file) {
const uploadFormData = new FormData();
uploadFormData.append('file', file);
try {
const response = await axios.post('/api/upload-teaching-material', uploadFormData);
const material = response.data.material;
teachingMaterials.value.push(material);
formData.value.teachingMaterialId = material.id;
ElMessage.success('教材上传成功!');
} catch (error) {
console.error('上传教材失败:', error);
ElMessage.error('教材上传失败!');
}
}
};
const getTeachingMaterialName = (materialId) => {
const material = teachingMaterials.value.find(mat => mat.id === materialId);
return material ? material.fileName : '未选择文件';
};
const handlePreviewMaterial = (material) => {
currentMaterialPreview.value = material;
materialPreviewDialogVisible.value = true;
};
const handleCloseMaterialPreviewDialog = () => {
materialPreviewDialogVisible.value = false;
currentMaterialPreview.value = {};
};
const clearFile = () => {
formData.value.teachingMaterialId = '';
ElMessage.info('已清空选定的教材文件。');
};
// 教案生成与预览
const handleGenerateLessonPlan = async () => {
if (!selectedClassId.value) {
ElMessage.warning('请先选择班级!');
return;
}
try {
await lessonPlanForm.value.validate();
isGenerating.value = true;
taskProgressDialogVisible.value = true;
taskProgressEvents.value = [];
generatedDocUrl.value = null;
const payload = {
template_id: 'default', // 可以根据实际需求选择模板ID
user_inputs: {
subject: formData.value.subject,
courseTitle: formData.value.courseTitle,
courseType: formData.value.courseType === 'custom' ? formData.value.customCourseType : formData.value.courseType,
classDuration: formData.value.classDuration === 'custom' ? formData.value.customClassDuration : formData.value.classDuration,
studentGrade: formData.value.studentGrade,
knowledgeObjective: formData.value.knowledgeObjective,
methodObjective: formData.value.methodObjective,
emotionalObjective: formData.value.emotionalObjective,
studentBackground: formData.value.studentBackground,
studentCharacteristics: formData.value.studentCharacteristics,
teachingMaterialId: formData.value.teachingMaterialId,
referencePages: formData.value.referencePages,
// 学生列表可以作为额外信息传递
students: selectedClassStudents.value.map(s => ({
name: s.name,
gender: s.gender,
age: s.age,
academic_level: s.academic_level
}))
}
};
const response = await axios.post('/api/generate', payload);
currentTaskId.value = response.data.task_id;
ElMessage.success('教案生成任务已启动!');
taskProgressEvents.value.push({
timestamp: new Date().toLocaleString(),
message: '教案生成任务已启动',
status: 'info'
});
// 启动SSE连接以获取实时进度
setupEventSource(currentTaskId.value);
} catch (error) {
isGenerating.value = false;
taskProgressDialogVisible.value = false;
console.error('启动教案生成任务失败:', error);
ElMessage.error('启动教案生成任务失败!');
}
};
const setupEventSource = (taskId) => {
if (eventSource.value) {
eventSource.value.close();
}
eventSource.value = new EventSource(`/api/tasks/${taskId}/stream`);
eventSource.value.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('SSE Event:', data);
taskProgressEvents.value.push({
timestamp: new Date().toLocaleString(),
message: data.message,
detail: data.detail,
progress: data.progress,
status: data.status,
});
if (data.status === 'completed') {
isGenerating.value = false;
previewData.value = data.result.lesson_plan_data;
editingData.value = JSON.parse(JSON.stringify(previewData.value)); // 深拷贝
generatedDocUrl.value = data.result.download_url;
eventSource.value.close();
ElMessage.success('教案生成完成!');
} else if (data.status === 'error') {
isGenerating.value = false;
eventSource.value.close();
ElMessage.error(`教案生成失败: ${data.message}`);
}
};
eventSource.value.onerror = (error) => {
console.error('EventSource error:', error);
isGenerating.value = false;
eventSource.value.close();
ElMessage.error('与服务器的连接中断,请重试!');
};
};
const handleCloseTaskProgressDialog = () => {
taskProgressDialogVisible.value = false;
if (eventSource.value) {
eventSource.value.close();
}
if (isGenerating.value) {
ElMessage.warning('教案生成任务仍在后台运行。');
}
};
const handleDownloadGeneratedDoc = () => {
if (generatedDocUrl.value) {
window.open(generatedDocUrl.value, '_blank');
} else {
ElMessage.warning('暂无生成文档可供下载。');
}
};
const toggleEdit = () => {
isEditing.value = !isEditing.value;
if (!isEditing.value) {
// 从编辑模式切换到预览模式时,更新previewData
previewData.value = JSON.parse(JSON.stringify(editingData.value));
} else {
// 从预览模式切换到编辑模式时,确保editingData是最新的
editingData.value = JSON.parse(JSON.stringify(previewData.value));
}
};
const saveLessonPlan = async () => {
try {
// 这里需要实现保存教案的API调用
// 假设有一个PUT或POST /api/lesson_plans/<id> 接口来保存修改后的教案
const response = await axios.put(`/api/lesson_plans/${currentTaskId.value}`, editingData.value);
ElMessage.success('教案保存成功!');
previewData.value = JSON.parse(JSON.stringify(editingData.value)); // 更新预览数据
isEditing.value = false; // 切换回预览模式
} catch (error) {
console.error('保存教案失败:', error);
ElMessage.error('保存教案失败!');
}
};
const formatDate = (timestamp) => {
if (!timestamp) return '';
const date = new Date(timestamp);
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
};
// 生命周期钩子
onMounted(() => {
fetchClassList();
});
// 提供给子组件
provide('currentStep', currentStep);
</script>
<style scoped>
.lesson-plan-view {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
.main-title-card {
background-color: #f0f2f5;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.main-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.main-title-icon {
font-size: 24px;
color: #409eff;
}
.main-title-text {
font-size: 20px;
font-weight: bold;
color: #303133;
}
.step-card {
background-color: #f0f2f5;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.step-section-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.section-title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.section-title-icon {
font-size: 20px;
color: #409eff;
}
.section-title-text {
font-size: 18px;
font-weight: bold;
color: #303133;
}
.subsection-title {
font-size: 16px;
font-weight: bold;
color: #606266;
margin-bottom: 8px;
}
.upload-card {
background-color: #f0f2f5;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 16px;
margin-bottom: 16px;
}
.upload-area {
border: 2px dashed #c0ccda;
border-radius: 6px;
cursor: pointer;
padding: 20px;
text-align: center;
transition: border-color 0.3s ease;
}
.upload-area:hover {
border-color: #409eff;
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-icon {
font-size: 32px;
color: #409eff;
margin-bottom: 8px;
}
.upload-text {
text-align: center;
}
.main-text {
font-size: 16px;
font-weight: bold;
color: #303133;
}
.sub-text {
font-size: 14px;
color: #909399;
}
.file-info {
display: flex;
align-items: center;
gap: 8px;
}
.file-name {
font-size: 16px;
font-weight: bold;
color: #303133;
}
.panel-card {
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
padding: 16px;
}
.card-header {
margin-bottom: 16px;
}
.action-btn {
margin-left: 8px;
}
.preview-content {
text-align: left;
}
.lesson-plan-display {
max-width: 800px;
margin: 0 auto;
}
.table-btn-xs {
font-size: 12px;
padding: 4px 8px;
}
.btn-xs {
font-size: 12px;
padding: 4px 8px;
}
.task-progress-content {
max-height: 400px;
overflow-y: auto;
}
</style>
注意:ELessonPlanGeneration.vue 文件内容非常庞大,此处仅为部分代码片段。完整代码请查阅 frontend/src/views/ELessonPlanGeneration.vue 源文件。
我的代码运转思路
整个教案生成系统的前端运转思路可以概括为:用户通过多步骤界面输入信息,前端调用后端API进行智能生成,并实时展示进度。
以下是详细的运转流程:
-
用户交互与数据收集:
- 用户在
ELessonPlanGeneration.vue页面中,通过分步表单(如课程信息选择、教材上传、表单填写)输入教案所需的数据。 - 前端负责收集并验证这些输入数据。
- 用户在
-
触发教案生成任务:
- 当用户完成所有输入并提交时,前端会通过
api模块向后端发起POST /api/generate请求,将收集到的数据发送给后端。 - 前端立即获取后端返回的
task_id,用于后续查询任务状态和接收实时更新。
- 当用户完成所有输入并提交时,前端会通过
-
实时进度展示 (SSE):
- 前端利用
GET /api/tasks/<task_id>/stream接口建立 Server-Sent Events (SSE) 连接。 - 通过这个连接,前端可以实时接收后端推送的教案生成进度、中间结果等更新信息,并动态更新 UI,例如显示进度条或当前正在生成的教案部分。
- 前端利用
-
教案预览与编辑:
- 当后端完成教案生成并通知前端后,前端会接收到最终的教案内容。
ELessonPlanGeneration.vue页面会渲染这些内容,允许用户进行预览和必要的编辑。
- 当后端完成教案生成并通知前端后,前端会接收到最终的教案内容。
-
文档下载:
- 用户可以根据需要触发下载功能,前端将向后端请求生成的 Word 文档,并提供下载。
通过这样的前端设计,我为用户提供了一个流畅、响应迅速且功能完善的教案生成平台,极大地提升了用户体验。


被折叠的 条评论
为什么被折叠?



