智教助手平台:完整初始化

- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
This commit is contained in:
Zhang Jing Xuan
2026-07-29 16:29:10 +08:00
commit 0841b1a103
193 changed files with 44995 additions and 0 deletions
+873
View File
@@ -0,0 +1,873 @@
<template>
<div class="page-container">
<div class="gen-hero">
<div class="gen-hero-icon" style="background: var(--gradient-orange)">
<el-icon :size="28" color="#fff"><Trophy /></el-icon>
</div>
<div>
<h2>教学游戏</h2>
<p>AI生成闯关选择填空等互动练习学生可以直接在页面作答</p>
</div>
</div>
<div class="gen-input-section">
<div v-if="quickPromptNotice" class="quick-notice">
<el-icon><MagicStick /></el-icon>
<span>{{ quickPromptNotice }}</span>
</div>
<div class="prompt-box">
<el-input
v-model="form.prompt"
type="textarea"
:rows="4"
placeholder="描述你需要的练习,例如:生成10道初中数学一元二次方程的选择题..."
resize="none"
/>
<div class="prompt-footer">
<div class="prompt-tags">
<span class="prompt-tag" v-for="t in suggestions" :key="t" @click="form.prompt = t">{{ t }}</span>
</div>
<button class="tpl-btn" type="button" @click="showTemplateGallery = true">
<el-icon><Files /></el-icon>
<span>用模板</span>
</button>
<button class="gen-btn" style="background: var(--gradient-orange)" :disabled="!form.prompt || generating" @click="handleGenerate">
<el-icon v-if="!generating"><MagicStick /></el-icon>
<span v-if="generating" class="btn-loading"></span>
{{ generating ? 'AI生成中...' : '生成游戏' }}
</button>
</div>
</div>
<div class="material-row">
<MaterialPicker v-model="selectedMaterials" />
</div>
<div class="settings-row">
<div class="setting-item">
<label>练习类型</label>
<el-select v-model="form.exercise_type" size="default">
<el-option label="选择题" value="choice" />
<el-option label="填空题" value="fill_blank" />
<el-option label="判断题" value="true_false" />
<el-option label="匹配题" value="matching" />
<el-option label="排序拖拽" value="drag_sort" />
<el-option label="贪吃蛇闯关" value="game_snake" />
<el-option label="翻牌配对" value="game_match" />
<el-option label="冒险闯关" value="game_adventure" />
</el-select>
</div>
<div class="setting-item">
<label>学科</label>
<el-input v-model="form.subject" placeholder="学科" size="default" style="width: 120px" />
</div>
<div class="setting-item">
<label>题数</label>
<el-input-number v-model="form.count" :min="1" :max="50" size="default" />
</div>
</div>
</div>
<div v-if="generating" class="loading-section">
<div class="loading-bar"><div class="loading-bar-fill"></div></div>
<p>AI正在生成练习题...</p>
<p style="font-size:13px;color:var(--text-secondary);opacity:.65;margin-top:10px">模型深度推理中通常需要 1-2 分钟请耐心等待</p>
</div>
<div v-else-if="!result" class="guide-section">
<div class="capability-row">
<div v-for="item in capabilities" :key="item.title">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
<div class="guide-cards">
<div class="guide-card" v-for="g in guideExamples" :key="g.title" @click="form.prompt = g.prompt">
<div class="guide-icon" :style="{ background: g.bg }">
<el-icon :size="18" color="#fff"><component :is="g.icon" /></el-icon>
</div>
<div class="guide-info">
<h4>{{ g.title }}</h4>
<p>{{ g.prompt }}</p>
</div>
<span class="guide-arrow">试试 </span>
</div>
</div>
</div>
<div v-if="result" class="result-section">
<div class="result-header">
<div>
<h3>{{ result.title }}</h3>
<span class="result-meta"> {{ result.questions?.length || 0 }} · {{ typeLabel(form.exercise_type) }}</span>
</div>
<div class="header-actions">
<button class="mode-btn" :class="{ active: viewMode === 'interactive' }" @click="viewMode = 'interactive'">
<el-icon><Monitor /></el-icon> 互动
</button>
<button class="mode-btn" :class="{ active: viewMode === 'detail' }" @click="viewMode = 'detail'">
<el-icon><List /></el-icon> 详情
</button>
<button class="mode-btn" @click="reuseCurrent">
<el-icon><MagicStick /></el-icon> 用它生成
</button>
<button class="mode-btn publish" :disabled="publishing" @click="publishCurrent">
<el-icon><Share /></el-icon> {{ publishing ? '发布中' : '发布资源' }}
</button>
<button class="save-btn" style="background: var(--gradient-orange)" @click="handleSave"><el-icon><Check /></el-icon> 保存练习</button>
<button class="mode-btn" @click="downloadCurrent"><el-icon><Download /></el-icon> 下载Word</button>
</div>
</div>
<!-- Interactive Mode: answer questions and get feedback -->
<div v-if="viewMode === 'interactive' && result.questions?.length" class="interactive-mode">
<!-- Score bar -->
<div class="quiz-score-bar">
<span> {{ currentQIdx + 1 }} / {{ result.questions.length }} </span>
<span v-if="answeredCount > 0">已答 {{ answeredCount }} · 正确 {{ correctCount }} · 正确率 {{ scorePercent }}%</span>
<span v-if="attemptSummary" class="attempt-summary">已有 {{ attemptSummary.count }} 人完成 · 平均正确率 {{ attemptSummary.avgScore }}%</span>
<button v-if="answeredCount > 0" type="button" class="reset-btn" @click="resetQuiz">重新作答</button>
</div>
<!-- Question -->
<div class="quiz-question-card">
<div class="quiz-q-header">
<span class="q-num">{{ currentQIdx + 1 }}</span>
<el-tag size="small" effect="plain">{{ currentQuestion.type || form.exercise_type }}</el-tag>
</div>
<MarkdownContent :content="currentQuestion.question" class="q-text" />
<div v-if="currentQuestion.html" class="quiz-html-preview">
<HtmlPreview :html="currentQuestion.html" height="280px" />
</div>
<!-- Choice / True-False: option buttons -->
<div v-if="currentQuestion.options?.length" class="quiz-options">
<button
v-for="(opt, oi) in currentQuestion.options"
:key="oi"
type="button"
class="quiz-option"
:class="optionClass(oi)"
:disabled="isRevealed(currentQIdx)"
@click="selectOption(opt)"
>
<span class="opt-letter">{{ String.fromCharCode(65 + oi) }}</span>
<span>{{ opt }}</span>
<el-icon v-if="isRevealed(currentQIdx) && opt === currentQuestion.answer" class="opt-correct"><CircleCheckFilled /></el-icon>
<el-icon v-else-if="isRevealed(currentQIdx) && opt === getUserAnswer(currentQIdx) && opt !== currentQuestion.answer" class="opt-wrong"><CircleCloseFilled /></el-icon>
</button>
</div>
<!-- Fill-blank: text input -->
<div v-else class="quiz-fill-input">
<input
v-model="fillAnswer"
type="text"
class="fill-input"
placeholder="输入你的答案..."
:disabled="isRevealed(currentQIdx)"
@keyup.enter="submitFillAnswer"
/>
<button v-if="!isRevealed(currentQIdx)" type="button" class="submit-answer-btn" @click="submitFillAnswer">提交答案</button>
</div>
<!-- Feedback after answering -->
<div v-if="isRevealed(currentQIdx)" class="quiz-feedback" :class="{ correct: isCurrentCorrect, wrong: !isCurrentCorrect }">
<div class="feedback-head">
<el-icon v-if="isCurrentCorrect" :size="20"><CircleCheckFilled /></el-icon>
<el-icon v-else :size="20"><CircleCloseFilled /></el-icon>
<strong>{{ isCurrentCorrect ? '回答正确!' : '回答错误' }}</strong>
<span v-if="!isCurrentCorrect">正确答案{{ currentQuestion.answer }}</span>
</div>
<div v-if="currentQuestion.explanation" class="feedback-explain">
<MarkdownContent :content="currentQuestion.explanation" />
</div>
</div>
</div>
<!-- Navigation -->
<div class="interactive-nav">
<button class="nav-btn" :disabled="currentQIdx <= 0" @click="currentQIdx--">
<el-icon><ArrowLeft /></el-icon>
</button>
<div class="nav-dots">
<span
v-for="(q, idx) in result.questions"
:key="idx"
class="dot"
:class="{
active: currentQIdx === idx,
correct: isRevealed(idx) && isCorrect(idx),
wrong: isRevealed(idx) && !isCorrect(idx),
}"
@click="currentQIdx = idx"
>{{ idx + 1 }}</span>
</div>
<button class="nav-btn" :disabled="currentQIdx >= result.questions.length - 1" @click="currentQIdx++">
<el-icon><ArrowRight /></el-icon>
</button>
</div>
</div>
<!-- Detail Mode: show all questions -->
<div v-if="viewMode === 'detail'" class="detail-mode">
<div v-for="(q, idx) in result.questions" :key="idx" class="question-card">
<div class="q-header">
<span class="q-num">{{ idx + 1 }}</span>
<el-tag size="small" effect="plain">{{ q.type }}</el-tag>
<el-tag v-if="q.difficulty" size="small" effect="plain" :type="q.difficulty === 'easy' ? 'success' : q.difficulty === 'hard' ? 'danger' : 'warning'">
{{ q.difficulty === 'easy' ? '简单' : q.difficulty === 'hard' ? '困难' : '中等' }}
</el-tag>
</div>
<MarkdownContent :content="q.question" class="q-text" />
<div v-if="q.html" class="q-preview">
<HtmlPreview :html="q.html" height="300px" />
</div>
<div v-if="q.options" class="q-options">
<div v-for="(opt, oi) in q.options" :key="oi" class="q-option" :class="{ correct: opt === q.answer }">
<span class="opt-letter">{{ String.fromCharCode(65 + oi) }}</span>
<span>{{ opt }}</span>
</div>
</div>
<div class="q-answer">
<el-icon :size="14" color="#10B981"><Check /></el-icon>
<span>答案{{ q.answer }}</span>
</div>
<div v-if="q.explanation" class="q-explanation"><MarkdownContent :content="q.explanation" /></div>
</div>
</div>
</div>
<section class="saved-section">
<div class="saved-header">
<div>
<h3>已保存练习</h3>
<p>保存后的课堂练习会出现在这里方便二次打开和管理</p>
</div>
<button class="refresh-btn" type="button" @click="loadSavedExercises">
<el-icon><Refresh /></el-icon>
刷新
</button>
</div>
<div v-if="savedLoading" class="saved-loading">正在加载已保存练习...</div>
<div v-else-if="savedExercises.length === 0" class="saved-empty">
<el-icon><Trophy /></el-icon>
<span>暂无已保存练习</span>
</div>
<div v-else class="saved-grid">
<article v-for="item in savedExercises" :key="item.id" class="saved-card">
<div class="saved-cover">
<el-icon><Trophy /></el-icon>
<strong>{{ item.title }}</strong>
<span>{{ typeLabel(item.exercise_type) }} · {{ item.questions?.length || 0 }}</span>
</div>
<div class="saved-body">
<h4>{{ item.title }}</h4>
<p>{{ item.subject || '未设置学科' }} · {{ item.knowledge_points?.join('、') || '未设置知识点' }}</p>
<div class="saved-actions">
<button type="button" @click="previewSaved(item)"><el-icon><View /></el-icon>预览</button>
<button type="button" @click="publishSaved(item)"><el-icon><Share /></el-icon>发布</button>
<button type="button" @click="downloadSaved(item)"><el-icon><Download /></el-icon>下载</button>
<el-popconfirm title="确定删除这个练习?关联资源会同步下架。" @confirm="handleDeleteSaved(item.id)">
<template #reference>
<button type="button" class="danger"><el-icon><Delete /></el-icon>删除</button>
</template>
</el-popconfirm>
</div>
</div>
</article>
</div>
<TemplateGallery v-model="showTemplateGallery" kind="game" @use="handleUseTemplate" />
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { aiGenerateExercise, createExercise, deleteExercise, exportExerciseDocx, getExercise, getExercises, getExerciseAttempts, submitExerciseAttempt, updateExercise } from '@/api/exercise'
import { publishResource } from '@/api/resource'
import MarkdownContent from '@/components/common/MarkdownContent.vue'
import HtmlPreview from '@/components/common/HtmlPreview.vue'
import TemplateGallery from '@/components/common/TemplateGallery.vue'
import MaterialPicker from '@/components/common/MaterialPicker.vue'
import { useUserStore } from '@/stores/user'
import { downloadBlob } from '@/utils/download'
import { buildMaterialPromptBlock, type MaterialContextItem } from '@/utils/materialContext'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const generating = ref(false)
const result = ref<any>(null)
const savedLoading = ref(false)
const savedExercises = ref<any[]>([])
const quickPromptNotice = ref('')
const viewMode = ref<'interactive' | 'detail'>('interactive')
const currentQIdx = ref(0)
const userAnswers = reactive<Record<number, string>>({})
const revealedSet = reactive<Set<number>>(new Set())
const fillAnswer = ref('')
const attemptRecorded = ref(false)
const attemptSummary = ref<{ count: number; avgScore: number } | null>(null)
const answeredCount = computed(() => Object.keys(userAnswers).length)
const correctCount = computed(() => [...revealedSet].filter(idx => isCorrect(idx)).length)
const scorePercent = computed(() => answeredCount.value > 0 ? Math.round((correctCount.value / answeredCount.value) * 100) : 0)
const isCurrentCorrect = computed(() => isCorrect(currentQIdx.value))
watch(currentQIdx, () => {
fillAnswer.value = isRevealed(currentQIdx.value) ? getUserAnswer(currentQIdx.value) : ''
})
watch(answeredCount, (n) => {
const total = result.value?.questions?.length || 0
if (total > 0 && n >= total) recordAttempt()
})
const savedResultId = ref<number | null>(null)
const publishing = ref(false)
const selectedMaterials = ref<MaterialContextItem[]>([])
const showTemplateGallery = ref(false)
const suggestions = ['一元二次方程选择题', '英语语法填空题', '历史知识判断题']
const guideExamples = [
{ title: '数学选择题', prompt: '生成10道初中数学一元二次方程的选择题,难度中等,包含详细解析', icon: 'Trophy', bg: 'var(--gradient-orange)' },
{ title: '英语语法填空', prompt: '生成8道英语语法填空题,考查时态和语态的用法', icon: 'MagicStick', bg: 'var(--gradient-green)' },
{ title: '历史知识判断', prompt: '生成15道中国古代史判断题,涵盖秦汉至明清', icon: 'Flag', bg: 'var(--gradient-blue)' },
]
const capabilities = [
{ title: '即时作答', desc: '生成可直接在页面完成的互动题目' },
{ title: '答案解析', desc: '每道题保留答案、难度和解析说明' },
{ title: '课堂复用', desc: '保存后可按题型和知识点再次打开' },
]
const form = reactive({ prompt: '', exercise_type: 'choice', subject: '', count: 5, knowledge_points: [] as string[] })
const currentQuestion = computed(() => {
if (!result.value?.questions?.length) return { answer: '', explanation: '', html: '' }
return result.value.questions[currentQIdx.value] || { answer: '', explanation: '', html: '' }
})
function getUserAnswer(idx: number): string {
return userAnswers[idx] || ''
}
function isRevealed(idx: number): boolean {
return revealedSet.has(idx)
}
function isCorrect(idx: number): boolean {
if (!isRevealed(idx)) return false
const q = result.value?.questions?.[idx]
if (!q) return false
const ua = String(userAnswers[idx] || '').trim().toLowerCase()
const ca = String(q.answer || '').trim().toLowerCase()
return ua === ca
}
function selectOption(opt: string) {
if (isRevealed(currentQIdx.value)) return
userAnswers[currentQIdx.value] = opt
revealedSet.add(currentQIdx.value)
}
function submitFillAnswer() {
if (isRevealed(currentQIdx.value)) return
const ans = fillAnswer.value.trim()
if (!ans) return
userAnswers[currentQIdx.value] = ans
revealedSet.add(currentQIdx.value)
}
function optionClass(oi: number): string {
if (!isRevealed(currentQIdx.value)) return ''
const opt = currentQuestion.value.options?.[oi] || ''
if (opt === currentQuestion.value.answer) return 'is-correct'
if (opt === getUserAnswer(currentQIdx.value) && opt !== currentQuestion.value.answer) return 'is-wrong'
return 'is-dimmed'
}
function resetQuiz() {
recordAttempt()
Object.keys(userAnswers).forEach(k => delete userAnswers[Number(k)])
revealedSet.clear()
fillAnswer.value = ''
currentQIdx.value = 0
attemptRecorded.value = false
}
async function recordAttempt() {
if (attemptRecorded.value || answeredCount.value === 0 || !savedResultId.value) return
const total = result.value?.questions?.length || 0
attemptRecorded.value = true
try {
await submitExerciseAttempt(savedResultId.value, {
answers: Object.entries(userAnswers).map(([idx, ans]) => ({ q: Number(idx) + 1, a: ans })),
score: correctCount.value,
total: total || answeredCount.value,
})
} catch {
attemptRecorded.value = false
}
}
function handleUseTemplate(payload: { kind: string; template: any; params: Record<string, any>; html: string; title: string }) {
result.value = {
title: payload.title,
questions: [{
question: payload.template.name || '教学游戏',
html: payload.html,
answer: '',
explanation: payload.template.description || '',
options: [],
difficulty: '简单',
}],
}
savedResultId.value = null
resetQuiz()
const catMap: Record<string, string> = {
game_snake: 'game_snake', game_match: 'game_match', game_adventure: 'game_adventure',
drag_sort: 'drag_sort', matching: 'matching', memory: 'game_match',
}
if (payload.template.category && catMap[payload.template.category]) {
form.exercise_type = catMap[payload.template.category]
}
ElMessage.success('已应用模板,可直接预览或保存')
}
async function handleGenerate() {
if (!(await ensureLoggedIn())) return
generating.value = true
currentQIdx.value = 0
resetQuiz()
try {
const res: any = await aiGenerateExercise({
...form,
prompt: buildPromptWithMaterials(form.prompt),
})
userStore.refreshCreditsFromResponse(res)
result.value = res.data
savedResultId.value = null
ElMessage.success('练习生成成功')
} catch (e: any) {
if (handleAuthError(e)) return
ElMessage.error('生成失败:' + (e.response?.data?.detail || e.message || ''))
}
finally { generating.value = false }
}
function buildPromptWithMaterials(prompt: string) {
return `${prompt}${buildMaterialPromptBlock(selectedMaterials.value, '参考素材')}`.trim()
}
async function handleSave() {
if (!result.value) return
if (!(await ensureLoggedIn())) return
const payload = {
title: result.value.title,
exercise_type: form.exercise_type,
subject: form.subject,
knowledge_points: form.knowledge_points,
questions: result.value.questions,
}
const isUpdate = !!savedResultId.value
const saved: any = isUpdate
? await updateExercise(savedResultId.value!, payload)
: await createExercise(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
ElMessage.success(isUpdate ? '练习已更新' : '练习已保存')
await loadSavedExercises()
}
async function downloadCurrent() {
const id = await ensureSavedCurrent()
if (id) await downloadSaved({ id, title: result.value?.title || '课堂练习' })
}
async function downloadSaved(item: any) {
if (!(await ensureLoggedIn())) return
try {
const blob = await exportExerciseDocx(item.id)
downloadBlob(blob, `${safeFilename(item.title || '课堂练习')}-练习.docx`)
ElMessage.success('已开始下载')
} catch (e: any) {
ElMessage.error('下载失败:' + (e.response?.data?.detail || e.message || ''))
}
}
async function loadSavedExercises() {
if (!(await ensureLoggedIn({ silent: true }))) {
savedExercises.value = []
savedLoading.value = false
return
}
savedLoading.value = true
try {
const res: any = await getExercises({ limit: 50 })
savedExercises.value = res.data || res || []
} catch (e: any) {
if (handleAuthError(e)) {
savedExercises.value = []
return
}
ElMessage.error('加载已保存练习失败:' + (e.response?.data?.detail || e.message || ''))
} finally {
savedLoading.value = false
}
}
async function previewSaved(item: any) {
result.value = {
title: item.title,
questions: item.questions || [],
}
savedResultId.value = item.id
form.exercise_type = item.exercise_type || 'choice'
form.subject = item.subject || ''
form.knowledge_points = item.knowledge_points || []
currentQIdx.value = 0
viewMode.value = 'interactive'
attemptSummary.value = null
attemptRecorded.value = false
window.scrollTo({ top: 0, behavior: 'smooth' })
try {
const attempts: any = await getExerciseAttempts(item.id)
if (Array.isArray(attempts) && attempts.length > 0) {
const avg = Math.round(attempts.reduce((s: number, a: any) => s + (a.total > 0 ? (a.score / a.total) * 100 : 0), 0) / attempts.length)
attemptSummary.value = { count: attempts.length, avgScore: avg }
}
} catch { /* ignore */ }
}
async function handleDeleteSaved(id: number) {
if (!(await ensureLoggedIn())) return
await deleteExercise(id)
ElMessage.success('练习已删除,关联资源已下架')
await loadSavedExercises()
}
async function ensureSavedCurrent() {
if (!result.value) return null
if (!(await ensureLoggedIn())) return null
const payload = {
title: result.value.title || '课堂练习',
exercise_type: form.exercise_type,
subject: form.subject,
knowledge_points: form.knowledge_points,
questions: result.value.questions || [],
}
const saved: any = savedResultId.value
? await updateExercise(savedResultId.value, payload)
: await createExercise(payload)
const item = saved.data || saved
savedResultId.value = item?.id || null
await loadSavedExercises()
return savedResultId.value
}
async function publishCurrent() {
if (!result.value) return
publishing.value = true
try {
const id = await ensureSavedCurrent()
if (!id) return
await publishExerciseAsset({
id,
title: result.value.title || '课堂练习',
exercise_type: form.exercise_type,
subject: form.subject,
knowledge_points: form.knowledge_points,
questions: result.value.questions || [],
})
} finally {
publishing.value = false
}
}
async function publishSaved(item: any) {
if (!(await ensureLoggedIn())) return
await publishExerciseAsset(item)
}
async function publishExerciseAsset(item: any) {
const created: any = await publishResource({
resource_type: 'exercise',
title: item.title,
description: buildExerciseDescription(item),
content_ref: `exercise:${item.id}`,
file_url: '',
tags: ['教学游戏', typeLabel(item.exercise_type), ...(item.knowledge_points || []).slice(0, 3)],
subject: item.subject || '',
grade: '',
is_public: 1,
})
const resource = created.data || created
ElMessage.success('已发布到资源广场')
if (resource?.id) router.push(`/resources/${resource.id}`)
}
function buildExerciseDescription(item: any) {
const points = item.knowledge_points?.length ? `,覆盖${item.knowledge_points.join('、')}` : ''
return `${item.subject || '通用'}${typeLabel(item.exercise_type)}课堂练习,共${item.questions?.length || 0}${points},支持互动预览、答案解析和二次改编。`
}
function reuseCurrent() {
if (!result.value) return
localStorage.setItem('quick_create_prompt', `参考练习《${result.value.title || '课堂练习'}》,重新生成一套原创教学游戏。要求保留核心知识点,优化题目梯度、即时反馈和解析。`)
result.value = null
savedResultId.value = null
window.scrollTo({ top: 0, behavior: 'smooth' })
}
async function ensureLoggedIn(options: { silent?: boolean } = {}) {
if (!userStore.isLoggedIn()) {
if (!options.silent) ElMessage.warning('请先登录后再使用教学游戏功能')
await router.push('/login')
return false
}
if (!userStore.user) {
try {
await userStore.fetchProfile()
} catch {
if (!options.silent) ElMessage.warning('登录状态失效,请重新登录')
await router.push('/login')
return false
}
}
return true
}
function isAuthError(error: any) {
return error?.response?.status === 401 ||
(error?.response?.status === 403 && error?.response?.data?.detail === 'Not authenticated')
}
function handleAuthError(error: any) {
if (!isAuthError(error)) return false
userStore.clearToken()
ElMessage.warning('登录状态失效,请重新登录')
router.push('/login')
return true
}
function typeLabel(type: string) {
const map: Record<string, string> = {
choice: '选择题',
fill_blank: '填空题',
true_false: '判断题',
matching: '匹配题',
drag_sort: '排序拖拽',
game_snake: '贪吃蛇闯关',
game_match: '翻牌配对',
game_adventure: '冒险闯关',
}
return map[type] || '练习'
}
function safeFilename(value: string) {
return (value || '资源').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim() || '资源'
}
onMounted(async () => {
const quickPrompt = localStorage.getItem('quick_create_prompt')
if (quickPrompt) {
form.prompt = quickPrompt
form.subject = inferSubject(quickPrompt)
form.exercise_type = inferExerciseType(quickPrompt)
form.count = inferCount(quickPrompt)
quickPromptNotice.value = '已带入首页游戏/练习指令,可直接生成或继续调整题型。'
localStorage.removeItem('quick_create_prompt')
}
await loadSavedExercises()
await openSavedFromRoute()
})
async function openSavedFromRoute() {
const openId = Number(route.query.open)
if (!Number.isFinite(openId) || openId <= 0) return
try {
const data: any = await getExercise(openId)
const item = data.data || data
previewSaved(item)
} catch {
ElMessage.warning('未能打开改编后的练习')
}
}
function inferSubject(text: string) {
const subjects = ['数学', '英语', '语文', '物理', '化学', '历史', '信息科技']
return subjects.find(item => text.includes(item)) || ''
}
function inferExerciseType(text: string) {
if (/填空/.test(text)) return 'fill_blank'
if (/判断|对错/.test(text)) return 'true_false'
if (/匹配|连线/.test(text)) return 'matching'
return 'choice'
}
function inferCount(text: string) {
const match = text.match(/(\d+)\s*道/)
const count = match ? Number(match[1]) : 5
return Number.isFinite(count) ? Math.min(Math.max(count, 1), 50) : 5
}
</script>
<style scoped>
.gen-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.gen-hero-icon { width: 52px; height: 52px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
.gen-hero h2 { font-size: 24px; font-weight: 700; color: var(--text-primary); }
.gen-hero p { font-size: 14px; color: var(--text-muted); }
.gen-input-section { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 24px; margin-bottom: 24px; }
.quick-notice { min-height: 38px; display: flex; align-items: center; gap: 8px; border: 1px solid #dce8de; border-radius: 12px; background: #f0fbef; color: #00543d; padding: 8px 12px; margin-bottom: 14px; font-size: 13px; font-weight: 800; }
.prompt-box { margin-bottom: 16px; }
.prompt-box :deep(.el-textarea__inner) { border: none; background: #F8FAFC; border-radius: var(--radius-md); padding: 16px; font-size: 15px; line-height: 1.6; }
.prompt-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
.prompt-tags { display: flex; gap: 8px; }
.prompt-tag { padding: 4px 12px; background: #FFFBEB; color: #F59E0B; border-radius: 20px; font-size: 12px; cursor: pointer; transition: all 0.2s; }
.prompt-tag:hover { background: #F59E0B; color: #fff; }
.material-row { display: flex; flex-wrap: wrap; gap: 8px; padding: 14px 0 0; margin-top: 4px; border-top: 1px solid var(--border-color); }
.tpl-btn { display: inline-flex; align-items: center; gap: 6px; height: 40px; padding: 0 16px; border: 1px solid #fed7aa; border-radius: var(--radius-sm); background: #fff; color: #c2410c; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s; }
.tpl-btn:hover { border-color: #ea580c; background: #fff7ed; }
.gen-btn { display: flex; align-items: center; gap: 8px; border: none; color: #fff; padding: 10px 28px; border-radius: var(--radius-sm); font-size: 15px; font-weight: 600; cursor: pointer; transition: all 0.3s; }
.gen-btn:hover:not(:disabled) { opacity: 0.9; transform: translateY(-1px); }
.gen-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.settings-row { display: flex; gap: 24px; align-items: center; padding-top: 16px; border-top: 1px solid var(--border-color); }
.setting-item { display: flex; align-items: center; gap: 8px; }
.setting-item label { font-size: 13px; color: var(--text-muted); white-space: nowrap; }
.loading-section { text-align: center; padding: 48px 0; }
.loading-bar { width: 300px; height: 4px; background: #E2E8F0; border-radius: 2px; margin: 0 auto 20px; overflow: hidden; }
.loading-bar-fill { height: 100%; width: 40%; background: var(--gradient-orange); border-radius: 2px; animation: loadingSlide 1.5s ease-in-out infinite; }
@keyframes loadingSlide { 0% { transform: translateX(-100%); } 100% { transform: translateX(350%); } }
.loading-section p { font-size: 14px; color: var(--text-secondary); }
.guide-section { animation: fadeIn 0.4s ease; }
.capability-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 14px; }
.capability-row div { min-height: 78px; border: 1px solid #eadfcb; border-radius: 12px; background: #fff; padding: 14px 16px; }
.capability-row strong { display: block; color: #9a5f00; font-size: 15px; margin-bottom: 6px; }
.capability-row span { color: #7a6850; font-size: 12px; line-height: 1.45; }
.guide-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.guide-card { display: flex; flex-direction: column; gap: 10px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 18px; cursor: pointer; transition: all 0.3s; }
.guide-card:hover { box-shadow: var(--shadow-md); border-color: #F59E0B; transform: translateY(-2px); }
.guide-icon { width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.guide-info h4 { font-size: 14px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.guide-info p { font-size: 12px; color: var(--text-muted); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.guide-arrow { font-size: 12px; color: #F59E0B; font-weight: 500; margin-top: auto; }
.result-section { animation: fadeInUp 0.5s ease; }
.result-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.result-header h3 { font-size: 20px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.result-meta { font-size: 13px; color: var(--text-muted); }
.header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.mode-btn { display: flex; align-items: center; gap: 4px; padding: 6px 14px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); background: #fff; font-size: 13px; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.mode-btn.active { background: #FFF7ED; border-color: #F59E0B; color: #F59E0B; }
.mode-btn:hover { border-color: #F59E0B; }
.mode-btn.publish { border-color: #f59e0b; color: #9a5f00; font-weight: 800; }
.mode-btn:disabled { opacity: .58; cursor: not-allowed; }
.save-btn { display: flex; align-items: center; gap: 6px; border: none; color: #fff; padding: 8px 20px; border-radius: var(--radius-sm); font-size: 14px; font-weight: 500; cursor: pointer; transition: opacity 0.2s; }
.save-btn:hover { opacity: 0.9; }
/* Interactive Mode */
.interactive-mode { }
.interactive-stage { background: #F1F5F9; border-radius: var(--radius-lg); padding: 24px; margin-bottom: 16px; }
.interactive-stage :deep(.html-preview) { border-radius: var(--radius-md); box-shadow: 0 4px 24px rgba(0,0,0,0.08); }
.interactive-nav { display: flex; align-items: center; justify-content: center; gap: 16px; margin-bottom: 16px; }
.nav-btn { width: 40px; height: 40px; border-radius: 50%; border: 1px solid var(--border-color); background: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s; }
.nav-btn:hover:not(:disabled) { border-color: #F59E0B; color: #F59E0B; }
.nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
.nav-dots { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; }
.dot { width: 32px; height: 32px; border-radius: 8px; border: 1px solid var(--border-color); display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--text-muted); cursor: pointer; transition: all 0.2s; }
.dot.active { background: var(--gradient-orange); border-color: transparent; color: #fff; }
.dot:hover:not(.active) { border-color: #F59E0B; }
.interactive-answer { background: #F8FAFC; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 16px; }
.quiz-score-bar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-radius: 10px; background: #f0fdf4; border: 1px solid #dce8de; margin-bottom: 14px; font-size: 13px; font-weight: 700; color: #00543d; flex-wrap: wrap; }
.quiz-score-bar .reset-btn { border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #00543d; padding: 4px 12px; font-size: 12px; cursor: pointer; font-weight: 700; }
.quiz-score-bar .attempt-summary { color: #6b7f76; font-weight: 600; font-size: 12px; padding: 2px 10px; background: #fff; border-radius: 6px; border: 1px solid #e2ece4; }
.quiz-question-card { background: #fff; border: 1px solid var(--border-color); border-radius: 14px; padding: 20px; margin-bottom: 16px; }
.quiz-q-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.quiz-html-preview { margin: 12px 0; border-radius: 10px; overflow: hidden; border: 1px solid var(--border-color); }
.quiz-options { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
.quiz-option { display: flex; align-items: center; gap: 10px; padding: 12px 16px; border: 2px solid var(--border-color); border-radius: 10px; background: #fff; cursor: pointer; transition: all 0.2s; font-size: 14px; text-align: left; }
.quiz-option:hover:not(:disabled) { border-color: #F59E0B; background: #fffbeb; }
.quiz-option .opt-letter { width: 28px; height: 28px; border-radius: 50%; background: #f1f5f9; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 13px; color: var(--text-muted); flex-shrink: 0; }
.quiz-option.is-correct { border-color: #10B981; background: #ecfdf5; }
.quiz-option.is-correct .opt-letter { background: #10B981; color: #fff; }
.quiz-option.is-wrong { border-color: #EF4444; background: #fef2f2; }
.quiz-option.is-wrong .opt-letter { background: #EF4444; color: #fff; }
.quiz-option.is-dimmed { opacity: 0.5; }
.quiz-option:disabled { cursor: default; }
.opt-correct { color: #10B981; margin-left: auto; }
.opt-wrong { color: #EF4444; margin-left: auto; }
.quiz-fill-input { display: flex; gap: 8px; margin-top: 14px; }
.fill-input { flex: 1; height: 42px; border: 2px solid var(--border-color); border-radius: 10px; padding: 0 14px; font-size: 14px; outline: none; }
.fill-input:focus { border-color: #F59E0B; }
.fill-input:disabled { background: #f8fafc; }
.submit-answer-btn { height: 42px; padding: 0 18px; border: none; border-radius: 10px; background: #F59E0B; color: #fff; font-weight: 700; cursor: pointer; }
.quiz-feedback { margin-top: 16px; border-radius: 10px; padding: 14px 16px; }
.quiz-feedback.correct { background: #ecfdf5; border: 1px solid #a7f3d0; }
.quiz-feedback.wrong { background: #fef2f2; border: 1px solid #fecaca; }
.feedback-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 14px; }
.feedback-head strong { font-size: 15px; }
.quiz-feedback.correct .feedback-head { color: #065f46; }
.quiz-feedback.wrong .feedback-head { color: #991b1b; }
.feedback-explain { padding-top: 8px; border-top: 1px solid rgba(0,0,0,0.06); font-size: 13px; line-height: 1.6; }
.dot.correct { background: #10B981; border-color: transparent; color: #fff; }
.dot.wrong { background: #EF4444; border-color: transparent; color: #fff; }
.answer-explain { margin-top: 8px; font-size: 13px; color: var(--text-muted); line-height: 1.6; }
/* Detail Mode */
.detail-mode { display: flex; flex-direction: column; gap: 16px; }
.question-card { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-md); padding: 20px; transition: all 0.3s; }
.question-card:hover { box-shadow: var(--shadow-md); }
.q-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.q-num { width: 28px; height: 28px; background: var(--gradient-orange); border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; font-weight: 700; }
.q-text { font-size: 15px; line-height: 1.7; color: var(--text-primary); margin-bottom: 14px; }
.q-preview { margin-bottom: 14px; border-radius: var(--radius-sm); overflow: hidden; }
.q-options { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 14px; }
.q-option { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); font-size: 14px; transition: all 0.2s; }
.q-option.correct { border-color: #10B981; background: #ECFDF5; color: #059669; }
.opt-letter { width: 22px; height: 22px; background: #F1F5F9; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; color: var(--text-secondary); flex-shrink: 0; }
.q-option.correct .opt-letter { background: #10B981; color: #fff; }
.q-answer { display: flex; align-items: center; gap: 6px; color: #10B981; font-size: 14px; font-weight: 500; }
.q-explanation { margin-top: 10px; padding: 10px 14px; background: #F8FAFC; border-radius: var(--radius-sm); font-size: 13px; color: var(--text-muted); line-height: 1.6; }
.btn-loading { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
.saved-section { margin-top: 28px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 22px; }
.saved-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 18px; }
.saved-header h3 { font-size: 18px; font-weight: 700; color: var(--text-primary); margin-bottom: 4px; }
.saved-header p { font-size: 13px; color: var(--text-muted); }
.refresh-btn { height: 34px; display: inline-flex; align-items: center; gap: 6px; border: 1px solid #eadfcb; border-radius: 17px; background: #fff; color: #9a5f00; padding: 0 14px; font-weight: 700; cursor: pointer; }
.saved-loading,
.saved-empty { min-height: 120px; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--text-muted); border: 1px dashed #eadfcb; border-radius: var(--radius-md); background: #fffaf0; }
.saved-empty .el-icon { font-size: 24px; color: #d89a2a; }
.saved-grid { display: grid; grid-template-columns: repeat(3, minmax(220px, 1fr)); gap: 16px; }
.saved-card { overflow: hidden; border: 1px solid #eadfcb; border-radius: 12px; background: #fff; }
.saved-cover { height: 132px; display: flex; flex-direction: column; justify-content: center; gap: 8px; margin: 5px; padding: 18px; border-radius: 9px; color: #fff; background: linear-gradient(135deg, #c2410c, #f59e0b); }
.saved-cover .el-icon { font-size: 22px; opacity: .85; }
.saved-cover strong { font-size: 18px; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-cover span { width: fit-content; padding: 3px 8px; border-radius: 12px; background: rgba(255,255,255,.2); font-size: 12px; font-weight: 800; }
.saved-body { padding: 12px 16px 16px; }
.saved-body h4 { font-size: 15px; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 6px; }
.saved-body p { min-height: 36px; color: var(--text-muted); font-size: 12px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.saved-actions { display: flex; gap: 8px; margin-top: 12px; }
.saved-actions button { height: 32px; display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 1px solid #eadfcb; border-radius: 8px; background: #fffaf0; color: #9a5f00; padding: 0 12px; cursor: pointer; font-weight: 700; }
.saved-actions .danger:hover { border-color: #fca5a5; background: #fef2f2; color: #ef4444; }
@media (max-width: 1080px) {
.saved-grid { grid-template-columns: repeat(2, minmax(220px, 1fr)); }
}
@media (max-width: 760px) {
.prompt-footer,
.settings-row,
.result-header,
.saved-header,
.header-actions { flex-direction: column; align-items: stretch; }
.capability-row,
.guide-cards,
.saved-grid,
.q-options { grid-template-columns: 1fr; }
}
</style>