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:
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user