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
@@ -0,0 +1,45 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
// add admins field
collection.schema.addField(new SchemaField({
"system": false,
"id": "sf_admins",
"name": "admins",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": null,
"displayFields": null
}
}))
// add allowMemberManage field
collection.schema.addField(new SchemaField({
"system": false,
"id": "sf_allowmm",
"name": "allowMemberManage",
"type": "bool",
"required": false,
"presentable": false,
"unique": false,
"options": {}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
collection.schema.removeField("sf_admins")
collection.schema.removeField("sf_allowmm")
return dao.saveCollection(collection)
})
@@ -0,0 +1,158 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "sf_events_001",
"created": "2026-04-21 00:00:00.000Z",
"updated": "2026-04-21 00:00:00.000Z",
"name": "events",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "sf_e_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": "sf_e_creator",
"name": "creator",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_e_title",
"name": "title",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "sf_e_desc",
"name": "description",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 2000,
"pattern": ""
}
},
{
"system": false,
"id": "sf_e_location",
"name": "location",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "sf_e_start",
"name": "startTime",
"type": "date",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "sf_e_end",
"name": "endTime",
"type": "date",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "sf_e_status",
"name": "status",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"upcoming",
"ongoing",
"completed",
"cancelled"
]
}
},
{
"system": false,
"id": "sf_e_max",
"name": "maxParticipants",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": 1,
"max": null,
"noDecimal": true
}
}
],
"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",
"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("sf_events_001");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,72 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "sf_ec_001",
"created": "2026-04-21 00:00:00.000Z",
"updated": "2026-04-21 00:00:00.000Z",
"name": "event_comments",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "sf_ec_event",
"name": "event",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "sf_events_001",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_ec_user",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_ec_content",
"name": "content",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": 1,
"max": 500,
"pattern": ""
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
"updateRule": "user = @request.auth.id",
"deleteRule": "user = @request.auth.id || event.group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("sf_ec_001");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,91 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "sf_er_001",
"created": "2026-04-21 00:00:00.000Z",
"updated": "2026-04-21 00:00:00.000Z",
"name": "event_rsvps",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "sf_er_event",
"name": "event",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "sf_events_001",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_er_user",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "sf_er_type",
"name": "type",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"going",
"interested",
"maybe"
]
}
},
{
"system": false,
"id": "sf_er_comment",
"name": "comment",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 200,
"pattern": ""
}
}
],
"indexes": [
"CREATE UNIQUE INDEX idx_event_user ON event_rsvps (event, user)"
],
"listRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
"updateRule": "user = @request.auth.id",
"deleteRule": "user = @request.auth.id || event.group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("sf_er_001");
return dao.deleteCollection(collection);
})
+163
View File
@@ -0,0 +1,163 @@
import pb from './pocketbase'
import type { Event, EventComment, EventRSVP, RSVPType } from '@/types'
// 获取群组的活动列表
export async function listEvents(groupId: string): Promise<Event[]> {
const result = await pb.collection('events').getFullList({
filter: `group="${groupId}"`,
sort: '-startTime',
expand: 'creator',
$autoCancel: false
})
return result as unknown as Event[]
}
// 获取活动详情
export async function getEvent(eventId: string): Promise<Event> {
return pb.collection('events').getOne(eventId, {
expand: 'creator,group',
$autoCancel: false
}) as unknown as Event
}
// 创建活动
export async function createEvent(data: {
group: string
title: string
description?: string
location?: string
startTime: string
endTime?: string
maxParticipants?: number
}): Promise<Event> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
return pb.collection('events').create({
...data,
creator: user.id,
status: 'upcoming'
}) as unknown as Event
}
// 更新活动
export async function updateEvent(eventId: string, data: Partial<Event>): Promise<Event> {
return pb.collection('events').update(eventId, data) as unknown as Event
}
// 删除活动
export async function deleteEvent(eventId: string) {
return pb.collection('events').delete(eventId)
}
// 获取活动的评论
export async function listEventComments(eventId: string): Promise<EventComment[]> {
const result = await pb.collection('event_comments').getFullList({
filter: `event="${eventId}"`,
sort: '-created',
expand: 'user',
$autoCancel: false
})
return result as unknown as EventComment[]
}
// 创建评论
export async function createEventComment(eventId: string, content: string): Promise<EventComment> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
return pb.collection('event_comments').create({
event: eventId,
user: user.id,
content
}) as unknown as EventComment
}
// 删除评论
export async function deleteEventComment(commentId: string) {
return pb.collection('event_comments').delete(commentId)
}
// 获取活动的 RSVP 列表
export async function listEventRSVPs(eventId: string): Promise<EventRSVP[]> {
const result = await pb.collection('event_rsvps').getFullList({
filter: `event="${eventId}"`,
sort: '-created',
expand: 'user',
$autoCancel: false
})
return result as unknown as EventRSVP[]
}
// 创建/更新 RSVP
export async function setEventRSVP(
eventId: string,
type: RSVPType,
comment?: string
): Promise<EventRSVP> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
// 检查是否已有 RSVP
const existing = await pb.collection('event_rsvps').getList(1, 1, {
filter: `event="${eventId}" && user="${user.id}"`
})
if (existing.items.length > 0) {
return pb.collection('event_rsvps').update(existing.items[0].id, {
type,
comment
}) as unknown as EventRSVP
}
return pb.collection('event_rsvps').create({
event: eventId,
user: user.id,
type,
comment
}) as unknown as EventRSVP
}
// 取消 RSVP
export async function cancelEventRSVP(eventId: string) {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const existing = await pb.collection('event_rsvps').getList(1, 1, {
filter: `event="${eventId}" && user="${user.id}"`
})
if (existing.items.length > 0) {
return pb.collection('event_rsvps').delete(existing.items[0].id)
}
}
// 订阅活动变更
export function subscribeEvents(groupId: string, callback: () => void) {
return pb.collection('events').subscribe('*', (payload) => {
const record = payload.record as any
if (record.group === groupId) {
callback()
}
})
}
// 订阅活动评论变更
export function subscribeEventComments(eventId: string, callback: () => void) {
return pb.collection('event_comments').subscribe('*', (payload) => {
const record = payload.record as any
if (record.event === eventId) {
callback()
}
})
}
// 订阅 RSVP 变更
export function subscribeEventRSVPs(eventId: string, callback: () => void) {
return pb.collection('event_rsvps').subscribe('*', (payload) => {
const record = payload.record as any
if (record.event === eventId) {
callback()
}
})
}
+70
View File
@@ -189,6 +189,76 @@ export function subscribeGroup(groupId: string, callback: (group: Group) => void
})
}
// 转让群主
export async function transferGroupOwnership(groupId: string, newOwnerId: string) {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const group = await pb.collection('groups').getOne(groupId)
if (group.owner !== user.id) {
throw new Error('只有群主可以转让群组')
}
const admins = (group.admins as string[]) || []
// 将原群主加入 admins,新群主从 admins 中移除
const newAdmins = [...new Set([...admins, user.id])].filter(id => id !== newOwnerId)
return pb.collection('groups').update(groupId, {
owner: newOwnerId,
admins: newAdmins
})
}
// 添加管理员
export async function addGroupAdmin(groupId: string, userId: string) {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const group = await pb.collection('groups').getOne(groupId)
if (group.owner !== user.id) {
throw new Error('只有群主可以设置管理员')
}
const admins = (group.admins as string[]) || []
if (admins.includes(userId)) {
throw new Error('该用户已是管理员')
}
return pb.collection('groups').update(groupId, {
admins: [...admins, userId]
})
}
// 移除管理员
export async function removeGroupAdmin(groupId: string, userId: string) {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const group = await pb.collection('groups').getOne(groupId)
if (group.owner !== user.id) {
throw new Error('只有群主可以移除管理员')
}
const admins = (group.admins as string[]) || []
return pb.collection('groups').update(groupId, {
admins: admins.filter(id => id !== userId)
})
}
// 更新全员管理设置
export async function updateGroupMemberManage(groupId: string, allow: boolean) {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const group = await pb.collection('groups').getOne(groupId)
if (group.owner !== user.id) {
throw new Error('只有群主可以修改此设置')
}
return pb.collection('groups').update(groupId, { allowMemberManage: allow })
}
// 订阅加入申请变更
export function subscribeJoinRequests(groupId: string, callback: (request: JoinRequest) => void) {
return pb.collection('join_requests').subscribe('*', (payload) => {
@@ -0,0 +1,209 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { useEventStore } from '@/stores/event'
const props = defineProps<{
modelValue: boolean
groupId: string
editEvent?: { id: string; title: string; description?: string; location?: string; startTime: string; endTime?: string; maxParticipants?: number } | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
created: []
updated: []
}>()
const eventStore = useEventStore()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const title = ref('')
const description = ref('')
const location = ref('')
const startTime = ref('')
const endTime = ref('')
const maxParticipants = ref<number | undefined>(undefined)
const submitting = ref(false)
const isEdit = computed(() => !!props.editEvent)
watch(() => props.editEvent, (evt) => {
if (evt) {
title.value = evt.title
description.value = evt.description || ''
location.value = evt.location || ''
startTime.value = formatForInput(evt.startTime)
endTime.value = evt.endTime ? formatForInput(evt.endTime) : ''
maxParticipants.value = evt.maxParticipants
} else {
reset()
}
}, { immediate: true })
function formatForInput(dateStr: string): string {
const d = new Date(dateStr)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function reset() {
title.value = ''
description.value = ''
location.value = ''
startTime.value = ''
endTime.value = ''
maxParticipants.value = undefined
}
async function handleSubmit() {
if (!title.value.trim()) {
ElMessage.warning('请输入活动标题')
return
}
if (!startTime.value) {
ElMessage.warning('请选择开始时间')
return
}
submitting.value = true
try {
const data = {
group: props.groupId,
title: title.value.trim(),
description: description.value.trim() || undefined,
location: location.value.trim() || undefined,
startTime: new Date(startTime.value).toISOString(),
endTime: endTime.value ? new Date(endTime.value).toISOString() : undefined,
maxParticipants: maxParticipants.value
}
if (isEdit.value && props.editEvent) {
await eventStore.editEvent(props.editEvent.id, data)
ElMessage.success('活动已更新')
emit('updated')
} else {
await eventStore.addEvent(data)
ElMessage.success('活动创建成功')
emit('created')
}
visible.value = false
reset()
} catch (err: any) {
ElMessage.error(err.message || '操作失败')
} finally {
submitting.value = false
}
}
function onClose() {
if (!isEdit.value) reset()
}
</script>
<template>
<el-dialog
v-model="visible"
:title="isEdit ? '编辑活动' : '发起活动'"
width="520px"
@close="onClose"
>
<div class="form">
<div class="field">
<label>活动标题 <span class="required">*</span></label>
<el-input v-model="title" placeholder="例如:周末面基开黑" maxlength="200" show-word-limit />
</div>
<div class="field">
<label>活动地点</label>
<el-input v-model="location" placeholder="例如:XX 网咖 / XX 餐厅" maxlength="200" />
</div>
<div class="field-row">
<div class="field field--half">
<label>开始时间 <span class="required">*</span></label>
<el-date-picker
v-model="startTime"
type="datetime"
placeholder="选择开始时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DDTHH:mm"
style="width: 100%"
/>
</div>
<div class="field field--half">
<label>结束时间</label>
<el-date-picker
v-model="endTime"
type="datetime"
placeholder="选择结束时间(可选)"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DDTHH:mm"
style="width: 100%"
/>
</div>
</div>
<div class="field">
<label>人数上限</label>
<el-input-number v-model="maxParticipants" :min="1" :max="999" placeholder="不限" style="width: 100%" />
</div>
<div class="field">
<label>活动详情</label>
<el-input
v-model="description"
type="textarea"
:rows="4"
placeholder="描述一下活动内容..."
maxlength="2000"
show-word-limit
/>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
{{ isEdit ? '保存' : '创建' }}
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.required {
color: var(--gg-danger);
}
.field-row {
display: flex;
gap: 12px;
}
.field--half {
flex: 1;
}
</style>
+413
View File
@@ -0,0 +1,413 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { pb } from '@/api/pocketbase'
import { useGroupStore } from '@/stores/group'
import { useEventStore } from '@/stores/event'
import { EventStatusMap, EventStatusColor, RSVPTypeMap, displayName } from '@/types'
import type { Event, RSVPType } from '@/types'
import { Calendar, Location, User, ChatDotRound } from '@element-plus/icons-vue'
const props = defineProps<{
event: Event
expanded: boolean
}>()
const emit = defineEmits<{
toggle: [event: Event]
rsvp: [eventId: string, type: RSVPType]
comment: [eventId: string, content: string]
delete: [event: Event]
edit: [event: Event]
}>()
const groupStore = useGroupStore()
const eventStore = useEventStore()
const commentInput = ref('')
const canManage = computed(() => {
const userId = pb.authStore.model?.id
return groupStore.isGroupOwner || groupStore.isGroupAdmin ||
(props.event.creator === userId)
})
function formatDateTime(dateStr: string): string {
const d = new Date(dateStr)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function formatRelative(dateStr: string): string {
const diff = new Date(dateStr).getTime() - Date.now()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
if (diff < 0 && days === 0) return '进行中'
if (diff < 0) return '已结束'
if (days === 0) return '今天'
if (days === 1) return '明天'
return `${days} 天后`
}
function getRSVPCounts(eventId: string) {
return {
going: eventStore.rsvps.filter(r => r.event === eventId && r.type === 'going').length,
interested: eventStore.rsvps.filter(r => r.event === eventId && r.type === 'interested').length,
maybe: eventStore.rsvps.filter(r => r.event === eventId && r.type === 'maybe').length
}
}
function getMyRSVP(eventId: string) {
const userId = pb.authStore.model?.id
return eventStore.rsvps.find(r => r.event === eventId && r.user === userId)
}
const counts = computed(() => getRSVPCounts(props.event.id))
const myRSVP = computed(() => getMyRSVP(props.event.id))
const comments = computed(() =>
eventStore.comments.filter(c => c.event === props.event.id)
)
</script>
<template>
<div class="event-card">
<div class="event-card-header" @click="emit('toggle', event)">
<div class="event-main">
<h3 class="event-title">{{ event.title }}</h3>
<span
class="event-status-tag"
:style="{ background: EventStatusColor[event.status] + '20', color: EventStatusColor[event.status] }"
>
{{ EventStatusMap[event.status] }}
</span>
</div>
<span class="event-relative">{{ formatRelative(event.startTime) }}</span>
</div>
<div class="event-info">
<span class="info-item">
<el-icon><Calendar /></el-icon>
{{ formatDateTime(event.startTime) }}
</span>
<span v-if="event.location" class="info-item">
<el-icon><Location /></el-icon>
{{ event.location }}
</span>
<span class="info-item">
<el-icon><User /></el-icon>
{{ counts.going }} 参加 · {{ counts.interested }} 感兴趣
</span>
</div>
<div v-if="expanded" class="event-expanded">
<p v-if="event.description" class="event-desc">{{ event.description }}</p>
<div class="rsvp-section">
<span class="rsvp-label">我要参加</span>
<div class="rsvp-buttons">
<button
v-for="type in (['going', 'interested', 'maybe'] as RSVPType[])"
:key="type"
class="rsvp-btn"
:class="{ 'rsvp-btn--active': myRSVP?.type === type }"
@click.stop="emit('rsvp', event.id, type)"
>
{{ RSVPTypeMap[type] }} {{ counts[type] }}
</button>
</div>
</div>
<div class="comments-section">
<div class="comments-header">
<el-icon><ChatDotRound /></el-icon>
评论 ({{ comments.length }})
</div>
<p v-if="comments.length === 0" class="no-comments">暂无评论来说两句吧</p>
<div v-else class="comments-list">
<div v-for="c in comments" :key="c.id" class="comment-item">
<img :src="c.expand?.user?.avatar || '/default-avatar.svg'" class="comment-avatar" />
<div class="comment-body">
<div class="comment-meta">
<span class="comment-author">{{ displayName(c.expand?.user) }}</span>
<span class="comment-time">{{ formatDateTime(c.created) }}</span>
</div>
<p class="comment-text">{{ c.content }}</p>
</div>
</div>
</div>
<div class="comment-input-row">
<input
v-model="commentInput"
class="comment-input"
placeholder="写个评论..."
@keyup.enter="() => { emit('comment', event.id, commentInput); commentInput = '' }"
/>
<button
class="comment-send-btn"
@click.stop="() => { emit('comment', event.id, commentInput); commentInput = '' }"
>
发送
</button>
</div>
</div>
<div v-if="canManage" class="event-actions">
<button class="action-link" @click.stop="emit('edit', event)">编辑</button>
<button class="action-link action-link--danger" @click.stop="emit('delete', event)">删除</button>
</div>
</div>
</div>
</template>
<style scoped>
.event-card {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
overflow: hidden;
transition: border-color 0.2s;
}
.event-card:hover {
border-color: var(--gg-primary);
}
.event-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
cursor: pointer;
gap: 12px;
}
.event-main {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.event-title {
font-size: 16px;
font-weight: 600;
margin: 0;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.event-status-tag {
font-size: 11px;
padding: 2px 10px;
border-radius: 20px;
font-weight: 500;
flex-shrink: 0;
}
.event-relative {
font-size: 13px;
color: var(--gg-text-muted);
flex-shrink: 0;
}
.event-info {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 0 18px 12px;
font-size: 13px;
color: var(--gg-text-secondary);
}
.info-item {
display: flex;
align-items: center;
gap: 4px;
}
.event-expanded {
padding: 0 18px 16px;
border-top: 1px solid var(--gg-border);
margin-top: 4px;
padding-top: 14px;
display: flex;
flex-direction: column;
gap: 16px;
}
.event-desc {
font-size: 14px;
color: var(--gg-text-secondary);
margin: 0;
line-height: 1.6;
}
.rsvp-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.rsvp-label {
font-size: 13px;
font-weight: 500;
color: var(--gg-text);
}
.rsvp-buttons {
display: flex;
gap: 8px;
}
.rsvp-btn {
flex: 1;
padding: 8px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
background: var(--gg-bg);
color: var(--gg-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.rsvp-btn:hover {
border-color: var(--gg-primary);
color: var(--gg-primary);
}
.rsvp-btn--active {
background: var(--gg-primary);
border-color: var(--gg-primary);
color: white;
}
.comments-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.comments-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--gg-text);
}
.no-comments {
font-size: 13px;
color: var(--gg-text-muted);
margin: 0;
padding: 8px 0;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.comment-item {
display: flex;
gap: 10px;
padding: 10px;
background: var(--gg-bg);
border-radius: var(--gg-radius-sm);
}
.comment-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.comment-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.comment-meta {
display: flex;
align-items: center;
gap: 8px;
}
.comment-author {
font-size: 13px;
font-weight: 500;
color: var(--gg-text);
}
.comment-time {
font-size: 11px;
color: var(--gg-text-muted);
}
.comment-text {
font-size: 13px;
color: var(--gg-text-secondary);
margin: 0;
line-height: 1.5;
}
.comment-input-row {
display: flex;
gap: 8px;
}
.comment-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
background: var(--gg-bg);
color: var(--gg-text);
font-size: 13px;
outline: none;
}
.comment-input:focus {
border-color: var(--gg-primary);
}
.comment-send-btn {
padding: 8px 16px;
background: var(--gg-gradient);
border: none;
border-radius: var(--gg-radius-sm);
color: white;
font-size: 13px;
cursor: pointer;
}
.comment-send-btn:hover {
opacity: 0.9;
}
.event-actions {
display: flex;
gap: 16px;
padding-top: 8px;
}
.action-link {
background: none;
border: none;
color: var(--gg-primary);
font-size: 13px;
cursor: pointer;
padding: 0;
}
.action-link--danger {
color: var(--gg-danger);
}
</style>
+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>
@@ -3,7 +3,15 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
import { getGroupJoinRequests, updateGroupApproval, subscribeJoinRequests } from '@/api/groups'
import {
getGroupJoinRequests,
updateGroupApproval,
subscribeJoinRequests,
transferGroupOwnership,
addGroupAdmin,
removeGroupAdmin,
updateGroupMemberManage
} from '@/api/groups'
import { ElSwitch } from 'element-plus'
import type { JoinRequest } from '@/types'
import JoinRequestCard from './JoinRequestCard.vue'
@@ -13,13 +21,20 @@ const userStore = useUserStore()
const group = computed(() => groupStore.currentGroup)
const members = computed(() => groupStore.currentMembers)
const isOwner = computed(() => group.value?.owner === userStore.userId)
const isOwner = computed(() => groupStore.isGroupOwner)
const isAdmin = computed(() => groupStore.isGroupAdmin)
const canManage = computed(() => groupStore.canManageGroup)
const joinRequests = ref<JoinRequest[]>([])
const approvalLoading = ref(false)
const memberManageLoading = ref(false)
const inviteLink = computed(() => {
if (!group.value?.id) return ''
return `${window.location.origin}/join/group/${group.value.id}`
})
onMounted(async () => {
if (group.value && isOwner.value) {
if (group.value && canManage.value) {
await loadJoinRequests()
subscribeJoinRequests(group.value.id, () => {
loadJoinRequests()
@@ -38,22 +53,23 @@ async function loadJoinRequests() {
} catch { /* ignore */ }
}
function copyGroupId() {
function copyInviteLink() {
if (group.value?.id) {
navigator.clipboard.writeText(group.value.id)
ElMessage.success('群组 ID 已复制,分享给好友即可加入')
navigator.clipboard.writeText(inviteLink.value)
ElMessage.success('邀请链接已复制,分享给好友即可加入')
}
}
async function removeMember(userId: string, username: string) {
if (!isOwner.value) return
if (!canManage.value) return
try {
await ElMessageBox.confirm(`确定要将 ${username} 移出群组吗?`, '确认', { type: 'warning' })
const grp = groupStore.currentGroup
if (!grp) return
const { pb } = await import('@/api/pocketbase')
const newMembers = grp.members.filter(id => id !== userId)
await pb.collection('groups').update(grp.id, { members: newMembers })
const newAdmins = (grp.admins || []).filter(id => id !== userId)
await pb.collection('groups').update(grp.id, { members: newMembers, admins: newAdmins })
await groupStore.setCurrentGroup(grp.id)
ElMessage.success('已移除成员')
} catch {
@@ -75,12 +91,64 @@ async function handleApprovalChange(val: string | number | boolean) {
}
}
async function handleMemberManageChange(val: string | number | boolean) {
if (!group.value || !isOwner.value) return
memberManageLoading.value = true
try {
await updateGroupMemberManage(group.value.id, !!val)
await groupStore.setCurrentGroup(group.value.id)
ElMessage.success(val ? '已开启全员管理' : '已关闭全员管理')
} catch {
ElMessage.error('更新设置失败')
} finally {
memberManageLoading.value = false
}
}
async function onJoinRequestResponded(requestId: string) {
joinRequests.value = joinRequests.value.filter(r => r.id !== requestId)
if (group.value) {
await groupStore.setCurrentGroup(group.value.id)
}
}
async function handleTransferOwnership(userId: string, username: string) {
if (!isOwner.value) return
try {
await ElMessageBox.confirm(
`确定要将群主转让给 ${username} 吗?转让后你将成为管理员。`,
'确认转让群主',
{ type: 'warning' }
)
await transferGroupOwnership(group.value!.id, userId)
await groupStore.setCurrentGroup(group.value!.id)
ElMessage.success('群主转让成功')
} catch (err: any) {
if (err.message) ElMessage.error(err.message)
}
}
async function handleAddAdmin(userId: string, username: string) {
if (!isOwner.value) return
try {
await addGroupAdmin(group.value!.id, userId)
await groupStore.setCurrentGroup(group.value!.id)
ElMessage.success(`${username} 已成为管理员`)
} catch (err: any) {
ElMessage.error(err.message || '操作失败')
}
}
async function handleRemoveAdmin(userId: string, username: string) {
if (!isOwner.value) return
try {
await removeGroupAdmin(group.value!.id, userId)
await groupStore.setCurrentGroup(group.value!.id)
ElMessage.success(`${username} 已取消管理员身份`)
} catch (err: any) {
ElMessage.error(err.message || '操作失败')
}
}
</script>
<template>
@@ -89,17 +157,17 @@ async function onJoinRequestResponded(requestId: string) {
<h3>群组信息</h3>
</div>
<!-- 分享群组 ID -->
<!-- 分享邀请链接 -->
<div class="share-section">
<label>群组 ID分享给好友</label>
<label>邀请链接分享给好友</label>
<div class="share-row">
<code class="group-id">{{ group.id }}</code>
<button class="copy-btn" @click="copyGroupId">复制</button>
<code class="group-id">{{ inviteLink }}</code>
<button class="copy-btn" @click="copyInviteLink">复制</button>
</div>
</div>
<!-- 审核开关仅群主可见 -->
<div v-if="isOwner" class="approval-row">
<!-- 审核开关管理员可见 -->
<div v-if="canManage" class="approval-row">
<span class="info-label">加入需审核</span>
<el-switch
:model-value="group.requireApproval"
@@ -108,13 +176,23 @@ async function onJoinRequestResponded(requestId: string) {
/>
</div>
<!-- 全员管理开关仅群主可见 -->
<div v-if="isOwner" class="approval-row">
<span class="info-label">全员管理</span>
<el-switch
:model-value="group.allowMemberManage"
:loading="memberManageLoading"
@change="handleMemberManageChange"
/>
</div>
<div class="info-row">
<span class="info-label">成员</span>
<span class="info-value">{{ members.length }} / {{ group.maxMembers }}</span>
</div>
<!-- 待审核申请仅群主可见 -->
<div v-if="isOwner && joinRequests.length > 0" class="requests-section">
<!-- 待审核申请管理员可见 -->
<div v-if="canManage && joinRequests.length > 0" class="requests-section">
<h4 class="requests-title">待审核申请 ({{ joinRequests.length }})</h4>
<div class="requests-list">
<JoinRequestCard
@@ -132,9 +210,40 @@ async function onJoinRequestResponded(requestId: string) {
<div class="member-info">
<span class="member-name">{{ member.name || member.username }}</span>
<span v-if="member.id === group.owner" class="owner-badge">群主</span>
<span v-else-if="(group.admins || []).includes(member.id)" class="admin-badge">管理</span>
</div>
<!-- 管理操作菜单 -->
<div v-if="isOwner && member.id !== group.owner && member.id !== userStore.userId" class="member-actions">
<button
v-if="member.id !== group.owner && !(group.admins || []).includes(member.id)"
class="action-btn action-btn--admin"
@click="handleAddAdmin(member.id, member.name || member.username)"
>
设管
</button>
<button
v-if="(group.admins || []).includes(member.id)"
class="action-btn action-btn--admin"
@click="handleRemoveAdmin(member.id, member.name || member.username)"
>
取消管理
</button>
<button
class="action-btn action-btn--transfer"
@click="handleTransferOwnership(member.id, member.name || member.username)"
>
转让
</button>
<button
class="action-btn action-btn--remove"
@click="removeMember(member.id, member.name || member.username)"
>
移除
</button>
</div>
<!-- 管理员移除成员 -->
<button
v-if="isOwner && member.id !== group.owner && member.id !== userStore.userId"
v-else-if="isAdmin && member.id !== group.owner && member.id !== userStore.userId"
class="remove-btn"
@click="removeMember(member.id, member.name || member.username)"
>
@@ -284,10 +393,14 @@ async function onJoinRequestResponded(requestId: string) {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.member-name {
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.owner-badge {
@@ -296,6 +409,63 @@ async function onJoinRequestResponded(requestId: string) {
background: var(--gg-gradient);
color: white;
border-radius: 10px;
flex-shrink: 0;
}
.admin-badge {
font-size: 11px;
padding: 1px 8px;
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
border-radius: 10px;
flex-shrink: 0;
}
.member-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.action-btn {
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: all 0.2s;
border: none;
white-space: nowrap;
}
.action-btn--admin {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.action-btn--admin:hover {
background: rgba(59, 130, 246, 0.2);
}
.action-btn--transfer {
background: rgba(245, 158, 11, 0.1);
color: #f59e0b;
border: 1px solid rgba(245, 158, 11, 0.3);
}
.action-btn--transfer:hover {
background: rgba(245, 158, 11, 0.2);
}
.action-btn--remove {
background: none;
border: 1px solid var(--gg-danger);
color: var(--gg-danger);
opacity: 0.7;
}
.action-btn--remove:hover {
opacity: 1;
}
.remove-btn {
@@ -308,9 +478,10 @@ async function onJoinRequestResponded(requestId: string) {
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
flex-shrink: 0;
}
.remove-btn:hover {
opacity: 1;
}
</style>
</style>
@@ -50,6 +50,18 @@ async function endGame() {
}
}
const inviteLink = computed(() => {
if (!session.value) return ''
return `${window.location.origin}/join/team/${session.value.id}`
})
function copyInviteLink() {
if (inviteLink.value) {
navigator.clipboard.writeText(inviteLink.value)
ElMessage.success('邀请链接已复制')
}
}
async function handleGameSelected(gameName: string) {
let group = groupStore.currentGroup
if (!group) {
@@ -92,6 +104,12 @@ async function handleGameSelected(gameName: string) {
<span class="game-name">{{ session.gameName }}</span>
</div>
<div class="invite-link-row">
<span class="link-label">邀请:</span>
<code class="link-code">{{ inviteLink }}</code>
<button class="link-copy-btn" @click="copyInviteLink">复制</button>
</div>
<div class="members-section">
<h4 class="members-title">成员 ({{ memberDetails.length }})</h4>
<div class="members-list">
@@ -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;
+12
View File
@@ -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',
+200
View File
@@ -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<Event[]>([])
const currentEvent = ref<Event | null>(null)
const comments = ref<EventComment[]>([])
const rsvps = ref<EventRSVP[]>([])
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<typeof createEvent>[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<Event>) {
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
}
})
+11
View File
@@ -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,
+78
View File
@@ -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<EventStatus, string> = {
upcoming: '即将开始',
ongoing: '进行中',
completed: '已结束',
cancelled: '已取消'
}
export const EventStatusColor: Record<EventStatus, string> = {
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<RSVPType, string> = {
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'
+10 -2
View File
@@ -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<string | null>(null)
const viewingBetId = ref<string | null>(null)
@@ -310,6 +311,13 @@ async function checkExpiredBets() {
<BetDetail v-if="viewingBetId" :bet-id="viewingBetId" @back="viewingBetId = null" />
<BetList v-else @view-bet="viewingBetId = $event" />
</el-tab-pane>
<el-tab-pane name="events">
<template #label>
<span class="fancy-tab"><el-icon><Calendar /></el-icon> 活动</span>
</template>
<EventList />
</el-tab-pane>
</el-tabs>
</div>
</template>
+1 -1
View File
@@ -78,7 +78,7 @@ function openGameDetail(game: Game) {
<span v-if="userStore.user?.statusNote" class="status-note">{{ userStore.user.statusNote }}</span>
</div>
</div>
<div class="welcome-actions">
<div v-if="!hasNoGroup" class="welcome-actions">
<button class="cta-btn cta-btn--primary" @click="showCreateGroup = true">
<el-icon><Plus /></el-icon> 创建群组
</button>
+295
View File
@@ -0,0 +1,295 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getGroup, joinGroup, createJoinRequest } from '@/api/groups'
import { useGroupStore } from '@/stores/group'
import { isAuthenticated } from '@/api/pocketbase'
import type { Group } from '@/types'
import { User, Link } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const groupStore = useGroupStore()
const group = ref<Group | null>(null)
const loading = ref(true)
const joining = ref(false)
const error = ref('')
const groupId = route.params.groupId as string
onMounted(async () => {
try {
group.value = await getGroup(groupId)
} catch {
error.value = '群组不存在或已解散'
} finally {
loading.value = false
}
})
function goToLogin() {
router.push({ name: 'Login', query: { redirect: route.fullPath } })
}
function goToGroup() {
router.push({ name: 'GroupView', params: { id: groupId } })
}
async function handleJoin() {
if (!isAuthenticated()) {
goToLogin()
return
}
joining.value = true
try {
if (group.value?.requireApproval) {
await createJoinRequest(groupId)
ElMessage.success('已提交加入申请,等待群主审核')
} else {
await joinGroup(groupId)
ElMessage.success('已成功加入群组')
await groupStore.loadGroups()
goToGroup()
}
} catch (err: any) {
ElMessage.error(err.message || '加入失败')
} finally {
joining.value = false
}
}
const isMember = computed(() => {
if (!group.value) return false
const userId = groupStore.groups.find(g => g.id === groupId)
return !!userId
})
const isLoggedIn = computed(() => isAuthenticated())
</script>
<template>
<div class="join-page">
<div class="join-card">
<div class="join-header">
<el-icon :size="40" class="header-icon"><Link /></el-icon>
<h1>群组邀请</h1>
</div>
<div v-if="loading" class="loading-state">
<div class="loading-spinner" />
<span>加载中...</span>
</div>
<div v-else-if="error" class="error-state">
<p>{{ error }}</p>
<button class="action-btn" @click="$router.push('/')">返回首页</button>
</div>
<div v-else-if="group" class="group-info">
<div class="info-block">
<h2 class="group-name">{{ group.name }}</h2>
<p v-if="group.description" class="group-desc">{{ group.description }}</p>
<div class="meta-row">
<span class="meta-item">
<el-icon><User /></el-icon>
{{ group.members?.length || 0 }} / {{ group.maxMembers }}
</span>
<span v-if="group.requireApproval" class="tag tag--warning">需审核</span>
<span v-else class="tag tag--success">可直接加入</span>
</div>
</div>
<div v-if="isMember" class="action-block">
<p class="tip">你已经是该群组的成员</p>
<button class="action-btn action-btn--primary" @click="goToGroup">
进入群组
</button>
</div>
<div v-else-if="!isLoggedIn" class="action-block">
<p class="tip">登录后即可加入群组</p>
<button class="action-btn action-btn--primary" @click="goToLogin">
登录 / 注册
</button>
</div>
<div v-else class="action-block">
<p class="tip">{{ group.requireApproval ? '加入该群组需要群主审核' : '点击即可加入群组' }}</p>
<button
class="action-btn action-btn--primary"
:disabled="joining"
@click="handleJoin"
>
{{ joining ? '处理中...' : (group.requireApproval ? '申请加入' : '立即加入') }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.join-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--gg-bg);
}
.join-card {
width: 100%;
max-width: 440px;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-lg);
padding: 32px;
}
.join-header {
text-align: center;
margin-bottom: 24px;
}
.join-header h1 {
font-size: 20px;
font-weight: 700;
margin: 12px 0 0;
color: var(--gg-text);
}
.header-icon {
color: var(--gg-primary);
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px;
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); }
}
.error-state {
text-align: center;
padding: 24px;
color: var(--gg-danger);
}
.error-state p {
margin: 0 0 16px;
}
.group-info {
display: flex;
flex-direction: column;
gap: 20px;
}
.info-block {
text-align: center;
}
.group-name {
font-size: 22px;
font-weight: 700;
margin: 0 0 8px;
color: var(--gg-text);
}
.group-desc {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0 0 12px;
line-height: 1.5;
}
.meta-row {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 13px;
color: var(--gg-text-secondary);
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.tag {
font-size: 12px;
padding: 2px 10px;
border-radius: 20px;
font-weight: 500;
}
.tag--success {
background: rgba(5, 150, 105, 0.12);
color: var(--gg-primary);
}
.tag--warning {
background: rgba(245, 158, 11, 0.12);
color: #f59e0b;
}
.action-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.tip {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0;
}
.action-btn {
width: 100%;
padding: 12px;
border-radius: var(--gg-radius-sm);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.action-btn--primary {
background: var(--gg-gradient-green);
color: white;
}
.action-btn--primary:hover {
opacity: 0.9;
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.3);
}
.action-btn--primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
+302
View File
@@ -0,0 +1,302 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { pb, isAuthenticated } from '@/api/pocketbase'
import { useGroupStore } from '@/stores/group'
import { joinTeamSession } from '@/api/sessions'
import type { TeamSession } from '@/types'
import { Promotion, User } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const groupStore = useGroupStore()
const session = ref<TeamSession | null>(null)
const loading = ref(true)
const joining = ref(false)
const error = ref('')
const sessionId = route.params.sessionId as string
onMounted(async () => {
try {
const record = await pb.collection('team_sessions').getOne(sessionId, {
expand: 'sourceGroup',
$autoCancel: false
}) as any
session.value = record as unknown as TeamSession
} catch {
error.value = '小队不存在或已解散'
} finally {
loading.value = false
}
})
function goToLogin() {
router.push({ name: 'Login', query: { redirect: route.fullPath } })
}
function goToGroup() {
const groupId = session.value?.sourceGroup
if (groupId) {
router.push({ name: 'GroupView', params: { id: groupId } })
}
}
async function handleJoin() {
if (!isAuthenticated()) {
goToLogin()
return
}
joining.value = true
try {
await joinTeamSession(sessionId)
ElMessage.success('已成功加入小队')
goToGroup()
} catch (err: any) {
ElMessage.error(err.message || '加入失败')
} finally {
joining.value = false
}
}
const isLoggedIn = computed(() => isAuthenticated())
const isInTeam = computed(() => {
const userId = pb.authStore.model?.id
if (!userId || !session.value) return false
return session.value.members?.includes(userId)
})
const isInSourceGroup = computed(() => {
const userId = pb.authStore.model?.id
if (!userId || !session.value) return false
return groupStore.groups.some(g => g.id === session.value?.sourceGroup)
})
</script>
<template>
<div class="join-page">
<div class="join-card">
<div class="join-header">
<el-icon :size="40" class="header-icon"><Promotion /></el-icon>
<h1>组队邀请</h1>
</div>
<div v-if="loading" class="loading-state">
<div class="loading-spinner" />
<span>加载中...</span>
</div>
<div v-else-if="error" class="error-state">
<p>{{ error }}</p>
<button class="action-btn" @click="$router.push('/')">返回首页</button>
</div>
<div v-else-if="session" class="session-info">
<div class="info-block">
<h2 class="session-name">{{ session.name }}</h2>
<p class="session-game">游戏{{ session.gameName }}</p>
<div class="meta-row">
<span class="meta-item">
<el-icon><User /></el-icon>
{{ session.members?.length || 0 }}
</span>
<span class="tag tag--info">{{ session.status === 'recruiting' ? '招募中' : '游戏中' }}</span>
</div>
</div>
<div v-if="isInTeam" class="action-block">
<p class="tip">你已经是该小队的成员</p>
<button class="action-btn action-btn--primary" @click="goToGroup">
进入小队
</button>
</div>
<div v-else-if="!isLoggedIn" class="action-block">
<p class="tip">登录后即可加入小队</p>
<button class="action-btn action-btn--primary" @click="goToLogin">
登录 / 注册
</button>
</div>
<div v-else-if="!isInSourceGroup" class="action-block">
<p class="tip">你需要先加入该小队所属的群组</p>
<button class="action-btn action-btn--primary" @click="goToGroup">
查看群组
</button>
</div>
<div v-else class="action-block">
<p class="tip">点击即可加入临时小队</p>
<button
class="action-btn action-btn--primary"
:disabled="joining"
@click="handleJoin"
>
{{ joining ? '处理中...' : '立即加入' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.join-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: var(--gg-bg);
}
.join-card {
width: 100%;
max-width: 440px;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-lg);
padding: 32px;
}
.join-header {
text-align: center;
margin-bottom: 24px;
}
.join-header h1 {
font-size: 20px;
font-weight: 700;
margin: 12px 0 0;
color: var(--gg-text);
}
.header-icon {
color: var(--gg-primary);
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px;
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); }
}
.error-state {
text-align: center;
padding: 24px;
color: var(--gg-danger);
}
.error-state p {
margin: 0 0 16px;
}
.session-info {
display: flex;
flex-direction: column;
gap: 20px;
}
.info-block {
text-align: center;
}
.session-name {
font-size: 22px;
font-weight: 700;
margin: 0 0 8px;
color: var(--gg-text);
}
.session-game {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0 0 12px;
}
.meta-row {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 13px;
color: var(--gg-text-secondary);
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.tag {
font-size: 12px;
padding: 2px 10px;
border-radius: 20px;
font-weight: 500;
}
.tag--info {
background: rgba(59, 130, 246, 0.12);
color: #3b82f6;
}
.action-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.tip {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0;
}
.action-btn {
width: 100%;
padding: 12px;
border-radius: var(--gg-radius-sm);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.action-btn--primary {
background: var(--gg-gradient-green);
color: white;
}
.action-btn--primary:hover {
opacity: 0.9;
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.3);
}
.action-btn--primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>