【项目实训-教案生成-前端讲解】

教案生成系统 - 前端讲解

作为一名前端开发者,我负责构建教案生成系统的用户界面和交互逻辑。这个前端系统基于 Vue 3 和 Nuxt 3,旨在提供直观、高效的教案创建体验。

我的代码是如何实现的?

前端项目主要位于 frontend 文件夹中,其核心逻辑和组件都组织在 src 目录下。

前端架构 (frontend/src 文件夹)

前端采用模块化设计,主要包含以下部分:

  1. 入口文件与全局配置 (main.js, App.vue)

    • main.js 是 Vue 应用的入口文件,我在这里初始化了 Vue 应用,集成了 Pinia (用于状态管理)、ElementPlus (UI 组件库),并引入了全局 CSS 样式。
    • 此外,我还配置了 Axios,包括设置 baseURL、请求超时时间以及请求和响应拦截器。特别地,我在请求拦截器中设置了 Authorization 请求头,确保所有API请求都能携带认证信息。
    • App.vue 是应用的根组件,它承载了整个应用的布局和视图。
  2. 页面 (views 文件夹)

    • views 文件夹存放着系统的主要页面组件。其中,ELessonPlanGeneration.vue 是核心页面,它实现了多步骤的教案生成流程:包括课程信息选择、教材上传、表单填写、教案预览与编辑等功能。
  3. 组件 (components 文件夹)

    • components 文件夹包含了可重用的 Vue 组件,这些组件被用于构建各个页面。例如,表单输入组件、上传组件、进度显示组件等,它们遵循 Composition API 规范,提高了代码的复用性和可维护性。
  4. API 服务 (api 文件夹)

    • api 文件夹负责定义与后端交互的 API 接口。我将所有的后端请求封装在这里,使得前端页面与后端解耦,便于管理和维护。
  5. 状态管理 (stores 文件夹)

    • 通过 Pinia 实现状态管理,stores 文件夹中定义了各种 store,用于集中管理应用的状态,例如教案生成任务的状态、用户输入的数据等。这确保了数据在应用中的一致性和可预测性。
  6. 路由 (router 文件夹)

    • router 文件夹定义了前端的路由配置,负责管理页面之间的导航和视图切换。
  7. 工具函数 (utils 文件夹)

    • utils 文件夹包含了各种通用的工具函数,例如数据格式化、验证等,这些函数可以在整个项目中复用。
  8. 样式 (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进行智能生成,并实时展示进度。

以下是详细的运转流程:

  1. 用户交互与数据收集

    • 用户在 ELessonPlanGeneration.vue 页面中,通过分步表单(如课程信息选择、教材上传、表单填写)输入教案所需的数据。
    • 前端负责收集并验证这些输入数据。
  2. 触发教案生成任务

    • 当用户完成所有输入并提交时,前端会通过 api 模块向后端发起 POST /api/generate 请求,将收集到的数据发送给后端。
    • 前端立即获取后端返回的 task_id,用于后续查询任务状态和接收实时更新。
  3. 实时进度展示 (SSE)

    • 前端利用 GET /api/tasks/<task_id>/stream 接口建立 Server-Sent Events (SSE) 连接。
    • 通过这个连接,前端可以实时接收后端推送的教案生成进度、中间结果等更新信息,并动态更新 UI,例如显示进度条或当前正在生成的教案部分。
  4. 教案预览与编辑

    • 当后端完成教案生成并通知前端后,前端会接收到最终的教案内容。ELessonPlanGeneration.vue 页面会渲染这些内容,允许用户进行预览和必要的编辑。
  5. 文档下载

    • 用户可以根据需要触发下载功能,前端将向后端请求生成的 Word 文档,并提供下载。

通过这样的前端设计,我为用户提供了一个流畅、响应迅速且功能完善的教案生成平台,极大地提升了用户体验。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值