feat(bulletin): add group bulletin board (信息公示板)
- Add bulletin_posts and bulletin_reads PocketBase collections with migrations - Add BulletinPost/BulletinRead types and priority levels (low/normal/high/urgent) - Add bulletin API (CRUD, read tracking, realtime subscription) - Add bulletin Pinia store with pinned/normal sections and expiration filtering - Add BulletinPinned header component for pinned announcements - Add BulletinBoard tab component with sectioned list layout - Add BulletinPostCard with priority tags, unread dots, and hover actions - Add CreateBulletinDialog with title, content, priority, pinned, expiresAt - Integrate bulletin tab into GroupView with Bell icon and real-time updates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { BulletinPost, BulletinRead } from '@/types'
|
||||
|
||||
export async function createBulletin(data: {
|
||||
group: string
|
||||
title: string
|
||||
content: string
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent'
|
||||
pinned?: boolean
|
||||
expiresAt?: string
|
||||
}): Promise<BulletinPost> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const post = await pb.collection('bulletin_posts').create({
|
||||
group: data.group,
|
||||
creator: user.id,
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
priority: data.priority || 'normal',
|
||||
pinned: data.pinned || false,
|
||||
expiresAt: data.expiresAt || '',
|
||||
})
|
||||
return post as unknown as BulletinPost
|
||||
}
|
||||
|
||||
export async function listBulletins(groupId: string): Promise<BulletinPost[]> {
|
||||
const result = await pb.collection('bulletin_posts').getFullList({
|
||||
filter: `group="${groupId}"`,
|
||||
sort: '-pinned,-created',
|
||||
expand: 'creator',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as BulletinPost[]
|
||||
}
|
||||
|
||||
export async function getBulletin(postId: string): Promise<BulletinPost> {
|
||||
const result = await pb.collection('bulletin_posts').getOne(postId, {
|
||||
expand: 'creator',
|
||||
})
|
||||
return result as unknown as BulletinPost
|
||||
}
|
||||
|
||||
export async function updateBulletin(
|
||||
postId: string,
|
||||
data: {
|
||||
title?: string
|
||||
content?: string
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent'
|
||||
pinned?: boolean
|
||||
expiresAt?: string
|
||||
}
|
||||
): Promise<BulletinPost> {
|
||||
const payload: Record<string, any> = {}
|
||||
if (data.title !== undefined) payload.title = data.title
|
||||
if (data.content !== undefined) payload.content = data.content
|
||||
if (data.priority !== undefined) payload.priority = data.priority
|
||||
if (data.pinned !== undefined) payload.pinned = data.pinned
|
||||
if (data.expiresAt !== undefined) payload.expiresAt = data.expiresAt
|
||||
|
||||
const result = await pb.collection('bulletin_posts').update(postId, payload)
|
||||
return result as unknown as BulletinPost
|
||||
}
|
||||
|
||||
export async function deleteBulletin(postId: string): Promise<void> {
|
||||
await pb.collection('bulletin_posts').delete(postId)
|
||||
}
|
||||
|
||||
// 已读追踪
|
||||
export async function markBulletinAsRead(postId: string): Promise<BulletinRead | null> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return null
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = await pb.collection('bulletin_reads').getList(1, 1, {
|
||||
filter: `post="${postId}" && user="${user.id}"`,
|
||||
$autoCancel: false,
|
||||
})
|
||||
if (existing.items.length > 0) return existing.items[0] as unknown as BulletinRead
|
||||
|
||||
const result = await pb.collection('bulletin_reads').create({
|
||||
post: postId,
|
||||
user: user.id,
|
||||
})
|
||||
return result as unknown as BulletinRead
|
||||
}
|
||||
|
||||
export async function listMyReads(): Promise<BulletinRead[]> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return []
|
||||
|
||||
const result = await pb.collection('bulletin_reads').getFullList({
|
||||
filter: `user="${user.id}"`,
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as BulletinRead[]
|
||||
}
|
||||
|
||||
// 实时订阅
|
||||
export async function subscribeBulletins(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => void> {
|
||||
await pb.collection('bulletin_posts').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) {
|
||||
callback(data)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
pb.collection('bulletin_posts').unsubscribe('*')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useBulletinStore } from '@/stores/bulletin'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { subscribeBulletins } from '@/api/bulletins'
|
||||
import BulletinPostCard from './BulletinPostCard.vue'
|
||||
import CreateBulletinDialog from './CreateBulletinDialog.vue'
|
||||
import type { BulletinPost } from '@/types'
|
||||
|
||||
const bulletinStore = useBulletinStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const showCreate = ref(false)
|
||||
const editingPost = ref<BulletinPost | null>(null)
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
|
||||
async function loadAll() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
await bulletinStore.loadPosts(groupId)
|
||||
}
|
||||
|
||||
async function startSubscription() {
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) return
|
||||
|
||||
unsubscribeFn = await subscribeBulletins(groupId, () => {
|
||||
loadAll()
|
||||
})
|
||||
}
|
||||
|
||||
function stopSubscription() {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(post: BulletinPost) {
|
||||
bulletinStore.markAsRead(post.id)
|
||||
}
|
||||
|
||||
function handleEdit(post: BulletinPost) {
|
||||
editingPost.value = post
|
||||
showCreate.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(post: BulletinPost) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除公告 "${post.title}" 吗?`,
|
||||
'确认删除',
|
||||
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
await bulletinStore.remove(post.id)
|
||||
ElMessage.success('删除成功')
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(error.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDialogClose() {
|
||||
editingPost.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (groupStore.currentGroupId) {
|
||||
loadAll()
|
||||
startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
loadAll()
|
||||
if (!unsubscribeFn) startSubscription()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSubscription()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bulletin-board">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="bulletin-board__header">
|
||||
<h3 class="bulletin-board__title">公告</h3>
|
||||
<button class="bulletin-board__create-btn" @click="showCreate = true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="bulletin-board__create-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
发布公告
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CreateBulletinDialog
|
||||
v-model="showCreate"
|
||||
:edit-post="editingPost"
|
||||
@created="loadAll(); showCreate = false; handleDialogClose()"
|
||||
@updated="loadAll(); showCreate = false; handleDialogClose()"
|
||||
/>
|
||||
|
||||
<!-- 置顶公告 -->
|
||||
<section v-if="bulletinStore.pinnedPosts.length > 0" class="bulletin-board__section">
|
||||
<div class="bulletin-board__section-header">
|
||||
<span class="bulletin-board__section-dot bulletin-board__section-dot--pinned"></span>
|
||||
<span class="bulletin-board__section-label">置顶公告</span>
|
||||
<span class="bulletin-board__section-count">{{ bulletinStore.pinnedPosts.length }}</span>
|
||||
</div>
|
||||
<div class="bulletin-board__grid">
|
||||
<BulletinPostCard
|
||||
v-for="post in bulletinStore.pinnedPosts"
|
||||
:key="post.id"
|
||||
:post="post"
|
||||
:is-read="bulletinStore.isRead(post.id)"
|
||||
@click="handleCardClick(post)"
|
||||
@edit="handleEdit(post)"
|
||||
@delete="handleDelete(post)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 普通公告 -->
|
||||
<section v-if="bulletinStore.normalPosts.length > 0" class="bulletin-board__section">
|
||||
<div class="bulletin-board__section-header">
|
||||
<span class="bulletin-board__section-dot bulletin-board__section-dot--normal"></span>
|
||||
<span class="bulletin-board__section-label">普通公告</span>
|
||||
<span class="bulletin-board__section-count">{{ bulletinStore.normalPosts.length }}</span>
|
||||
</div>
|
||||
<div class="bulletin-board__grid">
|
||||
<BulletinPostCard
|
||||
v-for="post in bulletinStore.normalPosts"
|
||||
:key="post.id"
|
||||
:post="post"
|
||||
:is-read="bulletinStore.isRead(post.id)"
|
||||
@click="handleCardClick(post)"
|
||||
@edit="handleEdit(post)"
|
||||
@delete="handleDelete(post)"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="bulletinStore.pinnedPosts.length === 0 && bulletinStore.normalPosts.length === 0 && !bulletinStore.loading"
|
||||
class="bulletin-board__empty"
|
||||
>
|
||||
<svg class="bulletin-board__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
<p class="bulletin-board__empty-text">暂无公告</p>
|
||||
<p class="bulletin-board__empty-hint">群组内还没有发布过公告</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulletin-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* 顶部操作栏 */
|
||||
.bulletin-board__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bulletin-board__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.bulletin-board__create-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 16px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #ffffff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.bulletin-board__create-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.bulletin-board__create-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 分栏 */
|
||||
.bulletin-board__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bulletin-board__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.bulletin-board__section-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.bulletin-board__section-dot--pinned {
|
||||
background: var(--gg-primary);
|
||||
box-shadow: 0 0 6px var(--gg-primary);
|
||||
}
|
||||
|
||||
.bulletin-board__section-dot--normal {
|
||||
background: var(--gg-info);
|
||||
box-shadow: 0 0 6px var(--gg-info);
|
||||
}
|
||||
|
||||
.bulletin-board__section-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.bulletin-board__section-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
background: var(--gg-bg-elevated);
|
||||
padding: 1px 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 网格布局 */
|
||||
.bulletin-board__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.bulletin-board__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bulletin-board__empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bulletin-board__empty-text {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-secondary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.bulletin-board__empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.bulletin-board__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useBulletinStore } from '@/stores/bulletin'
|
||||
import { BulletinPriorityMap } from '@/types'
|
||||
import type { BulletinPost } from '@/types'
|
||||
|
||||
const emit = defineEmits<{
|
||||
viewAll: []
|
||||
}>()
|
||||
|
||||
const bulletinStore = useBulletinStore()
|
||||
|
||||
const hasPinned = computed(() => bulletinStore.pinnedPosts.length > 0)
|
||||
|
||||
function handleClick(post: BulletinPost) {
|
||||
bulletinStore.markAsRead(post.id)
|
||||
emit('viewAll')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasPinned" class="bulletin-pinned">
|
||||
<div
|
||||
v-for="post in bulletinStore.pinnedPosts"
|
||||
:key="post.id"
|
||||
class="pinned-item"
|
||||
:class="{ 'pinned-item--urgent': post.priority === 'urgent' }"
|
||||
@click="handleClick(post)"
|
||||
>
|
||||
<span class="pinned-item__dot" />
|
||||
<span class="pinned-item__priority">{{ BulletinPriorityMap[post.priority] }}</span>
|
||||
<span class="pinned-item__title">{{ post.title }}</span>
|
||||
<span v-if="!bulletinStore.isRead(post.id)" class="pinned-item__unread" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulletin-pinned {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pinned-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-left: 3px solid var(--gg-primary);
|
||||
border-radius: var(--gg-radius-md);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.pinned-item:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.08);
|
||||
}
|
||||
|
||||
.pinned-item--urgent {
|
||||
border-left-color: var(--gg-danger);
|
||||
background: rgba(239, 68, 68, 0.04);
|
||||
}
|
||||
|
||||
.pinned-item--urgent:hover {
|
||||
box-shadow: 0 0 16px rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.pinned-item__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pinned-item--urgent .pinned-item__dot {
|
||||
background: var(--gg-danger);
|
||||
}
|
||||
|
||||
.pinned-item__priority {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pinned-item--urgent .pinned-item__priority {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.pinned-item__title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pinned-item__unread {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-danger);
|
||||
box-shadow: 0 0 6px var(--gg-danger);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { BulletinPost } from '@/types'
|
||||
import { displayName, BulletinPriorityMap, BulletinPriorityColor } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
post: BulletinPost
|
||||
isRead: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
edit: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const priorityLabel = computed(() => BulletinPriorityMap[props.post.priority])
|
||||
const priorityColor = computed(() => BulletinPriorityColor[props.post.priority])
|
||||
|
||||
const creatorName = computed(() =>
|
||||
displayName(props.post.expand?.creator)
|
||||
)
|
||||
|
||||
const creatorAvatar = computed(() =>
|
||||
props.post.expand?.creator?.avatar || '/default-avatar.svg'
|
||||
)
|
||||
|
||||
// 内容摘要(最多3行)
|
||||
const contentSummary = computed(() => {
|
||||
const text = props.post.content.replace(/<[^>]+>/g, ' ').trim()
|
||||
return text.length > 120 ? text.slice(0, 120) + '...' : text
|
||||
})
|
||||
|
||||
// 格式化时间
|
||||
const timeText = computed(() => {
|
||||
const d = new Date(props.post.created)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
const minutes = Math.floor(diff / (1000 * 60))
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}天前`
|
||||
if (hours > 0) return `${hours}小时前`
|
||||
if (minutes > 0) return `${minutes}分钟前`
|
||||
return '刚刚'
|
||||
})
|
||||
|
||||
const isExpired = computed(() => {
|
||||
if (!props.post.expiresAt) return false
|
||||
return new Date(props.post.expiresAt) <= new Date()
|
||||
})
|
||||
|
||||
function handleEdit(e: Event) {
|
||||
e.stopPropagation()
|
||||
emit('edit')
|
||||
}
|
||||
|
||||
function handleDelete(e: Event) {
|
||||
e.stopPropagation()
|
||||
emit('delete')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bulletin-card"
|
||||
:class="{
|
||||
'bulletin-card--pinned': post.pinned,
|
||||
'bulletin-card--urgent': post.priority === 'urgent',
|
||||
'bulletin-card--expired': isExpired,
|
||||
'bulletin-card--unread': !isRead,
|
||||
}"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- 未读标记 -->
|
||||
<span v-if="!isRead" class="bulletin-card__unread-dot" />
|
||||
|
||||
<!-- 顶部标签 -->
|
||||
<div class="bulletin-card__tags">
|
||||
<span
|
||||
class="bulletin-card__priority-tag"
|
||||
:style="{ background: priorityColor + '18', color: priorityColor }"
|
||||
>
|
||||
{{ priorityLabel }}
|
||||
</span>
|
||||
<span v-if="post.pinned" class="bulletin-card__pinned-tag">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
|
||||
<path d="M12 2l3 7h7l-5.5 4 2 7-6.5-5-6.5 5 2-7L2 9h7z" />
|
||||
</svg>
|
||||
置顶
|
||||
</span>
|
||||
<span v-if="isExpired" class="bulletin-card__expired-tag">已过期</span>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
<div class="bulletin-card__title">{{ post.title }}</div>
|
||||
|
||||
<!-- 内容摘要 -->
|
||||
<div class="bulletin-card__content" v-html="contentSummary" />
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="bulletin-card__footer">
|
||||
<div class="bulletin-card__author">
|
||||
<img :src="creatorAvatar" :alt="creatorName" class="bulletin-card__avatar" />
|
||||
<span class="bulletin-card__author-name">{{ creatorName }}</span>
|
||||
</div>
|
||||
<span class="bulletin-card__time">{{ timeText }}</span>
|
||||
<div class="bulletin-card__actions" @click.stop>
|
||||
<button class="bulletin-card__action-btn" @click="handleEdit">编辑</button>
|
||||
<button class="bulletin-card__action-btn bulletin-card__action-btn--danger" @click="handleDelete">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulletin-card {
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
padding: 16px 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bulletin-card:hover {
|
||||
border-color: var(--gg-primary-light);
|
||||
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.bulletin-card--pinned {
|
||||
border-left: 3px solid var(--gg-primary);
|
||||
}
|
||||
|
||||
.bulletin-card--urgent {
|
||||
border-left: 3px solid var(--gg-danger);
|
||||
}
|
||||
|
||||
.bulletin-card--urgent:hover {
|
||||
box-shadow: 0 0 20px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.bulletin-card--expired {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* 未读标记 */
|
||||
.bulletin-card__unread-dot {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-danger);
|
||||
box-shadow: 0 0 6px var(--gg-danger);
|
||||
}
|
||||
|
||||
/* 标签 */
|
||||
.bulletin-card__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.bulletin-card__priority-tag,
|
||||
.bulletin-card__pinned-tag,
|
||||
.bulletin-card__expired-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.bulletin-card__pinned-tag {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.bulletin-card__expired-tag {
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 标题 */
|
||||
.bulletin-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* 内容 */
|
||||
.bulletin-card__content {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.bulletin-card__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.bulletin-card__author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bulletin-card__avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bulletin-card__author-name {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.bulletin-card__time {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bulletin-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.bulletin-card:hover .bulletin-card__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bulletin-card__action-btn {
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.bulletin-card__action-btn:hover {
|
||||
background: var(--gg-bg-elevated);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.bulletin-card__action-btn--danger:hover {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 640px) {
|
||||
.bulletin-card__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useBulletinStore } from '@/stores/bulletin'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import type { BulletinPriority } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
editPost?: {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
priority: BulletinPriority
|
||||
pinned: boolean
|
||||
expiresAt?: string
|
||||
} | null
|
||||
}>()
|
||||
|
||||
const visible = defineModel<boolean>({ default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
created: []
|
||||
updated: []
|
||||
}>()
|
||||
|
||||
const bulletinStore = useBulletinStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const isEdit = computed(() => !!props.editPost)
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
content: '',
|
||||
priority: 'normal' as BulletinPriority,
|
||||
pinned: false,
|
||||
expiresAt: '' as string | Date,
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const priorityOptions: { label: string; value: BulletinPriority }[] = [
|
||||
{ label: '低', value: 'low' },
|
||||
{ label: '普通', value: 'normal' },
|
||||
{ label: '高', value: 'high' },
|
||||
{ label: '紧急', value: 'urgent' },
|
||||
]
|
||||
|
||||
function resetForm() {
|
||||
if (props.editPost) {
|
||||
form.value = {
|
||||
title: props.editPost.title,
|
||||
content: props.editPost.content,
|
||||
priority: props.editPost.priority,
|
||||
pinned: props.editPost.pinned,
|
||||
expiresAt: props.editPost.expiresAt || '',
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
title: '',
|
||||
content: '',
|
||||
priority: 'normal',
|
||||
pinned: false,
|
||||
expiresAt: '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
resetForm()
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.title.trim()) {
|
||||
ElMessage.warning('请输入公告标题')
|
||||
return
|
||||
}
|
||||
if (!form.value.content.trim()) {
|
||||
ElMessage.warning('请输入公告内容')
|
||||
return
|
||||
}
|
||||
|
||||
const groupId = groupStore.currentGroupId
|
||||
if (!groupId) {
|
||||
ElMessage.error('请先选择群组')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const data = {
|
||||
title: form.value.title.trim(),
|
||||
content: form.value.content.trim(),
|
||||
priority: form.value.priority,
|
||||
pinned: form.value.pinned,
|
||||
expiresAt: form.value.expiresAt
|
||||
? new Date(String(form.value.expiresAt)).toISOString()
|
||||
: undefined,
|
||||
}
|
||||
|
||||
if (isEdit.value && props.editPost) {
|
||||
await bulletinStore.update(props.editPost.id, data)
|
||||
ElMessage.success('公告更新成功')
|
||||
emit('updated')
|
||||
} else {
|
||||
await bulletinStore.create({ group: groupId, ...data })
|
||||
ElMessage.success('公告发布成功')
|
||||
emit('created')
|
||||
}
|
||||
|
||||
visible.value = false
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error.message || '操作失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="isEdit ? '编辑公告' : '发布公告'"
|
||||
width="520px"
|
||||
@open="handleOpen"
|
||||
>
|
||||
<div class="create-form">
|
||||
<!-- 标题 -->
|
||||
<div class="form-field">
|
||||
<label>标题 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.title"
|
||||
placeholder="请输入公告标题"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="form-field">
|
||||
<label>内容 <span class="required">*</span></label>
|
||||
<el-input
|
||||
v-model="form.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="支持 HTML 标签(如 <b>加粗</b>、<i>斜体</i>、<a href='...'>链接</a>)"
|
||||
maxlength="5000"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 优先级 -->
|
||||
<div class="form-field">
|
||||
<label>优先级</label>
|
||||
<el-select v-model="form.priority" style="width: 100%">
|
||||
<el-option
|
||||
v-for="opt in priorityOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 置顶开关 -->
|
||||
<div class="form-field form-field--inline">
|
||||
<label>置顶显示</label>
|
||||
<el-switch v-model="form.pinned" />
|
||||
</div>
|
||||
|
||||
<!-- 过期时间 -->
|
||||
<div class="form-field">
|
||||
<label>过期时间(可选)</label>
|
||||
<el-date-picker
|
||||
v-model="form.expiresAt"
|
||||
type="datetime"
|
||||
placeholder="选择过期时间"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||
{{ loading ? '提交中...' : (isEdit ? '保存' : '发布') }}
|
||||
</button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-field--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: var(--gg-gradient-green);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,170 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { BulletinPost, BulletinRead } from '@/types'
|
||||
import {
|
||||
listBulletins,
|
||||
listMyReads,
|
||||
markBulletinAsRead,
|
||||
createBulletin,
|
||||
updateBulletin,
|
||||
deleteBulletin,
|
||||
} from '@/api/bulletins'
|
||||
|
||||
export const useBulletinStore = defineStore('bulletin', () => {
|
||||
const posts = ref<BulletinPost[]>([])
|
||||
const reads = ref<BulletinRead[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
const now = computed(() => new Date())
|
||||
|
||||
const activePosts = computed(() => {
|
||||
return posts.value.filter((p) => {
|
||||
if (!p.expiresAt) return true
|
||||
return new Date(p.expiresAt) > now.value
|
||||
})
|
||||
})
|
||||
|
||||
const pinnedPosts = computed(() =>
|
||||
activePosts.value
|
||||
.filter((p) => p.pinned)
|
||||
.sort((a, b) => {
|
||||
const prioOrder = { urgent: 0, high: 1, normal: 2, low: 3 }
|
||||
return prioOrder[a.priority] - prioOrder[b.priority]
|
||||
})
|
||||
)
|
||||
|
||||
const normalPosts = computed(() =>
|
||||
activePosts.value
|
||||
.filter((p) => !p.pinned)
|
||||
.sort((a, b) => {
|
||||
const prioOrder = { urgent: 0, high: 1, normal: 2, low: 3 }
|
||||
const prioDiff = prioOrder[a.priority] - prioOrder[b.priority]
|
||||
if (prioDiff !== 0) return prioDiff
|
||||
return new Date(b.created).getTime() - new Date(a.created).getTime()
|
||||
})
|
||||
)
|
||||
|
||||
const readPostIds = computed(() => new Set(reads.value.map((r) => r.post)))
|
||||
|
||||
function isRead(postId: string): boolean {
|
||||
return readPostIds.value.has(postId)
|
||||
}
|
||||
|
||||
function unreadCount(groupId: string): number {
|
||||
return activePosts.value.filter(
|
||||
(p) => p.group === groupId && !isRead(p.id)
|
||||
).length
|
||||
}
|
||||
|
||||
async function loadPosts(groupId: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
const [fetchedPosts, fetchedReads] = await Promise.all([
|
||||
listBulletins(groupId),
|
||||
listMyReads(),
|
||||
])
|
||||
posts.value = fetchedPosts
|
||||
reads.value = fetchedReads
|
||||
} catch (error) {
|
||||
console.error('加载公告列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markAsRead(postId: string) {
|
||||
if (isRead(postId)) return
|
||||
try {
|
||||
const record = await markBulletinAsRead(postId)
|
||||
if (record) {
|
||||
reads.value.push(record)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('标记已读失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAsRead(groupId: string) {
|
||||
const unread = activePosts.value.filter(
|
||||
(p) => p.group === groupId && !isRead(p.id)
|
||||
)
|
||||
await Promise.all(unread.map((p) => markAsRead(p.id)))
|
||||
}
|
||||
|
||||
async function create(data: {
|
||||
group: string
|
||||
title: string
|
||||
content: string
|
||||
priority: 'low' | 'normal' | 'high' | 'urgent'
|
||||
pinned: boolean
|
||||
expiresAt?: string
|
||||
}) {
|
||||
try {
|
||||
const post = await createBulletin(data)
|
||||
posts.value.unshift(post)
|
||||
return post
|
||||
} catch (error: any) {
|
||||
console.error('创建公告失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function update(
|
||||
postId: string,
|
||||
data: {
|
||||
title?: string
|
||||
content?: string
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent'
|
||||
pinned?: boolean
|
||||
expiresAt?: string
|
||||
}
|
||||
) {
|
||||
try {
|
||||
const post = await updateBulletin(postId, data)
|
||||
const index = posts.value.findIndex((p) => p.id === postId)
|
||||
if (index !== -1) {
|
||||
posts.value[index] = { ...posts.value[index], ...post }
|
||||
}
|
||||
return post
|
||||
} catch (error: any) {
|
||||
console.error('更新公告失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(postId: string) {
|
||||
try {
|
||||
await deleteBulletin(postId)
|
||||
posts.value = posts.value.filter((p) => p.id !== postId)
|
||||
reads.value = reads.value.filter((r) => r.post !== postId)
|
||||
} catch (error: any) {
|
||||
console.error('删除公告失败:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
posts.value = []
|
||||
reads.value = []
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
posts,
|
||||
reads,
|
||||
loading,
|
||||
activePosts,
|
||||
pinnedPosts,
|
||||
normalPosts,
|
||||
readPostIds,
|
||||
isRead,
|
||||
unreadCount,
|
||||
loadPosts,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
clear,
|
||||
}
|
||||
})
|
||||
@@ -408,6 +408,48 @@ export interface PlayerBlacklistEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// 公告优先级
|
||||
export type BulletinPriority = 'low' | 'normal' | 'high' | 'urgent'
|
||||
|
||||
export const BulletinPriorityMap: Record<BulletinPriority, string> = {
|
||||
low: '低',
|
||||
normal: '普通',
|
||||
high: '高',
|
||||
urgent: '紧急'
|
||||
}
|
||||
|
||||
export const BulletinPriorityColor: Record<BulletinPriority, string> = {
|
||||
low: 'var(--gg-info)',
|
||||
normal: 'var(--gg-text-muted)',
|
||||
high: 'var(--gg-warning)',
|
||||
urgent: 'var(--gg-danger)'
|
||||
}
|
||||
|
||||
export interface BulletinPost {
|
||||
id: string
|
||||
group: string
|
||||
creator: string
|
||||
title: string
|
||||
content: string
|
||||
priority: BulletinPriority
|
||||
pinned: boolean
|
||||
expiresAt?: string
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
creator?: User
|
||||
group?: Group
|
||||
}
|
||||
}
|
||||
|
||||
export interface BulletinRead {
|
||||
id: string
|
||||
post: string
|
||||
user: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
// 竞猜状态
|
||||
export type BetStatus = 'open' | 'closed' | 'settled'
|
||||
|
||||
|
||||
@@ -16,14 +16,16 @@ import MemoryGrid from '@/components/memory/MemoryGrid.vue'
|
||||
import GroupStatsPanel from '@/components/stats/GroupStatsPanel.vue'
|
||||
import BetList from '@/components/bet/BetList.vue'
|
||||
import BetDetail from '@/components/bet/BetDetail.vue'
|
||||
import { Promotion, Opportunity, UserFilled, Wallet, Box, Warning } from '@element-plus/icons-vue'
|
||||
import { Promotion, Opportunity, UserFilled, Wallet, Box, Warning, Bell } from '@element-plus/icons-vue'
|
||||
import { DataLine, PictureFilled, TrendCharts, Trophy } from '@element-plus/icons-vue'
|
||||
import BulletinBoard from '@/components/bulletin/BulletinBoard.vue'
|
||||
import BulletinPinned from '@/components/bulletin/BulletinPinned.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const groupStore = useGroupStore()
|
||||
const teamStore = useTeamStore()
|
||||
|
||||
const activeTab = ref(route.query.tab === 'polls' ? 'polls' : route.query.tab === 'memories' ? 'memories' : route.query.tab === 'stats' ? 'stats' : route.query.tab === 'bets' ? 'bets' : 'activity')
|
||||
const activeTab = ref(route.query.tab === 'polls' ? 'polls' : route.query.tab === 'memories' ? 'memories' : route.query.tab === 'stats' ? 'stats' : route.query.tab === 'bets' ? 'bets' : route.query.tab === 'bulletins' ? 'bulletins' : 'activity')
|
||||
const viewingPollId = ref<string | null>(null)
|
||||
const viewingBetId = ref<string | null>(null)
|
||||
|
||||
@@ -204,6 +206,9 @@ async function checkExpiredBets() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 置顶公告 -->
|
||||
<BulletinPinned @view-all="activeTab = 'bulletins'" />
|
||||
|
||||
<!-- Tab 系统 -->
|
||||
<el-tabs v-model="activeTab" class="group-tabs group-tabs--fancy">
|
||||
<el-tab-pane name="activity">
|
||||
@@ -269,6 +274,13 @@ async function checkExpiredBets() {
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="bulletins">
|
||||
<template #label>
|
||||
<span class="fancy-tab"><el-icon><Bell /></el-icon> 公告</span>
|
||||
</template>
|
||||
<BulletinBoard />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="polls">
|
||||
<template #label>
|
||||
<span class="fancy-tab"><el-icon><DataLine /></el-icon> 投票</span>
|
||||
|
||||
Reference in New Issue
Block a user