Files
jiaoyu/frontend/src/views/Exercise.vue
T
Zhang Jing Xuan 0841b1a103 智教助手平台:完整初始化
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
2026-07-29 16:29:10 +08:00

874 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>