成员 ({{ memberDetails.length }})
@@ -181,6 +199,49 @@ async function handleGameSelected(gameName: string) {
border: 1px solid var(--gg-border);
}
+.invite-link-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 14px;
+ background: var(--gg-bg);
+ border-radius: var(--gg-radius-sm);
+ margin-bottom: 16px;
+ border: 1px solid var(--gg-border);
+}
+
+.link-label {
+ color: var(--gg-text-secondary);
+ font-size: 14px;
+ flex-shrink: 0;
+}
+
+.link-code {
+ flex: 1;
+ font-size: 11px;
+ color: var(--gg-text-muted);
+ background: transparent;
+ border: none;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.link-copy-btn {
+ padding: 4px 12px;
+ background: var(--gg-gradient);
+ border: none;
+ border-radius: 6px;
+ color: white;
+ font-size: 12px;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.link-copy-btn:hover {
+ opacity: 0.9;
+}
+
.game-label {
color: var(--gg-text-secondary);
font-size: 14px;
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 082a076..ad2976d 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -83,6 +83,18 @@ const routes: RouteRecordRaw[] = [
}
]
},
+ {
+ path: '/join/group/:groupId',
+ name: 'JoinGroup',
+ component: () => import('@/views/JoinGroupPage.vue'),
+ props: true
+ },
+ {
+ path: '/join/team/:sessionId',
+ name: 'JoinTeam',
+ component: () => import('@/views/JoinTeamPage.vue'),
+ props: true
+ },
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
diff --git a/frontend/src/stores/event.ts b/frontend/src/stores/event.ts
new file mode 100644
index 0000000..187ef42
--- /dev/null
+++ b/frontend/src/stores/event.ts
@@ -0,0 +1,200 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import type { Event, EventComment, EventRSVP, RSVPType } from '@/types'
+import {
+ listEvents,
+ listEventComments,
+ listEventRSVPs,
+ createEvent,
+ updateEvent,
+ deleteEvent,
+ createEventComment,
+ deleteEventComment,
+ setEventRSVP,
+ cancelEventRSVP
+} from '@/api/events'
+
+export const useEventStore = defineStore('event', () => {
+ const events = ref([])
+ const currentEvent = ref(null)
+ const comments = ref([])
+ const rsvps = ref([])
+ const loading = ref(false)
+
+ const upcomingEvents = computed(() =>
+ events.value.filter(e => e.status === 'upcoming' || e.status === 'ongoing')
+ .sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime())
+ )
+
+ const pastEvents = computed(() =>
+ events.value.filter(e => e.status === 'completed' || e.status === 'cancelled')
+ .sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime())
+ )
+
+ const goingCount = computed(() =>
+ rsvps.value.filter(r => r.type === 'going').length
+ )
+
+ const interestedCount = computed(() =>
+ rsvps.value.filter(r => r.type === 'interested').length
+ )
+
+ async function loadEvents(groupId: string) {
+ try {
+ loading.value = true
+ events.value = await listEvents(groupId)
+ } catch (error) {
+ console.error('加载活动列表失败:', error)
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function loadEventDetail(eventId: string) {
+ try {
+ loading.value = true
+ const [, commentData, rsvpData] = await Promise.all([
+ listEventComments(eventId).then(() => null).catch(() => null),
+ listEventComments(eventId),
+ listEventRSVPs(eventId)
+ ])
+ comments.value = commentData
+ rsvps.value = rsvpData
+ } catch (error) {
+ console.error('加载活动详情失败:', error)
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function loadCommentsAndRSVPs(eventId: string) {
+ try {
+ const [commentData, rsvpData] = await Promise.all([
+ listEventComments(eventId),
+ listEventRSVPs(eventId)
+ ])
+ comments.value = commentData
+ rsvps.value = rsvpData
+ } catch (error) {
+ console.error('加载评论和 RSVP 失败:', error)
+ }
+ }
+
+ async function addEvent(data: Parameters[0]) {
+ try {
+ const event = await createEvent(data)
+ events.value.unshift(event)
+ return event
+ } catch (error: any) {
+ console.error('创建活动失败:', error)
+ throw error
+ }
+ }
+
+ async function removeEvent(eventId: string) {
+ try {
+ await deleteEvent(eventId)
+ events.value = events.value.filter(e => e.id !== eventId)
+ if (currentEvent.value?.id === eventId) {
+ currentEvent.value = null
+ }
+ } catch (error: any) {
+ console.error('删除活动失败:', error)
+ throw error
+ }
+ }
+
+ async function editEvent(eventId: string, data: Partial) {
+ try {
+ const updated = await updateEvent(eventId, data)
+ const index = events.value.findIndex(e => e.id === eventId)
+ if (index !== -1) {
+ events.value[index] = updated
+ }
+ if (currentEvent.value?.id === eventId) {
+ currentEvent.value = updated
+ }
+ return updated
+ } catch (error: any) {
+ console.error('更新活动失败:', error)
+ throw error
+ }
+ }
+
+ async function addComment(eventId: string, content: string) {
+ try {
+ const comment = await createEventComment(eventId, content)
+ comments.value.unshift(comment)
+ return comment
+ } catch (error: any) {
+ console.error('创建评论失败:', error)
+ throw error
+ }
+ }
+
+ async function removeComment(commentId: string) {
+ try {
+ await deleteEventComment(commentId)
+ comments.value = comments.value.filter(c => c.id !== commentId)
+ } catch (error: any) {
+ console.error('删除评论失败:', error)
+ throw error
+ }
+ }
+
+ async function rsvp(eventId: string, type: RSVPType, comment?: string) {
+ try {
+ const rsvp = await setEventRSVP(eventId, type, comment)
+ const index = rsvps.value.findIndex(r => r.id === rsvp.id)
+ if (index !== -1) {
+ rsvps.value[index] = rsvp
+ } else {
+ rsvps.value.push(rsvp)
+ }
+ return rsvp
+ } catch (error: any) {
+ console.error('RSVP 失败:', error)
+ throw error
+ }
+ }
+
+ async function cancelRSVP(eventId: string) {
+ try {
+ await cancelEventRSVP(eventId)
+ rsvps.value = rsvps.value.filter(r => r.event !== eventId)
+ } catch (error: any) {
+ console.error('取消 RSVP 失败:', error)
+ throw error
+ }
+ }
+
+ function clear() {
+ events.value = []
+ currentEvent.value = null
+ comments.value = []
+ rsvps.value = []
+ }
+
+ return {
+ events,
+ currentEvent,
+ comments,
+ rsvps,
+ loading,
+ upcomingEvents,
+ pastEvents,
+ goingCount,
+ interestedCount,
+ loadEvents,
+ loadEventDetail,
+ loadCommentsAndRSVPs,
+ addEvent,
+ removeEvent,
+ editEvent,
+ addComment,
+ removeComment,
+ rsvp,
+ cancelRSVP,
+ clear
+ }
+})
diff --git a/frontend/src/stores/group.ts b/frontend/src/stores/group.ts
index f541a05..32baab9 100644
--- a/frontend/src/stores/group.ts
+++ b/frontend/src/stores/group.ts
@@ -17,6 +17,15 @@ export const useGroupStore = defineStore('group', () => {
const isGroupOwner = computed(() => {
return currentGroup.value?.owner === pb.authStore.model?.id
})
+ const isGroupAdmin = computed(() => {
+ const userId = pb.authStore.model?.id
+ if (!userId || !currentGroup.value) return false
+ return (currentGroup.value.admins || []).includes(userId)
+ })
+ const canManageGroup = computed(() => {
+ if (!currentGroup.value) return false
+ return isGroupOwner.value || isGroupAdmin.value || currentGroup.value.allowMemberManage
+ })
// 加载用户的群组列表
async function loadGroups() {
@@ -78,6 +87,8 @@ export const useGroupStore = defineStore('group', () => {
loading,
currentGroupId,
isGroupOwner,
+ isGroupAdmin,
+ canManageGroup,
loadGroups,
setCurrentGroup,
clearCurrentGroup,
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index ac1b89d..0dda5d9 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -66,13 +66,16 @@ export interface Group {
description?: string
owner: string
members: string[]
+ admins: string[]
maxMembers: number
requireApproval: boolean
+ allowMemberManage: boolean
created: string
updated: string
expand?: {
owner?: User
members?: User[]
+ admins?: User[]
}
}
@@ -450,6 +453,81 @@ export interface BulletinRead {
updated: string
}
+// 活动状态
+export type EventStatus = 'upcoming' | 'ongoing' | 'completed' | 'cancelled'
+
+export const EventStatusMap: Record = {
+ upcoming: '即将开始',
+ ongoing: '进行中',
+ completed: '已结束',
+ cancelled: '已取消'
+}
+
+export const EventStatusColor: Record = {
+ upcoming: 'var(--gg-info)',
+ ongoing: 'var(--gg-primary)',
+ completed: 'var(--gg-text-muted)',
+ cancelled: 'var(--gg-danger)'
+}
+
+// RSVP 类型
+export type RSVPType = 'going' | 'interested' | 'maybe'
+
+export const RSVPTypeMap: Record = {
+ going: '参加',
+ interested: '感兴趣',
+ maybe: '也许'
+}
+
+// 活动
+export interface Event {
+ id: string
+ group: string
+ creator: string
+ title: string
+ description?: string
+ location?: string
+ startTime: string
+ endTime?: string
+ status: EventStatus
+ maxParticipants?: number
+ created: string
+ updated: string
+ expand?: {
+ creator?: User
+ group?: Group
+ }
+}
+
+// 活动评论
+export interface EventComment {
+ id: string
+ event: string
+ user: string
+ content: string
+ created: string
+ updated: string
+ expand?: {
+ user?: User
+ event?: Event
+ }
+}
+
+// 活动 RSVP
+export interface EventRSVP {
+ id: string
+ event: string
+ user: string
+ type: RSVPType
+ comment?: string
+ created: string
+ updated: string
+ expand?: {
+ user?: User
+ event?: Event
+ }
+}
+
// 竞猜状态
export type BetStatus = 'open' | 'closed' | 'settled'
diff --git a/frontend/src/views/GroupView.vue b/frontend/src/views/GroupView.vue
index b146a44..152a3e5 100644
--- a/frontend/src/views/GroupView.vue
+++ b/frontend/src/views/GroupView.vue
@@ -16,16 +16,17 @@ 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, Bell } from '@element-plus/icons-vue'
+import { Promotion, Opportunity, UserFilled, Wallet, Box, Warning, Bell, Calendar } 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'
+import EventList from '@/components/event/EventList.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' : route.query.tab === 'bulletins' ? 'bulletins' : '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' : route.query.tab === 'events' ? 'events' : 'activity')
const viewingPollId = ref(null)
const viewingBetId = ref(null)
@@ -310,6 +311,13 @@ async function checkExpiredBets() {
+
+
+
+ 活动
+
+
+
diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue
index 2c19c83..2d0149f 100644
--- a/frontend/src/views/Home.vue
+++ b/frontend/src/views/Home.vue
@@ -78,7 +78,7 @@ function openGameDetail(game: Game) {
{{ userStore.user.statusNote }}
暂无评论,来说两句吧
+{{ c.content }}
+