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:
@@ -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('未登录')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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('邀请链接已复制')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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())
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user