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

462 lines
20 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="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>