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',
|
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>
|
||||||
Reference in New Issue
Block a user