feat(mobile): stage 11 - events + join landing pages (final stage)

- new EventListMobile.vue: upcoming/past sections + expandable detail
  with RSVP (going/interested/maybe) + comments + realtime subscription
- new CreateEventSheetMobile.vue: title/desc/location/start/end/max-participants
- new JoinGroupPageMobile.vue: group invite landing (login/join/approval flow)
- new JoinTeamPageMobile.vue: team invite landing (login/join/source-group check)
- GroupViewMobile: add '活动' tab, wire EventListMobile; all 8 tabs now live
- router: wire JoinGroup/JoinTeam mobile views; remove unused mobilePlaceholder
- reuses uat event store (loadEvents/rsvp/cancelRSVP/addComment/removeEvent)
  + sessions API (getTeamSession/joinTeamSession)

All 11 stages complete. Full mobile frontend on uat covering all PC features
including uat-exclusive bulletin board, events, group-scoped games, and
invite landing pages.

build verified: vue-tsc + vite build pass
This commit is contained in:
锦麟 王
2026-06-18 11:33:23 +08:00
parent 062c044295
commit 2af0025c3e
6 changed files with 953 additions and 21 deletions
@@ -0,0 +1,168 @@
<!-- src/components-mobile/event/CreateEventSheetMobile.vue -->
<!-- 创建活动底部面板标题/描述/地点/开始/结束/人数上限 -->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useEventStore } from '@/stores/event'
import { showSuccessToast, showFailToast } from 'vant'
const props = defineProps<{
show: boolean
groupId: string
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
'created': []
}>()
const eventStore = useEventStore()
const visible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
const form = ref({
title: '',
description: '',
location: '',
startTime: '',
endTime: '',
maxParticipants: 0
})
const saving = ref(false)
watch(() => props.show, (val) => {
if (val) {
// 默认开始时间为当前+1小时(取整点)
const now = new Date()
now.setHours(now.getHours() + 1, 0, 0, 0)
form.value = {
title: '',
description: '',
location: '',
startTime: toLocalInput(now),
endTime: '',
maxParticipants: 0
}
}
})
function toLocalInput(d: Date): string {
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())}`
}
async function handleSubmit() {
if (!form.value.title.trim()) {
showFailToast('请输入活动标题')
return
}
if (!form.value.startTime) {
showFailToast('请设置开始时间')
return
}
const startTime = new Date(form.value.startTime).toISOString()
const endTime = form.value.endTime ? new Date(form.value.endTime).toISOString() : undefined
if (endTime && new Date(endTime) <= new Date(startTime)) {
showFailToast('结束时间必须晚于开始时间')
return
}
saving.value = true
try {
await eventStore.addEvent({
group: props.groupId,
title: form.value.title.trim(),
description: form.value.description.trim() || undefined,
location: form.value.location.trim() || undefined,
startTime,
endTime,
maxParticipants: form.value.maxParticipants > 0 ? form.value.maxParticipants : undefined
})
showSuccessToast('创建成功')
emit('created')
} catch (e: any) {
showFailToast(e.message || '创建失败')
} finally {
saving.value = false
}
}
</script>
<template>
<van-popup
v-model:show="visible"
position="bottom"
round
closeable
:style="{ height: '80%' }"
>
<div class="create-sheet">
<div class="sheet-title">发起活动</div>
<van-cell-group inset>
<van-field v-model="form.title" label="标题" placeholder="活动名称" required maxlength="100" />
<van-field
v-model="form.description"
type="textarea"
label="描述"
placeholder="活动详情(可选)"
rows="3"
autosize
/>
<van-field v-model="form.location" label="地点" placeholder="如:语音房 / 线下某处" />
<van-field
v-model="form.startTime"
type="datetime-local"
label="开始时间"
placeholder="选择时间"
required
/>
<van-field
v-model="form.endTime"
type="datetime-local"
label="结束时间"
placeholder="可选"
/>
<van-field name="stepper" label="人数上限">
<template #input>
<van-stepper v-model="form.maxParticipants" min="0" max="200" />
</template>
</van-field>
</van-cell-group>
<div class="hint">人数上限为 0 表示不限制</div>
<div class="sheet-actions">
<van-button type="primary" block round :loading="saving" @click="handleSubmit">
创建活动
</van-button>
</div>
</div>
</van-popup>
</template>
<style scoped>
.create-sheet {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
padding: 16px 0 24px;
}
.sheet-title {
text-align: center;
font-size: 16px;
font-weight: 600;
padding: 8px 0 16px;
}
.hint {
padding: 8px 16px;
font-size: 12px;
color: var(--gg-text-muted);
}
.sheet-actions {
padding: 16px;
margin-top: auto;
}
</style>
@@ -0,0 +1,328 @@
<!-- src/components-mobile/event/EventListMobile.vue -->
<!-- 手机端事件列表 + 展开详情RSVP + 评论+ 创建入口 -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useEventStore } from '@/stores/event'
import { useGroupStore } from '@/stores/group'
import { pb } from '@/api/pocketbase'
import { subscribeEvents, subscribeEventComments, subscribeEventRSVPs } from '@/api/events'
import { EventStatusMap, RSVPTypeMap } from '@/types'
import type { Event, RSVPType } from '@/types'
import { displayName } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
import CreateEventSheetMobile from './CreateEventSheetMobile.vue'
const props = defineProps<{ groupId: string }>()
const eventStore = useEventStore()
const groupStore = useGroupStore()
const upcomingEvents = computed(() => eventStore.upcomingEvents)
const pastEvents = computed(() => eventStore.pastEvents)
const expandedId = ref<string | null>(null)
const showCreate = ref(false)
let unsubEvents: (() => void) | null = null
let unsubComments: (() => void) | null = null
let unsubRSVPs: (() => void) | null = null
const currentUserId = computed(() => pb.authStore.model?.id || '')
onMounted(async () => {
await eventStore.loadEvents(props.groupId)
try {
unsubEvents = await subscribeEvents(props.groupId, async () => {
await eventStore.loadEvents(props.groupId)
if (expandedId.value) await loadDetail(expandedId.value)
})
} catch (e) { console.error(e) }
})
onUnmounted(() => {
if (unsubEvents) unsubEvents()
if (unsubComments) unsubComments()
if (unsubRSVPs) unsubRSVPs()
eventStore.clear()
})
async function loadDetail(eventId: string) {
await eventStore.loadCommentsAndRSVPs(eventId)
}
async function toggleExpand(event: Event) {
if (expandedId.value === event.id) {
expandedId.value = null
if (unsubComments) { unsubComments(); unsubComments = null }
if (unsubRSVPs) { unsubRSVPs(); unsubRSVPs = null }
} else {
expandedId.value = event.id
await loadDetail(event.id)
if (unsubComments) unsubComments()
if (unsubRSVPs) unsubRSVPs()
try {
unsubComments = await subscribeEventComments(event.id, () => loadDetail(event.id))
unsubRSVPs = await subscribeEventRSVPs(event.id, () => loadDetail(event.id))
} catch (e) { console.error(e) }
}
}
const rsvps = computed(() => eventStore.rsvps)
const comments = computed(() => eventStore.comments)
function myRsvp(eventId: string) {
if (expandedId.value !== eventId) return null
return rsvps.value.find(r => r.user === currentUserId.value) || null
}
function goingCount(eventId: string): number {
if (expandedId.value !== eventId) return 0
return rsvps.value.filter(r => r.type === 'going').length
}
async function doRsvp(event: Event, type: RSVPType) {
try {
await eventStore.rsvp(event.id, type)
showSuccessToast(`${RSVPTypeMap[type]}`)
} catch (e: any) {
showFailToast(e.message || '操作失败')
}
}
async function cancelRsvp(event: Event) {
try {
await eventStore.cancelRSVP(event.id)
showSuccessToast('已取消')
} catch (e: any) {
showFailToast(e.message || '操作失败')
}
}
// 评论
const newComment = ref('')
const commentSubmitting = ref(false)
async function submitComment(eventId: string) {
if (!newComment.value.trim()) {
showFailToast('请输入评论')
return
}
commentSubmitting.value = true
try {
await eventStore.addComment(eventId, newComment.value.trim())
newComment.value = ''
} catch (e: any) {
showFailToast(e.message || '评论失败')
} finally {
commentSubmitting.value = false
}
}
async function handleDelete(event: Event) {
showConfirmDialog({
title: '删除事件',
message: `确定要删除「${event.title}」吗?`
}).then(async () => {
try {
await eventStore.removeEvent(event.id)
showSuccessToast('已删除')
expandedId.value = null
} catch (e: any) {
showFailToast(e.message || '删除失败')
}
}).catch(() => {})
}
function canManage(event: Event): boolean {
if (groupStore.isGroupOwner) return true
if (groupStore.isGroupAdmin) return true
if (event.creator === currentUserId.value) return true
return false
}
function statusType(s: string): any {
return s === 'upcoming' ? 'primary' : s === 'ongoing' ? 'success' : 'default'
}
function formatDateTime(dateStr: string): string {
return new Date(dateStr).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
async function onCreated() {
showCreate.value = false
await eventStore.loadEvents(props.groupId)
}
</script>
<template>
<div class="event-mobile">
<div class="toolbar">
<van-button type="primary" size="small" round icon="plus" @click="showCreate = true">
发起活动
</van-button>
</div>
<div v-if="upcomingEvents.length === 0 && pastEvents.length === 0" class="empty">
<van-empty description="暂无活动" image-size="100">
<van-button type="primary" size="small" round @click="showCreate = true">发起第一个活动</van-button>
</van-empty>
</div>
<!-- 即将开始 -->
<div v-if="upcomingEvents.length > 0" class="section-block">
<div class="block-title">即将开始</div>
<div v-for="event in upcomingEvents" :key="event.id" class="event-card-wrap">
<div class="event-card" @click="toggleExpand(event)">
<div class="event-card-header">
<van-tag :type="statusType(event.status)" size="medium">{{ EventStatusMap[event.status] }}</van-tag>
<span v-if="expandedId !== event.id && goingCount(event.id) > 0" class="event-going">
{{ goingCount(event.id) }} 人参加
</span>
<van-icon :name="expandedId === event.id ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
</div>
<div class="event-title">{{ event.title }}</div>
<div class="event-time">🕐 {{ formatDateTime(event.startTime) }}</div>
<div v-if="event.location" class="event-loc">📍 {{ event.location }}</div>
</div>
<!-- 展开详情 -->
<div v-if="expandedId === event.id" class="event-detail">
<p v-if="event.description" class="detail-desc">{{ event.description }}</p>
<div v-if="event.endTime" class="detail-meta">结束{{ formatDateTime(event.endTime) }}</div>
<div v-if="event.maxParticipants" class="detail-meta">人数上限{{ event.maxParticipants }}</div>
<!-- RSVP -->
<div class="detail-section">
<div class="detail-section-title">参加报名{{ goingCount(event.id) }} </div>
<div class="rsvp-row">
<template v-if="myRsvp(event.id)">
<van-tag :type="myRsvp(event.id)!.type === 'going' ? 'success' : 'primary'" size="large">
{{ RSVPTypeMap[myRsvp(event.id)!.type] }}
</van-tag>
<van-button size="mini" plain round @click="cancelRsvp(event)">取消报名</van-button>
</template>
<template v-else>
<van-button size="small" type="primary" round @click="doRsvp(event, 'going')">参加</van-button>
<van-button size="small" round @click="doRsvp(event, 'interested')">感兴趣</van-button>
<van-button size="small" round @click="doRsvp(event, 'maybe')">可能参加</van-button>
</template>
</div>
</div>
<!-- 评论 -->
<div class="detail-section">
<div class="detail-section-title">评论 ({{ comments.length }})</div>
<div class="comment-input-row">
<van-field v-model="newComment" placeholder="写评论..." class="comment-input" />
<van-button size="small" type="primary" :loading="commentSubmitting" @click="submitComment(event.id)">发送</van-button>
</div>
<div v-if="comments.length === 0" class="comment-empty">暂无评论</div>
<div v-else class="comment-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" alt="" />
<div class="comment-body">
<div class="comment-author">{{ displayName(c.expand?.user) }}</div>
<div class="comment-content">{{ c.content }}</div>
</div>
</div>
</div>
</div>
<!-- 管理操作 -->
<div v-if="canManage(event)" class="detail-actions">
<van-button size="small" plain type="danger" round icon="delete-o" @click="handleDelete(event)">删除</van-button>
</div>
</div>
</div>
</div>
<!-- 已结束 -->
<div v-if="pastEvents.length > 0" class="section-block">
<div class="block-title">已结束</div>
<div v-for="event in pastEvents" :key="event.id" class="event-card-wrap">
<div class="event-card ended" @click="toggleExpand(event)">
<div class="event-card-header">
<van-tag type="default" size="medium">{{ EventStatusMap[event.status] }}</van-tag>
<van-icon :name="expandedId === event.id ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
</div>
<div class="event-title">{{ event.title }}</div>
<div class="event-time">🕐 {{ formatDateTime(event.startTime) }}</div>
</div>
<div v-if="expandedId === event.id" class="event-detail">
<p v-if="event.description" class="detail-desc">{{ event.description }}</p>
<div v-if="canManage(event)" class="detail-actions">
<van-button size="small" plain type="danger" round icon="delete-o" @click="handleDelete(event)">删除</van-button>
</div>
</div>
</div>
</div>
<CreateEventSheetMobile v-model:show="showCreate" :group-id="props.groupId" @created="onCreated" />
</div>
</template>
<style scoped>
.event-mobile { padding: 12px; }
.toolbar { display: flex; justify-content: flex-end; margin-bottom: 12px; }
.empty { padding: 20px 0; }
.section-block { margin-bottom: 16px; }
.block-title {
font-size: 13px;
font-weight: 600;
color: var(--gg-text-muted);
margin-bottom: 8px;
padding: 0 2px;
}
.event-card-wrap { margin-bottom: 10px; }
.event-card {
background: var(--gg-bg-card);
border-radius: var(--gg-radius-md);
padding: 14px;
box-shadow: var(--gg-shadow);
}
.event-card:active { opacity: 0.85; }
.event-card.ended { opacity: 0.7; }
.event-card-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.event-going { font-size: 12px; color: var(--gg-text-muted); margin-left: auto; }
.expand-icon { margin-left: auto; color: var(--gg-text-muted); font-size: 14px; }
.event-title { font-size: 16px; font-weight: 600; color: var(--gg-text); }
.event-time { font-size: 13px; color: var(--gg-text-secondary); margin-top: 6px; }
.event-loc { font-size: 13px; color: var(--gg-text-secondary); margin-top: 2px; }
.event-detail {
background: var(--gg-bg);
border-radius: 0 0 var(--gg-radius-md) var(--gg-radius-md);
padding: 14px;
margin-top: -4px;
}
.detail-desc { font-size: 14px; color: var(--gg-text); line-height: 1.6; margin: 0 0 12px; white-space: pre-wrap; }
.detail-meta { font-size: 12px; color: var(--gg-text-muted); margin-bottom: 4px; }
.detail-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--gg-border); }
.detail-section-title { font-size: 13px; font-weight: 600; color: var(--gg-text-secondary); margin-bottom: 10px; }
.rsvp-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.comment-input-row { display: flex; gap: 8px; align-items: center; }
.comment-input { flex: 1; background: var(--gg-bg-card); border-radius: var(--gg-radius-sm); padding: 4px 10px; }
.comment-empty { font-size: 12px; color: var(--gg-text-muted); padding: 12px 0; text-align: center; }
.comment-list { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
.comment-item { display: flex; gap: 8px; }
.comment-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
.comment-body { flex: 1; min-width: 0; }
.comment-author { font-size: 12px; font-weight: 600; color: var(--gg-text); }
.comment-content { font-size: 13px; color: var(--gg-text-secondary); margin-top: 2px; word-break: break-word; }
.detail-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--gg-border); }
</style>
+2 -14
View File
@@ -15,17 +15,6 @@ function view(desktop: () => Promise<any>, mobile: () => Promise<any>) {
return isMobile() ? mobile : desktop
}
// 阶段 1 占位:尚未实现的 mobile 视图统一指向 Placeholder,后续阶段逐个替换为真实组件
// 阶段 2: LoginMobile / RegisterMobile
// 阶段 3: HomeMobile / GroupsMobile / NotificationsMobile
// 阶段 4: GroupViewMobile
// 阶段 5: VoiceRoomMobile
// 阶段 7: GamesLibraryMobile
// 阶段 8: LedgerMobile / AssetMobile / BlacklistMobile
// 阶段 9: ProfileMobile / SettingsMobile / ChangelogMobile
// 阶段 11: JoinGroupPageMobile / JoinTeamPageMobile
const mobilePlaceholder = () => import('@/views-mobile/Placeholder.vue')
// 路由配置
const routes: RouteRecordRaw[] = [
{
@@ -161,10 +150,9 @@ const routes: RouteRecordRaw[] = [
{
path: '/join/group/:groupId',
name: 'JoinGroup',
// 邀请落地页:手机端组件阶段 11 填充,现先用 Placeholder 让分流跑通
component: view(
() => import('@/views/JoinGroupPage.vue'),
mobilePlaceholder
() => import('@/views-mobile/JoinGroupPageMobile.vue')
),
props: true
},
@@ -173,7 +161,7 @@ const routes: RouteRecordRaw[] = [
name: 'JoinTeam',
component: view(
() => import('@/views/JoinTeamPage.vue'),
mobilePlaceholder
() => import('@/views-mobile/JoinTeamPageMobile.vue')
),
props: true
},
@@ -13,6 +13,7 @@ import BetListMobile from '@/components-mobile/bet/BetListMobile.vue'
import MemoryGridMobile from '@/components-mobile/memory/MemoryGridMobile.vue'
import StatsPanelMobile from '@/components-mobile/stats/StatsPanelMobile.vue'
import BulletinListMobile from '@/components-mobile/bulletin/BulletinListMobile.vue'
import EventListMobile from '@/components-mobile/event/EventListMobile.vue'
import Placeholder from '@/views-mobile/Placeholder.vue'
import { Wallet, Box, Warning } from '@element-plus/icons-vue'
@@ -57,11 +58,11 @@ onUnmounted(async () => {
}
})
// 标签配置
// polls/bets/memories/stats 子组件已迁移;bulletin 阶段 10 新增;events 阶段 11 新增
// 标签配置(全部 tab 已接入)
const tabs = [
{ name: 'activity', label: '动态' },
{ name: 'bulletin', label: '公告' },
{ name: 'events', label: '活动' },
{ name: 'polls', label: '投票' },
{ name: 'bets', label: '竞猜' },
{ name: 'members', label: '成员' },
@@ -125,18 +126,14 @@ function goBlacklist() {
>
<van-tab v-for="tab in tabs" :key="tab.name" :name="tab.name" :title="tab.label">
<div class="tab-content">
<!-- 阶段 4 已迁移 -->
<ActivityFeedMobile v-if="activeTab === 'activity'" :group-id="groupId" />
<MemberListMobile v-else-if="activeTab === 'members'" :group-id="groupId" />
<!-- 阶段 6 已迁移 -->
<PollListMobile v-else-if="activeTab === 'polls'" :group-id="groupId" />
<BetListMobile v-else-if="activeTab === 'bets'" :group-id="groupId" />
<!-- 阶段 9 已迁移 -->
<MemoryGridMobile v-else-if="activeTab === 'memories'" :group-id="groupId" />
<StatsPanelMobile v-else-if="activeTab === 'stats'" :group-id="groupId" />
<!-- 阶段 10 新增 -->
<BulletinListMobile v-else-if="activeTab === 'bulletin'" :group-id="groupId" />
<!-- 以下 tab 子组件在阶段 11 迁移后接入现暂用占位 -->
<EventListMobile v-else-if="activeTab === 'events'" :group-id="groupId" />
<Placeholder v-else />
</div>
</van-tab>
@@ -0,0 +1,222 @@
<!-- src/views-mobile/JoinGroupPageMobile.vue -->
<!-- 手机端群组邀请落地页 -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getGroup, joinGroup, createJoinRequest } from '@/api/groups'
import { useGroupStore } from '@/stores/group'
import { pb, isAuthenticated } from '@/api/pocketbase'
import type { Group } from '@/types'
import { showSuccessToast, showFailToast } from 'vant'
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
}
})
const isLoggedIn = computed(() => isAuthenticated())
const isMember = computed(() => {
if (!group.value) return false
const userId = pb.authStore.model?.id
if (!userId) return false
return group.value.members?.includes(userId) || false
})
function goToLogin() {
router.push({ name: 'Login', query: { redirect: route.fullPath } })
}
function goToGroup() {
router.push({ name: 'GroupView', params: { id: groupId } })
}
async function handleJoin() {
if (!isLoggedIn.value) {
goToLogin()
return
}
joining.value = true
try {
if (group.value?.requireApproval) {
await createJoinRequest(groupId)
showSuccessToast('已提交加入申请,等待审核')
} else {
await joinGroup(groupId)
await groupStore.loadGroups()
showSuccessToast('已成功加入群组')
goToGroup()
}
} catch (e: any) {
showFailToast(e.message || '加入失败')
} finally {
joining.value = false
}
}
</script>
<template>
<div class="join-page">
<div class="join-card">
<div class="join-header">
<div class="header-icon">🔗</div>
<h1>群组邀请</h1>
</div>
<div v-if="loading" class="loading-state">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="error" class="error-state">
<van-icon name="warning-o" size="48" />
<p>{{ error }}</p>
<van-button block round @click="router.push('/')">返回首页</van-button>
</div>
<div v-else-if="group" class="group-info">
<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">
<van-icon name="friends-o" /> {{ group.members?.length || 0 }} / {{ group.maxMembers }}
</span>
</div>
<van-tag v-if="group.requireApproval" type="warning" size="large" round class="approval-tag">需审核</van-tag>
<van-tag v-else type="success" size="large" round class="approval-tag">可直接加入</van-tag>
<div v-if="isMember" class="action-block">
<p class="tip">你已经是该群组的成员</p>
<van-button type="primary" block round @click="goToGroup">进入群组</van-button>
</div>
<div v-else-if="!isLoggedIn" class="action-block">
<p class="tip">登录后即可加入群组</p>
<van-button type="primary" block round @click="goToLogin">登录 / 注册</van-button>
</div>
<div v-else class="action-block">
<p class="tip">{{ group.requireApproval ? '加入该群组需要群主审核' : '点击即可加入群组' }}</p>
<van-button type="primary" block round :loading="joining" @click="handleJoin">
{{ group.requireApproval ? '申请加入' : '立即加入' }}
</van-button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.join-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: var(--gg-bg);
}
.join-card {
width: 100%;
max-width: 420px;
background: var(--gg-bg-card);
border-radius: var(--gg-radius-lg);
padding: 32px 24px;
box-shadow: var(--gg-shadow);
}
.join-header {
text-align: center;
margin-bottom: 24px;
}
.header-icon {
font-size: 40px;
margin-bottom: 8px;
}
.join-header h1 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.loading-state {
display: flex;
justify-content: center;
padding: 40px;
}
.error-state {
text-align: center;
padding: 16px 0;
color: var(--gg-danger);
}
.error-state p { margin: 12px 0 16px; font-size: 14px; }
.error-state .van-button { max-width: 200px; margin: 0 auto; }
.group-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.group-name {
font-size: 22px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.group-desc {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0;
line-height: 1.5;
}
.meta-row {
font-size: 13px;
color: var(--gg-text-secondary);
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.approval-tag { margin: 4px 0 12px; }
.action-block {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.tip {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0;
}
</style>
@@ -0,0 +1,229 @@
<!-- src/views-mobile/JoinTeamPageMobile.vue -->
<!-- 手机端组队邀请落地页 -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { pb, isAuthenticated } from '@/api/pocketbase'
import { useGroupStore } from '@/stores/group'
import { joinTeamSession, getTeamSession } from '@/api/sessions'
import type { TeamSession } from '@/types'
import { showSuccessToast, showFailToast } from 'vant'
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 {
if (isAuthenticated()) {
await groupStore.loadGroups()
}
session.value = await getTeamSession(sessionId)
} catch {
error.value = '小队不存在或已解散'
} finally {
loading.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) || false
})
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)
})
function goToLogin() {
router.push({ name: 'Login', query: { redirect: route.fullPath } })
}
function goToGroup() {
const gid = session.value?.sourceGroup
if (gid) router.push({ name: 'GroupView', params: { id: gid } })
}
async function handleJoin() {
if (!isLoggedIn.value) {
goToLogin()
return
}
joining.value = true
try {
await joinTeamSession(sessionId)
showSuccessToast('已成功加入小队')
goToGroup()
} catch (e: any) {
showFailToast(e.message || '加入失败')
} finally {
joining.value = false
}
}
</script>
<template>
<div class="join-page">
<div class="join-card">
<div class="join-header">
<div class="header-icon">🎮</div>
<h1>组队邀请</h1>
</div>
<div v-if="loading" class="loading-state">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="error" class="error-state">
<van-icon name="warning-o" size="48" />
<p>{{ error }}</p>
<van-button block round @click="router.push('/')">返回首页</van-button>
</div>
<div v-else-if="session" class="team-info">
<h2 class="team-game">{{ session.gameName }}</h2>
<p class="team-name">{{ session.name }}</p>
<div class="meta-row">
<van-icon name="friends-o" />
<span>{{ session.members?.length || 0 }} </span>
</div>
<!-- 已在队中 -->
<div v-if="isInTeam" class="action-block">
<p class="tip">你已在该小队中</p>
<van-button type="primary" block round @click="goToGroup">进入群组</van-button>
</div>
<!-- 未登录 -->
<div v-else-if="!isLoggedIn" class="action-block">
<p class="tip">登录后即可加入小队</p>
<van-button type="primary" block round @click="goToLogin">登录 / 注册</van-button>
</div>
<!-- 未在源群组 -->
<div v-else-if="!isInSourceGroup" class="action-block">
<p class="tip warn">需要先加入该小队所在的群组</p>
<van-button type="primary" block round @click="goToGroup">前往群组</van-button>
</div>
<!-- 可加入 -->
<div v-else class="action-block">
<p class="tip">点击加入小队一起开黑</p>
<van-button type="primary" block round :loading="joining" @click="handleJoin">
立即加入
</van-button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.join-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: var(--gg-bg);
}
.join-card {
width: 100%;
max-width: 420px;
background: var(--gg-bg-card);
border-radius: var(--gg-radius-lg);
padding: 32px 24px;
box-shadow: var(--gg-shadow);
}
.join-header {
text-align: center;
margin-bottom: 24px;
}
.header-icon {
font-size: 40px;
margin-bottom: 8px;
}
.join-header h1 {
font-size: 20px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.loading-state {
display: flex;
justify-content: center;
padding: 40px;
}
.error-state {
text-align: center;
padding: 16px 0;
color: var(--gg-danger);
}
.error-state p { margin: 12px 0 16px; font-size: 14px; }
.error-state .van-button { max-width: 200px; margin: 0 auto; }
.team-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
text-align: center;
}
.team-game {
font-size: 22px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.team-name {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0;
}
.meta-row {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--gg-text-secondary);
margin: 4px 0 16px;
}
.action-block {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.tip {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0;
}
.tip.warn {
color: var(--gg-warning);
}
</style>