From f99c44f716d8719b1942157c499aa836d844514d Mon Sep 17 00:00:00 2001 From: wjl <2452821485@qq.com> Date: Tue, 21 Apr 2026 12:46:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(bulletin):=20add=20group=20bulletin=20boar?= =?UTF-8?q?d=20(=E4=BF=A1=E6=81=AF=E5=85=AC=E7=A4=BA=E6=9D=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../1776700000_created_bulletin_posts.js | 122 +++++++ .../1776700001_created_bulletin_reads.js | 60 ++++ frontend/src/api/bulletins.ts | 113 +++++++ .../src/components/bulletin/BulletinBoard.vue | 300 ++++++++++++++++++ .../components/bulletin/BulletinPinned.vue | 118 +++++++ .../components/bulletin/BulletinPostCard.vue | 298 +++++++++++++++++ .../bulletin/CreateBulletinDialog.vue | 241 ++++++++++++++ frontend/src/stores/bulletin.ts | 170 ++++++++++ frontend/src/types/index.ts | 42 +++ frontend/src/views/GroupView.vue | 16 +- 10 files changed, 1478 insertions(+), 2 deletions(-) create mode 100644 backend/pb_migrations/1776700000_created_bulletin_posts.js create mode 100644 backend/pb_migrations/1776700001_created_bulletin_reads.js create mode 100644 frontend/src/api/bulletins.ts create mode 100644 frontend/src/components/bulletin/BulletinBoard.vue create mode 100644 frontend/src/components/bulletin/BulletinPinned.vue create mode 100644 frontend/src/components/bulletin/BulletinPostCard.vue create mode 100644 frontend/src/components/bulletin/CreateBulletinDialog.vue create mode 100644 frontend/src/stores/bulletin.ts diff --git a/backend/pb_migrations/1776700000_created_bulletin_posts.js b/backend/pb_migrations/1776700000_created_bulletin_posts.js new file mode 100644 index 0000000..6e4748e --- /dev/null +++ b/backend/pb_migrations/1776700000_created_bulletin_posts.js @@ -0,0 +1,122 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "bulletin_posts", + "created": "2026-04-21 00:00:00.000Z", + "updated": "2026-04-21 00:00:00.000Z", + "name": "bulletin_posts", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "bp_group", + "name": "group", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "es63bkyiblpnxdf", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "bp_creator", + "name": "creator", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "bp_title", + "name": "title", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": 1, + "max": 200, + "pattern": "" + } + }, + { + "system": false, + "id": "bp_content", + "name": "content", + "type": "text", + "required": true, + "presentable": false, + "unique": false, + "options": { + "min": 1, + "max": 5000, + "pattern": "" + } + }, + { + "system": false, + "id": "bp_priority", + "name": "priority", + "type": "select", + "required": true, + "presentable": false, + "unique": false, + "options": { + "maxSelect": 1, + "values": ["low", "normal", "high", "urgent"] + } + }, + { + "system": false, + "id": "bp_pinned", + "name": "pinned", + "type": "bool", + "required": false, + "presentable": false, + "unique": false, + "options": {} + }, + { + "system": false, + "id": "bp_expires_at", + "name": "expiresAt", + "type": "date", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": "", + "max": "" + } + } + ], + "indexes": [], + "listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id", + "updateRule": "creator = @request.auth.id || group.owner = @request.auth.id", + "deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("bulletin_posts"); + return dao.deleteCollection(collection); +}) diff --git a/backend/pb_migrations/1776700001_created_bulletin_reads.js b/backend/pb_migrations/1776700001_created_bulletin_reads.js new file mode 100644 index 0000000..12162aa --- /dev/null +++ b/backend/pb_migrations/1776700001_created_bulletin_reads.js @@ -0,0 +1,60 @@ +/// +migrate((db) => { + const collection = new Collection({ + "id": "bulletin_reads", + "created": "2026-04-21 00:00:01.000Z", + "updated": "2026-04-21 00:00:01.000Z", + "name": "bulletin_reads", + "type": "base", + "system": false, + "schema": [ + { + "system": false, + "id": "br_post", + "name": "post", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "bulletin_posts", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + }, + { + "system": false, + "id": "br_user", + "name": "user", + "type": "relation", + "required": true, + "presentable": false, + "unique": false, + "options": { + "collectionId": "_pb_users_auth_", + "cascadeDelete": true, + "minSelect": null, + "maxSelect": 1, + "displayFields": null + } + } + ], + "indexes": [ + "CREATE UNIQUE INDEX `idx_bulletin_reads_post_user` ON `bulletin_reads` (\n `post`,\n `user`\n)" + ], + "listRule": "@request.auth.id != \"\" && user = @request.auth.id", + "viewRule": "@request.auth.id != \"\" && user = @request.auth.id", + "createRule": "@request.auth.id != \"\" && user = @request.auth.id", + "updateRule": null, + "deleteRule": "user = @request.auth.id || post.group.owner = @request.auth.id", + "options": {} + }); + + return Dao(db).saveCollection(collection); +}, (db) => { + const dao = new Dao(db); + const collection = dao.findCollectionByNameOrId("bulletin_reads"); + return dao.deleteCollection(collection); +}) diff --git a/frontend/src/api/bulletins.ts b/frontend/src/api/bulletins.ts new file mode 100644 index 0000000..482c942 --- /dev/null +++ b/frontend/src/api/bulletins.ts @@ -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 { + 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 { + 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 { + 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 { + const payload: Record = {} + 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 { + await pb.collection('bulletin_posts').delete(postId) +} + +// 已读追踪 +export async function markBulletinAsRead(postId: string): Promise { + 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 { + 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('*') + } +} diff --git a/frontend/src/components/bulletin/BulletinBoard.vue b/frontend/src/components/bulletin/BulletinBoard.vue new file mode 100644 index 0000000..40ffab5 --- /dev/null +++ b/frontend/src/components/bulletin/BulletinBoard.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/frontend/src/components/bulletin/BulletinPinned.vue b/frontend/src/components/bulletin/BulletinPinned.vue new file mode 100644 index 0000000..7e2af97 --- /dev/null +++ b/frontend/src/components/bulletin/BulletinPinned.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/frontend/src/components/bulletin/BulletinPostCard.vue b/frontend/src/components/bulletin/BulletinPostCard.vue new file mode 100644 index 0000000..7ce8ac7 --- /dev/null +++ b/frontend/src/components/bulletin/BulletinPostCard.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/frontend/src/components/bulletin/CreateBulletinDialog.vue b/frontend/src/components/bulletin/CreateBulletinDialog.vue new file mode 100644 index 0000000..a6f9211 --- /dev/null +++ b/frontend/src/components/bulletin/CreateBulletinDialog.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/frontend/src/stores/bulletin.ts b/frontend/src/stores/bulletin.ts new file mode 100644 index 0000000..5bd5f36 --- /dev/null +++ b/frontend/src/stores/bulletin.ts @@ -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([]) + const reads = ref([]) + 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, + } +}) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e442a35..ac1b89d 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -408,6 +408,48 @@ export interface PlayerBlacklistEntry { } } +// 公告优先级 +export type BulletinPriority = 'low' | 'normal' | 'high' | 'urgent' + +export const BulletinPriorityMap: Record = { + low: '低', + normal: '普通', + high: '高', + urgent: '紧急' +} + +export const BulletinPriorityColor: Record = { + 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' diff --git a/frontend/src/views/GroupView.vue b/frontend/src/views/GroupView.vue index 7ad136f..b146a44 100644 --- a/frontend/src/views/GroupView.vue +++ b/frontend/src/views/GroupView.vue @@ -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(null) const viewingBetId = ref(null) @@ -204,6 +206,9 @@ async function checkExpiredBets() { + + + @@ -269,6 +274,13 @@ async function checkExpiredBets() { + + + + +