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 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-21 22:19:18 +08:00
parent 2fec2108ca
commit a062889a11
13 changed files with 125 additions and 52 deletions
+2
View File
@@ -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('未登录')
+8
View File
@@ -38,6 +38,14 @@ export async function getGroupTeamSessions(groupId: string): Promise<TeamSession
return result.items as unknown as TeamSession[]
}
// 获取单个临时小组详情
export async function getTeamSession(sessionId: string): Promise<TeamSession> {
return pb.collection('team_sessions').getOne(sessionId, {
expand: 'sourceGroup',
$autoCancel: false
}) as unknown as TeamSession
}
// 更新临时小组状态
export async function updateTeamStatus(sessionId: string, status: TeamStatus): Promise<TeamSession> {
const updateData: Partial<TeamSession> = { status }
@@ -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 {
+19 -12
View File
@@ -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] }}
</span>
</div>
<span class="event-relative">{{ formatRelative(event.startTime) }}</span>
<span class="event-relative">{{ formatRelative(event) }}</span>
</div>
<div class="event-info">
@@ -123,7 +130,7 @@ const comments = computed(() =>
<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" />
<img :src="c.expand?.user?.avatar ? `${pb.baseUrl}/api/files/users/${c.user}/${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>
@@ -149,9 +156,9 @@ const comments = computed(() =>
</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 v-if="canManage || canDelete" class="event-actions">
<button v-if="canManage" class="action-link" @click.stop="emit('edit', event)">编辑</button>
<button v-if="canDelete" class="action-link action-link--danger" @click.stop="emit('delete', event)">删除</button>
</div>
</div>
</div>
+22 -8
View File
@@ -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<Event | null>(null)
const expandedEventId = ref<string | null>(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() {
<div class="event-list">
<div class="list-header">
<h2 class="list-title">群组活动</h2>
<button v-if="canManage" class="create-btn" @click="showCreateDialog = true">
<button v-if="isMember" class="create-btn" @click="showCreateDialog = true">
<el-icon><Plus /></el-icon> 发起活动
</button>
</div>
@@ -111,7 +125,7 @@ function onDialogClosed() {
<div v-else-if="eventStore.events.length === 0" class="empty-state">
<p>暂无活动</p>
<p v-if="canManage" class="empty-hint">点击右上角发起一个活动吧</p>
<p v-if="isMember" class="empty-hint">点击右上角发起一个活动吧</p>
</div>
<div v-else class="event-sections">
@@ -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('邀请链接已复制,分享给好友即可加入')
}
}
@@ -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('邀请链接已复制')
}
}
+7 -3
View File
@@ -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
+20
View File
@@ -10,6 +10,26 @@ interface LogEntry {
}
const logs = ref<LogEntry[]>([
{
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',
-8
View File
@@ -78,14 +78,6 @@ function openGameDetail(game: Game) {
<span v-if="userStore.user?.statusNote" class="status-note">{{ userStore.user.statusNote }}</span>
</div>
</div>
<div v-if="!hasNoGroup" class="welcome-actions">
<button class="cta-btn cta-btn--primary" @click="showCreateGroup = true">
<el-icon><Plus /></el-icon> 创建群组
</button>
<button class="cta-btn cta-btn--secondary" @click="showJoinGroup = true">
<el-icon><Search /></el-icon> 加入群组
</button>
</div>
</section>
<!-- 无群组引导 -->
+4 -3
View File
@@ -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())
+5 -6
View File
@@ -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 {
-8
View File
@@ -173,14 +173,6 @@ const pageTitle = computed(() => {
<h2 class="page-title">{{ pageTitle }}</h2>
<div class="header-actions">
<button class="header-action-btn" @click="showCreateGroup = true" title="创建群组">
<el-icon><Plus /></el-icon>
<span class="header-action-label">创建群组</span>
</button>
<button class="header-action-btn" @click="showJoinGroup = true" title="加入群组">
<el-icon><Search /></el-icon>
<span class="header-action-label">加入群组</span>
</button>
<button class="icon-btn" @click="notificationStore.togglePanel()">
<span v-if="notificationStore.unreadCount > 0" class="badge">
{{ notificationStore.unreadCount }}