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

admin
🌐 经济型:买域名、轻量云服务器、用途:游戏 网站等 《腾讯云》特点:特价机便宜 适合初学者用 点我优惠购买
🚀 拓展型:买域名、轻量云服务器、用途:游戏 网站等 《阿里云》特点:中档服务器便宜 域名备案事多 点我优惠购买
🛡️ 稳定型:买域名、轻量云服务器、用途:游戏 网站等 《西部数码》 特点:比上两家略贵但是稳定性超好事也少 点我优惠购买

豫唐智教案在线生成平台基于 Vue 3 + Vite 开发的现代化教师辅助工具,旨在提升备课与教学效率。
经过从 0.0.0 内测阶段的深度打磨,我们正式迎来 1.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.  

  8. import html2canvas from 'html2canvas'

  9. import jsPDF from 'jspdf'

  10. import { saveAs } from 'file-saver'

  11. import localforage from 'localforage'

  12. import { useRoute } from 'vue-router'

  13.  

  14. import { sendToQwenAIDialogue } from '../api/qwenAPI'

  15.  

  16. import { useSettingsStore } from '../store/settings'

  17. import { DEFAULT_MODEL_ID } from '../config/models'

  18.  

  19. const settings = useSettingsStore()

  20.  

  21. const currentDocId = ref('')

  22. const examData = ref(null)

  23. const isGeneratingExam = ref(false)

  24.  

  25.  

  26. const TEMPLATES_KEY = 'exam_paper_templates_v1'

  27. const LAST_ACTIVE_KEY = 'last_active_doc_v1'

  28.  

  29. const savedTemplates = ref([])

  30. const showSaveModal = ref(false)

  31. const showLoadModal = ref(false)

  32. const showDeleteConfirmModal = ref(false)

  33. const showResetConfirmModal = ref(false)

  34.  

  35. // 添加AI生成确认弹窗状态

  36. const showAIGenConfirmModal = ref(false)

  37.  

  38. const pendingDeleteTemplateIndex = ref(-1)

  39. const pendingLoadTemplate = ref(null)

  40. const templateName = ref('')

  41. const isExporting = ref(false)

  42. const showApiKeyAlertModal = ref(false) // New Alert Modal

  43.  

  44.  

  45. const route = useRoute()

  46.  

  47. // Helper to create empty exam structure

  48. const createEmptyExam = (title = '新试题') => ({

  49.   title: title,

  50.   subTitle: '考试时间:__分钟  满分:__分',

  51.   items: []

  52. })

  53.  

  54. const getStorageKey = (docId) => {

  55.   return `exam_data_v1_paper_${currentModelId.value}_${docId}`

  56. }

  57.  

  58. const currentModelId = ref(localStorage.getItem('last_active_model_id') || DEFAULT_MODEL_ID)

  59.  

  60. const handleModelChange = async (newModelId) => {

  61.     currentModelId.value = newModelId

  62.     localStorage.setItem('last_active_model_id', newModelId)

  63.     await loadCurrentData()

  64. }

  65.  

  66. const loadCurrentData = async () => {

  67.   const storageKey = getStorageKey(currentDocId.value)

  68.   let loaded = false

  69.    

  70.   try {

  71.     const cached = await localforage.getItem(storageKey)

  72.     if (cached) {

  73.       examData.value = typeof cached === 'string' ? JSON.parse(cached) : cached

  74.       loaded = true

  75.     }

  76.   } catch (e) {

  77.     console.error('Failed to parse cached data', e)

  78.   }

  79.  

  80.   if (!loaded) {

  81.     // Default initialization logic

  82.     let title = route.query.title || currentDocId.value

  83.     let subTitle = ''

  84.      

  85.     // Attempt to sync with Generator State if applicable

  86.     const { courseName, chapterId } = route.query

  87.     if (courseName && chapterId) {

  88.         const GENERATOR_STORAGE_KEY = 'lesson_plan_generator_state_v3'

  89.         try {

  90.             const rawState = localStorage.getItem(GENERATOR_STORAGE_KEY)

  91.             if (rawState) {

  92.                 const state = JSON.parse(rawState)

  93.                 if (state.courseName === courseName) {

  94.                     const foundChapter = state.generatedChapters.find(c => c.id === Number(chapterId))

  95.                     if (foundChapter) {

  96.                         title = `${courseName} - ${foundChapter.mainTitle}`

  97.                         subTitle = foundChapter.subTitle ? `章节:${foundChapter.subTitle}` : ''

  98.                     }

  99.                 }

  100.             }

  101.         } catch(e) { console.error(e) }

  102.     }

  103.  

  104.     if (currentDocId.value === 'default_doc') {

  105.          try {

  106.             const baseUrl = import.meta.env.BASE_URL.endsWith('/') ? import.meta.env.BASE_URL : import.meta.env.BASE_URL + '/'

  107.             const response = await fetch(`${baseUrl}exam_data.json`)

  108.             examData.value = await response.json()

  109.          } catch(e) {

  110.             examData.value = createEmptyExam(title)

  111.          }

  112.     } else {

  113.          examData.value = createEmptyExam(title)

  114.          if (subTitle) examData.value.subTitle = subTitle

  115.     }

  116.   }

  117. }

  118.  

  119. onMounted(async () => {

  120.  

  121.   // 1. Load Templates

  122.   try {

  123.     const cachedTemplates = await localforage.getItem(TEMPLATES_KEY)

  124.     if (cachedTemplates) {

  125.       savedTemplates.value = typeof cachedTemplates === 'string' ? JSON.parse(cachedTemplates) : cachedTemplates

  126.     }

  127.   } catch (e) {

  128.     console.error('Failed to load templates', e)

  129.   }

  130.  

  131.   // 2. Determine Document ID (Persistence Key)

  132.   const { courseName, chapterId } = route.query

  133.    

  134.   if (courseName && chapterId) {

  135.     currentDocId.value = `${courseName}_ch${chapterId}`

  136.   } else if (route.query.title) {

  137.     currentDocId.value = route.query.title

  138.   } else {

  139.     currentDocId.value = localStorage.getItem(LAST_ACTIVE_KEY) || 'default_doc'

  140.   }

  141.    

  142.   localStorage.setItem(LAST_ACTIVE_KEY, currentDocId.value)

  143.  

  144.   // 3. Load Data

  145.   await loadCurrentData()

  146. })

  147.  

  148. // Auto-save to local storage (IndexedDB)

  149. watch(examData, async (newVal) => {

  150.   if (newVal && currentDocId.value) {

  151.     try {

  152.       const storageKey = getStorageKey(currentDocId.value)

  153.       await localforage.setItem(storageKey, JSON.parse(JSON.stringify(newVal)))

  154.     } catch (e) {

  155.       console.error('Auto-save failed', e)

  156.     }

  157.   }

  158. }, { deep: true })

  159.  

  160. const saveTemplatesToStorage = async () => {

  161.   try {

  162.     await localforage.setItem(TEMPLATES_KEY, JSON.parse(JSON.stringify(savedTemplates.value)))

  163.   } catch (e) {

  164.     alert('保存模板失败: ' + e.message)

  165.   }

  166. }

  167.  

  168. const handleExportPDF = async () => {

  169.   const element = document.getElementById('exam-paper')

  170.   if (!element) return

  171.  

  172.   isExporting.value = true

  173.   await nextTick()

  174.  

  175.   try {

  176.     const scale = 2

  177.     const canvas = await html2canvas(element, {

  178.       scale: scale,

  179.       useCORS: true,

  180.       backgroundColor: '#ffffff'

  181.     })

  182.      

  183.     const contentWidth = canvas.width

  184.     const contentHeight = canvas.height

  185.     const pdf = new jsPDF('p', 'mm', 'a4')

  186.     const pdfPageWidth = pdf.internal.pageSize.getWidth()

  187.     const pdfPageHeight = pdf.internal.pageSize.getHeight()

  188.     const pxPerMm = contentWidth / pdfPageWidth

  189.     const marginMm = 20

  190.     const marginPx = marginMm * pxPerMm

  191.     const pageHeightInPx = (pdfPageHeight * pxPerMm) - (marginPx * 2) // Printable area height in px

  192.      

  193.     // Get all question items to check for cuts

  194.     const questionElements = element.querySelectorAll('.question-item')

  195.     // Calculate logical positions (unscaled) then scale them

  196.     // Note: html2canvas scale affects the image size, but DOM offsetTop is unscaled.

  197.     // We need to map DOM coordinates to Canvas coordinates.

  198.     // Canvas is scaled by 'scale'. DOM offsets are 1x.

  199.     // So we multiply DOM offsets by scale.

  200.      

  201.     const questions = Array.from(questionElements).map(el => {

  202.       // Get offset relative to the exam-paper element

  203.       // offsetTop is relative to offsetParent.

  204.       // We assume exam-paper is the offsetParent or we calculate cumulative offset.

  205.       // safest is getBoundingClientRect

  206.       const rect = el.getBoundingClientRect()

  207.       const containerRect = element.getBoundingClientRect()

  208.       const top = (rect.top - containerRect.top) * scale

  209.       const height = rect.height * scale

  210.       return { top, bottom: top + height }

  211.     })

  212.  

  213.     let currentY = 0

  214.     let remainingHeight = contentHeight

  215.  

  216.     while (currentY < contentHeight) {

  217.       if (currentY > 0) pdf.addPage()

  218.       

  219.       // Default: Fill the page

  220.       let sliceHeight = Math.min(pageHeightInPx, contentHeight - currentY)

  221.       let nextCutY = currentY + sliceHeight

  222.  

  223.       // Check if we are cutting through a question

  224.       // A question is cut if: q.top < nextCutY AND q.bottom > nextCutY

  225.       // We look for the FIRST question that satisfies this

  226.       const crossingQuestion = questions.find(q => q.top < nextCutY && q.bottom > nextCutY)

  227.  

  228.       if (crossingQuestion) {

  229.         // If the question is taller than the page itself, we can't avoid cutting it.

  230.         // We only adjust if the question starts AFTER currentY (it fits on the page partially, but we prefer to push it)

  231.         // OR if it could fit on the NEXT page.

  232.         // Simplified logic: If the cut is strictly inside the question, and the question top is below currentY,

  233.         // we cut AT the question top (pushing it to next page).

  234.         if (crossingQuestion.top > currentY) {

  235.             // Adjust cut to be at the start of the question

  236.             nextCutY = crossingQuestion.top

  237.             sliceHeight = nextCutY - currentY

  238.         }

  239.         // If crossingQuestion.top <= currentY, it means a huge question starting before this page even began

  240.         // (or at the top) is continuing. We just have to cut it.

  241.       }

  242.  

  243.       // Create a canvas for this slice

  244.       const sliceCanvas = document.createElement('canvas')

  245.       sliceCanvas.width = contentWidth

  246.       sliceCanvas.height = sliceHeight

  247.       

  248.       const sCtx = sliceCanvas.getContext('2d')

  249.       

  250.       // Draw the slice

  251.       // drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)

  252.       sCtx.drawImage(canvas, 0, currentY, contentWidth, sliceHeight, 0, 0, contentWidth, sliceHeight)

  253.       

  254.       const sliceData = sliceCanvas.toDataURL('image/png')

  255.       // PDF height needs to be calculated based on the actual sliceHeight drawn

  256.       const pdfSliceHeight = sliceHeight / pxPerMm

  257.       

  258.       pdf.addImage(sliceData, 'PNG', 0, marginMm, pdfPageWidth, pdfSliceHeight)

  259.       

  260.       currentY += sliceHeight

  261.       // Add a tiny buffer to avoid potential rounding loops, though logic should be robust

  262.       if (sliceHeight <= 0) break; // Safety break

  263.     }

  264.      

  265.     const now = new Date()

  266.     const timeStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`

  267.     const fileName = `${examData.value.title || 'Exam'}_${timeStr}.pdf`

  268.     pdf.save(fileName)

  269.   } catch (error) {

  270.     console.error('PDF Export Failed:', error)

  271.     alert('导出失败,请重试')

  272.   } finally {

  273.     isExporting.value = false

  274.   }

  275. }

  276.  

  277. const handleExportJSON = () => {

  278.   const blob = new Blob([JSON.stringify(examData.value, null, 2)], { type: 'application/json' })

  279.   const now = new Date()

  280.   const timeStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`

  281.   const fileName = `${examData.value.title || 'Exam'}_${timeStr}.json`

  282.   saveAs(blob, fileName)

  283. }

  284.  

  285. const handleSaveTemplate = () => {

  286.   if (!examData.value) return

  287.   templateName.value = `模板 ${savedTemplates.value.length + 1}`

  288.   showSaveModal.value = true

  289. }

  290.  

  291. const confirmSaveTemplate = async () => {

  292.   if (!templateName.value) {

  293.     alert('请输入模板名称')

  294.     return

  295.   }

  296.   const newTemplate = {

  297.     id: Date.now(),

  298.     name: templateName.value,

  299.     data: JSON.parse(JSON.stringify(examData.value)),

  300.     date: new Date().toLocaleString(),

  301.     type: 'exam'

  302.   }

  303.   savedTemplates.value.unshift(newTemplate)

  304.   await saveTemplatesToStorage()

  305.   showSaveModal.value = false

  306. }

  307.  

  308. const handleLoadTemplate = () => {

  309.   showLoadModal.value = true

  310. }

  311.  

  312. const loadTemplate = (template) => {

  313.   if (template.type !== 'exam') {

  314.       alert('无法在试题编辑器中加载教案模板')

  315.       return

  316.   }

  317.   pendingLoadTemplate.value = template

  318.   showLoadConfirmModal.value = true

  319. }

  320.  

  321. const confirmLoadTemplate = () => {

  322.   if (pendingLoadTemplate.value) {

  323.     examData.value = JSON.parse(JSON.stringify(pendingLoadTemplate.value.data))

  324.     showLoadModal.value = false

  325.     pendingLoadTemplate.value = null

  326.   }

  327.   showLoadConfirmModal.value = false

  328. }

  329.  

  330. const deleteTemplate = (index) => {

  331.   pendingDeleteTemplateIndex.value = index

  332.   showDeleteConfirmModal.value = true

  333. }

  334.  

  335. const confirmDeleteTemplate = async () => {

  336.   if (pendingDeleteTemplateIndex.value > -1) {

  337.     savedTemplates.value.splice(pendingDeleteTemplateIndex.value, 1)

  338.     await saveTemplatesToStorage()

  339.     pendingDeleteTemplateIndex.value = -1

  340.   }

  341.   showDeleteConfirmModal.value = false

  342. }

  343.  

  344. const cancelDeleteTemplate = () => {

  345.   pendingDeleteTemplateIndex.value = -1

  346.   showDeleteConfirmModal.value = false

  347. }

  348.  

  349. const handleImportJSON = (json) => {

  350.   examData.value = json

  351. }

  352.  

  353. const handleReset = () => {

  354.   showResetConfirmModal.value = true

  355. }

  356.  

  357. const confirmReset = async () => {

  358.   try {

  359.     if (currentDocId.value) {

  360.         const key = getStorageKey(currentDocId.value)

  361.         await localforage.removeItem(key)

  362.     }

  363.      

  364.     examData.value = createEmptyExamData(currentDocId.value || '示范课程 - 示范章节')

  365.   } catch (e) {

  366.     console.error('Failed to reset', e)

  367.   }

  368.   showResetConfirmModal.value = false

  369. }

  370.  

  371. // 修改generateExamPaper函数,使用自定义弹窗

  372. const generateExamPaper = async () => {

  373.   if (isGeneratingExam.value) return

  374.   if (examData.value && examData.value.problems && examData.value.problems.length > 0) {

  375.     // 显示自定义确认弹窗而不是原生confirm

  376.     showAIGenConfirmModal.value = true

  377.     return

  378.   }

  379.    

  380.   // 如果没有现有试题,直接生成

  381.   await confirmGenerateExamPaper()

  382. }

  383.  

  384. // 新增确认生成函数

  385. const confirmGenerateExamPaper = async () => {

  386.   showAIGenConfirmModal.value = false

  387.   isGeneratingExam.value = true

  388.   // Extract course title

  389.   let title = "计算机相关课程"

  390.   if (route.query.title) {

  391.       title = route.query.title

  392.   } else if (examData.value && examData.value.title) {

  393.       title = examData.value.title

  394.   }

  395.  

  396.   const questionCount = settings.examQuestionCount || 5

  397.   const prompt = `请为课程"${title}"生成一份包含 ${questionCount} 道题目的试题数据。

  398.    

  399.   请严格按照以下 JSON 格式返回,不要包含代码块:

  400.   {

  401.     "title": "${title}",

  402.     "info": ["姓名: _______________", "学号: _______________", "得分: ___________"],

  403.     "footer": "~ End of Practice ~",

  404.     "problems": [

  405.       {

  406.         "qNum": "Q1.",

  407.         "title": "题目名称",

  408.         "tags": "知识点",

  409.         "desc": "详细的题目描述(HTML supported)...",

  410.         "input": "输入样例(仅编程题需要, 非编程题请省略)",

  411.         "output": "输出样例(仅编程题需要, 非编程题请省略)"

  412.       }

  413.       // ... 请生成一共 ${questionCount} 道题目

  414.     ]

  415.   }

  416.   注意:如果是编程类课程,请提供 input/output 样例;如果是理论、文学、数学等非编程类课程,请勿在JSON中包含 input 和 output 字段。`

  417.  

  418.   const messages = [{ role: 'user', content: prompt }]

  419.    

  420.   let fullText = ''

  421.   await sendToQwenAIDialogue(messages, (text, isComplete) => {

  422.     fullText = text

  423.     if (isComplete) {

  424.       isGeneratingExam.value = false

  425.       try {

  426.         const cleanText = fullText.replace(/```json/g, '').replace(/```/g, '').trim()

  427.          

  428.         // Check for specific error message from worker/API

  429.         if (cleanText.includes('请先配置 API Key') || cleanText.includes('API Key not configured')) {

  430.             showApiKeyAlertModal.value = true

  431.             return

  432.         }

  433.  

  434.         const newData = JSON.parse(cleanText)

  435.          

  436.         // Ensure image fields exist even if empty

  437.         if (newData.problems) {

  438.             newData.problems.forEach(p => {

  439.                 if (!p.image) p.image = ""

  440.             })

  441.         }

  442.  

  443.         examData.value = newData

  444.       } catch (e) {

  445.         console.error('Failed to parse AI exam', e)

  446.         alert('生成失败,AI 返回格式不正确。')

  447.       }

  448.     }

  449.   })

  450. }

  451.  

  452. const showAIChat = ref(false)

  453.  

  454. const handleAIUpdate = (newData) => {

  455.   if (!newData) return

  456.   // Merge or replace

  457.   // We'll replace the fields that exist in newData

  458.   Object.keys(newData).forEach(key => {

  459.     examData.value[key] = newData[key]

  460.   })

  461. }

  462. </script>

  463. <template>

  464.   <div class="app-container">

  465.     <div class="home-link">

  466.       <router-link to="/">&#127968; 返回首页</router-link>

  467.     </div>

  468.  

  469.     <Toolbar

  470.       @export-pdf="handleExportPDF"

  471.       @export-json="handleExportJSON"

  472.       @save-template="handleSaveTemplate"

  473.       @load-template="handleLoadTemplate"

  474.       @import-json="handleImportJSON"

  475.       @reset-data="handleReset"

  476.       @open-settings="showSettingsModal = true"

  477.     />

  478.      

  479.     <div class="ai-actions">

  480.       <button class="ai-gen-btn" @click="generateExamPaper" :disabled="isGeneratingExam">

  481.         {{ isGeneratingExam ? 'AI 生成中...' : `AI 一键生成试题 (${settings.examQuestionCount}题)` }}

  482.       </button>

  483.     </div>

  484.  

  485.     <!-- AI Chat Assistant -->

  486.     <AIChatAssistant

  487.       v-model="showAIChat"

  488.       :currentContent="examData"

  489.       systemContext="您是试题助手。请根据用户的指令调整当前的试卷(JSON对象)。例如:'增加两道关于函数的选择题' 或 '把最后一道题的难度加大'。"

  490.       @update-content="handleAIUpdate"

  491.     />

  492.  

  493.     <!-- Floating AI Chat Button -->

  494.     <button class="ai-chat-fab" @click="showAIChat = !showAIChat" title="AI 助手">

  495.       &#129302; 试题助手

  496.     </button>

  497.  

  498.     <div class="content-area" v-if="examData">

  499.       <ExamPaper :examData="examData" :class="{ 'exporting': isExporting }" />

  500.     </div>

  501.     <div v-else class="loading">

  502.       Loading Data...

  503.     </div>

  504.  

  505.     <!-- Modals (Save, Load, Delete, Reset, Export, Settings, Chat) -->

  506.     <!-- Copying existing modal structure directly -->

  507.      

  508.     <!-- Save Template Modal -->

  509.     <div class="modal-overlay" v-if="showSaveModal">

  510.       <div class="modal-content">

  511.         <h3>&#128190; 保存为模板</h3>

  512.         <input v-model="templateName" placeholder="给模板起个名字..." class="modal-input" @keyup.enter="confirmSaveTemplate" />

  513.         <div class="modal-actions">

  514.           <button class="modal-btn cancel" @click="showSaveModal = false">取消</button>

  515.           <button class="modal-btn confirm" @click="confirmSaveTemplate">保存</button>

  516.         </div>

  517.       </div>

  518.     </div>

  519.  

  520.     <!-- Load Template Modal -->

  521.     <div class="modal-overlay" v-if="showLoadModal">

  522.       <div class="modal-content load-modal">

  523.         <h3>&#128194; 导入模板 (仅展示试题)</h3>

  524.         <div class="template-list" v-if="savedTemplates.filter(t => t.type === 'exam').length > 0">

  525.           <div v-for="(template, index) in savedTemplates.filter(t => t.type === 'exam')" :key="template.id" class="template-item">

  526.             <div class="template-info" @click="loadTemplate(template)">

  527.               <div class="t-name">

  528.                 <span class="tag-exam">试题</span>

  529.                 {{ template.name }}

  530.               </div>

  531.               <div class="t-date">{{ template.date }}</div>

  532.             </div>

  533.             <button class="delete-template-btn" @click.stop="deleteTemplate(index)" title="删除模板">×</button>

  534.           </div>

  535.         </div>

  536.         <div v-else class="empty-list">

  537.           暂无保存的模板

  538.         </div>

  539.         <div class="modal-actions">

  540.           <button class="modal-btn cancel" @click="showLoadModal = false">关闭</button>

  541.         </div>

  542.       </div>

  543.     </div>

  544.     <!-- Delete Template Confirmation Modal -->

  545.     <div class="modal-overlay" v-if="showDeleteConfirmModal" style="z-index: 2100;">

  546.       <div class="modal-content">

  547.         <h3>&#128465;&#65039; 确认删除模板?</h3>

  548.         <p>确定要删除这个模板吗?此操作无法撤销。</p>

  549.         <div class="modal-actions">

  550.           <button class="modal-btn cancel" @click="cancelDeleteTemplate">取消</button>

  551.           <button class="modal-btn confirm" @click="confirmDeleteTemplate">删除</button>

  552.         </div>

  553.       </div>

  554.     </div>

  555.  

  556.     <!-- Reset Data Confirmation Modal -->

  557.     <div class="modal-overlay" v-if="showResetConfirmModal" style="z-index: 2100;">

  558.       <div class="modal-content">

  559.         <h3>&#129529; 确认重置?</h3>

  560.         <p>确定要清空所有修改吗?<br>这将恢复到默认状态。此操作无法撤销!</p>

  561.         <div class="modal-actions">

  562.           <button class="modal-btn cancel" @click="showResetConfirmModal = false">取消</button>

  563.           <button class="modal-btn confirm" @click="confirmReset">重置</button>

  564.         </div>

  565.       </div>

  566.     </div>

  567.  

  568.     <!-- Load Template Confirmation Modal -->

  569.     <div class="modal-overlay" v-if="showLoadConfirmModal" style="z-index: 2200;">

  570.       <div class="modal-content">

  571.         <h3>&#128214; 确认加载?</h3>

  572.         <p v-if="pendingLoadTemplate">确定要加载模板 "<b>{{ pendingLoadTemplate.name }}</b>" 吗?<br>当前未保存的修改将会丢失。</p>

  573.         <div class="modal-actions">

  574.           <button class="modal-btn cancel" @click="showLoadConfirmModal = false">取消</button>

  575.           <button class="modal-btn confirm" @click="confirmLoadTemplate">加载</button>

  576.         </div>

  577.       </div>

  578.     </div>

  579.  

  580.     <!-- API Key Alert Modal -->

  581.     <div class="modal-overlay" v-if="showApiKeyAlertModal" style="z-index: 2300;">

  582.       <div class="modal-content">

  583.         <h3>&#9888;&#65039; 需要配置 API Key</h3>

  584.         <p>AI 功能需要配置阿里云 DashScope API Key 才能使用。</p>

  585.         <div class="modal-actions">

  586.           <button class="modal-btn cancel" @click="showApiKeyAlertModal = false">取消</button>

  587.           <button class="modal-btn confirm" @click="showApiKeyAlertModal = false; showSettingsModal = true">去配置</button>

  588.         </div>

  589.       </div>

  590.     </div>

  591.  

  592.     <!-- AI Generation Confirmation Modal -->

  593.     <div class="modal-overlay" v-if="showAIGenConfirmModal" style="z-index: 2200;">

  594.       <div class="modal-content">

  595.         <h3>AI 一键生成</h3>

  596.         <p>AI 将根据当前的课程信息自动生成试题。<br><b>注意:此操作可能会覆盖您已手动输入的内容。</b></p>

  597.         <div class="modal-actions">

  598.           <button class="modal-btn cancel" @click="showAIGenConfirmModal = false">取消</button>

  599.           <button class="modal-btn confirm" @click="confirmGenerateExamPaper">&#10024; 开始生成</button>

  600.         </div>

  601.       </div>

  602.     </div>

  603.  

  604.     <!-- Export Loading Overlay -->

  605.     <div class="modal-overlay" v-if="isExporting" style="z-index: 3000; cursor: wait;">

  606.       <div class="modal-content" style="max-width: 300px;">

  607.         <h3>&#128424;&#65039; 正在导出...</h3>

  608.         <p>正在努力生成高清 PDF,<br>请稍候片刻...</p>

  609.         <div class="loading-spinner">&#9999;&#65039;</div>

  610.       </div>

  611.     </div>

  612.  

  613.  

  614.  

  615.     <!-- AI Components -->

  616.     <!-- AI Components -->

  617.     <SettingsModal

  618.       v-if="showSettingsModal"

  619.       :currentModelId="currentModelId"

  620.       :show-model-selector="true"

  621.       @change-model="handleModelChange"

  622.       @close="showSettingsModal = false"

  623.     />

  624.  

  625.   </div>

  626. </template>

  627.  

  628. <style scoped>

  629. .app-container {

  630.   padding: 20px;

  631. }

  632. .home-link {

  633.   position: fixed;

  634.   top: 20px;

  635.   left: 20px;

  636.   z-index: 100;

  637. }

  638. .home-link a {

  639.   text-decoration: none;

  640.   font-weight: bold;

  641.   color: #2c3.50;

  642.   background: white;

  643.   padding: 10px 15px;

  644.   border-radius: 20px;

  645.   border: 2px solid #2c3e50;

  646.   box-shadow: 2px 2px 0 #2c3e50;

  647.   transition: transform 0.1s;

  648. }

  649. .home-link a:hover {

  650.   transform: scale(1.05);

  651. }

  652.  

  653. .ai-actions {

  654.   text-align: center;

  655.   margin-bottom: 20px;

  656. }

  657.  

  658.  

  659.  

  660.  

  661. .ai-actions {

  662.   text-align: center;

  663.   margin-bottom: 20px;

  664. }

  665.  

  666. .ai-gen-btn {

  667.     background: white;

  668.     color: #2c3e50;

  669.     border: 3px solid #2c3e50;

  670.     padding: 12px 30px;

  671.     font-size: 1.2em;

  672.     border-radius: 255px 15px 225px 15px / 15px 225px 15px 255px;

  673.     cursor: pointer;

  674.     box-shadow: 4px 4px 0 #2c3e50;

  675.     font-weight: bold;

  676.     font-family: inherit;

  677.     transition: all 0.2s;

  678. }

  679.  

  680. .ai-gen-btn:hover:not(:disabled) {

  681.   transform: translate(-2px, -2px);

  682.   box-shadow: 6px 6px 0 #2c3e50;

  683.   background: #f3e5f5;

  684. }

  685.  

  686. .ai-gen-btn:disabled {

  687.     background: #eee;

  688.     color: #999;

  689.     border-color: #999;

  690.     box-shadow: none;

  691.     cursor: wait;

  692. }

  693.  

  694. .tag-plan {

  695.   background: #e1f5fe;

  696.   color: #039be5;

  697.   font-size: 0.8em;

  698.   padding: 2px 6px;

  699.   border-radius: 4px;

  700.   margin-right: 5px;

  701. }

  702.  

  703. .tag-exam {

  704.   background: #fff3e0;

  705.   color: #fb8c00;

  706.   font-size: 0.8em;

  707.   padding: 2px 6px;

  708.   border-radius: 4px;

  709.   margin-right: 5px;

  710. }

  711.  

  712. .loading {

  713.   text-align: center;

  714.   font-size: 1.5em;

  715.   margin-top: 100px;

  716.   color: #666;

  717. }

  718.  

  719. /* Modal Styles Global */

  720. .modal-overlay {

  721.   position: fixed;

  722.   top: 0;

  723.   left: 0;

  724.   right: 0;

  725.   bottom: 0;

  726.   background: rgba(0,0,0,0.5);

  727.   display: flex;

  728.   align-items: center;

  729.   justify-content: center;

  730.   z-index: 2000;

  731.   backdrop-filter: blur(3px);

  732. }

  733.  

  734. .modal-content {

  735.   background: #fdfbf7;

  736.   padding: 30px;

  737.   border-radius: 255px 15px 225px 15px / 15px 225px 15px 255px;

  738.   border: 3px solid #2c3e50;

  739.   box-shadow: 10px 10px 0 rgba(0,0,0,0.2);

  740.   width: 90%;

  741.   max-width: 4.0px;

  742.   text-align: center;

  743.   font-family: 'Architects Daughter', cursive;

  744. }

  745.  

  746. .modal-content h3 {

  747.   font-size: 1.5em;

  748.   margin-bottom: 20px;

  749.   border-bottom: 1px dashed #ccc;

  750.   padding-bottom: 10px;

  751. }

  752.  

  753. .modal-input {

  754.   width: 80%;

  755.   padding: 10px;

  756.   margin-bottom: 20px;

  757.   font-size: 1.2em;

  758.   font-family: inherit;

  759.   border: 2px solid #2c3e50;

  760.   border-radius: 5px;

  761.   outline: none;

  762. }

  763.  

  764. .modal-actions {

  765.   display: flex;

  766.   justify-content: center;

  767.   gap: 20px;

  768.   margin-top: 20px;

  769. }

  770.  

  771. .modal-btn {

  772.   padding: 8px 20px;

  773.   border: 2px solid #2c3e50;

  774.   background: transparent;

  775.   font-family: inherit;

  776.   font-size: 1.1em;

  777.   cursor: pointer;

  778.   border-radius: 255px 15px 225px 15px / 15px 225px 15px 255px;

  779.   transition: transform 0.1s;

  780. }

  781.  

  782. .modal-btn:hover {

  783.   transform: scale(1.05);

  784. }

  785.  

  786. .modal-btn.confirm {

  787.   background: #e74c3c;

  788.   color: white;

  789.   border-color: #e74c3c;

  790. }

  791.  

  792. .modal-btn.cancel {

  793.   border-style: dashed;

  794. }

  795.  

  796. /* Template List */

  797. .load-modal {

  798.   max-width: 500px;

  799. }

  800.  

  801. .template-list {

  802.   max-height: 300px;

  803.   overflow-y: auto;

  804.   text-align: left;

  805. }

  806.  

  807. .template-item {

  808.   display: flex;

  809.   justify-content: space-between;

  810.   align-items: center;

  811.   padding: 10px;

  812.   border-bottom: 1px solid #eee;

  813.   cursor: pointer;

  814.   transition: background 0.2s;

  815. }

  816.  

  817. .template-item:hover {

  818.   background: rgba(0,0,0,0.05);

  819. }

  820.  

  821. .template-info {

  822.   flex: 1;

  823. }

  824.  

  825. .t-name {

  826.   font-weight: bold;

  827.   font-size: 1.1em;

  828. }

  829.  

  830. .ai-chat-fab {

  831.   position: fixed;

  832.   bottom: 20px;

  833.   right: 20px;

  834.   background: #2c3e50;

  835.   color: white;

  836.   border: none;

  837.   border-radius: 30px;

  838.   padding: 12px 24px;

  839.   font-size: 1.1em;

  840.   font-weight: bold;

  841.   box-shadow: 0 4px 10px rgba(0,0,0,0.3);

  842.   cursor: pointer;

  843.   z-index: 900;

  844.   transition: transform 0.2s;

  845.   font-family: 'Architects Daughter', cursive;

  846.   border: 2px solid white;

  847. }

  848.  

  849. .ai-chat-fab:hover {

  850.   transform: scale(1.05);

  851.   background: #34495e;

  852. }

  853.  

  854. .t-date {

  855.   font-size: 0.8em;

  856.   color: #888;

  857. }

  858.  

  859. .delete-template-btn {

  860.   background: transparent;

  861.   border: none;

  862.   color: #ccc;

  863.   font-size: 1.5em;

  864.   cursor: pointer;

  865.   padding: 0 10px;

  866. }

  867.  

  868. .delete-template-btn:hover {

  869.   color: #c0392b;

  870. }

  871.  

  872. .empty-list {

  873.   color: #999;

  874.   padding: 20px;

  875. }

  876.  

  877. .loading-spinner {

  878.   font-size: 3em;

  879.   animation: writing 1s infinite alternate;

  880.   margin-top: 20px;

  881. }

  882.  

  883. @keyframes writing {

  884.   from { transform: translateX(-20px) rotate(-10deg); }

  885.   to { transform: translateX(20px) rotate(10deg); }

  886. }

  887. </style>

 




文章版权声明:除非注明,否则均为AI虎哥的工具库原创文章,转载或复制请以超链接形式并注明出处。

发表评论

快捷回复: 表情:
AddoilApplauseBadlaughBombCoffeeFabulousFacepalmFecesFrownHeyhaInsidiousKeepFightingNoProbPigHeadShockedSinistersmileSlapSocialSweatTolaughWatermelonWittyWowYeahYellowdog
验证码
评论列表 (暂无评论,2人围观)

还没有评论,来说两句吧...

目录[+]

取消
微信二维码
微信二维码
支付宝二维码