智教助手平台:完整初始化
- 前端: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:
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div class="materials-page">
|
||||
<section class="materials-hero">
|
||||
<div>
|
||||
<span class="eyebrow">我的素材库</span>
|
||||
<h1>沉淀教材、课件、试题和课堂资料</h1>
|
||||
<p>上传解析后的材料会自动保存,可按学科、类型和关键词检索,并一键带入课件、组题、教案、命题或数据回收。</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button class="upload-btn secondary" type="button" :disabled="uploading" @click="pickFile('image')">
|
||||
<el-icon><Camera /></el-icon>
|
||||
{{ uploadingMode === 'image' ? '识别中' : '拍照/图片' }}
|
||||
</button>
|
||||
<button class="upload-btn" type="button" :disabled="uploading" @click="pickFile('file')">
|
||||
<el-icon><Upload /></el-icon>
|
||||
{{ uploadingMode === 'file' ? '解析中' : '上传解析' }}
|
||||
</button>
|
||||
</div>
|
||||
<input ref="fileInputRef" type="file" class="hidden-input" :accept="uploadAccept" @change="handleFileChange" />
|
||||
</section>
|
||||
|
||||
<section class="filter-panel">
|
||||
<div class="segmented">
|
||||
<button
|
||||
v-for="type in materialTypes"
|
||||
:key="type.value"
|
||||
type="button"
|
||||
:class="{ active: filters.material_type === type.value }"
|
||||
@click="setType(type.value)"
|
||||
>
|
||||
{{ type.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-line">
|
||||
<el-select v-model="filters.subject" placeholder="全部学科" clearable @change="loadData" style="width: 150px">
|
||||
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
<div class="search-box">
|
||||
<el-icon><Search /></el-icon>
|
||||
<input v-model="filters.keyword" placeholder="搜索素材标题、文件名或正文摘要" @keyup.enter="loadData" />
|
||||
<button v-if="filters.keyword" type="button" @click="clearKeyword">清除</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="material-grid" v-loading="loading">
|
||||
<article v-for="item in materials" :key="item.id" class="material-card">
|
||||
<div class="material-cover" :class="coverClass(item)">
|
||||
<span>{{ typeLabel(item.material_type) }}</span>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<em>{{ item.subject || '通用' }} · {{ item.grade || formatFileSize(item.size) }}</em>
|
||||
</div>
|
||||
<div class="material-body">
|
||||
<div class="material-title-row">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<el-dropdown trigger="click" @command="(cmd: string) => reuseMaterial(item, cmd)">
|
||||
<button class="more-btn" type="button"><el-icon><MoreFilled /></el-icon></button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="courseware">生成课件</el-dropdown-item>
|
||||
<el-dropdown-item command="animation">生成动画</el-dropdown-item>
|
||||
<el-dropdown-item command="exercise">生成练习</el-dropdown-item>
|
||||
<el-dropdown-item command="lesson">生成教案</el-dropdown-item>
|
||||
<el-dropdown-item command="exam">智能命题</el-dropdown-item>
|
||||
<el-dropdown-item command="classroom">数据回收</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<p>{{ item.summary || '暂无摘要' }}</p>
|
||||
<div class="material-meta">
|
||||
<span>{{ item.filename }}</span>
|
||||
<span>{{ formatCount(item.char_count) }}字</span>
|
||||
</div>
|
||||
<div class="tag-line" v-if="item.tags?.length">
|
||||
<el-tag v-for="tag in item.tags" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button type="button" @click="openEdit(item)">编辑</button>
|
||||
<button type="button" class="primary" @click="reuseMaterial(item, 'courseware')">带入课件</button>
|
||||
<button type="button" @click="reuseMaterial(item, 'animation')">生成动画</button>
|
||||
<el-popconfirm title="确定删除这个素材?" @confirm="removeMaterial(item.id)">
|
||||
<template #reference>
|
||||
<button type="button" class="danger">删除</button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div v-if="!loading && materials.length === 0" class="empty-state">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
<strong>还没有素材</strong>
|
||||
<span>上传教材、课件、试题或图片,解析后会自动进入素材库。</span>
|
||||
<button type="button" @click="pickFile('file')">上传第一个素材</button>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showEdit" title="编辑素材信息" width="640px" :close-on-click-modal="false">
|
||||
<el-form :model="editForm" label-position="top" class="edit-form">
|
||||
<el-form-item label="素材标题">
|
||||
<el-input v-model="editForm.title" />
|
||||
</el-form-item>
|
||||
<div class="edit-grid">
|
||||
<el-form-item label="学科">
|
||||
<el-select v-model="editForm.subject" filterable allow-create clearable style="width: 100%">
|
||||
<el-option v-for="s in subjects" :key="s" :label="s" :value="s" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="年级">
|
||||
<el-input v-model="editForm.grade" placeholder="例如:六年级下册" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="标签">
|
||||
<el-select v-model="editForm.tags" multiple filterable allow-create placeholder="输入标签后回车" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要">
|
||||
<el-input v-model="editForm.summary" type="textarea" :rows="8" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEdit = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveEdit">保存修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { parseMaterial } from '@/api/ai'
|
||||
import { deleteMaterial, getMaterials, updateMaterial } from '@/api/material'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const uploadAccept = ref('.txt,.md,.csv,.json,.docx,.pptx,.pdf,.xlsx,image/*')
|
||||
const uploadMode = ref<'file' | 'image'>('file')
|
||||
const uploadingMode = ref<'file' | 'image' | ''>('')
|
||||
const loading = ref(false)
|
||||
const uploading = ref(false)
|
||||
const saving = ref(false)
|
||||
const showEdit = ref(false)
|
||||
const materials = ref<any[]>([])
|
||||
const subjects = ['语文', '数学', '英语', '物理', '化学', '生物', '历史', '地理', '信息科技', '课堂工具']
|
||||
const materialTypes = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'txt', label: '文本' },
|
||||
{ value: 'docx', label: 'Word' },
|
||||
{ value: 'pptx', label: 'PPT' },
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'csv', label: '表格' },
|
||||
{ value: 'json', label: '结构化' },
|
||||
]
|
||||
const filters = reactive({ material_type: '', subject: '', keyword: '' })
|
||||
const editForm = reactive({
|
||||
id: 0,
|
||||
title: '',
|
||||
subject: '',
|
||||
grade: '',
|
||||
tags: [] as string[],
|
||||
summary: '',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
filters.keyword = typeof route.query.keyword === 'string' ? route.query.keyword : ''
|
||||
loadData()
|
||||
})
|
||||
|
||||
watch(() => route.query.keyword, (keyword) => {
|
||||
filters.keyword = typeof keyword === 'string' ? keyword : ''
|
||||
loadData()
|
||||
})
|
||||
|
||||
function setType(type: string) {
|
||||
filters.material_type = type
|
||||
loadData()
|
||||
}
|
||||
|
||||
function clearKeyword() {
|
||||
filters.keyword = ''
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!ensureLoggedIn(false)) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data: any = await getMaterials({
|
||||
material_type: filters.material_type || undefined,
|
||||
subject: filters.subject || undefined,
|
||||
keyword: filters.keyword || undefined,
|
||||
limit: 100,
|
||||
})
|
||||
materials.value = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function pickFile(type: 'file' | 'image') {
|
||||
if (!ensureLoggedIn(true)) return
|
||||
uploadMode.value = type
|
||||
uploadAccept.value = type === 'image' ? 'image/*' : '.txt,.md,.csv,.json,.docx,.pptx,.pdf,.xlsx,image/*'
|
||||
if (fileInputRef.value) {
|
||||
if (type === 'image') fileInputRef.value.setAttribute('capture', 'environment')
|
||||
else fileInputRef.value.removeAttribute('capture')
|
||||
}
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
async function handleFileChange(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
uploadingMode.value = uploadMode.value
|
||||
try {
|
||||
const parsed: any = await parseMaterial(file)
|
||||
userStore.refreshCreditsFromResponse(parsed)
|
||||
const data = parsed.data || parsed
|
||||
ElMessage.success(data.material_type === 'image' ? '图片素材已保存,可带入生成' : '素材已解析并保存')
|
||||
await loadData()
|
||||
} finally {
|
||||
uploading.value = false
|
||||
uploadingMode.value = ''
|
||||
;(event.target as HTMLInputElement).value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(item: any) {
|
||||
editForm.id = item.id
|
||||
editForm.title = item.title || item.filename || ''
|
||||
editForm.subject = item.subject || ''
|
||||
editForm.grade = item.grade || ''
|
||||
editForm.tags = Array.isArray(item.tags) ? [...item.tags] : []
|
||||
editForm.summary = item.summary || ''
|
||||
showEdit.value = true
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editForm.id) return
|
||||
saving.value = true
|
||||
try {
|
||||
await updateMaterial(editForm.id, {
|
||||
title: editForm.title,
|
||||
subject: editForm.subject,
|
||||
grade: editForm.grade,
|
||||
tags: editForm.tags,
|
||||
summary: editForm.summary,
|
||||
})
|
||||
ElMessage.success('素材已更新')
|
||||
showEdit.value = false
|
||||
await loadData()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMaterial(id: number) {
|
||||
await deleteMaterial(id)
|
||||
ElMessage.success('素材已删除')
|
||||
await loadData()
|
||||
}
|
||||
|
||||
function reuseMaterial(item: any, mode: string) {
|
||||
const prompt = buildPrompt(item, mode)
|
||||
localStorage.setItem('quick_create_prompt', prompt)
|
||||
if (mode === 'exam') router.push('/exam')
|
||||
else if (mode === 'exercise') router.push('/exercise')
|
||||
else if (mode === 'lesson') router.push('/lesson-plan')
|
||||
else if (mode === 'animation') router.push('/animation')
|
||||
else if (mode === 'classroom') router.push('/classroom')
|
||||
else router.push('/courseware/create')
|
||||
}
|
||||
|
||||
function buildPrompt(item: any, mode: string) {
|
||||
const imageHint = item.material_type === 'image'
|
||||
? '\n\n【图片使用要求】\n这是一份拍照/图片素材,请优先围绕图片中的教材页、题目、板书、图表或课堂场景设计内容;如果图片文字未完全识别,请保留“请教师补充图片关键文字”的提示位。'
|
||||
: ''
|
||||
const base = `请基于素材《${item.title || item.filename}》进行教学创作。\n\n【素材摘要】\n${item.summary || '暂无摘要,请结合素材标题和类型进行创作。'}${imageHint}`
|
||||
if (mode === 'exam') return `${base}\n\n请据此生成一套原创试题,覆盖核心知识点,附答案和解析。`
|
||||
if (mode === 'exercise') return `${base}\n\n请据此生成可课堂互动的练习/教学游戏,题目有梯度并附解析。`
|
||||
if (mode === 'lesson') return `${base}\n\n请据此生成结构化大单元教案,包含目标、重难点、活动、评价和作业。`
|
||||
if (mode === 'animation') return `${base}\n\n请据此生成一个课堂教学动画,突出知识形成过程、关键变化和可观察的步骤标注。`
|
||||
if (mode === 'classroom') return `${base}\n\n请据此设计一个课堂数据回收活动,用于收集学生理解、选择或反馈。`
|
||||
return `${base}\n\n请据此生成专业级互动课件,包含导入、讲解、互动练习和课堂总结。`
|
||||
}
|
||||
|
||||
function ensureLoggedIn(redirect: boolean) {
|
||||
if (localStorage.getItem('access_token')) return true
|
||||
ElMessage.warning('请先登录后使用素材库')
|
||||
if (redirect) router.push({ path: '/login', query: { redirect: '/materials' } })
|
||||
return false
|
||||
}
|
||||
|
||||
function typeLabel(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
docx: 'Word',
|
||||
pptx: 'PPT',
|
||||
csv: '表格',
|
||||
json: '结构化',
|
||||
md: 'Markdown',
|
||||
txt: '文本',
|
||||
text: '文本',
|
||||
image: '图片',
|
||||
}
|
||||
return map[type] || '素材'
|
||||
}
|
||||
|
||||
function coverClass(item: any) {
|
||||
if (item.material_type === 'image') return 'cover-mint'
|
||||
if (item.material_type === 'pptx') return 'cover-blue'
|
||||
if (item.material_type === 'docx') return 'cover-green'
|
||||
if (item.material_type === 'csv' || item.material_type === 'json') return 'cover-yellow'
|
||||
return 'cover-cream'
|
||||
}
|
||||
|
||||
function formatFileSize(size: number) {
|
||||
const n = Number(size || 0)
|
||||
if (n < 1024) return `${n}B`
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`
|
||||
return `${(n / 1024 / 1024).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
function formatCount(value: number) {
|
||||
const n = Number(value || 0)
|
||||
if (n >= 10000) return `${(n / 10000).toFixed(1)}万`
|
||||
return String(n)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.materials-page { min-height: 100vh; background: #f7faf6; padding: 26px 36px 40px; color: #173f35; }
|
||||
.materials-hero { max-width: 1280px; margin: 0 auto 18px; display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; border: 1px solid #dce8de; border-radius: 14px; background: #fff; padding: 24px; }
|
||||
.eyebrow { color: #00724f; font-size: 13px; font-weight: 900; }
|
||||
.materials-hero h1 { margin: 8px 0 8px; color: #173f35; font-size: 28px; line-height: 1.2; }
|
||||
.materials-hero p { max-width: 760px; color: #6a7f77; line-height: 1.7; }
|
||||
.hero-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.upload-btn { height: 38px; display: inline-flex; align-items: center; gap: 7px; border: none; border-radius: 19px; background: #00543d; color: #fff; padding: 0 18px; font-weight: 900; cursor: pointer; white-space: nowrap; }
|
||||
.upload-btn.secondary { border: 1px solid #9ed0aa; background: #eaf7e9; color: #00543d; }
|
||||
.upload-btn:disabled { opacity: .62; cursor: not-allowed; }
|
||||
.hidden-input { display: none; }
|
||||
.filter-panel { max-width: 1280px; margin: 0 auto 18px; border: 1px solid #dce8de; border-radius: 14px; background: #fff; padding: 14px; }
|
||||
.segmented { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||
.segmented button { height: 32px; border: 1px solid #dbe8de; border-radius: 16px; background: #fff; color: #51665f; padding: 0 13px; cursor: pointer; }
|
||||
.segmented button.active, .segmented button:hover { border-color: #9ed0aa; background: #eaf7e9; color: #00543d; font-weight: 900; }
|
||||
.filter-line { display: flex; align-items: center; gap: 10px; }
|
||||
.search-box { height: 34px; flex: 1; min-width: 220px; display: flex; align-items: center; gap: 8px; border: 1px solid #dbe8de; border-radius: 17px; padding: 0 10px; background: #f8faf8; }
|
||||
.search-box input { flex: 1; min-width: 0; border: none; outline: none; background: transparent; color: #173f35; }
|
||||
.search-box button { border: none; background: transparent; color: #00724f; cursor: pointer; font-weight: 800; }
|
||||
.material-grid { max-width: 1280px; margin: 0 auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 14px; }
|
||||
.material-card { overflow: hidden; border: 1px solid #dce8de; border-radius: 14px; background: #fff; }
|
||||
.material-cover { min-height: 148px; display: flex; flex-direction: column; justify-content: flex-end; padding: 18px; }
|
||||
.material-cover span { width: fit-content; border-radius: 999px; background: rgba(255,255,255,.86); color: #00543d; padding: 4px 10px; font-size: 12px; font-weight: 900; }
|
||||
.material-cover strong { margin-top: 12px; color: #173f35; font-size: 19px; line-height: 1.25; }
|
||||
.material-cover em { margin-top: 7px; color: #51665f; font-style: normal; font-size: 13px; }
|
||||
.cover-green { background: linear-gradient(135deg, #dff7e5, #9ed0aa); }
|
||||
.cover-blue { background: linear-gradient(135deg, #dbe8ff, #a8c7ff); }
|
||||
.cover-mint { background: linear-gradient(135deg, #d7f8ef, #8be1c6); }
|
||||
.cover-yellow { background: linear-gradient(135deg, #fff1a1, #ffd84d); }
|
||||
.cover-cream { background: linear-gradient(135deg, #f6edd3, #e6d0a3); }
|
||||
.material-body { padding: 16px; }
|
||||
.material-title-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.material-title-row h3 { color: #173f35; font-size: 16px; line-height: 1.35; }
|
||||
.more-btn { width: 30px; height: 30px; border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #31584d; cursor: pointer; }
|
||||
.material-body p { height: 86px; margin-top: 8px; color: #6a7f77; font-size: 13px; line-height: 1.6; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.material-meta { margin-top: 12px; display: flex; justify-content: space-between; gap: 10px; color: #87958f; font-size: 12px; }
|
||||
.material-meta span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.tag-line { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
|
||||
.card-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 14px; }
|
||||
.card-actions button { height: 32px; border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #31584d; font-weight: 900; cursor: pointer; }
|
||||
.card-actions button.primary { border-color: #00543d; background: #00543d; color: #fff; }
|
||||
.card-actions button.danger { color: #b42318; }
|
||||
.empty-state { max-width: 1280px; min-height: 320px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; border: 1px dashed #cfe0d3; border-radius: 14px; background: #fff; color: #75867f; }
|
||||
.empty-state .el-icon { font-size: 40px; color: #9aaba4; }
|
||||
.empty-state strong { color: #173f35; }
|
||||
.empty-state button { border: none; border-radius: 18px; background: #00543d; color: #fff; padding: 8px 18px; font-weight: 900; cursor: pointer; }
|
||||
.edit-grid { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 0 14px; }
|
||||
@media (max-width: 760px) {
|
||||
.materials-page { padding: 16px; }
|
||||
.materials-hero { align-items: flex-start; flex-direction: column; }
|
||||
.hero-actions { width: 100%; justify-content: flex-start; }
|
||||
.filter-line { align-items: stretch; flex-direction: column; }
|
||||
.search-box { width: 100%; }
|
||||
.card-actions { grid-template-columns: repeat(2, 1fr); }
|
||||
.edit-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user