0841b1a103
- 前端:Vue3 + TS + Element Plus,24 个页面路由(课件/组题/教案/动画/思维导图/作文批改/命题/课堂/资源/社区等) - 后端:FastAPI + SQLAlchemy + SQLite,17 个路由模块,AI 服务层含降级模板 - AI:glm-5.x 推理模型已禁用思维链,确保输出真实内容 - 修复:ai_service 两处请求体注入 enable_thinking/thinking disabled - 测试账号:13900999999 / test1234
462 lines
20 KiB
Vue
462 lines
20 KiB
Vue
<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>
|