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:
@@ -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);
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user