马上注册,结交更多好友,享用更多功能,让你轻松玩转小K网。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
豫唐智能教案在线生成平台,基于 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]
- <script setup>
- import { ref, onMounted, watch, nextTick } from 'vue'
- import ExamPaper from '../components/ExamPaper.vue'
- import AIChatAssistant from '../components/AIChatAssistant.vue'
- import Toolbar from '../components/Toolbar.vue'
- import SettingsModal from '../components/SettingsModal.vue'
-
- import html2canvas from 'html2canvas'
- import jsPDF from 'jspdf'
- import { saveAs } from 'file-saver'
- import localforage from 'localforage'
- import { useRoute } from 'vue-router'
-
- import { sendToQwenAIDialogue } from '../api/qwenAPI'
-
- import { useSettingsStore } from '../store/settings'
- import { DEFAULT_MODEL_ID } from '../config/models'
-
- const settings = useSettingsStore()
-
- const currentDocId = ref('')
- const examData = ref(null)
- const isGeneratingExam = ref(false)
-
-
- const TEMPLATES_KEY = 'exam_paper_templates_v1'
- const LAST_ACTIVE_KEY = 'last_active_doc_v1'
-
- const savedTemplates = ref([])
- const showSaveModal = ref(false)
- const showLoadModal = ref(false)
- const showDeleteConfirmModal = ref(false)
- const showResetConfirmModal = ref(false)
-
- // 添加AI生成确认弹窗状态
- const showAIGenConfirmModal = ref(false)
-
- const pendingDeleteTemplateIndex = ref(-1)
- const pendingLoadTemplate = ref(null)
- const templateName = ref('')
- const isExporting = ref(false)
- const showApiKeyAlertModal = ref(false) // New Alert Modal
-
-
- const route = useRoute()
-
- // Helper to create empty exam structure
- const createEmptyExam = (title = '新试题') => ({
- title: title,
- subTitle: '考试时间:__分钟 满分:__分',
- items: []
- })
-
- const getStorageKey = (docId) => {
- return `exam_data_v1_paper_${currentModelId.value}_${docId}`
- }
-
- const currentModelId = ref(localStorage.getItem('last_active_model_id') || DEFAULT_MODEL_ID)
-
- const handleModelChange = async (newModelId) => {
- currentModelId.value = newModelId
- localStorage.setItem('last_active_model_id', newModelId)
- await loadCurrentData()
- }
-
- const loadCurrentData = async () => {
- const storageKey = getStorageKey(currentDocId.value)
- let loaded = false
-
- try {
- const cached = await localforage.getItem(storageKey)
- if (cached) {
- examData.value = typeof cached === 'string' ? JSON.parse(cached) : cached
- loaded = true
- }
- } catch (e) {
- console.error('Failed to parse cached data', e)
- }
-
- if (!loaded) {
- // Default initialization logic
- let title = route.query.title || currentDocId.value
- let subTitle = ''
-
- // Attempt to sync with Generator State if applicable
- const { courseName, chapterId } = route.query
- if (courseName && chapterId) {
- const GENERATOR_STORAGE_KEY = 'lesson_plan_generator_state_v3'
- try {
- const rawState = localStorage.getItem(GENERATOR_STORAGE_KEY)
- if (rawState) {
- const state = JSON.parse(rawState)
- if (state.courseName === courseName) {
- const foundChapter = state.generatedChapters.find(c => c.id === Number(chapterId))
- if (foundChapter) {
- title = `${courseName} - ${foundChapter.mainTitle}`
- subTitle = foundChapter.subTitle ? `章节:${foundChapter.subTitle}` : ''
- }
- }
- }
- } catch(e) { console.error(e) }
- }
-
- if (currentDocId.value === 'default_doc') {
- try {
- const baseUrl = import.meta.env.BASE_URL.endsWith('/') ? import.meta.env.BASE_URL : import.meta.env.BASE_URL + '/'
- const response = await fetch(`${baseUrl}exam_data.json`)
- examData.value = await response.json()
- } catch(e) {
- examData.value = createEmptyExam(title)
- }
- } else {
- examData.value = createEmptyExam(title)
- if (subTitle) examData.value.subTitle = subTitle
- }
- }
- }
-
- onMounted(async () => {
-
- // 1. Load Templates
- try {
- const cachedTemplates = await localforage.getItem(TEMPLATES_KEY)
- if (cachedTemplates) {
- savedTemplates.value = typeof cachedTemplates === 'string' ? JSON.parse(cachedTemplates) : cachedTemplates
- }
- } catch (e) {
- console.error('Failed to load templates', e)
- }
-
- // 2. Determine Document ID (Persistence Key)
- const { courseName, chapterId } = route.query
-
- if (courseName && chapterId) {
- currentDocId.value = `${courseName}_ch${chapterId}`
- } else if (route.query.title) {
- currentDocId.value = route.query.title
- } else {
- currentDocId.value = localStorage.getItem(LAST_ACTIVE_KEY) || 'default_doc'
- }
-
- localStorage.setItem(LAST_ACTIVE_KEY, currentDocId.value)
-
- // 3. Load Data
- await loadCurrentData()
- })
-
- // Auto-save to local storage (IndexedDB)
- watch(examData, async (newVal) => {
- if (newVal && currentDocId.value) {
- try {
- const storageKey = getStorageKey(currentDocId.value)
- await localforage.setItem(storageKey, JSON.parse(JSON.stringify(newVal)))
- } catch (e) {
- console.error('Auto-save failed', e)
- }
- }
- }, { deep: true })
-
- const saveTemplatesToStorage = async () => {
- try {
- await localforage.setItem(TEMPLATES_KEY, JSON.parse(JSON.stringify(savedTemplates.value)))
- } catch (e) {
- alert('保存模板失败: ' + e.message)
- }
- }
-
- const handleExportPDF = async () => {
- const element = document.getElementById('exam-paper')
- if (!element) return
-
- isExporting.value = true
- await nextTick()
-
- try {
- const scale = 2
- const canvas = await html2canvas(element, {
- scale: scale,
- useCORS: true,
- backgroundColor: '#ffffff'
- })
-
- const contentWidth = canvas.width
- const contentHeight = canvas.height
- const pdf = new jsPDF('p', 'mm', 'a4')
- const pdfPageWidth = pdf.internal.pageSize.getWidth()
- const pdfPageHeight = pdf.internal.pageSize.getHeight()
- const pxPerMm = contentWidth / pdfPageWidth
- const marginMm = 20
- const marginPx = marginMm * pxPerMm
- const pageHeightInPx = (pdfPageHeight * pxPerMm) - (marginPx * 2) // Printable area height in px
-
- // Get all question items to check for cuts
- const questionElements = element.querySelectorAll('.question-item')
- // Calculate logical positions (unscaled) then scale them
- // Note: html2canvas scale affects the image size, but DOM offsetTop is unscaled.
- // We need to map DOM coordinates to Canvas coordinates.
- // Canvas is scaled by 'scale'. DOM offsets are 1x.
- // So we multiply DOM offsets by scale.
-
- const questions = Array.from(questionElements).map(el => {
- // Get offset relative to the exam-paper element
- // offsetTop is relative to offsetParent.
- // We assume exam-paper is the offsetParent or we calculate cumulative offset.
- // safest is getBoundingClientRect
- const rect = el.getBoundingClientRect()
- const containerRect = element.getBoundingClientRect()
- const top = (rect.top - containerRect.top) * scale
- const height = rect.height * scale
- return { top, bottom: top + height }
- })
-
- let currentY = 0
- let remainingHeight = contentHeight
-
- while (currentY < contentHeight) {
- if (currentY > 0) pdf.addPage()
-
- // Default: Fill the page
- let sliceHeight = Math.min(pageHeightInPx, contentHeight - currentY)
- let nextCutY = currentY + sliceHeight
-
- // Check if we are cutting through a question
- // A question is cut if: q.top < nextCutY AND q.bottom > nextCutY
- // We look for the FIRST question that satisfies this
- const crossingQuestion = questions.find(q => q.top < nextCutY && q.bottom > nextCutY)
-
- if (crossingQuestion) {
- // If the question is taller than the page itself, we can't avoid cutting it.
- // We only adjust if the question starts AFTER currentY (it fits on the page partially, but we prefer to push it)
- // OR if it could fit on the NEXT page.
- // Simplified logic: If the cut is strictly inside the question, and the question top is below currentY,
- // we cut AT the question top (pushing it to next page).
- if (crossingQuestion.top > currentY) {
- // Adjust cut to be at the start of the question
- nextCutY = crossingQuestion.top
- sliceHeight = nextCutY - currentY
- }
- // If crossingQuestion.top <= currentY, it means a huge question starting before this page even began
- // (or at the top) is continuing. We just have to cut it.
- }
-
- // Create a canvas for this slice
- const sliceCanvas = document.createElement('canvas')
- sliceCanvas.width = contentWidth
- sliceCanvas.height = sliceHeight
-
- const sCtx = sliceCanvas.getContext('2d')
-
- // Draw the slice
- // drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
- sCtx.drawImage(canvas, 0, currentY, contentWidth, sliceHeight, 0, 0, contentWidth, sliceHeight)
-
- const sliceData = sliceCanvas.toDataURL('image/png')
- // PDF height needs to be calculated based on the actual sliceHeight drawn
- const pdfSliceHeight = sliceHeight / pxPerMm
-
- pdf.addImage(sliceData, 'PNG', 0, marginMm, pdfPageWidth, pdfSliceHeight)
-
- currentY += sliceHeight
- // Add a tiny buffer to avoid potential rounding loops, though logic should be robust
- if (sliceHeight <= 0) break; // Safety break
- }
-
- const now = new Date()
- const timeStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
- const fileName = `${examData.value.title || 'Exam'}_${timeStr}.pdf`
- pdf.save(fileName)
- } catch (error) {
- console.error('PDF Export Failed:', error)
- alert('导出失败,请重试')
- } finally {
- isExporting.value = false
- }
- }
-
- const handleExportJSON = () => {
- const blob = new Blob([JSON.stringify(examData.value, null, 2)], { type: 'application/json' })
- const now = new Date()
- const timeStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`
- const fileName = `${examData.value.title || 'Exam'}_${timeStr}.json`
- saveAs(blob, fileName)
- }
-
- const handleSaveTemplate = () => {
- if (!examData.value) return
- templateName.value = `模板 ${savedTemplates.value.length + 1}`
- showSaveModal.value = true
- }
-
- const confirmSaveTemplate = async () => {
- if (!templateName.value) {
- alert('请输入模板名称')
- return
- }
- const newTemplate = {
- id: Date.now(),
- name: templateName.value,
- data: JSON.parse(JSON.stringify(examData.value)),
- date: new Date().toLocaleString(),
- type: 'exam'
- }
- savedTemplates.value.unshift(newTemplate)
- await saveTemplatesToStorage()
- showSaveModal.value = false
- }
-
- const handleLoadTemplate = () => {
- showLoadModal.value = true
- }
-
- const loadTemplate = (template) => {
- if (template.type !== 'exam') {
- alert('无法在试题编辑器中加载教案模板')
- return
- }
- pendingLoadTemplate.value = template
- showLoadConfirmModal.value = true
- }
-
- const confirmLoadTemplate = () => {
- if (pendingLoadTemplate.value) {
- examData.value = JSON.parse(JSON.stringify(pendingLoadTemplate.value.data))
- showLoadModal.value = false
- pendingLoadTemplate.value = null
- }
- showLoadConfirmModal.value = false
- }
-
- const deleteTemplate = (index) => {
- pendingDeleteTemplateIndex.value = index
- showDeleteConfirmModal.value = true
- }
-
- const confirmDeleteTemplate = async () => {
- if (pendingDeleteTemplateIndex.value > -1) {
- savedTemplates.value.splice(pendingDeleteTemplateIndex.value, 1)
- await saveTemplatesToStorage()
- pendingDeleteTemplateIndex.value = -1
- }
- showDeleteConfirmModal.value = false
- }
-
- const cancelDeleteTemplate = () => {
- pendingDeleteTemplateIndex.value = -1
- showDeleteConfirmModal.value = false
- }
-
- const handleImportJSON = (json) => {
- examData.value = json
- }
-
- const handleReset = () => {
- showResetConfirmModal.value = true
- }
-
- const confirmReset = async () => {
- try {
- if (currentDocId.value) {
- const key = getStorageKey(currentDocId.value)
- await localforage.removeItem(key)
- }
-
- examData.value = createEmptyExamData(currentDocId.value || '示范课程 - 示范章节')
- } catch (e) {
- console.error('Failed to reset', e)
- }
- showResetConfirmModal.value = false
- }
-
- // 修改generateExamPaper函数,使用自定义弹窗
- const generateExamPaper = async () => {
- if (isGeneratingExam.value) return
- if (examData.value && examData.value.problems && examData.value.problems.length > 0) {
- // 显示自定义确认弹窗而不是原生confirm
- showAIGenConfirmModal.value = true
- return
- }
-
- // 如果没有现有试题,直接生成
- await confirmGenerateExamPaper()
- }
-
- // 新增确认生成函数
- const confirmGenerateExamPaper = async () => {
- showAIGenConfirmModal.value = false
- isGeneratingExam.value = true
- // Extract course title
- let title = "计算机相关课程"
- if (route.query.title) {
- title = route.query.title
- } else if (examData.value && examData.value.title) {
- title = examData.value.title
- }
-
- const questionCount = settings.examQuestionCount || 5
- const prompt = `请为课程"${title}"生成一份包含 ${questionCount} 道题目的试题数据。
-
- 请严格按照以下 JSON 格式返回,不要包含代码块:
- {
- "title": "${title}",
- "info": ["姓名: _______________", "学号: _______________", "得分: ___________"],
- "footer": "~ End of Practice ~",
- "problems": [
- {
- "qNum": "Q1.",
- "title": "题目名称",
- "tags": "知识点",
- "desc": "详细的题目描述(HTML supported)...",
- "input": "输入样例(仅编程题需要, 非编程题请省略)",
- "output": "输出样例(仅编程题需要, 非编程题请省略)"
- }
- // ... 请生成一共 ${questionCount} 道题目
- ]
- }
- 注意:如果是编程类课程,请提供 input/output 样例;如果是理论、文学、数学等非编程类课程,请勿在JSON中包含 input 和 output 字段。`
-
- const messages = [{ role: 'user', content: prompt }]
-
- let fullText = ''
- await sendToQwenAIDialogue(messages, (text, isComplete) => {
- fullText = text
- if (isComplete) {
- isGeneratingExam.value = false
- try {
- const cleanText = fullText.replace(/```json/g, '').replace(/```/g, '').trim()
-
- // Check for specific error message from worker/API
- if (cleanText.includes('请先配置 API Key') || cleanText.includes('API Key not configured')) {
- showApiKeyAlertModal.value = true
- return
- }
-
- const newData = JSON.parse(cleanText)
-
- // Ensure image fields exist even if empty
- if (newData.problems) {
- newData.problems.forEach(p => {
- if (!p.image) p.image = ""
- })
- }
-
- examData.value = newData
- } catch (e) {
- console.error('Failed to parse AI exam', e)
- alert('生成失败,AI 返回格式不正确。')
- }
- }
- })
- }
-
- const showAIChat = ref(false)
-
- const handleAIUpdate = (newData) => {
- if (!newData) return
- // Merge or replace
- // We'll replace the fields that exist in newData
- Object.keys(newData).forEach(key => {
- examData.value[key] = newData[key]
- })
- }
- </script>
- <template>
- <div class="app-container">
- <div class="home-link">
- <router-link to="/">🏠 返回首页</router-link>
- </div>
-
- <Toolbar
- @export-pdf="handleExportPDF"
- @export-json="handleExportJSON"
- @save-template="handleSaveTemplate"
- @load-template="handleLoadTemplate"
- @import-json="handleImportJSON"
- @reset-data="handleReset"
- @open-settings="showSettingsModal = true"
- />
-
- <div class="ai-actions">
- <button class="ai-gen-btn" @click="generateExamPaper" :disabled="isGeneratingExam">
- {{ isGeneratingExam ? 'AI 生成中...' : `AI 一键生成试题 (${settings.examQuestionCount}题)` }}
- </button>
- </div>
-
- <!-- AI Chat Assistant -->
- <AIChatAssistant
- v-model="showAIChat"
- :currentContent="examData"
- systemContext="您是试题助手。请根据用户的指令调整当前的试卷(JSON对象)。例如:'增加两道关于函数的选择题' 或 '把最后一道题的难度加大'。"
- @update-content="handleAIUpdate"
- />
-
- <!-- Floating AI Chat Button -->
- <button class="ai-chat-fab" @click="showAIChat = !showAIChat" title="AI 助手">
- 🤖 试题助手
- </button>
-
- <div class="content-area" v-if="examData">
- <ExamPaper :examData="examData" :class="{ 'exporting': isExporting }" />
- </div>
- <div v-else class="loading">
- Loading Data...
- </div>
-
- <!-- Modals (Save, Load, Delete, Reset, Export, Settings, Chat) -->
- <!-- Copying existing modal structure directly -->
-
- <!-- Save Template Modal -->
- <div class="modal-overlay" v-if="showSaveModal">
- <div class="modal-content">
- <h3>💾 保存为模板</h3>
- <input v-model="templateName" placeholder="给模板起个名字..." class="modal-input" @keyup.enter="confirmSaveTemplate" />
- <div class="modal-actions">
- <button class="modal-btn cancel" @click="showSaveModal = false">取消</button>
- <button class="modal-btn confirm" @click="confirmSaveTemplate">保存</button>
- </div>
- </div>
- </div>
-
- <!-- Load Template Modal -->
- <div class="modal-overlay" v-if="showLoadModal">
- <div class="modal-content load-modal">
- <h3>📂 导入模板 (仅展示试题)</h3>
- <div class="template-list" v-if="savedTemplates.filter(t => t.type === 'exam').length > 0">
- <div v-for="(template, index) in savedTemplates.filter(t => t.type === 'exam')" :key="template.id" class="template-item">
- <div class="template-info" @click="loadTemplate(template)">
- <div class="t-name">
- <span class="tag-exam">试题</span>
- {{ template.name }}
- </div>
- <div class="t-date">{{ template.date }}</div>
- </div>
- <button class="delete-template-btn" @click.stop="deleteTemplate(index)" title="删除模板">×</button>
- </div>
- </div>
- <div v-else class="empty-list">
- 暂无保存的模板
- </div>
- <div class="modal-actions">
- <button class="modal-btn cancel" @click="showLoadModal = false">关闭</button>
- </div>
- </div>
- </div>
- <!-- Delete Template Confirmation Modal -->
- <div class="modal-overlay" v-if="showDeleteConfirmModal" style="z-index: 2100;">
- <div class="modal-content">
- <h3>🗑️ 确认删除模板?</h3>
- <p>确定要删除这个模板吗?此操作无法撤销。</p>
- <div class="modal-actions">
- <button class="modal-btn cancel" @click="cancelDeleteTemplate">取消</button>
- <button class="modal-btn confirm" @click="confirmDeleteTemplate">删除</button>
- </div>
- </div>
- </div>
-
- <!-- Reset Data Confirmation Modal -->
- <div class="modal-overlay" v-if="showResetConfirmModal" style="z-index: 2100;">
- <div class="modal-content">
- <h3>🧹 确认重置?</h3>
- <p>确定要清空所有修改吗?<br>这将恢复到默认状态。此操作无法撤销!</p>
- <div class="modal-actions">
- <button class="modal-btn cancel" @click="showResetConfirmModal = false">取消</button>
- <button class="modal-btn confirm" @click="confirmReset">重置</button>
- </div>
- </div>
- </div>
-
- <!-- Load Template Confirmation Modal -->
- <div class="modal-overlay" v-if="showLoadConfirmModal" style="z-index: 2200;">
- <div class="modal-content">
- <h3>📖 确认加载?</h3>
- <p v-if="pendingLoadTemplate">确定要加载模板 "<b>{{ pendingLoadTemplate.name }}</b>" 吗?<br>当前未保存的修改将会丢失。</p>
- <div class="modal-actions">
- <button class="modal-btn cancel" @click="showLoadConfirmModal = false">取消</button>
- <button class="modal-btn confirm" @click="confirmLoadTemplate">加载</button>
- </div>
- </div>
- </div>
-
- <!-- API Key Alert Modal -->
- <div class="modal-overlay" v-if="showApiKeyAlertModal" style="z-index: 2300;">
- <div class="modal-content">
- <h3>⚠️ 需要配置 API Key</h3>
- <p>AI 功能需要配置阿里云 DashScope API Key 才能使用。</p>
- <div class="modal-actions">
- <button class="modal-btn cancel" @click="showApiKeyAlertModal = false">取消</button>
- <button class="modal-btn confirm" @click="showApiKeyAlertModal = false; showSettingsModal = true">去配置</button>
- </div>
- </div>
- </div>
-
- <!-- AI Generation Confirmation Modal -->
- <div class="modal-overlay" v-if="showAIGenConfirmModal" style="z-index: 2200;">
- <div class="modal-content">
- <h3>AI 一键生成</h3>
- <p>AI 将根据当前的课程信息自动生成试题。<br><b>注意:此操作可能会覆盖您已手动输入的内容。</b></p>
- <div class="modal-actions">
- <button class="modal-btn cancel" @click="showAIGenConfirmModal = false">取消</button>
- <button class="modal-btn confirm" @click="confirmGenerateExamPaper">✨ 开始生成</button>
- </div>
- </div>
- </div>
-
- <!-- Export Loading Overlay -->
- <div class="modal-overlay" v-if="isExporting" style="z-index: 3000; cursor: wait;">
- <div class="modal-content" style="max-width: 300px;">
- <h3>🖨️ 正在导出...</h3>
- <p>正在努力生成高清 PDF,<br>请稍候片刻...</p>
- <div class="loading-spinner">✏️</div>
- </div>
- </div>
-
-
-
- <!-- AI Components -->
- <!-- AI Components -->
- <SettingsModal
- v-if="showSettingsModal"
- :currentModelId="currentModelId"
- :show-model-selector="true"
- @change-model="handleModelChange"
- @close="showSettingsModal = false"
- />
-
- </div>
- </template>
-
- <style scoped>
- .app-container {
- padding: 20px;
- }
- .home-link {
- position: fixed;
- top: 20px;
- left: 20px;
- z-index: 100;
- }
- .home-link a {
- text-decoration: none;
- font-weight: bold;
- color: #2c3e50;
- background: white;
- padding: 10px 15px;
- border-radius: 20px;
- border: 2px solid #2c3e50;
- box-shadow: 2px 2px 0 #2c3e50;
- transition: transform 0.1s;
- }
- .home-link a:hover {
- transform: scale(1.05);
- }
-
- .ai-actions {
- text-align: center;
- margin-bottom: 20px;
- }
-
-
-
-
- .ai-actions {
- text-align: center;
- margin-bottom: 20px;
- }
-
- .ai-gen-btn {
- background: white;
- color: #2c3e50;
- border: 3px solid #2c3e50;
- padding: 12px 30px;
- font-size: 1.2em;
- border-radius: 255px 15px 225px 15px / 15px 225px 15px 255px;
- cursor: pointer;
- box-shadow: 4px 4px 0 #2c3e50;
- font-weight: bold;
- font-family: inherit;
- transition: all 0.2s;
- }
-
- .ai-gen-btn:hover:not(:disabled) {
- transform: translate(-2px, -2px);
- box-shadow: 6px 6px 0 #2c3e50;
- background: #f3e5f5;
- }
-
- .ai-gen-btn:disabled {
- background: #eee;
- color: #999;
- border-color: #999;
- box-shadow: none;
- cursor: wait;
- }
-
- .tag-plan {
- background: #e1f5fe;
- color: #039be5;
- font-size: 0.8em;
- padding: 2px 6px;
- border-radius: 4px;
- margin-right: 5px;
- }
-
- .tag-exam {
- background: #fff3e0;
- color: #fb8c00;
- font-size: 0.8em;
- padding: 2px 6px;
- border-radius: 4px;
- margin-right: 5px;
- }
-
- .loading {
- text-align: center;
- font-size: 1.5em;
- margin-top: 100px;
- color: #666;
- }
-
- /* Modal Styles Global */
- .modal-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(0,0,0,0.5);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 2000;
- backdrop-filter: blur(3px);
- }
-
- .modal-content {
- background: #fdfbf7;
- padding: 30px;
- border-radius: 255px 15px 225px 15px / 15px 225px 15px 255px;
- border: 3px solid #2c3e50;
- box-shadow: 10px 10px 0 rgba(0,0,0,0.2);
- width: 90%;
- max-width: 400px;
- text-align: center;
- font-family: 'Architects Daughter', cursive;
- }
-
- .modal-content h3 {
- font-size: 1.5em;
- margin-bottom: 20px;
- border-bottom: 1px dashed #ccc;
- padding-bottom: 10px;
- }
-
- .modal-input {
- width: 80%;
- padding: 10px;
- margin-bottom: 20px;
- font-size: 1.2em;
- font-family: inherit;
- border: 2px solid #2c3e50;
- border-radius: 5px;
- outline: none;
- }
-
- .modal-actions {
- display: flex;
- justify-content: center;
- gap: 20px;
- margin-top: 20px;
- }
-
- .modal-btn {
- padding: 8px 20px;
- border: 2px solid #2c3e50;
- background: transparent;
- font-family: inherit;
- font-size: 1.1em;
- cursor: pointer;
- border-radius: 255px 15px 225px 15px / 15px 225px 15px 255px;
- transition: transform 0.1s;
- }
-
- .modal-btn:hover {
- transform: scale(1.05);
- }
-
- .modal-btn.confirm {
- background: #e74c3c;
- color: white;
- border-color: #e74c3c;
- }
-
- .modal-btn.cancel {
- border-style: dashed;
- }
-
- /* Template List */
- .load-modal {
- max-width: 500px;
- }
-
- .template-list {
- max-height: 300px;
- overflow-y: auto;
- text-align: left;
- }
-
- .template-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 10px;
- border-bottom: 1px solid #eee;
- cursor: pointer;
- transition: background 0.2s;
- }
-
- .template-item:hover {
- background: rgba(0,0,0,0.05);
- }
-
- .template-info {
- flex: 1;
- }
-
- .t-name {
- font-weight: bold;
- font-size: 1.1em;
- }
-
- .ai-chat-fab {
- position: fixed;
- bottom: 20px;
- right: 20px;
- background: #2c3e50;
- color: white;
- border: none;
- border-radius: 30px;
- padding: 12px 24px;
- font-size: 1.1em;
- font-weight: bold;
- box-shadow: 0 4px 10px rgba(0,0,0,0.3);
- cursor: pointer;
- z-index: 900;
- transition: transform 0.2s;
- font-family: 'Architects Daughter', cursive;
- border: 2px solid white;
- }
-
- .ai-chat-fab:hover {
- transform: scale(1.05);
- background: #34495e;
- }
-
- .t-date {
- font-size: 0.8em;
- color: #888;
- }
-
- .delete-template-btn {
- background: transparent;
- border: none;
- color: #ccc;
- font-size: 1.5em;
- cursor: pointer;
- padding: 0 10px;
- }
-
- .delete-template-btn:hover {
- color: #c0392b;
- }
-
- .empty-list {
- color: #999;
- padding: 20px;
- }
-
- .loading-spinner {
- font-size: 3em;
- animation: writing 1s infinite alternate;
- margin-top: 20px;
- }
-
- @keyframes writing {
- from { transform: translateX(-20px) rotate(-10deg); }
- to { transform: translateX(20px) rotate(10deg); }
- }
- </style>
复制代码
|