feat: onboarding optimization, invite links, admin roles, and event board

- Home: hide duplicate create/join buttons when user has no groups
- Invite links: /join/group/:id and /join/team/:id pages for one-click joining
- Admin: group admins field, ownership transfer, member management toggle
- Events: new events collection with RSVP (going/interested/maybe) and comments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wjl
2026-04-21 17:27:24 +08:00
parent 0cde794c85
commit 2fec2108ca
19 changed files with 2644 additions and 21 deletions
+264
View File
@@ -0,0 +1,264 @@
<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 } 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 canManage = computed(() => groupStore.canManageGroup)
let unsubscribeFn: (() => 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()
unsubscribeFn = null
}
eventStore.clear()
})
async function toggleExpand(event: Event) {
if (expandedEventId.value === event.id) {
expandedEventId.value = null
} else {
expandedEventId.value = event.id
await 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="canManage" 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="canManage" 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>