Files
gamegroup2/frontend/src/components/event/EventList.vue
T
congsh a062889a11 fix: bug fixes and UX improvements (v0.3.5)
- Fix clipboard copy error in HTTP environment with execCommand fallback
- Fix team invite page not loading user groups, always showing "join group first"
- Fix JoinGroupPage isMember check using group object instead of user ID
- Fix cancelRSVP deleting all users' RSVP records instead of current user's
- Fix event detail not loading event data itself
- Fix event comment avatar URL missing PocketBase baseUrl prefix
- Fix event creation missing endTime > startTime validation
- Fix event manage/delete permission split (creator+owner vs creator+owner)
- Fix event create button only visible to admins, now all members can create
- Fix event expand not subscribing to comments/RSVP realtime updates
- Fix event relative time not using status field
- Remove duplicate create/join group buttons from header and welcome bar
- Refactor team invite link to use API function

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 22:19:18 +08:00

279 lines
7.0 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useEventStore } from '@/stores/event'
import { useGroupStore } from '@/stores/group'
import { ElMessage, ElMessageBox } from 'element-plus'
import { pb } from '@/api/pocketbase'
import type { Event } from '@/types'
import { Plus } from '@element-plus/icons-vue'
import CreateEventDialog from './CreateEventDialog.vue'
import EventCard from './EventCard.vue'
import { subscribeEvents, subscribeEventComments, subscribeEventRSVPs } from '@/api/events'
const route = useRoute()
const eventStore = useEventStore()
const groupStore = useGroupStore()
const groupId = route.params.id as string
const showCreateDialog = ref(false)
const editingEvent = ref<Event | null>(null)
const expandedEventId = ref<string | null>(null)
const isMember = computed(() => !!groupStore.currentGroup)
let unsubscribeFn: (() => void) | null = null
let unsubscribeCommentsFn: (() => void) | null = null
let unsubscribeRSVPsFn: (() => void) | null = null
onMounted(async () => {
await eventStore.loadEvents(groupId)
unsubscribeFn = await subscribeEvents(groupId, () => {
eventStore.loadEvents(groupId)
if (expandedEventId.value) {
eventStore.loadCommentsAndRSVPs(expandedEventId.value)
}
})
})
onUnmounted(() => {
if (unsubscribeFn) unsubscribeFn()
if (unsubscribeCommentsFn) unsubscribeCommentsFn()
if (unsubscribeRSVPsFn) unsubscribeRSVPsFn()
unsubscribeFn = null
unsubscribeCommentsFn = null
unsubscribeRSVPsFn = null
eventStore.clear()
})
async function toggleExpand(event: Event) {
if (expandedEventId.value === event.id) {
expandedEventId.value = null
if (unsubscribeCommentsFn) { unsubscribeCommentsFn(); unsubscribeCommentsFn = null }
if (unsubscribeRSVPsFn) { unsubscribeRSVPsFn(); unsubscribeRSVPsFn = null }
} else {
expandedEventId.value = event.id
await eventStore.loadCommentsAndRSVPs(event.id)
if (unsubscribeCommentsFn) unsubscribeCommentsFn()
if (unsubscribeRSVPsFn) unsubscribeRSVPsFn()
unsubscribeCommentsFn = await subscribeEventComments(event.id, () => {
eventStore.loadCommentsAndRSVPs(event.id)
})
unsubscribeRSVPsFn = await subscribeEventRSVPs(event.id, () => {
eventStore.loadCommentsAndRSVPs(event.id)
})
}
}
async function handleRSVP(eventId: string, type: 'going' | 'interested' | 'maybe') {
const current = eventStore.rsvps.find(r => r.event === eventId && r.user === pb.authStore.model?.id)
if (current?.type === type) {
await eventStore.cancelRSVP(eventId)
return
}
try {
await eventStore.rsvp(eventId, type)
} catch (err: any) {
ElMessage.error(err.message || '操作失败')
}
}
async function handleComment(eventId: string, content: string) {
const text = content.trim()
if (!text) return
try {
await eventStore.addComment(eventId, text)
} catch (err: any) {
ElMessage.error(err.message || '评论失败')
}
}
async function handleDelete(event: Event) {
try {
await ElMessageBox.confirm('确定要删除这个活动吗?', '确认删除', { type: 'warning' })
await eventStore.removeEvent(event.id)
ElMessage.success('已删除')
} catch {
// 取消
}
}
function handleEdit(event: Event) {
editingEvent.value = event
showCreateDialog.value = true
}
function onDialogClosed() {
editingEvent.value = null
}
</script>
<template>
<div class="event-list">
<div class="list-header">
<h2 class="list-title">群组活动</h2>
<button v-if="isMember" class="create-btn" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon> 发起活动
</button>
</div>
<div v-if="eventStore.loading && eventStore.events.length === 0" class="loading-state">
<div class="loading-spinner" />
<span>加载中...</span>
</div>
<div v-else-if="eventStore.events.length === 0" class="empty-state">
<p>暂无活动</p>
<p v-if="isMember" class="empty-hint">点击右上角发起一个活动吧</p>
</div>
<div v-else class="event-sections">
<div v-if="eventStore.upcomingEvents.length > 0" class="section">
<h3 class="section-label">即将开始</h3>
<div class="event-cards">
<EventCard
v-for="event in eventStore.upcomingEvents"
:key="event.id"
:event="event"
:expanded="expandedEventId === event.id"
@toggle="toggleExpand"
@rsvp="handleRSVP"
@comment="handleComment"
@delete="handleDelete"
@edit="handleEdit"
/>
</div>
</div>
<div v-if="eventStore.pastEvents.length > 0" class="section">
<h3 class="section-label">历史活动</h3>
<div class="event-cards">
<EventCard
v-for="event in eventStore.pastEvents"
:key="event.id"
:event="event"
:expanded="expandedEventId === event.id"
@toggle="toggleExpand"
@rsvp="handleRSVP"
@comment="handleComment"
@delete="handleDelete"
@edit="handleEdit"
/>
</div>
</div>
</div>
<CreateEventDialog
v-model="showCreateDialog"
:group-id="groupId"
:edit-event="editingEvent"
@created="eventStore.loadEvents(groupId)"
@updated="eventStore.loadEvents(groupId)"
@update:model-value="onDialogClosed"
/>
</div>
</template>
<style scoped>
.event-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.list-title {
font-size: 18px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.create-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--gg-gradient-green);
color: white;
border: none;
border-radius: var(--gg-radius-sm);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.create-btn:hover {
opacity: 0.9;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 60px;
color: var(--gg-text-muted);
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid var(--gg-border);
border-top-color: var(--gg-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--gg-text-muted);
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
}
.empty-state p {
margin: 0;
}
.empty-hint {
font-size: 13px;
margin-top: 8px !important;
opacity: 0.7;
}
.event-sections {
display: flex;
flex-direction: column;
gap: 24px;
}
.section-label {
font-size: 14px;
font-weight: 600;
color: var(--gg-text-muted);
margin: 0 0 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.event-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
</style>