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

494 lines
21 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="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>