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:
wjl
2026-04-21 12:46:35 +08:00
parent 3c2b68bbc3
commit f99c44f716
10 changed files with 1478 additions and 2 deletions
+113
View File
@@ -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 标签(如 &lt;b&gt;加粗&lt;/b&gt;、&lt;i&gt;斜体&lt;/i&gt;、&lt;a href='...'&gt;链接&lt;/a&gt;"
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>
+170
View File
@@ -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,
}
})
+42
View File
@@ -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'
+14 -2
View File
@@ -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>