From a062889a1155d3443cd3cc055697c52638475f9c Mon Sep 17 00:00:00 2001 From: congsh Date: Tue, 21 Apr 2026 22:19:18 +0800 Subject: [PATCH] fix: bug fixes and UX improvements (v0.3.5) - Fix clipboard copy error in HTTP environment with execCommand fallback - Fix team invite page not loading user groups, always showing "join group first" - Fix JoinGroupPage isMember check using group object instead of user ID - Fix cancelRSVP deleting all users' RSVP records instead of current user's - Fix event detail not loading event data itself - Fix event comment avatar URL missing PocketBase baseUrl prefix - Fix event creation missing endTime > startTime validation - Fix event manage/delete permission split (creator+owner vs creator+owner) - Fix event create button only visible to admins, now all members can create - Fix event expand not subscribing to comments/RSVP realtime updates - Fix event relative time not using status field - Remove duplicate create/join group buttons from header and welcome bar - Refactor team invite link to use API function Co-Authored-By: Claude Opus 4.7 --- frontend/src/api/groups.ts | 2 ++ frontend/src/api/sessions.ts | 8 +++++ .../components/event/CreateEventDialog.vue | 4 +++ frontend/src/components/event/EventCard.vue | 31 ++++++++++++------- frontend/src/components/event/EventList.vue | 30 +++++++++++++----- .../components/group/GroupMembersPanel.vue | 19 ++++++++++-- .../src/components/team/TeamSessionPanel.vue | 19 ++++++++++-- frontend/src/stores/event.ts | 10 ++++-- frontend/src/views/Changelog.vue | 20 ++++++++++++ frontend/src/views/Home.vue | 8 ----- frontend/src/views/JoinGroupPage.vue | 7 +++-- frontend/src/views/JoinTeamPage.vue | 11 +++---- frontend/src/views/Layout.vue | 8 ----- 13 files changed, 125 insertions(+), 52 deletions(-) diff --git a/frontend/src/api/groups.ts b/frontend/src/api/groups.ts index f81ad7c..87d2e05 100644 --- a/frontend/src/api/groups.ts +++ b/frontend/src/api/groups.ts @@ -190,6 +190,8 @@ export function subscribeGroup(groupId: string, callback: (group: Group) => void } // 转让群主 +// 安全提示:PB groups updateRule 允许认证用户更新,前端校验是唯一防线。 +// 如需服务端强制保护,需添加 PocketBase JS hooks 或改用更严格的 updateRule。 export async function transferGroupOwnership(groupId: string, newOwnerId: string) { const user = pb.authStore.model if (!user) throw new Error('未登录') diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 9fbb104..988d3f5 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -38,6 +38,14 @@ export async function getGroupTeamSessions(groupId: string): Promise { + return pb.collection('team_sessions').getOne(sessionId, { + expand: 'sourceGroup', + $autoCancel: false + }) as unknown as TeamSession +} + // 更新临时小组状态 export async function updateTeamStatus(sessionId: string, status: TeamStatus): Promise { const updateData: Partial = { status } diff --git a/frontend/src/components/event/CreateEventDialog.vue b/frontend/src/components/event/CreateEventDialog.vue index 4e8e577..870e2e2 100644 --- a/frontend/src/components/event/CreateEventDialog.vue +++ b/frontend/src/components/event/CreateEventDialog.vue @@ -69,6 +69,10 @@ async function handleSubmit() { ElMessage.warning('请选择开始时间') return } + if (endTime.value && new Date(endTime.value) <= new Date(startTime.value)) { + ElMessage.warning('结束时间必须晚于开始时间') + return + } submitting.value = true try { diff --git a/frontend/src/components/event/EventCard.vue b/frontend/src/components/event/EventCard.vue index 7eed733..59bf8db 100644 --- a/frontend/src/components/event/EventCard.vue +++ b/frontend/src/components/event/EventCard.vue @@ -27,7 +27,13 @@ const commentInput = ref('') const canManage = computed(() => { const userId = pb.authStore.model?.id - return groupStore.isGroupOwner || groupStore.isGroupAdmin || + return groupStore.isGroupOwner || + (props.event.creator === userId) +}) + +const canDelete = computed(() => { + const userId = pb.authStore.model?.id + return groupStore.isGroupOwner || (props.event.creator === userId) }) @@ -37,12 +43,13 @@ function formatDateTime(dateStr: string): string { 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 '今天' +function formatRelative(event: Event): string { + if (event.status === 'completed') return '已结束' + if (event.status === 'cancelled') return '已取消' + if (event.status === 'ongoing') return '进行中' + const diff = new Date(event.startTime).getTime() - Date.now() + const days = Math.ceil(diff / (1000 * 60 * 60 * 24)) + if (days <= 0) return '今天' if (days === 1) return '明天' return `${days} 天后` } @@ -79,7 +86,7 @@ const comments = computed(() => {{ EventStatusMap[event.status] }} - {{ formatRelative(event.startTime) }} + {{ formatRelative(event) }}
@@ -123,7 +130,7 @@ const comments = computed(() =>

暂无评论,来说两句吧

- +
{{ displayName(c.expand?.user) }} @@ -149,9 +156,9 @@ const comments = computed(() =>
-
- - +
+ +
diff --git a/frontend/src/components/event/EventList.vue b/frontend/src/components/event/EventList.vue index 8015b8e..7d8333d 100644 --- a/frontend/src/components/event/EventList.vue +++ b/frontend/src/components/event/EventList.vue @@ -9,7 +9,7 @@ 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' +import { subscribeEvents, subscribeEventComments, subscribeEventRSVPs } from '@/api/events' const route = useRoute() const eventStore = useEventStore() @@ -20,9 +20,11 @@ const showCreateDialog = ref(false) const editingEvent = ref(null) const expandedEventId = ref(null) -const canManage = computed(() => groupStore.canManageGroup) +const isMember = computed(() => !!groupStore.currentGroup) let unsubscribeFn: (() => void) | null = null +let unsubscribeCommentsFn: (() => void) | null = null +let unsubscribeRSVPsFn: (() => void) | null = null onMounted(async () => { await eventStore.loadEvents(groupId) @@ -35,19 +37,31 @@ onMounted(async () => { }) onUnmounted(() => { - if (unsubscribeFn) { - unsubscribeFn() - unsubscribeFn = null - } + if (unsubscribeFn) unsubscribeFn() + if (unsubscribeCommentsFn) unsubscribeCommentsFn() + if (unsubscribeRSVPsFn) unsubscribeRSVPsFn() + unsubscribeFn = null + unsubscribeCommentsFn = null + unsubscribeRSVPsFn = null eventStore.clear() }) async function toggleExpand(event: Event) { if (expandedEventId.value === event.id) { expandedEventId.value = null + if (unsubscribeCommentsFn) { unsubscribeCommentsFn(); unsubscribeCommentsFn = null } + if (unsubscribeRSVPsFn) { unsubscribeRSVPsFn(); unsubscribeRSVPsFn = null } } else { expandedEventId.value = event.id await eventStore.loadCommentsAndRSVPs(event.id) + if (unsubscribeCommentsFn) unsubscribeCommentsFn() + if (unsubscribeRSVPsFn) unsubscribeRSVPsFn() + unsubscribeCommentsFn = await subscribeEventComments(event.id, () => { + eventStore.loadCommentsAndRSVPs(event.id) + }) + unsubscribeRSVPsFn = await subscribeEventRSVPs(event.id, () => { + eventStore.loadCommentsAndRSVPs(event.id) + }) } } @@ -99,7 +113,7 @@ function onDialogClosed() {

群组活动

-
@@ -111,7 +125,7 @@ function onDialogClosed() {

暂无活动

-

点击右上角发起一个活动吧

+

点击右上角发起一个活动吧

diff --git a/frontend/src/components/group/GroupMembersPanel.vue b/frontend/src/components/group/GroupMembersPanel.vue index d536255..68668b9 100644 --- a/frontend/src/components/group/GroupMembersPanel.vue +++ b/frontend/src/components/group/GroupMembersPanel.vue @@ -54,8 +54,23 @@ async function loadJoinRequests() { } function copyInviteLink() { - if (group.value?.id) { - navigator.clipboard.writeText(inviteLink.value) + if (!group.value?.id) return + const text = inviteLink.value + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(() => { + ElMessage.success('邀请链接已复制,分享给好友即可加入') + }).catch(() => { + ElMessage.error('复制失败,请手动复制') + }) + } else { + const ta = document.createElement('textarea') + ta.value = text + ta.style.position = 'fixed' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) ElMessage.success('邀请链接已复制,分享给好友即可加入') } } diff --git a/frontend/src/components/team/TeamSessionPanel.vue b/frontend/src/components/team/TeamSessionPanel.vue index 9d572be..f1d4b5b 100644 --- a/frontend/src/components/team/TeamSessionPanel.vue +++ b/frontend/src/components/team/TeamSessionPanel.vue @@ -56,8 +56,23 @@ const inviteLink = computed(() => { }) function copyInviteLink() { - if (inviteLink.value) { - navigator.clipboard.writeText(inviteLink.value) + if (!inviteLink.value) return + const text = inviteLink.value + if (navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).then(() => { + ElMessage.success('邀请链接已复制') + }).catch(() => { + ElMessage.error('复制失败,请手动复制') + }) + } else { + const ta = document.createElement('textarea') + ta.value = text + ta.style.position = 'fixed' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) ElMessage.success('邀请链接已复制') } } diff --git a/frontend/src/stores/event.ts b/frontend/src/stores/event.ts index 187ef42..5441c07 100644 --- a/frontend/src/stores/event.ts +++ b/frontend/src/stores/event.ts @@ -1,8 +1,10 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' +import pb from '@/api/pocketbase' import type { Event, EventComment, EventRSVP, RSVPType } from '@/types' import { listEvents, + getEvent, listEventComments, listEventRSVPs, createEvent, @@ -53,11 +55,12 @@ export const useEventStore = defineStore('event', () => { async function loadEventDetail(eventId: string) { try { loading.value = true - const [, commentData, rsvpData] = await Promise.all([ - listEventComments(eventId).then(() => null).catch(() => null), + const [eventData, commentData, rsvpData] = await Promise.all([ + getEvent(eventId), listEventComments(eventId), listEventRSVPs(eventId) ]) + currentEvent.value = eventData comments.value = commentData rsvps.value = rsvpData } catch (error) { @@ -161,7 +164,8 @@ export const useEventStore = defineStore('event', () => { async function cancelRSVP(eventId: string) { try { await cancelEventRSVP(eventId) - rsvps.value = rsvps.value.filter(r => r.event !== eventId) + const userId = pb.authStore.model?.id + rsvps.value = rsvps.value.filter(r => !(r.event === eventId && r.user === userId)) } catch (error: any) { console.error('取消 RSVP 失败:', error) throw error diff --git a/frontend/src/views/Changelog.vue b/frontend/src/views/Changelog.vue index 66473f0..4bec289 100644 --- a/frontend/src/views/Changelog.vue +++ b/frontend/src/views/Changelog.vue @@ -10,6 +10,26 @@ interface LogEntry { } const logs = ref([ + { + version: 'v0.3.5', + date: '2026-04-21', + title: 'Bug 修复与体验优化', + items: [ + { type: 'fix', text: '修复邀请链接复制在 HTTP 环境下报错的问题,添加 execCommand 降级方案' }, + { type: 'fix', text: '修复小队邀请链接页面未加载用户群组数据,导致始终提示"需要先加入群组"' }, + { type: 'fix', text: '修复加入群组页面 isMember 判断错误,使用了群组对象而非用户 ID' }, + { type: 'fix', text: '修复取消 RSVP 时误删所有用户 RSVP 记录的问题' }, + { type: 'fix', text: '修复活动详情加载时未获取活动本身数据的问题' }, + { type: 'fix', text: '修复活动评论头像 URL 未拼接 PocketBase baseUrl 导致图片加载失败' }, + { type: 'fix', text: '修复活动创建未校验结束时间必须晚于开始时间' }, + { type: 'fix', text: '修复活动管理权限判断,拆分编辑权限(创建者+群主)和删除权限(创建者+群主)' }, + { type: 'fix', text: '修复活动发起按钮仅管理员可见,改为所有群组成员可发起' }, + { type: 'fix', text: '修复活动展开详情后未订阅评论和 RSVP 实时更新' }, + { type: 'fix', text: '修复活动相对时间未使用 status 字段判断状态的问题' }, + { type: 'refactor', text: '移除顶部 Header 和首页欢迎条中重复的"创建群组""加入群组"按钮' }, + { type: 'refactor', text: '小队邀请链接改用 API 函数替代原始 PocketBase 调用' }, + ] + }, { version: 'v0.3.4', date: '2026-04-21', diff --git a/frontend/src/views/Home.vue b/frontend/src/views/Home.vue index 2d0149f..4379a7e 100644 --- a/frontend/src/views/Home.vue +++ b/frontend/src/views/Home.vue @@ -78,14 +78,6 @@ function openGameDetail(game: Game) { {{ userStore.user.statusNote }}
-
- - -
diff --git a/frontend/src/views/JoinGroupPage.vue b/frontend/src/views/JoinGroupPage.vue index 52ee3c7..854ed68 100644 --- a/frontend/src/views/JoinGroupPage.vue +++ b/frontend/src/views/JoinGroupPage.vue @@ -4,7 +4,7 @@ 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 { pb, isAuthenticated } from '@/api/pocketbase' import type { Group } from '@/types' import { User, Link } from '@element-plus/icons-vue' @@ -63,8 +63,9 @@ async function handleJoin() { const isMember = computed(() => { if (!group.value) return false - const userId = groupStore.groups.find(g => g.id === groupId) - return !!userId + const userId = pb.authStore.model?.id + if (!userId) return false + return group.value.members?.includes(userId) || false }) const isLoggedIn = computed(() => isAuthenticated()) diff --git a/frontend/src/views/JoinTeamPage.vue b/frontend/src/views/JoinTeamPage.vue index 35cee66..7251de0 100644 --- a/frontend/src/views/JoinTeamPage.vue +++ b/frontend/src/views/JoinTeamPage.vue @@ -4,7 +4,7 @@ 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 { joinTeamSession, getTeamSession } from '@/api/sessions' import type { TeamSession } from '@/types' import { Promotion, User } from '@element-plus/icons-vue' @@ -21,11 +21,10 @@ 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 + if (isAuthenticated()) { + await groupStore.loadGroups() + } + session.value = await getTeamSession(sessionId) } catch { error.value = '小队不存在或已解散' } finally { diff --git a/frontend/src/views/Layout.vue b/frontend/src/views/Layout.vue index 9980448..19a278a 100644 --- a/frontend/src/views/Layout.vue +++ b/frontend/src/views/Layout.vue @@ -173,14 +173,6 @@ const pageTitle = computed(() => {

{{ pageTitle }}

- -