diff --git a/backend/pb_migrations/1776760462_updated_groups.js b/backend/pb_migrations/1776760462_updated_groups.js new file mode 100644 index 0000000..3de9971 --- /dev/null +++ b/backend/pb_migrations/1776760462_updated_groups.js @@ -0,0 +1,45 @@ +/// +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) +}) diff --git a/backend/pb_migrations/1776761256_created_events.js b/backend/pb_migrations/1776761256_created_events.js new file mode 100644 index 0000000..9c57102 --- /dev/null +++ b/backend/pb_migrations/1776761256_created_events.js @@ -0,0 +1,158 @@ +/// +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); +}) diff --git a/backend/pb_migrations/1776761257_created_event_comments.js b/backend/pb_migrations/1776761257_created_event_comments.js new file mode 100644 index 0000000..f06161f --- /dev/null +++ b/backend/pb_migrations/1776761257_created_event_comments.js @@ -0,0 +1,72 @@ +/// +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); +}) diff --git a/backend/pb_migrations/1776761258_created_event_rsvps.js b/backend/pb_migrations/1776761258_created_event_rsvps.js new file mode 100644 index 0000000..373eeea --- /dev/null +++ b/backend/pb_migrations/1776761258_created_event_rsvps.js @@ -0,0 +1,91 @@ +/// +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); +}) diff --git a/frontend/src/api/events.ts b/frontend/src/api/events.ts new file mode 100644 index 0000000..851d6a2 --- /dev/null +++ b/frontend/src/api/events.ts @@ -0,0 +1,163 @@ +import pb from './pocketbase' +import type { Event, EventComment, EventRSVP, RSVPType } from '@/types' + +// 获取群组的活动列表 +export async function listEvents(groupId: string): Promise { + 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 { + 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 { + 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): Promise { + 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 { + 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 { + 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 { + 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 { + 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() + } + }) +} diff --git a/frontend/src/api/groups.ts b/frontend/src/api/groups.ts index 18ab9c7..f81ad7c 100644 --- a/frontend/src/api/groups.ts +++ b/frontend/src/api/groups.ts @@ -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) => { diff --git a/frontend/src/components/event/CreateEventDialog.vue b/frontend/src/components/event/CreateEventDialog.vue new file mode 100644 index 0000000..4e8e577 --- /dev/null +++ b/frontend/src/components/event/CreateEventDialog.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/frontend/src/components/event/EventCard.vue b/frontend/src/components/event/EventCard.vue new file mode 100644 index 0000000..7eed733 --- /dev/null +++ b/frontend/src/components/event/EventCard.vue @@ -0,0 +1,413 @@ + + + + + diff --git a/frontend/src/components/event/EventList.vue b/frontend/src/components/event/EventList.vue new file mode 100644 index 0000000..8015b8e --- /dev/null +++ b/frontend/src/components/event/EventList.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/frontend/src/components/group/GroupMembersPanel.vue b/frontend/src/components/group/GroupMembersPanel.vue index 99cfd2c..d536255 100644 --- a/frontend/src/components/group/GroupMembersPanel.vue +++ b/frontend/src/components/group/GroupMembersPanel.vue @@ -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([]) 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 || '操作失败') + } +} diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index 2c19c83..2d0149f 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -78,7 +78,7 @@ function openGameDetail(game: Game) { {{ userStore.user.statusNote }} -
+
diff --git a/frontend/src/views/JoinGroupPage.vue b/frontend/src/views/JoinGroupPage.vue new file mode 100644 index 0000000..52ee3c7 --- /dev/null +++ b/frontend/src/views/JoinGroupPage.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/frontend/src/views/JoinTeamPage.vue b/frontend/src/views/JoinTeamPage.vue new file mode 100644 index 0000000..35cee66 --- /dev/null +++ b/frontend/src/views/JoinTeamPage.vue @@ -0,0 +1,302 @@ + + + + +