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

393 lines
18 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="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>