feat(mobile): stage 4 - group detail core (GroupViewMobile + activity/members tabs)

- migrate GroupViewMobile.vue (group header + quick entries + swipeable tabs)
- migrate ActivityFeedMobile.vue (team status + status-grouped members + create team popup)
- migrate MemberListMobile.vue (status groups + owner management: remove/approval/join requests)
- router: wire GroupView mobile view
- tabs structure: activity/members live; polls/bets/memories/stats use Placeholder
  pending stage 6/9 (sub-components not yet migrated)
- verified: sessions/group/user stores + groups/sessions/invitations APIs match uat

build verified: vue-tsc + vite build pass
This commit is contained in:
锦麟 王
2026-06-18 11:07:19 +08:00
parent a303415857
commit 446dbf8ae0
4 changed files with 946 additions and 1 deletions
@@ -0,0 +1,400 @@
<!-- src/components-mobile/group/ActivityFeedMobile.vue -->
<!-- 群组动态当前组队状态 + 空闲成员 + 所有成员按状态分组 -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import { createTeamSession } from '@/api/sessions'
import { sendBulkInvitations } from '@/api/invitations'
import type { User } from '@/types'
import { showSuccessToast, showFailToast } from 'vant'
const props = defineProps<{ groupId: string }>()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const userStore = useUserStore()
const router = useRouter()
const members = computed(() => groupStore.currentMembers)
// 按状态分组
const membersByStatus = computed(() => {
const groups: Record<string, User[]> = { idle: [], in_team: [], working: [], away: [] }
for (const m of members.value) {
const s = (m.status || 'idle') as keyof typeof groups
if (groups[s]) groups[s].push(m)
else groups.away.push(m)
}
return groups
})
const statusLabels: Record<string, string> = {
idle: '空闲',
in_team: '组队中',
working: '工作中',
away: '离开'
}
const statusColors: Record<string, string> = {
idle: 'var(--gg-success)',
in_team: 'var(--gg-info)',
working: 'var(--gg-danger)',
away: 'var(--gg-text-muted)'
}
// 发起组队
const showCreateTeam = ref(false)
const teamName = ref('')
const teamGame = ref('')
const selectedMembers = ref<string[]>([])
const teamLoading = ref(false)
const idleMembers = computed(() => membersByStatus.value.idle)
async function handleCreateTeam() {
if (!teamGame.value.trim()) {
showFailToast('请输入游戏名称')
return
}
teamLoading.value = true
try {
const me = userStore.userId
const memberIds = [...new Set([me, ...selectedMembers.value])]
const session = await createTeamSession({
sourceGroup: props.groupId,
name: teamName.value.trim() || `${teamGame.value}小队`,
gameName: teamGame.value.trim(),
members: memberIds
})
// 邀请选中的成员
const toInvite = selectedMembers.value.filter(id => id !== me)
if (toInvite.length > 0 && session?.id) {
await sendBulkInvitations(toInvite, session.id)
}
await teamStore.loadActiveSession()
showCreateTeam.value = false
teamName.value = ''
teamGame.value = ''
selectedMembers.value = []
showSuccessToast('组队成功')
} catch (e: any) {
showFailToast(e.message || '组队失败')
} finally {
teamLoading.value = false
}
}
function toggleMember(id: string) {
const idx = selectedMembers.value.indexOf(id)
if (idx === -1) selectedMembers.value.push(id)
else selectedMembers.value.splice(idx, 1)
}
// 当前组队状态
const hasSession = computed(() => !!teamStore.currentSession)
const sessionInThisGroup = computed(() =>
teamStore.currentSession?.sourceGroup === props.groupId
)
function goVoiceRoom() {
const s = teamStore.currentSession
if (s) {
router.push({
name: 'VoiceRoom',
params: { groupId: s.sourceGroup, sessionId: s.id }
})
}
}
</script>
<template>
<div class="activity-feed">
<!-- 当前组队卡片 -->
<div v-if="hasSession && sessionInThisGroup" class="current-team-card" @click="goVoiceRoom">
<div class="team-header">
<van-icon name="volume-o" />
<span class="team-label">进行中</span>
</div>
<div class="team-game">{{ teamStore.currentSession?.gameName }}</div>
<div class="team-meta">
{{ teamStore.currentSession?.name }} · {{ teamStore.currentSession?.members?.length || 0 }}
</div>
<div class="team-go">进入语音房 </div>
</div>
<!-- 快速组队按钮 -->
<van-button
v-else
type="primary"
block
round
icon="plus"
@click="showCreateTeam = true"
>
发起组队
</van-button>
<!-- 成员状态分组 -->
<div class="members-section">
<div
v-for="(list, status) in membersByStatus"
:key="status"
v-show="list.length > 0"
class="status-group"
>
<div class="status-group-header">
<span class="status-dot" :style="{ background: statusColors[status] }" />
<span class="status-group-label">{{ statusLabels[status] }}</span>
<span class="status-group-count">{{ list.length }}</span>
</div>
<div class="member-list">
<div v-for="m in list" :key="m.id" class="member-item">
<img :src="m.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
<div class="member-info">
<div class="member-name">{{ m.name || m.username }}</div>
<div v-if="m.statusNote" class="member-note">{{ m.statusNote }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 发起组队弹层 -->
<van-popup
v-model:show="showCreateTeam"
position="bottom"
round
closeable
:style="{ height: '75%' }"
>
<div class="popup-content">
<div class="popup-title">发起组队</div>
<van-cell-group inset>
<van-field v-model="teamGame" label="游戏" placeholder="玩什么游戏" required />
<van-field v-model="teamName" label="队名" placeholder="可选,默认游戏+小队" />
</van-cell-group>
<div class="invite-title">邀请成员空闲{{ idleMembers.length }}</div>
<div class="invite-list">
<div
v-for="m in idleMembers.filter(x => x.id !== userStore.userId)"
:key="m.id"
class="invite-member"
:class="{ 'invite-selected': selectedMembers.includes(m.id) }"
@click="toggleMember(m.id)"
>
<img :src="m.avatar || '/default-avatar.svg'" class="invite-avatar" alt="" />
<span class="invite-name">{{ m.name || m.username }}</span>
<van-icon
v-if="selectedMembers.includes(m.id)"
name="success"
class="invite-check"
/>
</div>
<div v-if="idleMembers.length <= 1" class="no-idle">暂无其他空闲成员</div>
</div>
<div class="popup-actions">
<van-button
type="primary"
block
round
:loading="teamLoading"
@click="handleCreateTeam"
>
开始组队
</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.activity-feed {
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.current-team-card {
background: linear-gradient(135deg, var(--gg-primary), var(--gg-accent));
border-radius: var(--gg-radius-lg);
padding: 16px;
color: #fff;
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.25);
}
.team-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
opacity: 0.9;
}
.team-game {
font-size: 18px;
font-weight: 700;
margin-top: 6px;
}
.team-meta {
font-size: 13px;
opacity: 0.85;
margin-top: 4px;
}
.team-go {
text-align: right;
font-size: 13px;
margin-top: 8px;
font-weight: 500;
}
.members-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.status-group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0 2px 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-group-label {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.status-group-count {
font-size: 12px;
color: var(--gg-text-muted);
background: var(--gg-bg-elevated);
padding: 1px 8px;
border-radius: 10px;
}
.member-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.member-item {
display: flex;
align-items: center;
gap: 6px;
background: var(--gg-bg-card);
padding: 6px 10px 6px 6px;
border-radius: var(--gg-radius-sm);
box-shadow: var(--gg-shadow);
}
.member-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}
.member-info {
min-width: 0;
}
.member-name {
font-size: 13px;
color: var(--gg-text);
}
.member-note {
font-size: 11px;
color: var(--gg-text-muted);
}
/* 弹层 */
.popup-content {
padding: 16px 0 24px;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.popup-title {
text-align: center;
font-size: 16px;
font-weight: 600;
padding: 8px 0 16px;
color: var(--gg-text);
}
.invite-title {
padding: 16px 16px 8px;
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.invite-list {
padding: 0 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.invite-member {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--gg-bg);
border-radius: var(--gg-radius-sm);
border: 1px solid var(--gg-border);
}
.invite-member.invite-selected {
background: rgba(5, 150, 105, 0.1);
border-color: var(--gg-primary);
}
.invite-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.invite-name {
font-size: 13px;
color: var(--gg-text);
}
.invite-check {
color: var(--gg-primary);
font-size: 14px;
}
.no-idle {
font-size: 13px;
color: var(--gg-text-muted);
padding: 8px 0;
}
.popup-actions {
padding: 20px 16px 0;
}
</style>
@@ -0,0 +1,315 @@
<!-- src/components-mobile/group/MemberListMobile.vue -->
<!-- 群组成员列表按状态展示 + 群主管理移除成员审核开关入群申请 -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
import { getGroupJoinRequests, updateGroupApproval } from '@/api/groups'
import { respondJoinRequest } from '@/api/groups'
import { pb } from '@/api/pocketbase'
import type { JoinRequest, User } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const props = defineProps<{ groupId: string }>()
const groupStore = useGroupStore()
const userStore = useUserStore()
const group = computed(() => groupStore.currentGroup)
const members = computed(() => groupStore.currentMembers)
const isOwner = computed(() => group.value?.owner === userStore.userId)
const joinRequests = ref<JoinRequest[]>([])
onMounted(async () => {
if (isOwner.value && group.value) {
try {
joinRequests.value = await getGroupJoinRequests(group.value.id)
} catch { /* ignore */ }
}
})
// 按状态分组
const membersByStatus = computed(() => {
const groups: Record<string, User[]> = { idle: [], in_team: [], working: [], away: [] }
for (const m of members.value) {
const s = (m.status || 'idle') as keyof typeof groups
if (groups[s]) groups[s].push(m)
else groups.away.push(m)
}
return groups
})
const statusLabels: Record<string, string> = { idle: '空闲', in_team: '组队中', working: '工作中', away: '离开' }
const statusColors: Record<string, string> = {
idle: 'var(--gg-success)', in_team: 'var(--gg-info)',
working: 'var(--gg-danger)', away: 'var(--gg-text-muted)'
}
// 移除成员(群主)
async function removeMember(userId: string, name: string) {
if (!isOwner.value || !group.value) return
showConfirmDialog({
title: '移除成员',
message: `确定要将 ${name} 移出群组吗?`
}).then(async () => {
try {
const newMembers = group.value!.members.filter(id => id !== userId)
await pb.collection('groups').update(group.value!.id, { members: newMembers })
await groupStore.setCurrentGroup(group.value!.id)
showSuccessToast('已移除')
} catch (e: any) {
showFailToast(e.message || '操作失败')
}
}).catch(() => {})
}
// 审核开关
async function toggleApproval(val: boolean) {
if (!group.value) return
try {
await updateGroupApproval(group.value.id, val)
await groupStore.setCurrentGroup(group.value.id)
showSuccessToast(val ? '已开启审核' : '已关闭审核')
} catch (e: any) {
showFailToast('更新失败')
}
}
// 处理入群申请
const requestLoading = ref<string | null>(null)
async function handleRequest(requestId: string, status: 'approved' | 'rejected') {
requestLoading.value = requestId
try {
await respondJoinRequest(requestId, status)
joinRequests.value = joinRequests.value.filter(r => r.id !== requestId)
await groupStore.setCurrentGroup(props.groupId)
showSuccessToast(status === 'approved' ? '已通过' : '已拒绝')
} catch (e: any) {
showFailToast(e.message || '操作失败')
} finally {
requestLoading.value = null
}
}
</script>
<template>
<div class="member-list-mobile">
<!-- 群主管理区 -->
<div v-if="isOwner" class="owner-panel">
<!-- 入群申请 -->
<div v-if="joinRequests.length > 0" class="requests-block">
<div class="block-title">入群申请{{ joinRequests.length }}</div>
<div
v-for="req in joinRequests"
:key="req.id"
class="request-item"
>
<img
:src="req.expand?.user?.avatar || '/default-avatar.svg'"
class="req-avatar"
alt=""
/>
<div class="req-info">
<div class="req-name">{{ req.expand?.user?.name || req.expand?.user?.username }}</div>
</div>
<div class="req-actions">
<van-button
type="primary"
size="mini"
round
:loading="requestLoading === req.id"
@click="handleRequest(req.id, 'approved')"
>通过</van-button>
<van-button
size="mini"
round
:loading="requestLoading === req.id"
@click="handleRequest(req.id, 'rejected')"
>拒绝</van-button>
</div>
</div>
</div>
<!-- 审核开关 -->
<van-cell-group inset>
<van-cell title="加入审核" center>
<template #right-icon>
<van-switch
:model-value="group?.requireApproval"
size="22px"
@update:model-value="toggleApproval"
/>
</template>
</van-cell>
</van-cell-group>
</div>
<!-- 成员分组列表 -->
<div
v-for="(list, status) in membersByStatus"
:key="status"
v-show="list.length > 0"
class="status-section"
>
<div class="status-header">
<span class="status-dot" :style="{ background: statusColors[status] }" />
<span class="status-label">{{ statusLabels[status] }}</span>
<span class="status-count">{{ list.length }}</span>
</div>
<div class="members-grid">
<div
v-for="m in list"
:key="m.id"
class="member-card"
>
<img :src="m.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
<div class="member-info">
<div class="member-name">
{{ m.name || m.username }}
<van-tag v-if="m.id === group?.owner" type="warning" size="medium">群主</van-tag>
</div>
<div v-if="m.statusNote" class="member-note">{{ m.statusNote }}</div>
</div>
<van-button
v-if="isOwner && m.id !== group?.owner && m.id !== userStore.userId"
size="mini"
plain
type="danger"
@click="removeMember(m.id, m.name || m.username)"
>移除</van-button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.member-list-mobile {
padding: 12px;
display: flex;
flex-direction: column;
gap: 16px;
}
.owner-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.block-title {
font-size: 13px;
font-weight: 600;
color: var(--gg-text-muted);
margin-bottom: 8px;
padding: 0 4px;
}
.request-item {
display: flex;
align-items: center;
gap: 10px;
background: var(--gg-bg-card);
padding: 10px 12px;
border-radius: var(--gg-radius-sm);
margin-bottom: 8px;
box-shadow: var(--gg-shadow);
}
.req-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
}
.req-info {
flex: 1;
min-width: 0;
}
.req-name {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
}
.req-actions {
display: flex;
gap: 6px;
}
.status-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0 4px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-label {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.status-count {
font-size: 12px;
color: var(--gg-text-muted);
}
.members-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.member-card {
display: flex;
align-items: center;
gap: 10px;
background: var(--gg-bg-card);
padding: 10px 12px;
border-radius: var(--gg-radius-sm);
box-shadow: var(--gg-shadow);
}
.member-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.member-info {
flex: 1;
min-width: 0;
}
.member-name {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
display: flex;
align-items: center;
gap: 4px;
}
.member-note {
font-size: 12px;
color: var(--gg-text-muted);
margin-top: 2px;
}
</style>
+1 -1
View File
@@ -80,7 +80,7 @@ const routes: RouteRecordRaw[] = [
name: 'GroupView', name: 'GroupView',
component: view( component: view(
() => import('@/views/GroupView.vue'), () => import('@/views/GroupView.vue'),
mobilePlaceholder () => import('@/views-mobile/GroupViewMobile.vue')
), ),
props: true props: true
}, },
@@ -0,0 +1,230 @@
<!-- src/views-mobile/GroupViewMobile.vue -->
<!-- 手机端群组详情群信息 + 可横滑标签栏 -->
<!-- 标签动态/投票/竞猜/回忆/成员/账本/资产/黑名单/统计 + [阶段10]公示板 + [阶段11]活动 -->
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import { pb } from '@/api/pocketbase'
import ActivityFeedMobile from '@/components-mobile/group/ActivityFeedMobile.vue'
import MemberListMobile from '@/components-mobile/group/MemberListMobile.vue'
import Placeholder from '@/views-mobile/Placeholder.vue'
import { Wallet, Box, Warning } from '@element-plus/icons-vue'
const route = useRoute()
const router = useRouter()
const groupStore = useGroupStore()
const groupId = route.params.id as string
const activeTab = ref(route.query.tab as string || 'activity')
const group = computed(() => groupStore.currentGroup)
const members = computed(() => groupStore.currentMembers)
const ownerName = computed(() => {
const owner = members.value.find(m => m.id === group.value?.owner)
return owner?.name || owner?.username || '未知'
})
const unsubFns: (() => Promise<void>)[] = []
async function loadGroup() {
await groupStore.setCurrentGroup(groupId)
}
onMounted(async () => {
await loadGroup()
// 订阅群组、成员、会话变更
try {
unsubFns.push(await pb.collection('users').subscribe('*', () => loadGroup()))
unsubFns.push(await pb.collection('groups').subscribe('*', (payload) => {
if (payload.record.id === groupId) loadGroup()
}))
} catch (e) {
console.error('订阅失败:', e)
}
})
onUnmounted(async () => {
for (const fn of unsubFns) {
try { await fn() } catch { /* ignore */ }
}
})
// 标签配置
// 注:polls/bets/memories/stats 子组件阶段 6/9 迁移后接入;
// bulletin/events 阶段 10/11 新增
const tabs = [
{ name: 'activity', label: '动态' },
{ name: 'polls', label: '投票' },
{ name: 'bets', label: '竞猜' },
{ name: 'members', label: '成员' },
{ name: 'memories', label: '回忆' },
{ name: 'stats', label: '统计' },
]
function onTabChange(name: string) {
activeTab.value = name
}
// 跳转群组级独立页面
function goLedger() {
router.push({ name: 'LedgerView', params: { groupId } })
}
function goAssets() {
router.push({ name: 'AssetView', params: { groupId } })
}
function goBlacklist() {
router.push({ name: 'BlacklistView', params: { groupId } })
}
</script>
<template>
<div class="group-view-mobile">
<!-- 群信息条 -->
<div class="group-header">
<div class="header-row">
<h1 class="group-name">{{ group?.name || '加载中...' }}</h1>
<van-tag type="success" size="large">{{ members.length }} </van-tag>
</div>
<p class="group-desc">{{ group?.description || '暂无群组简介' }}</p>
<div class="header-meta">
<span class="meta-text">群主: {{ ownerName }}</span>
<span class="meta-divider">·</span>
<span class="meta-text">{{ members.length }}/{{ group?.maxMembers || '-' }}</span>
</div>
<!-- 快捷入口 -->
<div class="quick-entry">
<div class="entry-item" @click="goLedger">
<el-icon><Wallet /></el-icon>
<span>账本</span>
</div>
<div class="entry-item" @click="goAssets">
<el-icon><Box /></el-icon>
<span>资产</span>
</div>
<div class="entry-item" @click="goBlacklist">
<el-icon><Warning /></el-icon>
<span>黑名单</span>
</div>
</div>
</div>
<!-- 可横滑标签栏 -->
<van-tabs
v-model:active="activeTab"
class="group-tabs"
line-width="20"
@change="onTabChange"
>
<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" />
<!-- 以下 tab 子组件在阶段 6/9 迁移后接入现暂用占位 -->
<Placeholder v-else />
</div>
</van-tab>
</van-tabs>
</div>
</template>
<style scoped>
.group-view-mobile {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* 群信息条 */
.group-header {
background: var(--gg-bg-card);
padding: 16px;
box-shadow: var(--gg-shadow);
position: relative;
overflow: hidden;
}
.group-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--gg-gradient);
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.group-name {
font-size: 20px;
font-weight: 700;
color: var(--gg-text);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.group-desc {
font-size: 13px;
color: var(--gg-text-muted);
margin: 8px 0;
line-height: 1.5;
}
.header-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--gg-text-secondary);
}
.meta-divider {
color: var(--gg-text-muted);
}
.quick-entry {
display: flex;
gap: 8px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--gg-border);
}
.entry-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 8px;
background: var(--gg-bg);
border-radius: var(--gg-radius-sm);
font-size: 12px;
color: var(--gg-text-secondary);
}
.entry-item:active {
opacity: 0.7;
}
.entry-item .el-icon {
font-size: 20px;
color: var(--gg-primary);
}
/* 标签内容 */
.tab-content {
min-height: 50vh;
}
</style>