返回列表 发布新帖

豫唐智能教案在线生成平台V1.0.0

842 0
小K网牛逼 发表于 4 小时前 | 查看全部 阅读模式 <

马上注册,结交更多好友,享用更多功能,让你轻松玩转小K网。

您需要 登录 才可以下载或查看,没有账号?立即注册 微信登录

×
豫唐智能教案在线生成平台,基于 Vue 3 + Vite 开发的现代化教师辅助工具,旨在提升备课与教学效率。
经过从 0.0.0 内测阶段的深度打磨,我们正式迎来 1.0.0 正式版。本次更新不仅定义了全新的品牌名称,更在核心生成引擎上实现了质的飞跃,旨在为职业教育及企业内训提供高性能的辅助方案。

豫唐智能教案在线生成平台V1.0.0

豫唐智能教案在线生成平台V1.0.0

豫唐智能教案在线生成平台V1.0.0

豫唐智能教案在线生成平台V1.0.0

豫唐智能教案在线生成平台V1.0.0

豫唐智能教案在线生成平台V1.0.0

项目定位
豫唐教师辅助教学平台(Yutang Teacher Assistant Platform)是一款专为教育工作者打造的生产力工具。项目深度结合职业教育教学需求,通过 Vue 3 与现代 AI 技术栈,将传统繁琐的备课、出卷、课件制作流程转化为高度自动化的数字化工作流。

当前版本:v1.0.0

行业背景与核心价值
在数字化教学改革背景下,教师面临着教学内容更新快、教研压力大等挑战。本项目通过对智能化工作流的整合与结构化数据处理,实现了从教案构思到多格式文件(Word, PPT, PDF)输出的全链路闭环,旨在为职业教育及企业内训提供高性能的辅助方案。

在线体验
项目链接: https://www.ytecn.com/teacher/
开源地址:https://github.com/tcshowhand/teacher

核心功能与技术实现
1. 结构化教案生成系统 (AI Lesson Planning)
利用提示词工程(Prompt Engineering)引导 AI 生成符合教育逻辑的结构化内容:
深度覆盖:预设教学目标、学情分析、重难点突破及教学反思模块。
专业定制:内置针对教育场景优化的提示词模板,生成更加专业的教学内容。
文档引擎:基于 docx 与 docxtemplater 实现 Office Open XML 协议的无损导出。

2. 智能幻灯片生成模块 (Smart PPT Editor)
解决了从教学大纲到视觉演示的转换问题:
智能解析:利用大模型提取教学大纲关键信息,自动规划演示结构。
标准化导出:集成 pptxgenjs 库,支持生成标准 .pptx 文件,确保多端演示兼容性。

3. 专业测评构建引擎 (Exam Editor)
针对不同学科课程的评价需求,优化了题目管理逻辑:
全题型支持:涵盖选择题、判断题、代码填空及综合应用题。
高清排版:利用 jspdf 与 html2canvas 组合技术,支持生成高质量 PDF 试卷。

4. 智能化教学助手 (AI Assistant)
上下文感知:支持读取当前编辑的内容(如大纲或PPT),提供针对性的润色与建议。
灵活配置:支持用户自定义 API Key 及切换不同版本的模型(如 Qwen-Turbo/Plus)。

