Files
gamegroup2/frontend/src/components/group/GroupMembersPanel.vue
T
congsh 5cec2101af feat: support Chinese nickname in registration
- User input "昵称" stored in `name` field (supports Chinese)
- `username` auto-generated (PocketBase requires ASCII)
- Password rules displayed inline with real-time validation
- All UI displays prefer `name` over `username`

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-18 13:01:41 +08:00

317 lines
7.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
import { getGroupJoinRequests, updateGroupApproval, subscribeJoinRequests } from '@/api/groups'
import { ElSwitch } from 'element-plus'
import type { JoinRequest } from '@/types'
import JoinRequestCard from './JoinRequestCard.vue'
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[]>([])
const approvalLoading = ref(false)
onMounted(async () => {
if (group.value && isOwner.value) {
await loadJoinRequests()
subscribeJoinRequests(group.value.id, () => {
loadJoinRequests()
})
}
})
onUnmounted(() => {
// cleanup handled by PocketBase
})
async function loadJoinRequests() {
if (!group.value) return
try {
joinRequests.value = await getGroupJoinRequests(group.value.id)
} catch { /* ignore */ }
}
function copyGroupId() {
if (group.value?.id) {
navigator.clipboard.writeText(group.value.id)
ElMessage.success('群组 ID 已复制,分享给好友即可加入')
}
}
async function removeMember(userId: string, username: string) {
if (!isOwner.value) return
try {
await ElMessageBox.confirm(`确定要将 ${username} 移出群组吗?`, '确认', { type: 'warning' })
const grp = groupStore.currentGroup
if (!grp) return
const { pb } = await import('@/api/pocketbase')
const newMembers = grp.members.filter(id => id !== userId)
await pb.collection('groups').update(grp.id, { members: newMembers })
await groupStore.setCurrentGroup(grp.id)
ElMessage.success('已移除成员')
} catch {
// 用户取消
}
}
async function handleApprovalChange(val: string | number | boolean) {
if (!group.value) return
approvalLoading.value = true
try {
await updateGroupApproval(group.value.id, !!val)
await groupStore.setCurrentGroup(group.value.id)
ElMessage.success(val ? '已开启加入审核' : '已关闭加入审核')
} catch {
ElMessage.error('更新设置失败')
} finally {
approvalLoading.value = false
}
}
async function onJoinRequestResponded(requestId: string) {
joinRequests.value = joinRequests.value.filter(r => r.id !== requestId)
if (group.value) {
await groupStore.setCurrentGroup(group.value.id)
}
}
</script>
<template>
<div v-if="group" class="members-panel">
<div class="panel-header">
<h3>群组信息</h3>
</div>
<!-- 分享群组 ID -->
<div class="share-section">
<label>群组 ID分享给好友</label>
<div class="share-row">
<code class="group-id">{{ group.id }}</code>
<button class="copy-btn" @click="copyGroupId">复制</button>
</div>
</div>
<!-- 审核开关仅群主可见 -->
<div v-if="isOwner" class="approval-row">
<span class="info-label">加入需审核</span>
<el-switch
:model-value="group.requireApproval"
:loading="approvalLoading"
@change="handleApprovalChange"
/>
</div>
<div class="info-row">
<span class="info-label">成员</span>
<span class="info-value">{{ members.length }} / {{ group.maxMembers }}</span>
</div>
<!-- 待审核申请仅群主可见 -->
<div v-if="isOwner && joinRequests.length > 0" class="requests-section">
<h4 class="requests-title">待审核申请 ({{ joinRequests.length }})</h4>
<div class="requests-list">
<JoinRequestCard
v-for="req in joinRequests"
:key="req.id"
v-bind="req"
@responded="onJoinRequestResponded(req.id)"
/>
</div>
</div>
<div class="members-list">
<div v-for="member in members" :key="member.id" class="member-row">
<img :src="member.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
<div class="member-info">
<span class="member-name">{{ member.name || member.username }}</span>
<span v-if="member.id === group.owner" class="owner-badge">群主</span>
</div>
<button
v-if="isOwner && member.id !== group.owner && member.id !== userStore.userId"
class="remove-btn"
@click="removeMember(member.id, member.name || member.username)"
>
移除
</button>
</div>
</div>
</div>
</template>
<style scoped>
.members-panel {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
padding: 20px;
}
.panel-header {
margin-bottom: 16px;
}
.panel-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.share-section {
margin-bottom: 16px;
}
.share-section label {
display: block;
font-size: 12px;
color: var(--gg-text-secondary);
margin-bottom: 6px;
}
.share-row {
display: flex;
gap: 8px;
align-items: center;
}
.group-id {
flex: 1;
padding: 6px 10px;
background: var(--gg-bg);
border: 1px solid var(--gg-border);
border-radius: 6px;
font-size: 12px;
color: var(--gg-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
}
.copy-btn {
padding: 6px 14px;
background: var(--gg-gradient);
border: none;
border-radius: 6px;
color: white;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
}
.copy-btn:hover {
opacity: 0.9;
}
.approval-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid var(--gg-border);
margin-bottom: 12px;
}
.info-label {
font-size: 14px;
color: var(--gg-text-secondary);
}
.info-value {
font-size: 14px;
font-weight: 500;
}
.requests-section {
margin-bottom: 16px;
padding: 12px;
background: rgba(245, 158, 11, 0.06);
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: var(--gg-radius-sm);
}
.requests-title {
font-size: 13px;
font-weight: 600;
color: #f59e0b;
margin: 0 0 10px;
}
.requests-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.members-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.member-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 8px;
transition: background-color 0.2s;
}
.member-row:hover {
background: var(--gg-bg-hover);
}
.member-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
}
.member-info {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
.member-name {
font-size: 14px;
}
.owner-badge {
font-size: 11px;
padding: 1px 8px;
background: var(--gg-gradient);
color: white;
border-radius: 10px;
}
.remove-btn {
padding: 4px 10px;
background: none;
border: 1px solid var(--gg-danger);
border-radius: 4px;
color: var(--gg-danger);
font-size: 12px;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
}
.remove-btn:hover {
opacity: 1;
}
</style>