Files
jiaoyu/frontend/src/views/Home.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

1602 lines
44 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="zj-home" :class="{ 'task-mode': activeMode !== 'courseware' }">
<section v-if="activeMode === 'courseware'" class="courseware-shell">
<div class="top-composer">
<div class="composer-title">
<span class="mascot"></span>
<h1>智教助手一句话生成专业级互动课件</h1>
</div>
<div class="prompt-card">
<textarea v-model="prompt" :placeholder="activeConfig.placeholder"></textarea>
<div v-if="uploadedMaterials.length" class="materials-row">
<span v-for="item in uploadedMaterials" :key="item.id">
<el-icon><component :is="item.icon" /></el-icon>
{{ item.name }}
<button type="button" @click="removeMaterial(item.id)">×</button>
</span>
</div>
<div class="prompt-toolbar">
<div class="toolbar-left">
<button type="button" title="上传文件" @click="pickFile('file')"><el-icon><Paperclip /></el-icon></button>
<button type="button" title="上传图片" @click="pickFile('image')"><el-icon><Picture /></el-icon></button>
<input ref="fileInputRef" type="file" class="hidden-input" :accept="uploadAccept" @change="handleFileChange" />
<MaterialPicker v-model="uploadedMaterials" :show-selected="false" />
<button
v-for="chip in creatorChips"
:key="chip.mode"
type="button"
class="chip-btn"
:class="{ active: chip.mode === activeMode }"
@click="setMode(chip.mode)"
>
<el-icon><component :is="chip.icon" /></el-icon>
{{ chip.label }}
</button>
</div>
<div class="toolbar-right">
<button type="button" class="voice-btn" title="语音输入" :class="{ active: listening }" @click="toggleVoice"><el-icon><Microphone /></el-icon></button>
<button type="button" class="send-btn" title="开始生成" @click="startCreate"><el-icon><Top /></el-icon></button>
</div>
</div>
</div>
</div>
<section class="resource-toolbar">
<nav class="category-tabs">
<button
v-for="cat in categories"
:key="cat"
type="button"
:class="{ active: activeCategory === cat }"
@click="activeCategory = cat"
>
{{ cat }}
<span v-if="cat === '数学'" class="hot-mini">Hot</span>
</button>
</nav>
<form class="search-pill" @submit.prevent="goResourceSearch">
<el-icon><Search /></el-icon>
<input v-model="keyword" placeholder="快速搜索资源" />
</form>
</section>
<section class="resource-grid">
<article v-for="item in filteredResources" :key="item.id" class="resource-card" @click="openResource(item)">
<div class="cover" :class="item.coverClass">
<span v-if="item.hot" class="hot-badge">🔥热门</span>
<span v-if="item.featured" class="hot-badge featured">精选</span>
<span class="views"><el-icon><View /></el-icon>{{ item.views }}</span>
<div class="cover-content">
<small>{{ item.kicker }}</small>
<strong>{{ item.coverTitle }}</strong>
<em>{{ item.coverSub }}</em>
</div>
<div v-if="item.visual === 'slides'" class="mini-slides"><span></span><span></span><span></span></div>
<div v-if="item.visual === 'chart'" class="chart-art"><span></span><span></span><span></span></div>
<div v-if="item.visual === 'tower'" class="tower-art"></div>
<div v-if="item.visual === 'people'" class="people-art"><span></span><span></span></div>
<div v-if="item.visual === 'shape'" class="shape-art"><span></span><span></span><span></span></div>
<div v-if="item.visual === 'grid'" class="grid-art"><span></span><span></span><span></span><span></span></div>
<div v-if="item.visual === 'cave'" class="cave-art"></div>
<div v-if="item.visual === 'festival'" class="festival-art"></div>
</div>
<div class="card-body">
<h3>{{ item.title }}</h3>
<div class="author-row">
<span class="avatar-dot" :style="{ background: item.avatar }"></span>
<span>{{ item.author }}</span>
<span class="star"><el-icon><Star /></el-icon>{{ item.likes }}</span>
</div>
</div>
</article>
</section>
<form class="floating-composer" @submit.prevent="startCreate">
<span>{{ activeConfig.shortLabel }}</span>
<input v-model="prompt" :placeholder="activeConfig.floatPlaceholder" />
<button type="button" class="voice-btn" title="语音输入" @click="toggleVoice"><el-icon><Microphone /></el-icon></button>
<button type="submit" class="send-btn" title="开始生成"><el-icon><Top /></el-icon></button>
</form>
</section>
<section v-else class="task-shell">
<div class="task-center">
<div class="task-heading">
<span class="mascot large"></span>
<h1>Hi, 我是智教助手</h1>
</div>
<div class="task-workbench">
<aside class="tip-card" :class="activeMode">
<div class="tip-title">
<strong>{{ activeConfig.label }}</strong>
<span>小提示</span>
</div>
<p>{{ activeConfig.tip }}</p>
<div class="tip-preview">
<div class="mini-tabs">
<span
v-for="item in previewTabs"
:key="item.mode"
:class="{ active: activeMode === item.mode }"
>{{ item.label }}</span>
</div>
<div class="doc-chip" v-if="activeMode === 'exam'">
<strong>ChinaDaily.doc</strong>
<small>File · 54KB</small>
</div>
<p>{{ activeConfig.previewText }}</p>
</div>
<div class="tip-actions">
<button type="button" @click="prompt = ''">好的</button>
<button type="button" @click="applyPromptTemplate(activeConfig.examples[0])">试一试</button>
</div>
</aside>
<div class="task-main">
<div class="task-tabs">
<button
v-for="item in taskTabs"
:key="item.mode"
type="button"
:class="{ active: activeMode === item.mode }"
@click="setMode(item.mode)"
>
<el-icon><component :is="item.icon" /></el-icon>
{{ item.label }}
</button>
</div>
<div class="task-input-card">
<textarea v-model="prompt" :placeholder="activeConfig.inputPlaceholder"></textarea>
<div v-if="uploadedMaterials.length" class="materials-row task-materials-row">
<span v-for="item in uploadedMaterials" :key="item.id">
<el-icon><component :is="item.icon" /></el-icon>
{{ item.name }}
<button type="button" @click="removeMaterial(item.id)">×</button>
</span>
</div>
<div class="task-toolbar">
<div>
<button type="button" title="上传文件" @click="pickFile('file')"><el-icon><Paperclip /></el-icon></button>
<button type="button" title="上传图片" @click="pickFile('image')"><el-icon><Picture /></el-icon></button>
<MaterialPicker v-model="uploadedMaterials" :show-selected="false" />
</div>
<div>
<button type="button" class="voice-btn" title="语音输入" :class="{ active: listening }" @click="toggleVoice"><el-icon><Microphone /></el-icon></button>
<button type="button" class="send-btn muted" title="开始生成" @click="startCreate"><el-icon><Top /></el-icon></button>
</div>
</div>
</div>
<section class="hot-prompts">
<h2>大家都在用</h2>
<button v-for="item in activeConfig.examples" :key="item.prompt" type="button" @click="applyPromptTemplate(item)">
<el-icon><component :is="activeConfig.icon" /></el-icon>
<span>{{ item.prompt }}</span>
<em v-if="item.hot">热门</em>
<small>{{ item.heat }} 热度</small>
</button>
</section>
</div>
</div>
</div>
<section class="case-section">
<div class="case-tabs">
<button
v-for="tab in caseTabs"
:key="tab.mode"
type="button"
:class="{ active: caseFilter === tab.mode }"
@click="caseFilter = tab.mode"
>
{{ tab.label }}
</button>
</div>
<div class="case-grid">
<article v-for="item in filteredCases" :key="item.title" class="case-card">
<div class="case-cover" :class="item.theme">
<span>{{ item.type }}</span>
<div></div>
</div>
<h3>{{ item.title }}</h3>
<p><el-icon><Document /></el-icon>{{ item.file }}</p>
</article>
</div>
</section>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { parseMaterial } from '@/api/ai'
import MaterialPicker from '@/components/common/MaterialPicker.vue'
import { useUserStore } from '@/stores/user'
import { buildMaterialPromptBlock, fileParseToContext, formatMaterialFileSize, type MaterialContextItem } from '@/utils/materialContext'
import { createSpeechController } from '@/utils/speech'
type Mode = 'courseware' | 'exam' | 'exercise' | 'lesson' | 'animation' | 'analytics'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const prompt = ref('')
const keyword = ref('')
const activeCategory = ref('推荐')
const caseFilter = ref('all')
const fileInputRef = ref<HTMLInputElement>()
const uploadAccept = ref('.txt,.md,.csv,.json,.docx,.pptx,.pdf,.xlsx,image/*')
const listening = ref(false)
const uploadedMaterials = ref<MaterialContextItem[]>([])
const speech = createSpeechController({
onStart: () => { listening.value = true },
onText: (text) => { prompt.value = `${prompt.value} ${text}`.trim() },
onError: () => {
listening.value = false
ElMessage.warning('语音识别失败,请检查浏览器权限或直接输入文字')
},
onEnd: () => { listening.value = false },
})
const modeFromRoute = computed<Mode>(() => {
const mode = String(route.query.mode || 'courseware') as Mode
return ['courseware', 'exam', 'exercise', 'lesson', 'animation', 'analytics'].includes(mode) ? mode : 'courseware'
})
const activeMode = computed(() => modeFromRoute.value)
const taskTabs = [
{ mode: 'exam', label: 'AI命题', icon: 'Finished' },
{ mode: 'exercise', label: 'AI组题', icon: 'List' },
{ mode: 'lesson', label: 'AI教案 · 大单元', icon: 'Tickets' },
] as const
const creatorChips = [
{ mode: 'animation', label: '教学动画', icon: 'Film' },
{ mode: 'exercise', label: '教学游戏', icon: 'Trophy' },
{ mode: 'exam', label: 'AI命题', icon: 'Finished' },
{ mode: 'lesson', label: '大单元教案', icon: 'Tickets' },
{ mode: 'courseware', label: '互动课件', icon: 'Document' },
{ mode: 'analytics', label: '数据回收', icon: 'DataLine' },
] as const
const modeConfigs: Record<string, any> = {
courseware: {
label: 'AI互动课件',
shortLabel: '互动课件',
icon: 'MagicStick',
placeholder: '教学游戏|以《西游记》取经之路为故事线,生成六年级课内古诗词闯关游戏,让学生在游戏化体验中变“被动背诵”为“主动探究”。',
floatPlaceholder: '帮我生成初英人教七下U1 Grammar Focus的互动课件,通过精简而高效的课堂活动练习because的用法。',
},
exam: {
label: 'AI命题',
icon: 'Finished',
tip: '上传命题素材的资料和要求,AI即可命制一道原创且专业的题目。',
inputPlaceholder: '请输入内容',
previewText: '请根据chinadaily这篇文章,帮我命制北京英语中考阅读理解模拟题。',
examples: [
{ prompt: '根据我上传的ChinaDaily这篇文章,帮我命制北京英语中考阅读理解模拟题', heat: 27466, hot: true },
{ prompt: '请你找一篇适合中学生读的冰心散文,出一道中考记叙文阅读', heat: 26197, hot: true },
{ prompt: '根据人民日报“人工智能”主题报道,出一道北京西城区六年级难度应用题', heat: 24937 },
{ prompt: '结合古代科技相关情境,出一道“密度相关”中考难度的物理计算题', heat: 24250 },
],
},
exercise: {
label: 'AI组题',
icon: 'List',
tip: '输入组题的知识点范围难度等要求,AI即可从海量题库智能挑选组成一份题单。',
inputPlaceholder: '请输入内容',
previewText: '帮我按二次函数压轴题和模拟题中考查方式,组一份有难度梯度的练习。',
examples: [
{ prompt: '结合过往三年北京中考题和模拟题中考查“二次函数”的真题,组一份有难度梯度的专项练习', heat: 27105, hot: true },
{ prompt: '根据图中这名同学的错题,为他进行错因分析并组一套针对性练习', heat: 25882, hot: true },
{ prompt: '结合北上广深近三年中考语文的文言文阅读题,组一份题单,至少30题', heat: 24302 },
{ prompt: '我是广州一名初中英语老师,请帮我找过去五年广州中考英语真题', heat: 23898 },
],
},
lesson: {
label: 'AI教案 · 大单元',
icon: 'Tickets',
tip: '输入课程单元等信息,AI即可生成一份专业的大单元教案设计。',
inputPlaceholder: '请输入内容',
previewText: '帮我出一份课时教学设计,关于人教版新一上第四单元11-20的认识。',
examples: [
{ prompt: '帮我出一份课时教学设计,关于人教版新一上第四单元11~20的认识第1课时', heat: 27347, hot: true },
{ prompt: '基于这份教案进行修改,在“0的意义”的部分更体现游戏化教学', heat: 27190, hot: true },
{ prompt: '结合“我校的玉兰树春天开花”这一情境,帮我生成语文部编版七上第一单元教案', heat: 24961 },
{ prompt: '基于这份教案进行修改,不改变其他内容,在一个环节中增加使用AI工具', heat: 24025 },
],
},
animation: {
label: '教学动画',
shortLabel: '教学动画',
icon: 'Film',
placeholder: '用动画演示二次函数 y=ax²+bx+c 的图像随参数 a、b、c 变化的过程,包含坐标轴、参数滑块和课堂提问。',
floatPlaceholder: '说出一个知识点,生成可投屏的教学动画。',
tip: '把抽象概念、实验过程或知识推演转成可播放的课堂动画。',
inputPlaceholder: '请输入动画主题或上传图片/材料后生成动画',
previewText: '展示圆柱体积公式从切拼到推导的全过程。',
examples: [
{ prompt: '展示二次函数 y=ax²+bx+c 的图像随参数 a、b、c 变化的过程', heat: 28120, hot: true },
{ prompt: '用动画演示圆柱体积公式推导,突出底面积和高的关系', heat: 26788, hot: true },
{ prompt: '用动画解释光合作用中水、二氧化碳、光照和氧气的变化', heat: 24806 },
],
},
analytics: {
label: '课堂数据回收',
shortLabel: '数据回收',
icon: 'DataLine',
placeholder: '设计一个课堂数据回收活动,收集学生对圆柱体积公式的理解程度、易错点和课后反馈。',
floatPlaceholder: '描述你要收集的课堂反馈或答题数据。',
tip: '创建投票、问答、测评等活动,课堂上实时收集学生反馈。',
inputPlaceholder: '请输入课堂数据回收目标',
previewText: '收集学生对本节课知识点掌握程度,并统计正确率和高频错因。',
examples: [
{ prompt: '创建一个课堂即时反馈,统计学生对圆柱体积公式的掌握程度', heat: 23340, hot: true },
{ prompt: '设计一个课后 exit ticket,收集学生最困惑的问题和自评等级', heat: 21902 },
{ prompt: '为英语阅读课创建投票和简答回收,分析学生对文章主旨的理解', heat: 20660 },
],
},
}
const activeConfig = computed(() => modeConfigs[activeMode.value] || modeConfigs.courseware)
const previewTabs = computed(() => {
const base = [
{ mode: 'exam', label: 'AI命题' },
{ mode: 'exercise', label: 'AI组题' },
{ mode: 'lesson', label: 'AI教案' },
]
if (['exam', 'exercise', 'lesson'].includes(activeMode.value)) return base
return [
{ mode: 'animation', label: '教学动画' },
{ mode: 'courseware', label: '互动课件' },
{ mode: 'analytics', label: '数据回收' },
]
})
const categories = ['推荐', '语文', '数学', '英语', '物理', '化学', '信息科技', '课堂工具', '学前教育', '高等教育', '职业教育', '其他']
const resources = [
{ id: 'guide', category: '推荐', title: '新手指引', author: 'TeacherYO', likes: 6266, views: '9.2万', kicker: 'AI TEACHING', coverTitle: '新手使用攻略', coverSub: '一句话生成专业级教学动画,让教学更生动', coverClass: 'green', visual: 'chart', avatar: '#8B5E34' },
{ id: 'tips', category: '课堂工具', hot: true, title: '【玩转互动课件】必看小技巧', author: 'TeacherYO', likes: 6108, views: '7.9万', kicker: '快速入门', coverTitle: '玩转互动课件', coverSub: '必看小技巧', coverClass: 'yellow', visual: 'slides', avatar: '#8B5E34' },
{ id: 'data', category: '课堂工具', hot: true, title: '【数据回收】新手指引', author: 'TeacherYO', likes: 6036, views: '6.7万', kicker: '课堂工具', coverTitle: '玩转数据回收', coverSub: '案例合集', coverClass: 'mint', visual: 'chart', avatar: '#8B5E34' },
{ id: 'case', category: '推荐', hot: true, title: '【持续更新】智教助手经典案例解析合集', author: 'TeacherYO', likes: 5925, views: '8.9万', kicker: '案例解析', coverTitle: '经典案例解析系列合集', coverSub: '想学习那些课件怎么做?', coverClass: 'blue', visual: 'slides', avatar: '#8B5E34' },
{ id: 'english-food', category: '英语', hot: true, title: 'Unit4 Healthy Food 第一课时', author: '海淀老师勇闯AI圈', likes: 5914, views: '7万', kicker: 'Unit 4 Healthy Food', coverTitle: "Let's speak English!", coverSub: 'Start', coverClass: 'english', visual: 'people', avatar: '#C08457' },
{ id: 'cylinder', category: '数学', hot: true, title: '【互动课件】小学数学六年级圆柱的体积', author: '小叶老师用AI', likes: 5569, views: '8万', kicker: '小学数学 人教版六年级下册', coverTitle: '圆柱的体积', coverSub: '天坛 · 祈年殿', coverClass: 'cream', visual: 'tower', avatar: '#5B8DEF' },
{ id: 'oral', category: '语文', featured: true, title: '一年级烟花口算', author: '智教用户0227', likes: 5553, views: '5.7万', kicker: '数学游戏', coverTitle: '星空烟花口算', coverSub: '开始练习', coverClass: 'space', visual: 'grid', avatar: '#94a3b8' },
{ id: 'vision', category: '信息科技', hot: true, title: 'AI如何“看”世界 —— 揭秘图像识别', author: '蒋老师的AI实验室', likes: 5479, views: '5.8万', kicker: '人工智能体验课', coverTitle: 'AI如何“看”世界?', coverSub: '揭秘图像识别的工作过程', coverClass: 'dark', visual: 'grid', avatar: '#B08968' },
{ id: 'festival', category: '语文', hot: true, title: '《祝福》教学动画(4.9号已更新)', author: '巧克力麦片', likes: 5413, views: '7.3万', kicker: '文学作品', coverTitle: '《祝福》', coverSub: '鲁迅小说中的悲剧意味', coverClass: 'night', visual: 'festival', avatar: '#e11d48' },
{ id: 'ranking', category: '历史', hot: true, title: '大明书院科举榜', author: '智教用户', likes: 5370, views: '8万', kicker: '历史课堂', coverTitle: '大明书院科举榜', coverSub: '排行榜互动游戏', coverClass: 'green', visual: 'shape', avatar: '#0f766e' },
{ id: 'number', category: '数学', hot: true, title: '大数的认识', author: '智教用户', likes: 5320, views: '5.9万', kicker: 'DIGITAL RECOVERY', coverTitle: '大数的认识', coverSub: 'ACCESS SYSTEM', coverClass: 'cyber', visual: 'grid', avatar: '#2563eb' },
{ id: 'cave', category: '语文', hot: true, title: '《记金华的双龙洞》互动课件61', author: '智教用户', likes: 5299, views: '5.8万', kicker: '游记阅读', coverTitle: '记金华的双龙洞', coverSub: '跟着叶圣陶先生拜访洞中奇景', coverClass: 'cave', visual: 'cave', avatar: '#b45309' },
]
const caseTabs = [
{ mode: 'all', label: '全部案例' },
{ mode: 'exam', label: 'AI命题' },
{ mode: 'exercise', label: 'AI组题' },
{ mode: 'lesson', label: 'AI教案 · 大单元' },
]
const cases = [
{ mode: 'exercise', type: 'AI组题', title: '错因分析并组针对性练习', file: '三角形花坛装饰瓷砖问题错因分析与针对...', theme: 'blue' },
{ mode: 'exam', type: 'AI命题', title: '生成中考叙述文阅读题', file: '长辈教诲晚辈悟守人生信条 中考记...', theme: 'green' },
{ mode: 'lesson', type: 'AI教案 · 大单元', title: '人教版七上第二章有理数运算教学...', file: '人教版七年级上册第二章 有理数的运...', theme: 'yellow' },
{ mode: 'exam', type: 'AI命题', title: '天干地支中等难度周期解答题', file: '天干地支周期问题解答题.docx', theme: 'green' },
{ mode: 'exercise', type: 'AI组题', title: '查找北京中考字形高频题', file: '北京地区近3年中考及一模二模字形考查...', theme: 'blue' },
{ mode: 'lesson', type: 'AI教案 · 大单元', title: '设计人教版七上整式加减教学方案', file: '人教版七上第四章《整式的加减》第二...', theme: 'yellow' },
{ mode: 'exam', type: 'AI命题', title: '生成中国航天反比例函数应用题', file: '中国航天相关反比例函数应用题.docx', theme: 'green' },
{ mode: 'exercise', type: 'AI组题', title: '整理近3年北京中考信息匹配题', file: '近3年北京中考英语真题信息匹配题汇总...', theme: 'blue' },
]
const filteredResources = computed(() => {
const kw = keyword.value.trim().toLowerCase()
return resources.filter(item => {
const categoryOk = activeCategory.value === '推荐' || item.category === activeCategory.value
const keywordOk = !kw || `${item.title}${item.author}${item.coverTitle}${item.category}`.toLowerCase().includes(kw)
return categoryOk && keywordOk
})
})
const filteredCases = computed(() => {
return caseFilter.value === 'all' ? cases : cases.filter(item => item.mode === caseFilter.value)
})
watch(() => route.query.mode, () => {
prompt.value = ''
})
function setMode(mode: Mode) {
router.push({ path: '/', query: { mode } })
}
function modePath(mode: string) {
if (mode === 'exam') return '/exam'
if (mode === 'exercise') return '/exercise'
if (mode === 'lesson') return '/lesson-plan'
if (mode === 'animation') return '/animation'
if (mode === 'analytics') return '/classroom'
return '/courseware/create'
}
function startCreate() {
const basePrompt = prompt.value.trim() || activeConfig.value.placeholder || activeConfig.value.examples?.[0]?.prompt || ''
const text = `${basePrompt}${buildMaterialPromptBlock(uploadedMaterials.value, '参考素材')}`.trim()
localStorage.setItem('quick_create_prompt', text)
router.push(modePath(activeMode.value))
}
function applyPromptTemplate(item: any) {
prompt.value = item.prompt
}
function pickFile(type: 'file' | 'image') {
uploadAccept.value = type === 'image' ? 'image/*' : '.txt,.md,.csv,.json,.docx,.pptx,.pdf,.xlsx'
fileInputRef.value?.click()
}
async function handleFileChange(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file) return
try {
const parsed: any = await parseMaterial(file)
userStore.refreshCreditsFromResponse(parsed)
const data = parsed.data || parsed
uploadedMaterials.value.push(fileParseToContext(file, data))
ElMessage.success('材料已解析并加入任务')
} catch {
await addFallbackMaterial(file)
} finally {
;(event.target as HTMLInputElement).value = ''
}
}
function inferTypeLabel(filename: string) {
const ext = filename.toLowerCase().split('.').pop() || ''
if (ext === 'pptx') return 'PPT材料'
if (ext === 'pdf') return 'PDF材料'
if (ext === 'xlsx' || ext === 'xls' || ext === 'csv') return '表格材料'
return 'Word材料'
}
async function addFallbackMaterial(file: File) {
if (file.type.startsWith('image/')) {
uploadedMaterials.value.push({
id: `${Date.now()}-${file.name}`,
name: file.name,
icon: 'Picture',
typeLabel: '图片',
sizeLabel: formatMaterialFileSize(file.size),
excerpt: '请根据图片内容识别知识点、题目信息或板书结构。',
})
} else if (/\.(docx|pptx|pdf|xlsx|xls)$/i.test(file.name)) {
uploadedMaterials.value.push({
id: `${Date.now()}-${file.name}`,
name: file.name,
icon: 'Document',
typeLabel: inferTypeLabel(file.name),
sizeLabel: formatMaterialFileSize(file.size),
excerpt: '该材料暂未解析成功,请结合文件名和当前输入要求生成教学内容。',
})
} else {
const text = await file.text()
uploadedMaterials.value.push({
id: `${Date.now()}-${file.name}`,
name: file.name,
icon: 'Document',
typeLabel: '文本材料',
sizeLabel: formatMaterialFileSize(file.size),
excerpt: text.slice(0, 4000),
})
}
ElMessage.warning('材料解析失败,已改用本地摘要')
}
function removeMaterial(id: string) {
uploadedMaterials.value = uploadedMaterials.value.filter(item => item.id !== id)
}
function toggleVoice() {
if (listening.value) {
speech.stop()
return
}
if (!speech.isSupported()) {
ElMessage.warning('当前浏览器不支持语音识别,请使用 Chrome/Edge 或直接输入文字')
return
}
speech.start()
}
function openResource(item: any) {
const demoMap: Record<string, string> = {
'english-food': 'food',
oral: 'data',
ranking: 'case',
number: 'vision',
festival: 'guide',
cave: 'cylinder',
}
router.push(`/demo/${demoMap[item.id] || item.id}`)
}
function goResourceSearch() {
const kw = keyword.value.trim()
router.push({ path: '/resources', query: kw ? { keyword: kw } : undefined })
}
</script>
<style scoped>
.zj-home {
min-height: 100vh;
color: #173f35;
background: #f8faf7;
}
.courseware-shell {
min-height: 100vh;
padding: 46px 52px 80px;
}
.top-composer,
.resource-toolbar,
.resource-grid {
width: min(100%, 1474px);
margin-left: auto;
margin-right: auto;
}
.top-composer {
margin-bottom: 38px;
}
.composer-title {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 24px;
}
.mascot {
width: 38px;
height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #dbe8de;
border-radius: 50%;
background: #fff;
color: #0f4f3e;
font-size: 20px;
flex-shrink: 0;
}
.mascot.large {
width: 44px;
height: 44px;
}
.composer-title h1,
.task-heading h1 {
margin: 0;
color: #164f3e;
font-size: 24px;
font-weight: 900;
letter-spacing: 0;
}
.prompt-card {
min-height: 148px;
border: 1px solid #cfe0d3;
border-radius: 18px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 18px 42px rgba(17, 69, 52, 0.07);
padding: 22px 24px 15px;
}
.prompt-card textarea,
.task-input-card textarea {
width: 100%;
resize: none;
border: none;
outline: none;
background: transparent;
color: #173f35;
font-family: inherit;
letter-spacing: 0;
}
.prompt-card textarea {
height: 62px;
font-size: 16px;
line-height: 1.75;
}
.prompt-card textarea::placeholder,
.task-input-card textarea::placeholder,
.floating-composer input::placeholder {
color: #a7b2ab;
}
.materials-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 4px 0 11px;
}
.task-materials-row {
margin-top: 8px;
}
.materials-row span {
min-width: 0;
height: 30px;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid #dbe8de;
border-radius: 15px;
background: #f6fbf6;
color: #31584d;
padding: 0 8px 0 11px;
font-size: 12px;
font-weight: 800;
}
.materials-row button {
border: none;
background: transparent;
color: #78908a;
cursor: pointer;
}
.hidden-input {
display: none;
}
.prompt-toolbar,
.task-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.toolbar-left,
.toolbar-right,
.task-toolbar > div {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.prompt-toolbar button,
.task-toolbar button,
.floating-composer button {
height: 39px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #dfebe2;
border-radius: 20px;
background: #fff;
color: #51665f;
padding: 0 13px;
font-size: 14px;
cursor: pointer;
}
.prompt-toolbar button:first-child,
.prompt-toolbar button:nth-child(2),
.voice-btn {
width: 39px;
padding: 0;
}
.chip-btn {
gap: 6px;
font-weight: 800;
}
.chip-btn.active,
.chip-btn:hover,
.voice-btn:hover,
.voice-btn.active {
border-color: #b4d6bd;
background: #f5fbf5;
color: #0f4f3e;
}
.send-btn,
.prompt-toolbar .send-btn,
.floating-composer .send-btn {
width: 40px;
border: none;
border-radius: 13px;
background: #0f5a42;
color: #fff;
padding: 0;
}
.send-btn.muted {
background: #9aa7a0;
}
.resource-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 22px;
margin-bottom: 16px;
}
.category-tabs {
min-width: 0;
display: flex;
align-items: center;
gap: 30px;
overflow-x: auto;
scrollbar-width: none;
}
.category-tabs::-webkit-scrollbar {
display: none;
}
.category-tabs button {
position: relative;
border: none;
background: transparent;
color: #47635a;
font-size: 14px;
white-space: nowrap;
cursor: pointer;
padding: 8px 0;
}
.category-tabs button.active {
color: #00543d;
font-weight: 900;
}
.category-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 3px;
height: 3px;
border-radius: 2px;
background: #00543d;
}
.hot-mini {
margin-left: 2px;
color: #ff4b20;
font-size: 9px;
font-weight: 900;
}
.search-pill {
width: 220px;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
border-radius: 18px;
background: #eef1ec;
color: #a5aea7;
padding: 0 14px;
}
.search-pill input {
min-width: 0;
flex: 1;
border: none;
outline: none;
background: transparent;
color: #173f35;
font-size: 13px;
}
.resource-grid {
display: grid;
grid-template-columns: repeat(4, minmax(240px, 1fr));
gap: 24px;
}
.resource-card {
min-width: 0;
overflow: hidden;
border: 1px solid #e1e8e2;
border-radius: 14px;
background: #fff;
cursor: pointer;
transition: transform .18s ease, box-shadow .18s ease;
}
.resource-card:hover {
transform: translateY(-3px);
box-shadow: 0 12px 24px rgba(17, 69, 52, 0.1);
}
.cover {
position: relative;
height: 190px;
overflow: hidden;
border-radius: 10px;
margin: 5px;
padding: 24px;
}
.hot-badge,
.views {
position: absolute;
z-index: 2;
height: 24px;
display: inline-flex;
align-items: center;
border-radius: 12px;
font-size: 12px;
font-weight: 900;
}
.hot-badge {
top: 8px;
left: 8px;
background: #fff;
color: #ff4b20;
padding: 0 8px;
}
.hot-badge.featured {
color: #d97706;
}
.views {
top: 8px;
right: 8px;
gap: 4px;
background: rgba(0, 0, 0, .26);
color: #fff;
padding: 0 9px;
}
.cover-content {
position: relative;
z-index: 1;
max-width: 74%;
display: flex;
flex-direction: column;
gap: 8px;
}
.cover-content small {
color: inherit;
opacity: .76;
font-size: 12px;
}
.cover-content strong {
font-size: 25px;
line-height: 1.18;
font-weight: 900;
letter-spacing: 0;
}
.cover-content em {
font-style: normal;
font-size: 13px;
line-height: 1.4;
}
.card-body {
padding: 10px 16px 14px;
}
.card-body h3 {
min-height: 22px;
color: #233c35;
font-size: 16px;
font-weight: 500;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.author-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
color: #87958f;
font-size: 13px;
}
.avatar-dot {
width: 18px;
height: 18px;
border-radius: 50%;
flex-shrink: 0;
}
.star {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
}
.floating-composer {
position: fixed;
left: calc(var(--active-sidebar-width, var(--sidebar-width)) + 450px);
right: 450px;
bottom: 20px;
z-index: 15;
height: 76px;
min-width: 520px;
display: flex;
align-items: center;
gap: 6px;
border: 1px solid #cfe0d3;
border-radius: 18px;
background: rgba(255, 255, 255, .94);
box-shadow: 0 12px 34px rgba(17, 69, 52, .12);
padding: 0 16px 0 22px;
}
.floating-composer span {
color: #a0aaa4;
white-space: nowrap;
}
.floating-composer input {
min-width: 0;
flex: 1;
border: none;
outline: none;
background: transparent;
color: #173f35;
font-size: 15px;
}
.task-shell {
min-height: 100vh;
padding: 148px 42px 76px;
}
.task-center {
width: min(100%, 940px);
margin: 0 auto;
}
.task-heading {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 38px;
}
.task-heading h1 {
font-size: 32px;
}
.task-workbench {
display: grid;
grid-template-columns: 340px minmax(0, 720px);
align-items: center;
justify-content: center;
gap: 16px;
}
.tip-card {
position: relative;
min-height: 344px;
border-radius: 13px;
background: #164f3e;
color: #fff;
padding: 17px 15px 16px;
}
.tip-card::after {
content: "";
position: absolute;
right: -8px;
top: 78px;
border-left: 8px solid #164f3e;
border-top: 7px solid transparent;
border-bottom: 7px solid transparent;
}
.tip-card.exercise {
background: #1f526f;
}
.tip-card.exercise::after {
border-left-color: #1f526f;
}
.tip-card.lesson {
background: #755300;
}
.tip-card.lesson::after {
border-left-color: #755300;
}
.tip-title {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 13px;
}
.tip-title strong {
font-size: 14px;
}
.tip-title span {
height: 18px;
display: inline-flex;
align-items: center;
border-radius: 4px;
background: #ff5b2e;
padding: 0 5px;
font-size: 10px;
font-weight: 900;
}
.tip-card > p {
min-height: 63px;
margin: 0;
font-size: 16px;
line-height: 1.52;
font-weight: 900;
}
.tip-preview {
height: 176px;
margin-top: 12px;
overflow: hidden;
border-radius: 10px;
background: #fff;
color: #173f35;
padding: 0 14px 14px;
}
.mini-tabs {
height: 35px;
display: flex;
justify-content: space-around;
align-items: center;
margin: 0 -14px 14px;
background: #f0f6ee;
color: #8a9992;
font-size: 12px;
font-weight: 900;
}
.mini-tabs span.active {
color: #0f4f3e;
border-bottom: 2px solid #0f4f3e;
}
.doc-chip {
width: 164px;
height: 47px;
display: grid;
justify-content: center;
border-radius: 8px;
background: #f4f6f7;
margin: 2px 0 12px 20px;
padding: 7px 9px;
}
.doc-chip strong {
font-size: 12px;
}
.doc-chip small {
color: #9aa7a0;
}
.tip-preview p {
color: #173f35;
font-size: 14px;
line-height: 1.6;
}
.tip-actions {
display: grid;
grid-template-columns: .48fr 1fr;
gap: 9px;
margin-top: 16px;
}
.tip-actions button {
height: 32px;
border: none;
border-radius: 16px;
background: rgba(255,255,255,.24);
color: #fff;
font-weight: 900;
cursor: pointer;
}
.tip-actions button:last-child {
background: #dff6d8;
color: #164f3e;
}
.task-main {
min-width: 0;
}
.task-tabs {
height: 49px;
display: grid;
grid-template-columns: repeat(3, 1fr);
border: 1px solid #cfe0d3;
border-bottom: none;
border-radius: 18px 18px 0 0;
background: #f1f6ef;
overflow: hidden;
}
.task-tabs button {
position: relative;
border: none;
background: transparent;
color: #637a72;
font-size: 15px;
font-weight: 900;
cursor: pointer;
}
.task-tabs button.active {
color: #0f4f3e;
}
.task-tabs button.active::after {
content: "";
position: absolute;
left: 50%;
bottom: 0;
width: 26px;
height: 2px;
transform: translateX(-50%);
background: #0f4f3e;
}
.task-input-card {
min-height: 148px;
border: 1px solid #cfe0d3;
border-radius: 0 0 18px 18px;
background: rgba(255,255,255,.84);
box-shadow: 0 18px 42px rgba(17, 69, 52, 0.07);
padding: 23px 22px 16px;
}
.task-input-card textarea {
height: 55px;
font-size: 16px;
line-height: 1.55;
}
.hot-prompts {
margin-top: 22px;
}
.hot-prompts h2 {
margin: 0 0 14px;
font-size: 16px;
color: #164f3e;
}
.hot-prompts button {
width: 100%;
min-height: 44px;
display: grid;
grid-template-columns: 21px minmax(0, 1fr) auto 92px;
align-items: center;
gap: 7px;
border: none;
background: transparent;
color: #50665e;
text-align: left;
cursor: pointer;
font-size: 15px;
}
.hot-prompts button span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hot-prompts em {
border: 1px solid #ff7f5c;
border-radius: 4px;
color: #ff5b2e;
padding: 0 4px;
font-style: normal;
font-size: 12px;
}
.hot-prompts small {
color: #9aa7a0;
text-align: right;
font-size: 12px;
}
.case-section {
width: min(100%, 1138px);
margin: 208px auto 0;
}
.case-tabs {
height: 36px;
width: min(100%, 720px);
display: grid;
grid-template-columns: repeat(4, 1fr);
align-items: center;
border-radius: 18px;
background: #eef5ee;
margin: 0 auto 28px;
padding: 3px;
}
.case-tabs button {
height: 30px;
border: none;
border-radius: 15px;
background: transparent;
color: #6a7f77;
font-weight: 900;
cursor: pointer;
}
.case-tabs button.active {
background: #fff;
color: #0f4f3e;
}
.case-grid {
display: grid;
grid-template-columns: repeat(4, minmax(190px, 1fr));
gap: 16px 22px;
}
.case-card {
min-width: 0;
border-radius: 8px;
background: #fff;
padding: 7px;
}
.case-cover {
height: 153px;
overflow: hidden;
border-radius: 7px;
padding: 7px;
}
.case-cover span {
display: inline-flex;
height: 24px;
align-items: center;
border-radius: 4px;
background: rgba(255,255,255,.65);
padding: 0 6px;
font-size: 13px;
font-weight: 900;
}
.case-cover div {
width: 80%;
height: 76%;
margin: 15px auto 0;
border-radius: 7px;
background: #fff;
}
.case-card h3 {
margin: 10px 7px 6px;
color: #173f35;
font-size: 16px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.case-card p {
display: flex;
align-items: center;
gap: 5px;
margin: 0 7px 8px;
color: #7f8d87;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.green {
color: #fff;
background: radial-gradient(circle at 78% 70%, rgba(101,255,0,.58), transparent 22%), linear-gradient(135deg, #063a2d, #00854f);
}
.yellow {
color: #714a00;
background: radial-gradient(circle at 82% 50%, rgba(255,255,255,.55), transparent 28%), linear-gradient(135deg, #fff2a8, #ffd84d);
}
.mint {
color: #00543d;
background: linear-gradient(90deg, rgba(255,255,255,.74), rgba(255,255,255,.1)), linear-gradient(135deg, #e7ffe9, #dff7e5);
}
.blue {
color: #003f68;
background: linear-gradient(135deg, #dbe8ff, #c8ddff);
}
.english {
color: #f48b00;
background: radial-gradient(circle at 28% 70%, rgba(255,255,255,.65), transparent 32%), linear-gradient(135deg, #f9e0b9, #eed0a8);
}
.cream {
color: #7a5600;
background: linear-gradient(135deg, #fff3c4, #f7e3a1);
}
.dark,
.cyber {
color: #fff;
background: radial-gradient(circle at 70% 46%, rgba(0,195,255,.26), transparent 28%), linear-gradient(135deg, #050505, #111827);
}
.space {
color: #fff;
background: radial-gradient(circle at 60% 40%, rgba(143,125,255,.46), transparent 30%), linear-gradient(135deg, #15162b, #48438f);
}
.night {
color: #fff;
background: linear-gradient(135deg, #101828, #3a1f1f);
}
.cave {
color: #4b2b00;
background: linear-gradient(135deg, #e7bd74, #6d4216);
}
.mini-slides {
position: absolute;
right: 18px;
top: 44px;
display: grid;
gap: 8px;
}
.mini-slides span {
width: 90px;
height: 42px;
border-radius: 7px;
background: rgba(255,255,255,.78);
box-shadow: 0 5px 12px rgba(0,0,0,.16);
}
.chart-art {
position: absolute;
right: 28px;
bottom: 24px;
display: flex;
align-items: flex-end;
gap: 8px;
}
.chart-art span {
width: 24px;
border-radius: 5px 5px 0 0;
background: rgba(255,255,255,.58);
}
.chart-art span:nth-child(1) { height: 42px; }
.chart-art span:nth-child(2) { height: 76px; }
.chart-art span:nth-child(3) { height: 56px; }
.tower-art {
position: absolute;
right: 34px;
bottom: 26px;
width: 112px;
height: 118px;
background:
linear-gradient(#0a6b7b, #0a6b7b) 28px 0 / 56px 13px no-repeat,
linear-gradient(#be5b2c, #be5b2c) 18px 28px / 76px 16px no-repeat,
linear-gradient(#0a6b7b, #0a6b7b) 10px 44px / 92px 18px no-repeat,
linear-gradient(#be5b2c, #be5b2c) 24px 72px / 64px 18px no-repeat,
linear-gradient(#e8dcc5, #e8dcc5) 0 94px / 112px 22px no-repeat;
border-radius: 50% 50% 4px 4px;
}
.people-art {
position: absolute;
right: 22px;
bottom: 16px;
display: flex;
align-items: flex-end;
gap: 14px;
}
.people-art span {
width: 62px;
height: 92px;
border-radius: 30px 30px 12px 12px;
background: rgba(255,255,255,.55);
}
.people-art span:nth-child(2) {
width: 48px;
height: 72px;
}
.shape-art {
position: absolute;
right: 18px;
bottom: 22px;
width: 150px;
height: 110px;
}
.shape-art span {
position: absolute;
display: block;
border-radius: 50%;
}
.shape-art span:nth-child(1) {
width: 96px;
height: 96px;
right: 12px;
bottom: 0;
background: #9ed797;
}
.shape-art span:nth-child(2) {
width: 72px;
height: 72px;
left: 0;
bottom: 8px;
background: #b8d1ff;
}
.shape-art span:nth-child(3) {
width: 0;
height: 0;
left: 16px;
top: 2px;
border-left: 18px solid transparent;
border-right: 18px solid transparent;
border-bottom: 36px solid #ff6b2b;
border-radius: 0;
background: transparent;
}
.grid-art {
position: absolute;
inset: 0;
opacity: .55;
background-image:
linear-gradient(rgba(0,195,255,.18) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,195,255,.18) 1px, transparent 1px);
background-size: 30px 30px;
}
.grid-art span {
position: absolute;
width: 54px;
height: 36px;
border: 1px solid rgba(0,225,255,.35);
background: rgba(0,225,255,.08);
}
.grid-art span:nth-child(1) { left: 18%; top: 25%; }
.grid-art span:nth-child(2) { left: 55%; top: 18%; }
.grid-art span:nth-child(3) { left: 36%; top: 58%; }
.grid-art span:nth-child(4) { left: 72%; top: 62%; }
.cave-art,
.festival-art {
position: absolute;
inset: 0;
opacity: .42;
}
.cave-art {
background: radial-gradient(ellipse at 50% 76%, rgba(255,225,143,.78), transparent 34%), radial-gradient(ellipse at 50% -10%, rgba(0,0,0,.52), transparent 44%);
}
.festival-art {
background: radial-gradient(circle at 20% 25%, rgba(220,38,38,.56), transparent 10%), radial-gradient(circle at 78% 32%, rgba(220,38,38,.46), transparent 11%);
}
@media (max-width: 1380px) {
.resource-grid,
.case-grid {
grid-template-columns: repeat(3, minmax(220px, 1fr));
}
.floating-composer {
left: calc(var(--active-sidebar-width, var(--sidebar-width)) + 220px);
right: 220px;
}
}
@media (max-width: 1080px) {
.courseware-shell {
padding: 30px 28px 80px;
}
.resource-grid,
.case-grid {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
.task-workbench {
grid-template-columns: 1fr;
}
.tip-card {
display: none;
}
.floating-composer {
left: calc(var(--active-sidebar-width, var(--sidebar-width)) + 30px);
right: 30px;
min-width: 0;
}
}
@media (max-width: 760px) {
.courseware-shell,
.task-shell {
padding: 20px 14px 90px;
}
.resource-toolbar,
.prompt-toolbar,
.task-toolbar {
align-items: stretch;
flex-direction: column;
}
.search-pill {
width: 100%;
}
.resource-grid,
.case-grid {
grid-template-columns: 1fr;
}
.floating-composer {
left: 96px;
right: 12px;
}
}
</style>