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:
@@ -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>
|
||||
@@ -80,7 +80,7 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'GroupView',
|
||||
component: view(
|
||||
() => import('@/views/GroupView.vue'),
|
||||
mobilePlaceholder
|
||||
() => import('@/views-mobile/GroupViewMobile.vue')
|
||||
),
|
||||
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>
|
||||
Reference in New Issue
Block a user