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

- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等)
- 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板
- AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容
- 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled
- 测试账号:13900999999 / test1234
This commit is contained in:
Zhang Jing Xuan
2026-07-29 16:29:10 +08:00
commit 0841b1a103
193 changed files with 44995 additions and 0 deletions
+493
View File
@@ -0,0 +1,493 @@
<template>
<div class="page-container">
<div class="page-header">
<div>
<h2>模板社区</h2>
<p>交流互动课件教学动画和课堂工具模板沉淀可改编的教学案例</p>
</div>
<button class="create-btn" @click="openCreateDialog">
<el-icon><EditPen /></el-icon> 发布模板
</button>
</div>
<div class="scope-tabs">
<button
v-for="scope in scopeOptions"
:key="scope.value"
type="button"
:class="{ active: activeScope === scope.value }"
@click="setScope(scope.value)"
>
{{ scope.label }}
</button>
</div>
<div class="community-tabs">
<button
v-for="tag in ['全部', ...hotTags]"
:key="tag"
type="button"
:class="{ active: activeTag === tag }"
@click="activeTag = tag"
>
{{ tag }}
</button>
</div>
<div class="community-layout">
<div class="posts-section">
<div v-loading="loading" class="post-list">
<div v-for="post in filteredPosts" :key="post.id" class="post-card" @click="openPost(post)">
<div class="post-cover" :class="postCover(post)">
<span>{{ postTypeLabel(post.post_type) }}</span>
</div>
<div class="post-body">
<div class="post-type-badge">
<el-tag :type="post.post_type === 'share' ? 'success' : post.post_type === 'help' ? 'warning' : undefined" size="small" effect="plain">
{{ postTypeLabel(post.post_type) }}
</el-tag>
</div>
<h3>{{ post.title }}</h3>
<div class="post-meta">
<span>{{ post.author?.name || '社区教师' }}</span>
<span v-if="primaryAttachment(post)">{{ resourceTypeLabel(primaryAttachment(post).resource_type) }}</span>
<span v-if="post.is_favorited">已收藏</span>
</div>
<p class="post-excerpt">{{ postExcerpt(post) }}</p>
<div class="post-tags">
<el-tag v-for="tag in post.tags" :key="tag" size="small" effect="plain" style="margin-right: 4px">{{ tag }}</el-tag>
</div>
<div class="post-stats">
<span><el-icon><View /></el-icon> {{ post.views }}</span>
<span><el-icon><Star /></el-icon> {{ post.likes }}</span>
<span><el-icon><ChatDotRound /></el-icon> {{ post.comments_count }}</span>
</div>
<div class="post-actions">
<button type="button" @click.stop="openPost(post)">查看详情</button>
<button type="button" class="primary" @click.stop="useTemplate(post)">使用模板</button>
<template v-if="isOwner(post)">
<button type="button" @click.stop="openEditDialog(post)">
<el-icon><Edit /></el-icon> 编辑
</button>
<el-popconfirm title="确定删除这个社区模板?" @confirm="handleDeletePost(post)">
<template #reference>
<button type="button" class="danger" @click.stop>
<el-icon><Delete /></el-icon> 删除
</button>
</template>
</el-popconfirm>
</template>
</div>
</div>
</div>
</div>
<div v-if="!loading && filteredPosts.length === 0" class="empty-state">
<el-icon :size="48" color="#CBD5E1"><ChatDotRound /></el-icon>
<p>{{ emptyText }}</p>
<button type="button" @click="openCreateDialog">发布第一个模板</button>
</div>
</div>
<div class="sidebar-section">
<div class="hot-tags-card">
<h4>热门标签</h4>
<div class="hot-tags">
<span v-for="tag in hotTags" :key="tag" class="hot-tag" @click="activeTag = tag">{{ tag }}</span>
</div>
</div>
<div class="hot-tags-card">
<h4>推荐模板</h4>
<div v-if="rankedPosts.length" class="template-rank">
<button v-for="(item, index) in rankedPosts" :key="item.id" type="button" @click="openPost(item)">
<span :class="rankTheme(index)">{{ index + 1 }}</span>
<div>
<strong>{{ item.title }}</strong>
<small>{{ primaryTag(item) }} · {{ formatCount(item.views) }}浏览</small>
</div>
</button>
</div>
<div v-else class="rank-empty">暂无推荐模板</div>
</div>
</div>
</div>
<el-dialog v-model="showCreate" :title="editingPost ? '编辑模板' : '发布模板'" width="600px" :close-on-click-modal="false">
<el-form :model="postForm" label-position="top">
<el-form-item label="标题">
<el-input v-model="postForm.title" placeholder="模板标题" />
</el-form-item>
<el-form-item label="类型">
<div class="type-select">
<label v-for="t in postTypes" :key="t.value" class="type-option" :class="{ active: postForm.post_type === t.value }" @click="postForm.post_type = t.value">
{{ t.label }}
</label>
</div>
</el-form-item>
<el-form-item label="内容">
<el-input v-model="postForm.content" type="textarea" :rows="8" placeholder="描述模板适用的学科、年级、教学场景和使用方法..." />
</el-form-item>
<el-form-item label="标签">
<el-select v-model="postForm.tags" multiple filterable allow-create placeholder="添加标签" style="width: 100%">
</el-select>
</el-form-item>
<el-form-item label="关联资源">
<el-select v-model="postForm.resource_id" clearable filterable placeholder="选择一个已发布资源作为模板来源" style="width: 100%" @visible-change="loadMyResourcesIfNeeded">
<el-option
v-for="item in myResources"
:key="item.id"
:label="`${item.title} · ${resourceTypeLabel(item.resource_type)}`"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" @click="handleSubmitPost">{{ editingPost ? '保存修改' : '发布' }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, ref, reactive, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getPosts, getPostRanking, createPost, updatePost, deletePost } from '@/api/community'
import { getMyResources } from '@/api/resource'
import { useUserStore } from '@/stores/user'
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loading = ref(false)
const posts = ref<any[]>([])
const rankedPosts = ref<any[]>([])
const hotTags = ['互动课件', '教学动画', '课堂游戏', 'AI工具', '公开课', '单元复习', '实验模拟']
const postTypes = [{ value: 'discussion', label: '案例' }, { value: 'share', label: '模板' }, { value: 'help', label: '需求' }]
const scopeOptions = [
{ value: 'all', label: '全部模板' },
{ value: 'mine', label: '我的发布' },
{ value: 'favorite', label: '我的收藏' },
]
const resourceTypes = [
{ value: 'courseware', label: '课件' },
{ value: 'animation', label: '动画' },
{ value: 'exercise', label: '习题' },
{ value: 'lesson_plan', label: '教案' },
{ value: 'exam', label: '试卷' },
{ value: 'other', label: '资源' },
]
const activeTag = ref('全部')
const activeScope = ref('all')
const showCreate = ref(false)
const editingPost = ref<any>(null)
const myResources = ref<any[]>([])
const loadingResources = ref(false)
const postForm = reactive({ title: '', content: '', post_type: 'discussion', tags: [] as string[], resource_id: null as number | null })
const isLoggedIn = computed(() => !!localStorage.getItem('access_token'))
const currentUserId = computed(() => userStore.user?.id)
const emptyText = computed(() => {
if (activeScope.value === 'mine') return '还没有发布模板'
if (activeScope.value === 'favorite') return '还没有收藏模板'
return '暂无模板内容'
})
const filteredPosts = computed(() => {
if (activeTag.value === '全部') return posts.value
return posts.value.filter(post => post.tags?.includes(activeTag.value))
})
function postTypeLabel(type: string) {
return postTypes.find(item => item.value === type)?.label || '讨论'
}
function resourceTypeLabel(type: string) {
return resourceTypes.find(item => item.value === type)?.label || '资源'
}
function primaryTag(post: any) {
return post.tags?.[0] || postTypeLabel(post.post_type)
}
function postExcerpt(post: any) {
const content = String(post.content || '')
return content.length > 150 ? `${content.slice(0, 150)}...` : content
}
function rankTheme(index: number) {
return ['rank-cream', 'rank-yellow', 'rank-mint'][index % 3]
}
function formatCount(value: any) {
const n = Number(value || 0)
if (n >= 10000) return `${(n / 10000).toFixed(1)}`
return String(n)
}
function postCover(post: any) {
if (post.post_type === 'share') return 'cover-green'
if (post.post_type === 'help') return 'cover-yellow'
return 'cover-blue'
}
function targetPathForPost(post: any) {
const attachment = primaryAttachment(post)
if (attachment?.content_ref) return pathFromContentRef(attachment.content_ref)
const tags = post.tags || []
if (tags.includes('教学动画') || tags.includes('实验模拟')) return '/animation'
if (tags.includes('课堂游戏')) return '/exercise'
if (tags.includes('AI工具')) return '/classroom'
if (tags.includes('单元复习')) return '/lesson-plan'
return '/courseware/create'
}
function useTemplate(post: any) {
const attachment = primaryAttachment(post)
if (attachment?.resource_id) {
router.push(`/resources/${attachment.resource_id}`)
return
}
const prompt = `参考社区模板《${post.title}》进行原创改编。模板说明:${post.content || ''}`
localStorage.setItem('quick_create_prompt', prompt)
router.push(targetPathForPost(post))
}
function primaryAttachment(post: any) {
return Array.isArray(post.attachments) ? post.attachments[0] : null
}
function pathFromContentRef(ref: string) {
const [type, id] = String(ref).split(':')
if (type === 'courseware') return `/courseware/${id}`
if (type === 'animation') return `/animation?open=${id}`
if (type === 'exercise') return `/exercise?open=${id}`
if (type === 'lesson_plan') return `/lesson-plan?open=${id}`
if (type === 'exam') return `/exam?open=${id}`
return '/courseware/create'
}
async function loadData() {
loading.value = true
try {
const params = activeScope.value === 'all' ? undefined : { scope: activeScope.value }
const [res, ranking] = await Promise.all([
getPosts(params),
getPostRanking({ limit: 5 }),
]) as any[]
posts.value = Array.isArray(res) ? res : []
rankedPosts.value = Array.isArray(ranking) ? ranking : []
} catch {
posts.value = []
rankedPosts.value = []
}
finally { loading.value = false }
}
function syncScopeFromRoute() {
const scope = route.query.scope
const nextScope = scopeOptions.some(item => item.value === scope) ? String(scope) : 'all'
if (nextScope !== 'all' && !ensureLoggedIn(`/community?scope=${nextScope}`)) {
activeScope.value = 'all'
return false
}
activeScope.value = nextScope
activeTag.value = '全部'
return true
}
function resetPostForm() {
postForm.title = ''
postForm.content = ''
postForm.post_type = 'discussion'
postForm.tags = []
postForm.resource_id = null
editingPost.value = null
}
function openCreateDialog() {
if (!ensureLoggedIn('/community')) return
resetPostForm()
showCreate.value = true
}
function openEditDialog(post: any) {
if (!ensureLoggedIn('/community')) return
editingPost.value = post
postForm.title = post.title || ''
postForm.content = post.content || ''
postForm.post_type = post.post_type || 'discussion'
postForm.tags = Array.isArray(post.tags) ? [...post.tags] : []
postForm.resource_id = primaryAttachment(post)?.resource_id || null
showCreate.value = true
}
async function handleSubmitPost() {
if (!ensureLoggedIn('/community')) return
if (!postForm.title.trim() || !postForm.content.trim()) {
ElMessage.warning('请填写标题和内容')
return
}
const selectedResource = myResources.value.find(item => item.id === postForm.resource_id)
const payload = {
title: postForm.title,
content: postForm.content,
post_type: postForm.post_type,
tags: postForm.tags,
attachments: selectedResource ? [{
resource_id: selectedResource.id,
resource_type: selectedResource.resource_type,
content_ref: selectedResource.content_ref,
title: selectedResource.title,
}] : [],
}
if (editingPost.value) {
await updatePost(editingPost.value.id, payload)
ElMessage.success('已保存修改')
} else {
await createPost(payload)
ElMessage.success('发布成功')
}
showCreate.value = false
resetPostForm()
loadData()
}
async function openPost(post: any) {
router.push(`/community/${post.id}`)
}
async function handleDeletePost(post: any) {
if (!ensureLoggedIn('/community')) return
await deletePost(post.id)
ElMessage.success('已删除模板')
loadData()
}
async function setScope(scope: string) {
if (scope !== 'all' && !ensureLoggedIn('/community')) return
activeScope.value = scope
activeTag.value = '全部'
const nextScope = scope === 'all' ? undefined : scope
if (route.query.scope !== nextScope) {
await router.replace({ path: '/community', query: scope === 'all' ? {} : { scope } })
return
}
await loadData()
}
function isOwner(post: any) {
return currentUserId.value && post.user_id === currentUserId.value
}
async function loadMyResourcesIfNeeded(open: boolean) {
if (!open || myResources.value.length || loadingResources.value) return
if (!ensureLoggedIn('/community')) return
loadingResources.value = true
try {
const data: any = await getMyResources({ limit: 100 })
myResources.value = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
} finally {
loadingResources.value = false
}
}
function ensureLoggedIn(redirect: string) {
if (localStorage.getItem('access_token')) return true
ElMessage.warning('请先登录后再参与社区互动')
router.push({ path: '/login', query: { redirect } })
return false
}
onMounted(async () => {
if (localStorage.getItem('access_token') && !userStore.user) {
await userStore.fetchProfile().catch(() => undefined)
}
if (syncScopeFromRoute()) loadData()
})
watch(() => route.query.scope, () => {
if (syncScopeFromRoute()) loadData()
})
</script>
<style scoped>
.page-header p { margin-top: 6px; color: var(--text-muted); font-size: 14px; }
.create-btn { display: flex; align-items: center; gap: 6px; background: var(--gradient); border: none; color: #fff; padding: 10px 20px; border-radius: var(--radius-sm); font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.3s; }
.create-btn:hover { opacity: 0.9; }
.scope-tabs,
.community-tabs { display: flex; align-items: center; gap: 10px; overflow-x: auto; scrollbar-width: none; }
.scope-tabs { margin-bottom: 10px; }
.community-tabs { margin-bottom: 18px; }
.scope-tabs::-webkit-scrollbar,
.community-tabs::-webkit-scrollbar { display: none; }
.scope-tabs button,
.community-tabs button { height: 34px; border: 1px solid #dbe8de; border-radius: 17px; background: #fff; color: #51665f; padding: 0 14px; white-space: nowrap; cursor: pointer; }
.scope-tabs button.active,
.scope-tabs button:hover,
.community-tabs button.active,
.community-tabs button:hover { border-color: #9ed0aa; background: #eaf7e9; color: #00543d; font-weight: 800; }
.community-layout { display: grid; grid-template-columns: 1fr 280px; gap: 24px; }
.post-list { display: flex; flex-direction: column; gap: 12px; }
.post-card { display: grid; grid-template-columns: 170px minmax(0, 1fr); gap: 16px; background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 12px; cursor: pointer; transition: all 0.3s; }
.post-card:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); }
.post-cover { position: relative; min-height: 124px; border-radius: 10px; overflow: hidden; }
.post-cover span { position: absolute; left: 10px; bottom: 10px; height: 24px; display: inline-flex; align-items: center; padding: 0 9px; border-radius: 12px; background: rgba(255,255,255,.88); color: #00543d; font-size: 12px; font-weight: 900; }
.cover-green { background: linear-gradient(135deg, #064231, #00844e); }
.cover-yellow { background: linear-gradient(135deg, #fff1a1, #ffd84d); }
.cover-blue { background: linear-gradient(135deg, #dbe8ff, #c8ddff); }
.post-body { min-width: 0; padding: 6px 4px; }
.post-type-badge { margin-bottom: 8px; }
.post-body h3 { font-size: 16px; font-weight: 600; color: var(--text-primary); margin-bottom: 8px; }
.post-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; margin-bottom: 7px; color: #75867f; font-size: 12px; }
.post-meta span { display: inline-flex; align-items: center; height: 22px; border-radius: 11px; background: #f1f6f1; padding: 0 8px; }
.post-excerpt { font-size: 14px; color: var(--text-muted); line-height: 1.6; margin-bottom: 12px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.post-tags { margin-bottom: 12px; }
.post-stats { display: flex; gap: 16px; font-size: 13px; color: var(--text-muted); }
.post-stats span { display: flex; align-items: center; gap: 4px; }
.post-actions { display: flex; gap: 8px; margin-top: 12px; }
.post-actions button { height: 32px; border: 1px solid #dbe8de; border-radius: 8px; background: #fff; color: #31584d; padding: 0 12px; font-weight: 800; cursor: pointer; }
.post-actions button .el-icon { margin-right: 4px; vertical-align: -2px; }
.post-actions button.primary { border-color: #00543d; background: #00543d; color: #fff; }
.post-actions button.danger { border-color: #f3c9c9; color: #b42318; }
.post-actions button:hover { border-color: #9ed0aa; background: #f6fbf6; color: #00543d; }
.post-actions button.primary:hover { background: #004532; color: #fff; }
.post-actions button.danger:hover { border-color: #f0a8a8; background: #fff5f5; color: #9f1c14; }
.sidebar-section { position: sticky; top: 32px; }
.hot-tags-card { background: #fff; border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: 20px; margin-bottom: 14px; }
.hot-tags-card h4 { font-size: 15px; font-weight: 600; color: var(--text-primary); margin-bottom: 12px; }
.hot-tags { display: flex; flex-wrap: wrap; gap: 8px; }
.hot-tag { padding: 4px 12px; background: var(--primary-bg); color: var(--primary); border-radius: 20px; font-size: 13px; cursor: pointer; transition: all 0.2s; }
.hot-tag:hover { background: var(--primary); color: #fff; }
.template-rank { display: flex; flex-direction: column; gap: 10px; }
.template-rank button { display: grid; grid-template-columns: 56px minmax(0, 1fr); gap: 10px; align-items: center; border: none; background: transparent; padding: 0; text-align: left; cursor: pointer; }
.template-rank button > span { height: 42px; border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; color: #173f35; font-weight: 900; }
.rank-cream { background: #f7e3a1; }
.rank-yellow { background: #ffd84d; }
.rank-mint { background: #dff7e5; }
.template-rank strong { display: block; color: #173f35; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.template-rank small { color: #87958f; font-size: 12px; }
.rank-empty { border: 1px dashed #dbe8de; border-radius: 10px; color: #87958f; font-size: 13px; padding: 14px; text-align: center; }
.type-select { display: flex; gap: 8px; }
.type-option { padding: 8px 20px; border: 1px solid var(--border-color); border-radius: var(--radius-sm); font-size: 14px; color: var(--text-secondary); cursor: pointer; transition: all 0.2s; }
.type-option.active { background: var(--primary-bg); border-color: var(--primary); color: var(--primary); font-weight: 500; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 60px 0; }
.empty-state p { color: var(--text-muted); font-size: 15px; }
.empty-state button { border: none; border-radius: 18px; background: #00543d; color: #fff; padding: 8px 18px; font-weight: 800; cursor: pointer; }
@media (max-width: 980px) {
.community-layout { grid-template-columns: 1fr; }
.sidebar-section { position: static; }
}
@media (max-width: 640px) {
.post-card { grid-template-columns: 1fr; }
.post-actions { flex-direction: column; }
}
</style>