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

- 前端: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
+461
View File
@@ -0,0 +1,461 @@
<template>
<div class="community-detail-page">
<section class="detail-shell" v-loading="loading">
<div class="top-row">
<button type="button" class="back-btn" @click="router.push('/community')">
<el-icon><ArrowLeft /></el-icon>
返回模板社区
</button>
<div class="top-actions">
<button v-if="isOwner" type="button" class="share-btn" @click="openEditDialog">
<el-icon><Edit /></el-icon>
编辑
</button>
<el-popconfirm v-if="isOwner" title="确定删除这个社区模板?" @confirm="handleDeletePost">
<template #reference>
<button type="button" class="share-btn danger">
<el-icon><Delete /></el-icon>
删除
</button>
</template>
</el-popconfirm>
<button type="button" class="share-btn" @click="sharePost">
<el-icon><Share /></el-icon>
分享
</button>
</div>
</div>
<div v-if="post" class="detail-layout">
<main class="post-panel">
<div class="post-cover" :class="postCover(post)">
<span>{{ postTypeLabel(post.post_type) }}</span>
<h1>{{ post.title }}</h1>
<p>{{ post.tags?.join(' · ') || '教学模板' }}</p>
</div>
<article class="content-block">
<h2>模板说明</h2>
<div class="author-line" v-if="post.author">
<span>{{ post.author.name?.[0] || '师' }}</span>
<strong>{{ post.author.name || '智教教师' }}</strong>
<em>{{ [post.author.school, post.author.subject].filter(Boolean).join(' · ') || '社区创作者' }}</em>
</div>
<p>{{ post.content }}</p>
</article>
<article class="content-block" v-if="primaryAttachment">
<h2>关联资源</h2>
<button type="button" class="attachment-card" @click="openAttachment">
<span>{{ resourceTypeLabel(primaryAttachment.resource_type) }}</span>
<strong>{{ primaryAttachment.title || '社区模板资源' }}</strong>
<em>打开资源详情或作品编辑入口</em>
</button>
</article>
<article class="content-block">
<h2>使用建议</h2>
<div class="tips-grid">
<div v-for="item in usageTips" :key="item.title">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
</article>
<article class="content-block">
<h2>评论 {{ post.comments_count || comments.length }}</h2>
<div class="comment-list" v-if="comments.length">
<div v-for="comment in comments" :key="comment.id" class="comment-item">
<div class="comment-avatar">{{ comment.author?.name?.[0] || 'U' }}</div>
<div>
<p>{{ comment.content }}</p>
<span>{{ comment.created_at || '刚刚' }}</span>
</div>
<el-popconfirm v-if="canDeleteComment(comment)" title="确定删除这条评论?" @confirm="handleDeleteComment(comment)">
<template #reference>
<button type="button" class="comment-delete">
<el-icon><Delete /></el-icon>
</button>
</template>
</el-popconfirm>
</div>
</div>
<div v-else class="comment-empty">暂无评论登录后可以参与讨论</div>
<div class="comment-input">
<el-input v-model="commentText" placeholder="写评论...">
<template #append>
<el-button @click="handleComment">{{ isLoggedIn ? '发送' : '登录评论' }}</el-button>
</template>
</el-input>
</div>
</article>
</main>
<aside class="side-panel">
<div class="stats-card">
<div><strong>{{ formatCount(post.views) }}</strong><span>浏览</span></div>
<div><strong>{{ formatCount(post.likes) }}</strong><span>点赞</span></div>
<div><strong>{{ formatCount(post.comments_count || comments.length) }}</strong><span>评论</span></div>
</div>
<div class="action-stack">
<button type="button" class="primary" @click="useTemplate(post)">
<el-icon><MagicStick /></el-icon>
使用模板生成
</button>
<button type="button" @click="likeCurrent">
<el-icon><Star /></el-icon>
{{ post.is_favorited ? '已收藏,点击取消' : '收藏模板' }}
</button>
<button type="button" @click="sharePost">
<el-icon><Share /></el-icon>
复制链接
</button>
</div>
<div class="action-stack danger-zone" v-if="isOwner">
<button type="button" @click="openEditDialog">
<el-icon><Edit /></el-icon>
编辑模板
</button>
<el-popconfirm title="确定删除这个社区模板?" @confirm="handleDeletePost">
<template #reference>
<button type="button" class="danger">
<el-icon><Delete /></el-icon>
删除模板
</button>
</template>
</el-popconfirm>
</div>
<div class="tag-card" v-if="post.tags?.length">
<strong>标签</strong>
<div>
<span v-for="tag in post.tags" :key="tag">{{ tag }}</span>
</div>
</div>
</aside>
</div>
<div v-else-if="!loading" class="empty-state">
<el-icon><ChatDotRound /></el-icon>
<strong>帖子不存在</strong>
<button type="button" @click="router.push('/community')">返回模板社区</button>
</div>
</section>
<el-dialog v-model="showEdit" title="编辑模板" width="600px" :close-on-click-modal="false">
<el-form :model="editForm" label-position="top">
<el-form-item label="标题">
<el-input v-model="editForm.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: editForm.post_type === t.value }"
@click="editForm.post_type = t.value"
>
{{ t.label }}
</label>
</div>
</el-form-item>
<el-form-item label="内容">
<el-input v-model="editForm.content" type="textarea" :rows="8" />
</el-form-item>
<el-form-item label="标签">
<el-select v-model="editForm.tags" multiple filterable allow-create placeholder="添加标签" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" @click="handleUpdatePost">保存修改</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { createComment, deleteComment, deletePost, getComments, getPost, likePost, unlikePost, updatePost } from '@/api/community'
import { useUserStore } from '@/stores/user'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
const post = ref<any>(null)
const comments = ref<any[]>([])
const commentText = ref('')
const showEdit = ref(false)
const editForm = reactive({ title: '', content: '', post_type: 'discussion', tags: [] as string[] })
const isLoggedIn = computed(() => !!localStorage.getItem('access_token'))
const currentUserId = computed(() => userStore.user?.id)
const isOwner = computed(() => post.value?.user_id && post.value.user_id === currentUserId.value)
const postTypes = [
{ value: 'discussion', label: '案例' },
{ value: 'share', label: '模板' },
{ value: 'help', label: '需求' },
]
const resourceTypes = [
{ value: 'courseware', label: '课件' },
{ value: 'animation', label: '动画' },
{ value: 'exercise', label: '习题' },
{ value: 'lesson_plan', label: '教案' },
{ value: 'exam', label: '试卷' },
{ value: 'other', label: '资源' },
]
const usageTips = computed(() => [
{ title: '快速改编', desc: '一键带入生成器,保留结构但输出原创内容。' },
{ title: '课堂复用', desc: '适合公开课、单元复习、课堂互动或课后练习。' },
{ title: '教研沉淀', desc: '可作为备课组案例,继续补充评论与改进建议。' },
])
const primaryAttachment = computed(() => Array.isArray(post.value?.attachments) ? post.value.attachments[0] : null)
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 postCover(item: any) {
if (item.post_type === 'share') return 'cover-green'
if (item.post_type === 'help') return 'cover-yellow'
return 'cover-blue'
}
function formatCount(value: any) {
const n = Number(value || 0)
if (n >= 10000) return `${(n / 10000).toFixed(1)}`
return String(n)
}
function targetPathForPost(item: any) {
const attachment = Array.isArray(item.attachments) ? item.attachments[0] : null
if (attachment?.content_ref) return pathFromContentRef(attachment.content_ref)
const tags = item.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(item: any) {
const attachment = Array.isArray(item.attachments) ? item.attachments[0] : null
if (attachment?.resource_id) {
router.push(`/resources/${attachment.resource_id}`)
return
}
const prompt = `参考社区模板《${item.title}》进行原创改编。模板说明:${item.content || ''}`
localStorage.setItem('quick_create_prompt', prompt)
router.push(targetPathForPost(item))
}
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'
}
function openAttachment() {
if (!primaryAttachment.value) return
if (primaryAttachment.value.resource_id) {
router.push(`/resources/${primaryAttachment.value.resource_id}`)
return
}
if (primaryAttachment.value.content_ref) {
router.push(pathFromContentRef(primaryAttachment.value.content_ref))
}
}
async function likeCurrent() {
if (!post.value?.id) return
if (!isLoggedIn.value) {
ElMessage.warning('请先登录后再收藏模板')
router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
const data: any = post.value.is_favorited
? await unlikePost(post.value.id)
: await likePost(post.value.id)
post.value.likes = data.likes
post.value.is_favorited = data.is_favorited
ElMessage.success(data.is_favorited ? '已收藏' : '已取消收藏')
}
async function handleComment() {
if (!post.value || !commentText.value.trim()) return
if (!isLoggedIn.value) {
ElMessage.warning('请先登录后再评论')
router.push({ path: '/login', query: { redirect: route.fullPath } })
return
}
await createComment(post.value.id, { content: commentText.value })
commentText.value = ''
comments.value = (await getComments(post.value.id)) as any
post.value.comments_count = comments.value.length
}
function openEditDialog() {
if (!post.value) return
editForm.title = post.value.title || ''
editForm.content = post.value.content || ''
editForm.post_type = post.value.post_type || 'discussion'
editForm.tags = Array.isArray(post.value.tags) ? [...post.value.tags] : []
showEdit.value = true
}
async function handleUpdatePost() {
if (!post.value?.id) return
if (!editForm.title.trim() || !editForm.content.trim()) {
ElMessage.warning('请填写标题和内容')
return
}
const updated: any = await updatePost(post.value.id, {
title: editForm.title,
content: editForm.content,
post_type: editForm.post_type,
tags: editForm.tags,
})
post.value = updated
showEdit.value = false
ElMessage.success('已保存修改')
}
async function handleDeletePost() {
if (!post.value?.id) return
await deletePost(post.value.id)
ElMessage.success('已删除模板')
router.push('/community')
}
function canDeleteComment(comment: any) {
return currentUserId.value && (comment.user_id === currentUserId.value || post.value?.user_id === currentUserId.value)
}
async function handleDeleteComment(comment: any) {
if (!post.value?.id) return
await deleteComment(post.value.id, comment.id)
comments.value = comments.value.filter(item => item.id !== comment.id)
post.value.comments_count = comments.value.length
ElMessage.success('已删除评论')
}
function sharePost() {
navigator.clipboard?.writeText(window.location.href)
ElMessage.success('链接已复制')
}
onMounted(async () => {
loading.value = true
try {
if (localStorage.getItem('access_token') && !userStore.user) {
await userStore.fetchProfile().catch(() => undefined)
}
const id = Number(route.params.id)
post.value = await getPost(id) as any
comments.value = await getComments(id) as any
} catch {
post.value = null
comments.value = []
} finally {
loading.value = false
}
})
</script>
<style scoped>
.community-detail-page { min-height: calc(100vh - var(--header-height)); background: #f7faf6; padding: 28px 36px; }
.detail-shell { max-width: 1200px; margin: 0 auto; }
.top-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; }
.top-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
.back-btn,
.share-btn { height: 36px; display: inline-flex; align-items: center; gap: 7px; border: 1px solid #dbe8de; border-radius: 18px; background: #fff; color: #31584d; padding: 0 14px; font-weight: 800; cursor: pointer; }
.share-btn.danger { border-color: #f3c9c9; color: #b42318; }
.share-btn.danger:hover { border-color: #f0a8a8; background: #fff5f5; }
.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 22px; }
.post-panel,
.side-panel,
.content-block,
.stats-card,
.tag-card { border: 1px solid #dce8de; border-radius: 14px; background: #fff; }
.post-panel { overflow: hidden; }
.post-cover { min-height: 300px; display: flex; flex-direction: column; justify-content: flex-end; padding: 38px; }
.post-cover span { width: fit-content; border-radius: 16px; background: rgba(255,255,255,.86); color: #00543d; padding: 5px 12px; font-size: 13px; font-weight: 900; }
.post-cover h1 { max-width: 720px; margin: 18px 0 10px; font-size: 40px; line-height: 1.14; }
.post-cover p { font-size: 14px; opacity: .78; }
.cover-green { color: #fff; background: linear-gradient(135deg, #064231, #00844e); }
.cover-yellow { color: #654100; background: linear-gradient(135deg, #fff1a1, #ffd84d); }
.cover-blue { color: #003e6b; background: linear-gradient(135deg, #dbe8ff, #c8ddff); }
.content-block { margin: 18px; padding: 20px; }
.content-block h2 { color: #173f35; font-size: 18px; margin-bottom: 12px; }
.content-block > p { color: #51665f; line-height: 1.9; white-space: pre-line; }
.author-line { display: flex; align-items: center; gap: 9px; margin-bottom: 14px; color: #75867f; }
.author-line span { width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center; border-radius: 10px; background: #d9f0d6; color: #00543d; font-weight: 900; }
.author-line strong { color: #173f35; font-size: 14px; }
.author-line em { font-style: normal; font-size: 12px; }
.attachment-card { width: 100%; display: grid; gap: 7px; border: 1px solid #dce8de; border-radius: 12px; background: #f8faf8; padding: 14px; text-align: left; cursor: pointer; }
.attachment-card:hover { border-color: #9ed0aa; background: #f1faf3; }
.attachment-card span { width: fit-content; border-radius: 999px; background: #eaf7e9; color: #00543d; padding: 4px 9px; font-size: 12px; font-weight: 900; }
.attachment-card strong { color: #173f35; font-size: 16px; }
.attachment-card em { color: #75867f; font-size: 12px; font-style: normal; }
.tips-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.tips-grid div { border: 1px solid #dce8de; border-radius: 12px; background: #f8faf8; padding: 14px; }
.tips-grid strong { display: block; color: #00543d; margin-bottom: 6px; }
.tips-grid span { color: #6a7f77; font-size: 13px; line-height: 1.5; }
.comment-list { display: grid; gap: 12px; margin-bottom: 14px; }
.comment-item { position: relative; display: flex; gap: 10px; border: 1px solid #eef3ef; border-radius: 12px; padding: 12px; background: #fbfdfb; }
.comment-avatar { width: 32px; height: 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; background: #d9f0d6; color: #00543d; font-weight: 900; }
.comment-item p { color: #31584d; line-height: 1.6; }
.comment-item span,
.comment-empty { color: #75867f; font-size: 12px; }
.comment-empty { margin-bottom: 14px; }
.comment-delete { position: absolute; top: 10px; right: 10px; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid #f3c9c9; border-radius: 8px; background: #fff; color: #b42318; cursor: pointer; }
.comment-delete:hover { background: #fff5f5; }
.side-panel { height: fit-content; position: sticky; top: 82px; padding: 16px; }
.stats-card { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 12px; text-align: center; }
.stats-card strong { display: block; color: #00543d; font-size: 20px; }
.stats-card span { color: #75867f; font-size: 12px; }
.action-stack { display: grid; gap: 10px; margin-top: 12px; }
.action-stack button { height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid #dbe8de; border-radius: 10px; background: #fff; color: #31584d; font-weight: 900; cursor: pointer; }
.action-stack .primary { border-color: #00543d; background: #00543d; color: #fff; }
.action-stack .danger { border-color: #f3c9c9; color: #b42318; }
.action-stack .danger:hover { border-color: #f0a8a8; background: #fff5f5; }
.danger-zone { border-top: 1px solid #e7eee8; padding-top: 12px; }
.type-select { display: flex; gap: 8px; flex-wrap: wrap; }
.type-option { padding: 8px 20px; border: 1px solid #dce8de; border-radius: 8px; color: #51665f; cursor: pointer; transition: all .2s; }
.type-option.active { background: #eaf7e9; border-color: #00543d; color: #00543d; font-weight: 800; }
.tag-card { margin-top: 12px; padding: 14px; }
.tag-card strong { display: block; color: #173f35; margin-bottom: 10px; }
.tag-card div { display: flex; flex-wrap: wrap; gap: 8px; }
.tag-card span { border-radius: 999px; background: #edf7ee; color: #00543d; padding: 6px 10px; font-size: 12px; font-weight: 800; }
.empty-state { min-height: 360px; 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: 36px; color: #9aaba4; }
.empty-state button { border: none; border-radius: 18px; background: #00543d; color: #fff; padding: 8px 18px; font-weight: 800; cursor: pointer; }
@media (max-width: 980px) {
.detail-layout,
.tips-grid { grid-template-columns: 1fr; }
.side-panel { position: static; }
}
@media (max-width: 640px) {
.community-detail-page { padding: 16px; }
.top-row { align-items: stretch; flex-direction: column; gap: 10px; }
.top-actions { justify-content: flex-start; }
.post-cover { min-height: 240px; padding: 28px; }
.post-cover h1 { font-size: 30px; }
}
</style>