Files
hfb_sys/frontend/src/features/announcement/views/AnnouncementDetailView.vue
T
ymlandClaude Opus 4.6 28b6e326e8 新增公告中心功能
## 功能概述
在钱包旁边新增公告中心,用户可以查看平台通知、教程、规则和常见问题。

## 主要变更

### 后端
- 新增 announcement 模块(handler/service/repository)
- 新增 announcements 数据库表
- 添加公告相关 API 端点(用户端和管理端)
- 扩展 response 包,新增 NotFound 和 InternalServerError 方法

### 前端
- 新增公告功能模块(/features/announcement)
- 实现公告列表页和详情页
- 在顶部导航添加公告入口(钱包旁边)
- 支持按分类筛选(通知、教程、规则、FAQ)
- 支持置顶和重要标记显示

### 数据库
- 迁移文件:000005_add_announcements.sql
- 示例数据:000006_insert_sample_announcements.sql
- 包含6条示例公告

## 技术特点
- 遵循项目现有架构模式
- 响应式设计,支持移动端
- 自动统计浏览次数
- 支持富文本内容展示

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-05 20:40:20 +08:00

306 lines
6.3 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.
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Bell, Calendar, Document, QuestionFilled, View, Warning } from '@element-plus/icons-vue'
import { fetchAnnouncementDetail, type Announcement } from '@/features/announcement'
import { formatDateTime } from '@/utils/time'
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const announcement = ref<Announcement | null>(null)
const categories = [
{ value: 'notice', label: '通知公告', icon: Bell, color: '#3b82f6' },
{ value: 'tutorial', label: '使用教程', icon: Document, color: '#10b981' },
{ value: 'rule', label: '规则说明', icon: Warning, color: '#f59e0b' },
{ value: 'faq', label: '常见问题', icon: QuestionFilled, color: '#8b5cf6' },
]
onMounted(loadAnnouncement)
async function loadAnnouncement() {
const id = Number(route.params.id)
if (!id) {
router.push('/announcements')
return
}
loading.value = true
try {
announcement.value = await fetchAnnouncementDetail(id)
} catch (err) {
console.error('加载公告失败', err)
router.push('/announcements')
} finally {
loading.value = false
}
}
function goBack() {
router.push('/announcements')
}
function getCategoryInfo(category: string) {
return categories.find(c => c.value === category) || categories[0]
}
function getCategoryLabel(category: string) {
const cat = categories.find(c => c.value === category)
return cat ? cat.label : category
}
</script>
<template>
<section class="page announcement-detail-page" v-loading="loading">
<div class="detail-header">
<button class="back-button" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
<span>返回列表</span>
</button>
</div>
<div v-if="announcement" class="announcement-detail-card">
<div class="detail-meta">
<el-tag
:type="announcement.category === 'notice' ? 'primary' : 'info'"
effect="plain"
size="small"
class="category-tag"
:style="{
borderColor: getCategoryInfo(announcement.category).color,
color: getCategoryInfo(announcement.category).color
}"
>
<el-icon><component :is="getCategoryInfo(announcement.category).icon" /></el-icon>
{{ getCategoryLabel(announcement.category) }}
</el-tag>
<el-tag v-if="announcement.is_pinned" type="warning" effect="plain" size="small">置顶</el-tag>
<el-tag v-if="announcement.is_important" type="danger" effect="plain" size="small">重要</el-tag>
</div>
<h1 class="detail-title">{{ announcement.title }}</h1>
<div class="detail-info">
<span class="info-item">
<el-icon><Calendar /></el-icon>
发布时间:{{ formatDateTime(announcement.published_at || announcement.created_at) }}
</span>
<span class="info-item">
<el-icon><View /></el-icon>
浏览次数:{{ announcement.view_count }}
</span>
</div>
<div class="detail-divider"></div>
<div class="detail-content" v-html="announcement.content"></div>
</div>
</section>
</template>
<style scoped>
.announcement-detail-page {
display: grid;
gap: 20px;
max-width: 900px;
margin: 0 auto;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.back-button {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #ffffff;
color: #64748b;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.back-button:hover {
border-color: #3b82f6;
background: #eff6ff;
color: #3b82f6;
}
.announcement-detail-card {
padding: 40px;
border: 1px solid #e6eaf2;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.05);
}
.detail-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
.category-tag {
display: flex;
align-items: center;
gap: 4px;
}
.detail-title {
margin: 0 0 20px;
color: #111827;
font-size: 28px;
font-weight: 800;
line-height: 1.3;
}
.detail-info {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 24px;
margin-bottom: 24px;
}
.info-item {
display: flex;
align-items: center;
gap: 6px;
color: #64748b;
font-size: 14px;
}
.info-item .el-icon {
font-size: 16px;
}
.detail-divider {
height: 1px;
margin-bottom: 32px;
background: #e6eaf2;
}
.detail-content {
color: #334155;
font-size: 16px;
line-height: 1.8;
}
.detail-content :deep(h1),
.detail-content :deep(h2),
.detail-content :deep(h3) {
margin: 28px 0 16px;
color: #111827;
font-weight: 700;
}
.detail-content :deep(h1) {
font-size: 24px;
}
.detail-content :deep(h2) {
font-size: 20px;
}
.detail-content :deep(h3) {
font-size: 18px;
}
.detail-content :deep(p) {
margin: 16px 0;
line-height: 1.8;
}
.detail-content :deep(ul),
.detail-content :deep(ol) {
margin: 16px 0;
padding-left: 24px;
}
.detail-content :deep(li) {
margin: 8px 0;
line-height: 1.7;
}
.detail-content :deep(code) {
padding: 2px 6px;
border-radius: 4px;
background: #f1f5f9;
color: #e11d48;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 14px;
}
.detail-content :deep(pre) {
margin: 20px 0;
padding: 16px;
border-radius: 8px;
background: #1e293b;
color: #e2e8f0;
font-family: 'SF Mono', 'Menlo', 'Consolas', monospace;
font-size: 14px;
line-height: 1.6;
overflow-x: auto;
}
.detail-content :deep(pre code) {
padding: 0;
background: transparent;
color: inherit;
}
.detail-content :deep(blockquote) {
margin: 20px 0;
padding: 12px 20px;
border-left: 4px solid #3b82f6;
background: #eff6ff;
color: #1e40af;
}
.detail-content :deep(a) {
color: #3b82f6;
text-decoration: none;
transition: color 0.2s;
}
.detail-content :deep(a:hover) {
color: #2563eb;
text-decoration: underline;
}
.detail-content :deep(img) {
max-width: 100%;
margin: 20px 0;
border-radius: 8px;
}
@media (max-width: 720px) {
.announcement-detail-card {
padding: 24px 20px;
}
.detail-title {
font-size: 22px;
}
.detail-content {
font-size: 15px;
}
.detail-info {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
}
</style>