a062889a11
- 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>
279 lines
7.0 KiB
Vue
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>
|