快速开始
1. 下载项目
git clone https://github.com/tcshowhand/teacher.git
cd teacher
2. 安装依赖
npm install
3. 启动开发服务器
npm run dev
4. 构建生产版本
npm run build
ExamEditor.vue
[Asm]
  1. <script setup>
  2. import { ref, onMounted, watch, nextTick } from 'vue'
  3. import ExamPaper from '../components/ExamPaper.vue'
  4. import AIChatAssistant from '../components/AIChatAssistant.vue'
  5. import Toolbar from '../components/Toolbar.vue'
  6. import SettingsModal from '../components/SettingsModal.vue'
  7. import html2canvas from 'html2canvas'
  8. import jsPDF from 'jspdf'
  9. import { saveAs } from 'file-saver'
  10. import localforage from 'localforage'
  11. import { useRoute } from 'vue-router'
  12. import { sendToQwenAIDialogue } from '../api/qwenAPI'
  13. import { useSettingsStore } from '../store/settings'
  14. import { DEFAULT_MODEL_ID } from '../config/models'
  15. const settings = useSettingsStore()
  16. const currentDocId = ref('')
  17. const examData = ref(null)
  18. const isGeneratingExam = ref(false)
  19. const TEMPLATES_KEY = 'exam_paper_templates_v1'
  20. const LAST_ACTIVE_KEY = 'last_active_doc_v1'
  21. const savedTemplates = ref([])
  22. const showSaveModal = ref(false)
  23. const showLoadModal = ref(false)
  24. const showDeleteConfirmModal = ref(false)
  25. const showResetConfirmModal = ref(false)
  26. // 添加AI生成确认弹窗状态
  27. const showAIGenConfirmModal = ref(false)
  28. const pendingDeleteTemplateIndex = ref(-1)
  29. const pendingLoadTemplate = ref(null)
  30. const templateName = ref('')
  31. const isExporting = ref(false)
  32. const showApiKeyAlertModal = ref(false) // New Alert Modal
  33. const route = useRoute()
  34. // Helper to create empty exam structure
  35. const createEmptyExam = (title = '新试题') => ({
  36.   title: title,
  37.   subTitle: '考试时间:__分钟  满分:__分',
  38.   items: []
  39. })
  40. const getStorageKey = (docId) => {
  41.   return `exam_data_v1_paper_${currentModelId.value}_${docId}`
  42. }
  43. const currentModelId = ref(localStorage.getItem('last_active_model_id') || DEFAULT_MODEL_ID)
  44. const handleModelChange = async (newModelId) => {
  45.     currentModelId.value = newModelId
  46.     localStorage.setItem('last_active_model_id', newModelId)
  47.     await loadCurrentData()
  48. }
  49. const loadCurrentData = async () => {
  50.   const storageKey = getStorageKey(currentDocId.value)
  51.   let loaded = false
  52.    
  53.   try {
  54.     const cached = await localforage.getItem(storageKey)
  55.     if (cached) {
  56.       examData.value = typeof cached === 'string' ? JSON.parse(cached) : cached
  57.       loaded = true
  58.     }
  59.   } catch (e) {
  60.     console.error('Failed to parse cached data', e)
  61.   }
  62.   if (!loaded) {
  63.     // Default initialization logic
  64.     let title = route.query.title || currentDocId.value
  65.     let subTitle = ''
  66.      
  67.     // Attempt to sync with Generator State if applicable
  68.     const { courseName, chapterId } = route.query
  69.     if (courseName && chapterId) {
  70.         const GENERATOR_STORAGE_KEY = 'lesson_plan_generator_state_v3'
  71.         try {
  72.             const rawState = localStorage.getItem(GENERATOR_STORAGE_KEY)
  73.             if (rawState) {
  74.                 const state = JSON.parse(rawState)
  75.                 if (state.courseName === courseName) {
  76.                     const foundChapter = state.generatedChapters.find(c => c.id === Number(chapterId))
  77.                     if (foundChapter) {
  78.                         title = `${courseName} - ${foundChapter.mainTitle}`
  79.                         subTitle = foundChapter.subTitle ? `章节:${foundChapter.subTitle}` : ''
  80.                     }
  81.                 }
  82.             }
  83.         } catch(e) { console.error(e) }
  84.     }
  85.     if (currentDocId.value === 'default_doc') {
  86.          try {
  87.             const baseUrl = import.meta.env.BASE_URL.endsWith('/') ? import.meta.env.BASE_URL : import.meta.env.BASE_URL + '/'
  88.             const response = await fetch(`${baseUrl}exam_data.json`)
  89.             examData.value = await response.json()
  90.          } catch(e) {
  91.             examData.value = createEmptyExam(title)
  92.          }
  93.     } else {
  94.          examData.value = createEmptyExam(title)
  95.          if (subTitle) examData.value.subTitle = subTitle
  96.     }
  97.   }
  98. }
  99. onMounted(async () => {
  100.   // 1. Load Templates
  101.   try {
  102.     const cachedTemplates = await localforage.getItem(TEMPLATES_KEY)
  103.     if (cachedTemplates) {
  104.       savedTemplates.value = typeof cachedTemplates === 'string' ? JSON.parse(cachedTemplates) : cachedTemplates
  105.     }
  106.   } catch (e) {
  107.     console.error('Failed to load templates', e)
  108.   }
  109.   // 2. Determine Document ID (Persistence Key)
  110.   const { courseName, chapterId } = route.query
  111.    
  112.   if (courseName && chapterId) {
  113.     currentDocId.value = `${courseName}_ch${chapterId}`
  114.   } else if (route.query.title) {
  115.     currentDocId.value = route.query.title
  116.   } else {
  117.     currentDocId.value = localStorage.getItem(LAST_ACTIVE_KEY) || 'default_doc'
  118.   }
  119.    
  120.   localStorage.setItem(LAST_ACTIVE_KEY, currentDocId.value)
  121.   // 3. Load Data
  122.   await loadCurrentData()
  123. })
  124. // Auto-save to local storage (IndexedDB)
  125. watch(examData, async (newVal) => {
  126.   if (newVal && currentDocId.value) {
  127.     try {
  128.       const storageKey = getStorageKey(currentDocId.value)
  129.       await localforage.setItem(storageKey, JSON.parse(JSON.stringify(newVal)))
  130.     } catch (e) {
  131.       console.error('Auto-save failed', e)
  132.     }
  133.   }
  134. }, { deep: true })
  135. const saveTemplatesToStorage = async () => {
  136.   try {
  137.     await localforage.setItem(TEMPLATES_KEY, JSON.parse(JSON.stringify(savedTemplates.value)))
  138.   } catch (e) {
  139.     alert('保存模板失败: ' + e.message)
  140.   }
  141. }
  142. const handleExportPDF = async () => {
  143.   const element = document.getElementById('exam-paper')
  144.   if (!element) return
  145.   isExporting.value = true
  146.   await nextTick()
  147.   try {
  148.     const scale = 2
  149.     const canvas = await html2canvas(element, {
  150.       scale: scale,
  151.       useCORS: true,
  152.       backgroundColor: '#ffffff'
  153.     })
  154.      
  155.     const contentWidth = canvas.width
  156.     const contentHeight = canvas.height
  157.     const pdf = new jsPDF('p', 'mm', 'a4')
  158.     const pdfPageWidth = pdf.internal.pageSize.getWidth()
  159.     const pdfPageHeight = pdf.internal.pageSize.getHeight()
  160.     const pxPerMm = contentWidth / pdfPageWidth
  161.     const marginMm = 20
  162.     const marginPx = marginMm * pxPerMm
  163.     const pageHeightInPx = (pdfPageHeight * pxPerMm) - (marginPx * 2) // Printable area height in px
  164.      
  165.     // Get all question items to check for cuts
  166.     const questionElements = element.querySelectorAll('.question-item')
  167.     // Calculate logical positions (unscaled) then scale them
  168.     // Note: html2canvas scale affects the image size, but DOM offsetTop is unscaled.
  169.     // We need to map DOM coordinates to Canvas coordinates.
  170.     // Canvas is scaled by 'scale'. DOM offsets are 1x.
  171.     // So we multiply DOM offsets by scale.
  172.      
  173.     const questions = Array.from(questionElements).map(el => {
  174.       // Get offset relative to the exam-paper element
  175.       // offsetTop is relative to offsetParent.
  176.       // We assume exam-paper is the offsetParent or we calculate cumulative offset.
  177.       // safest is getBoundingClientRect
  178.       const rect = el.getBoundingClientRect()
  179.       const containerRect = element.getBoundingClientRect()
  180.       const top = (rect.top - containerRect.top) * scale
  181.       const height = rect.height * scale
  182.       return { top, bottom: top + height }
  183.     })
  184.     let currentY = 0
  185.     let remainingHeight = contentHeight
  186.     while (currentY < contentHeight) {
  187.       if (currentY > 0) pdf.addPage()
  188.       
  189.       // Default: Fill the page
  190.       let sliceHeight = Math.min(pageHeightInPx, contentHeight - currentY)
  191.       let nextCutY = currentY + sliceHeight
  192.       // Check if we are cutting through a question
  193.       // A question is cut if: q.top < nextCutY AND q.bottom > nextCutY
  194.       // We look for the FIRST question that satisfies this
  195.       const crossingQuestion = questions.find(q => q.top < nextCutY && q.bottom > nextCutY)
  196.       if (crossingQuestion) {
  197.         // If the question is taller than the page itself, we can't avoid cutting it.
  198.         // We only adjust if the question starts AFTER currentY (it fits on the page partially, but we prefer to push it)
  199.         // OR if it could fit on the NEXT page.
  200.         // Simplified logic: If the cut is strictly inside the question, and the question top is below currentY,
  201.         // we cut AT the question top (pushing it to next page).
  202.         if (crossingQuestion.top > currentY) {
  203.             // Adjust cut to be at the start of the question
  204.             nextCutY = crossingQuestion.top
  205.             sliceHeight = nextCutY - currentY
  206.         }
  207.         // If crossingQuestion.top <= currentY, it means a huge question starting before this page even began
  208.         // (or at the top) is continuing. We just have to cut it.
  209.       }
  210.       // Create a canvas for this slice
  211.       const sliceCanvas = document.createElement('canvas')
  212.       sliceCanvas.width = contentWidth
  213.       sliceCanvas.height = sliceHeight
  214.       
  215.       const sCtx = sliceCanvas.getContext('2d')
  216.       
  217.       // Draw the slice
  218.       // drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
  219.       sCtx.drawImage(canvas, 0, currentY, contentWidth, sliceHeight, 0, 0, contentWidth, sliceHeight)
  220.       
  221.       const sliceData = sliceCanvas.toDataURL('image/png')
  222.       // PDF height needs to be calculated based on the actual sliceHeight drawn
  223.       const pdfSliceHeight = sliceHeight / pxPerMm
  224.       
  225.       pdf.addImage(sliceData, 'PNG', 0, marginMm, pdfPageWidth, pdfSliceHeight)
  226.       
  227.       currentY += sliceHeight
  228.       // Add a tiny buffer to avoid potential rounding loops, though logic should be robust
  229.       if (sliceHeight <= 0) break; // Safety break
  230.     }
  231.      
  232.     const now = new Date()
  233.     const timeStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
  234.     const fileName = `${examData.value.title || 'Exam'}_${timeStr}.pdf`
  235.     pdf.save(fileName)
  236.   } catch (error) {
  237.     console.error('PDF Export Failed:', error)
  238.     alert('导出失败,请重试')
  239.   } finally {
  240.     isExporting.value = false
  241.   }
  242. }
  243. const handleExportJSON = () => {
  244.   const blob = new Blob([JSON.stringify(examData.value, null, 2)], { type: 'application/json' })
  245.   const now = new Date()
  246.   const timeStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
  247.   const fileName = `${examData.value.title || 'Exam'}_${timeStr}.json`
  248.   saveAs(blob, fileName)
  249. }
  250. const handleSaveTemplate = () => {
  251.   if (!examData.value) return
  252.   templateName.value = `模板 ${savedTemplates.value.length + 1}`
  253.   showSaveModal.value = true
  254. }
  255. const confirmSaveTemplate = async () => {
  256.   if (!templateName.value) {
  257.     alert('请输入模板名称')
  258.     return
  259.   }
  260.   const newTemplate = {
  261.     id: Date.now(),
  262.     name: templateName.value,
  263.     data: JSON.parse(JSON.stringify(examData.value)),
  264.     date: new Date().toLocaleString(),
  265.     type: 'exam'
  266.   }
  267.   savedTemplates.value.unshift(newTemplate)
  268.   await saveTemplatesToStorage()
  269.   showSaveModal.value = false
  270. }
  271. const handleLoadTemplate = () => {
  272.   showLoadModal.value = true
  273. }
  274. const loadTemplate = (template) => {
  275.   if (template.type !== 'exam') {
  276.       alert('无法在试题编辑器中加载教案模板')
  277.       return
  278.   }
  279.   pendingLoadTemplate.value = template
  280.   showLoadConfirmModal.value = true
  281. }
  282. const confirmLoadTemplate = () => {
  283.   if (pendingLoadTemplate.value) {
  284.     examData.value = JSON.parse(JSON.stringify(pendingLoadTemplate.value.data))
  285.     showLoadModal.value = false
  286.     pendingLoadTemplate.value = null
  287.   }
  288.   showLoadConfirmModal.value = false
  289. }
  290. const deleteTemplate = (index) => {
  291.   pendingDeleteTemplateIndex.value = index
  292.   showDeleteConfirmModal.value = true
  293. }
  294. const confirmDeleteTemplate = async () => {
  295.   if (pendingDeleteTemplateIndex.value > -1) {
  296.     savedTemplates.value.splice(pendingDeleteTemplateIndex.value, 1)
  297.     await saveTemplatesToStorage()
  298.     pendingDeleteTemplateIndex.value = -1
  299.   }
  300.   showDeleteConfirmModal.value = false
  301. }
  302. const cancelDeleteTemplate = () => {
  303.   pendingDeleteTemplateIndex.value = -1
  304.   showDeleteConfirmModal.value = false
  305. }
  306. const handleImportJSON = (json) => {
  307.   examData.value = json
  308. }
  309. const handleReset = () => {
  310.   showResetConfirmModal.value = true
  311. }
  312. const confirmReset = async () => {
  313.   try {
  314.     if (currentDocId.value) {
  315.         const key = getStorageKey(currentDocId.value)
  316.         await localforage.removeItem(key)
  317.     }
  318.      
  319.     examData.value = createEmptyExamData(currentDocId.value || '示范课程 - 示范章节')
  320.   } catch (e) {
  321.     console.error('Failed to reset', e)
  322.   }
  323.   showResetConfirmModal.value = false
  324. }
  325. // 修改generateExamPaper函数,使用自定义弹窗
  326. const generateExamPaper = async () => {
  327.   if (isGeneratingExam.value) return
  328.   if (examData.value && examData.value.problems && examData.value.problems.length > 0) {
  329.     // 显示自定义确认弹窗而不是原生confirm
  330.     showAIGenConfirmModal.value = true
  331.     return
  332.   }
  333.    
  334.   // 如果没有现有试题,直接生成
  335.   await confirmGenerateExamPaper()
  336. }
  337. // 新增确认生成函数
  338. const confirmGenerateExamPaper = async () => {
  339.   showAIGenConfirmModal.value = false
  340.   isGeneratingExam.value = true
  341.   // Extract course title
  342.   let title = "计算机相关课程"
  343.   if (route.query.title) {
  344.       title = route.query.title
  345.   } else if (examData.value && examData.value.title) {
  346.       title = examData.value.title
  347.   }
  348.   const questionCount = settings.examQuestionCount || 5
  349.   const prompt = `请为课程"${title}"生成一份包含 ${questionCount} 道题目的试题数据。
  350.    
  351.   请严格按照以下 JSON 格式返回,不要包含代码块:
  352.   {
  353.     "title": "${title}",
  354.     "info": ["姓名: _______________", "学号: _______________", "得分: ___________"],
  355.     "footer": "~ End of Practice ~",
  356.     "problems": [
  357.       {
  358.         "qNum": "Q1.",
  359.         "title": "题目名称",
  360.         "tags": "知识点",
  361.         "desc": "详细的题目描述(HTML supported)...",
  362.         "input": "输入样例(仅编程题需要, 非编程题请省略)",
  363.         "output": "输出样例(仅编程题需要, 非编程题请省略)"
  364.       }
  365.       // ... 请生成一共 ${questionCount} 道题目
  366.     ]
  367.   }
  368.   注意:如果是编程类课程,请提供 input/output 样例;如果是理论、文学、数学等非编程类课程,请勿在JSON中包含 input 和 output 字段。`
  369.   const messages = [{ role: 'user', content: prompt }]
  370.    
  371.   let fullText = ''
  372.   await sendToQwenAIDialogue(messages, (text, isComplete) => {
  373.     fullText = text
  374.     if (isComplete) {
  375.       isGeneratingExam.value = false
  376.       try {
  377.         const cleanText = fullText.replace(/```json/g, '').replace(/```/g, '').trim()
  378.          
  379.         // Check for specific error message from worker/API
  380.         if (cleanText.includes('请先配置 API Key') || cleanText.includes('API Key not configured')) {
  381.             showApiKeyAlertModal.value = true
  382.             return
  383.         }
  384.         const newData = JSON.parse(cleanText)
  385.          
  386.         // Ensure image fields exist even if empty
  387.         if (newData.problems) {
  388.             newData.problems.forEach(p => {
  389.                 if (!p.image) p.image = ""
  390.             })
  391.         }
  392.         examData.value = newData
  393.       } catch (e) {
  394.         console.error('Failed to parse AI exam', e)
  395.         alert('生成失败,AI 返回格式不正确。')
  396.       }
  397.     }
  398.   })
  399. }
  400. const showAIChat = ref(false)
  401. const handleAIUpdate = (newData) => {
  402.   if (!newData) return
  403.   // Merge or replace
  404.   // We'll replace the fields that exist in newData
  405.   Object.keys(newData).forEach(key => {
  406.     examData.value[key] = newData[key]
  407.   })
  408. }
  409. </script>
  410. <template>
  411.   <div class="app-container">
  412.     <div class="home-link">
  413.       <router-link to="/">&#127968; 返回首页</router-link>
  414.     </div>
  415.     <Toolbar
  416.       @export-pdf="handleExportPDF"
  417.       @export-json="handleExportJSON"
  418.       @save-template="handleSaveTemplate"
  419.       @load-template="handleLoadTemplate"
  420.       @import-json="handleImportJSON"
  421.       @reset-data="handleReset"
  422.       @open-settings="showSettingsModal = true"
  423.     />
  424.      
  425.     <div class="ai-actions">
  426.       <button class="ai-gen-btn" @click="generateExamPaper" :disabled="isGeneratingExam">
  427.         {{ isGeneratingExam ? 'AI 生成中...' : `AI 一键生成试题 (${settings.examQuestionCount}题)` }}
  428.       </button>
  429.     </div>
  430.     <!-- AI Chat Assistant -->
  431.     <AIChatAssistant
  432.       v-model="showAIChat"
  433.       :currentContent="examData"
  434.       systemContext="您是试题助手。请根据用户的指令调整当前的试卷(JSON对象)。例如:'增加两道关于函数的选择题' 或 '把最后一道题的难度加大'。"
  435.       @update-content="handleAIUpdate"
  436.     />
  437.     <!-- Floating AI Chat Button -->
  438.     <button class="ai-chat-fab" @click="showAIChat = !showAIChat" title="AI 助手">
  439.       &#129302; 试题助手
  440.     </button>
  441.     <div class="content-area" v-if="examData">
  442.       <ExamPaper :examData="examData" :class="{ 'exporting': isExporting }" />
  443.     </div>
  444.     <div v-else class="loading">
  445.       Loading Data...
  446.     </div>
  447.     <!-- Modals (Save, Load, Delete, Reset, Export, Settings, Chat) -->
  448.     <!-- Copying existing modal structure directly -->
  449.      
  450.     <!-- Save Template Modal -->
  451.     <div class="modal-overlay" v-if="showSaveModal">
  452.       <div class="modal-content">
  453.         <h3>&#128190; 保存为模板</h3>
  454.         <input v-model="templateName" placeholder="给模板起个名字..." class="modal-input" @keyup.enter="confirmSaveTemplate" />
  455.         <div class="modal-actions">
  456.           <button class="modal-btn cancel" @click="showSaveModal = false">取消</button>
  457.           <button class="modal-btn confirm" @click="confirmSaveTemplate">保存</button>
  458.         </div>
  459.       </div>
  460.     </div>
  461.     <!-- Load Template Modal -->
  462.     <div class="modal-overlay" v-if="showLoadModal">
  463.       <div class="modal-content load-modal">
  464.         <h3>&#128194; 导入模板 (仅展示试题)</h3>
  465.         <div class="template-list" v-if="savedTemplates.filter(t => t.type === 'exam').length > 0">
  466.           <div v-for="(template, index) in savedTemplates.filter(t => t.type === 'exam')" :key="template.id" class="template-item">
  467.             <div class="template-info" @click="loadTemplate(template)">
  468.               <div class="t-name">
  469.                 <span class="tag-exam">试题</span>
  470.                 {{ template.name }}
  471.               </div>
  472.               <div class="t-date">{{ template.date }}</div>
  473.             </div>
  474.             <button class="delete-template-btn" @click.stop="deleteTemplate(index)" title="删除模板">×</button>
  475.           </div>
  476.         </div>
  477.         <div v-else class="empty-list">
  478.           暂无保存的模板
  479.         </div>
  480.         <div class="modal-actions">
  481.           <button class="modal-btn cancel" @click="showLoadModal = false">关闭</button>
  482.         </div>
  483.       </div>
  484.     </div>
  485.     <!-- Delete Template Confirmation Modal -->
  486.     <div class="modal-overlay" v-if="showDeleteConfirmModal" style="z-index: 2100;">
  487.       <div class="modal-content">
  488.         <h3>&#128465;&#65039; 确认删除模板?</h3>
  489.         <p>确定要删除这个模板吗?此操作无法撤销。</p>
  490.         <div class="modal-actions">
  491.           <button class="modal-btn cancel" @click="cancelDeleteTemplate">取消</button>
  492.           <button class="modal-btn confirm" @click="confirmDeleteTemplate">删除</button>
  493.         </div>
  494.       </div>
  495.     </div>
  496.     <!-- Reset Data Confirmation Modal -->
  497.     <div class="modal-overlay" v-if="showResetConfirmModal" style="z-index: 2100;">
  498.       <div class="modal-content">
  499.         <h3>&#129529; 确认重置?</h3>
  500.         <p>确定要清空所有修改吗?<br>这将恢复到默认状态。此操作无法撤销!</p>
  501.         <div class="modal-actions">
  502.           <button class="modal-btn cancel" @click="showResetConfirmModal = false">取消</button>
  503.           <button class="modal-btn confirm" @click="confirmReset">重置</button>
  504.         </div>
  505.       </div>
  506.     </div>
  507.     <!-- Load Template Confirmation Modal -->
  508.     <div class="modal-overlay" v-if="showLoadConfirmModal" style="z-index: 2200;">
  509.       <div class="modal-content">
  510.         <h3>&#128214; 确认加载?</h3>
  511.         <p v-if="pendingLoadTemplate">确定要加载模板 "<b>{{ pendingLoadTemplate.name }}</b>" 吗?<br>当前未保存的修改将会丢失。</p>
  512.         <div class="modal-actions">
  513.           <button class="modal-btn cancel" @click="showLoadConfirmModal = false">取消</button>
  514.           <button class="modal-btn confirm" @click="confirmLoadTemplate">加载</button>
  515.         </div>
  516.       </div>
  517.     </div>
  518.     <!-- API Key Alert Modal -->
  519.     <div class="modal-overlay" v-if="showApiKeyAlertModal" style="z-index: 2300;">
  520.       <div class="modal-content">
  521.         <h3>&#9888;&#65039; 需要配置 API Key</h3>
  522.         <p>AI 功能需要配置阿里云 DashScope API Key 才能使用。</p>
  523.         <div class="modal-actions">
  524.           <button class="modal-btn cancel" @click="showApiKeyAlertModal = false">取消</button>
  525.           <button class="modal-btn confirm" @click="showApiKeyAlertModal = false; showSettingsModal = true">去配置</button>
  526.         </div>
  527.       </div>
  528.     </div>
  529.     <!-- AI Generation Confirmation Modal -->
  530.     <div class="modal-overlay" v-if="showAIGenConfirmModal" style="z-index: 2200;">
  531.       <div class="modal-content">
  532.         <h3>AI 一键生成</h3>
  533.         <p>AI 将根据当前的课程信息自动生成试题。<br><b>注意:此操作可能会覆盖您已手动输入的内容。</b></p>
  534.         <div class="modal-actions">
  535.           <button class="modal-btn cancel" @click="showAIGenConfirmModal = false">取消</button>
  536.           <button class="modal-btn confirm" @click="confirmGenerateExamPaper">&#10024; 开始生成</button>
  537.         </div>
  538.       </div>
  539.     </div>
  540.     <!-- Export Loading Overlay -->
  541.     <div class="modal-overlay" v-if="isExporting" style="z-index: 3000; cursor: wait;">
  542.       <div class="modal-content" style="max-width: 300px;">
  543.         <h3>&#128424;&#65039; 正在导出...</h3>
  544.         <p>正在努力生成高清 PDF,<br>请稍候片刻...</p>
  545.         <div class="loading-spinner">&#9999;&#65039;</div>
  546.       </div>
  547.     </div>
  548.     <!-- AI Components -->
  549.     <!-- AI Components -->
  550.     <SettingsModal
  551.       v-if="showSettingsModal"
  552.       :currentModelId="currentModelId"
  553.       :show-model-selector="true"
  554.       @change-model="handleModelChange"
  555.       @close="showSettingsModal = false"
  556.     />
  557.   </div>
  558. </template>
  559. <style scoped>
  560. .app-container {
  561.   padding: 20px;
  562. }
  563. .home-link {
  564.   position: fixed;
  565.   top: 20px;
  566.   left: 20px;
  567.   z-index: 100;
  568. }
  569. .home-link a {
  570.   text-decoration: none;
  571.   font-weight: bold;
  572.   color: #2c3e50;
  573.   background: white;
  574.   padding: 10px 15px;
  575.   border-radius: 20px;
  576.   border: 2px solid #2c3e50;
  577.   box-shadow: 2px 2px 0 #2c3e50;
  578.   transition: transform 0.1s;
  579. }
  580. .home-link a:hover {
  581.   transform: scale(1.05);
  582. }
  583. .ai-actions {
  584.   text-align: center;
  585.   margin-bottom: 20px;
  586. }
  587. .ai-actions {
  588.   text-align: center;
  589.   margin-bottom: 20px;
  590. }
  591. .ai-gen-btn {
  592.     background: white;
  593.     color: #2c3e50;
  594.     border: 3px solid #2c3e50;
  595.     padding: 12px 30px;
  596.     font-size: 1.2em;
  597.     border-radius: 255px 15px 225px 15px / 15px 225px 15px 255px;
  598.     cursor: pointer;
  599.     box-shadow: 4px 4px 0 #2c3e50;
  600.     font-weight: bold;
  601.     font-family: inherit;
  602.     transition: all 0.2s;
  603. }
  604. .ai-gen-btn:hover:not(:disabled) {
  605.   transform: translate(-2px, -2px);
  606.   box-shadow: 6px 6px 0 #2c3e50;
  607.   background: #f3e5f5;
  608. }
  609. .ai-gen-btn:disabled {
  610.     background: #eee;
  611.     color: #999;
  612.     border-color: #999;
  613.     box-shadow: none;
  614.     cursor: wait;
  615. }
  616. .tag-plan {
  617.   background: #e1f5fe;
  618.   color: #039be5;
  619.   font-size: 0.8em;
  620.   padding: 2px 6px;
  621.   border-radius: 4px;
  622.   margin-right: 5px;
  623. }
  624. .tag-exam {
  625.   background: #fff3e0;
  626.   color: #fb8c00;
  627.   font-size: 0.8em;
  628.   padding: 2px 6px;
  629.   border-radius: 4px;
  630.   margin-right: 5px;
  631. }
  632. .loading {
  633.   text-align: center;
  634.   font-size: 1.5em;
  635.   margin-top: 100px;
  636.   color: #666;
  637. }
  638. /* Modal Styles Global */
  639. .modal-overlay {
  640.   position: fixed;
  641.   top: 0;
  642.   left: 0;
  643.   right: 0;
  644.   bottom: 0;
  645.   background: rgba(0,0,0,0.5);
  646.   display: flex;
  647.   align-items: center;
  648.   justify-content: center;
  649.   z-index: 2000;
  650.   backdrop-filter: blur(3px);
  651. }
  652. .modal-content {
  653.   background: #fdfbf7;
  654.   padding: 30px;
  655.   border-radius: 255px 15px 225px 15px / 15px 225px 15px 255px;
  656.   border: 3px solid #2c3e50;
  657.   box-shadow: 10px 10px 0 rgba(0,0,0,0.2);
  658.   width: 90%;
  659.   max-width: 400px;
  660.   text-align: center;
  661.   font-family: 'Architects Daughter', cursive;
  662. }
  663. .modal-content h3 {
  664.   font-size: 1.5em;
  665.   margin-bottom: 20px;
  666.   border-bottom: 1px dashed #ccc;
  667.   padding-bottom: 10px;
  668. }
  669. .modal-input {
  670.   width: 80%;
  671.   padding: 10px;
  672.   margin-bottom: 20px;
  673.   font-size: 1.2em;
  674.   font-family: inherit;
  675.   border: 2px solid #2c3e50;
  676.   border-radius: 5px;
  677.   outline: none;
  678. }
  679. .modal-actions {
  680.   display: flex;
  681.   justify-content: center;
  682.   gap: 20px;
  683.   margin-top: 20px;
  684. }
  685. .modal-btn {
  686.   padding: 8px 20px;
  687.   border: 2px solid #2c3e50;
  688.   background: transparent;
  689.   font-family: inherit;
  690.   font-size: 1.1em;
  691.   cursor: pointer;
  692.   border-radius: 255px 15px 225px 15px / 15px 225px 15px 255px;
  693.   transition: transform 0.1s;
  694. }
  695. .modal-btn:hover {
  696.   transform: scale(1.05);
  697. }
  698. .modal-btn.confirm {
  699.   background: #e74c3c;
  700.   color: white;
  701.   border-color: #e74c3c;
  702. }
  703. .modal-btn.cancel {
  704.   border-style: dashed;
  705. }
  706. /* Template List */
  707. .load-modal {
  708.   max-width: 500px;
  709. }
  710. .template-list {
  711.   max-height: 300px;
  712.   overflow-y: auto;
  713.   text-align: left;
  714. }
  715. .template-item {
  716.   display: flex;
  717.   justify-content: space-between;
  718.   align-items: center;
  719.   padding: 10px;
  720.   border-bottom: 1px solid #eee;
  721.   cursor: pointer;
  722.   transition: background 0.2s;
  723. }
  724. .template-item:hover {
  725.   background: rgba(0,0,0,0.05);
  726. }
  727. .template-info {
  728.   flex: 1;
  729. }
  730. .t-name {
  731.   font-weight: bold;
  732.   font-size: 1.1em;
  733. }
  734. .ai-chat-fab {
  735.   position: fixed;
  736.   bottom: 20px;
  737.   right: 20px;
  738.   background: #2c3e50;
  739.   color: white;
  740.   border: none;
  741.   border-radius: 30px;
  742.   padding: 12px 24px;
  743.   font-size: 1.1em;
  744.   font-weight: bold;
  745.   box-shadow: 0 4px 10px rgba(0,0,0,0.3);
  746.   cursor: pointer;
  747.   z-index: 900;
  748.   transition: transform 0.2s;
  749.   font-family: 'Architects Daughter', cursive;
  750.   border: 2px solid white;
  751. }
  752. .ai-chat-fab:hover {
  753.   transform: scale(1.05);
  754.   background: #34495e;
  755. }
  756. .t-date {
  757.   font-size: 0.8em;
  758.   color: #888;
  759. }
  760. .delete-template-btn {
  761.   background: transparent;
  762.   border: none;
  763.   color: #ccc;
  764.   font-size: 1.5em;
  765.   cursor: pointer;
  766.   padding: 0 10px;
  767. }
  768. .delete-template-btn:hover {
  769.   color: #c0392b;
  770. }
  771. .empty-list {
  772.   color: #999;
  773.   padding: 20px;
  774. }
  775. .loading-spinner {
  776.   font-size: 3em;
  777.   animation: writing 1s infinite alternate;
  778.   margin-top: 20px;
  779. }
  780. @keyframes writing {
  781.   from { transform: translateX(-20px) rotate(-10deg); }
  782.   to { transform: translateX(20px) rotate(10deg); }
  783. }
  784. </style>
复制代码


回复

您需要登录后才可以回帖 登录 | 立即注册 微信登录

本版积分规则

您需要 登录 后才可以回复,轻松玩转社区,没有帐号?立即注册
快速回复
关灯 在本版发帖
扫一扫添加微信客服
QQ客服返回顶部
快速回复 返回顶部 返回列表