This commit is contained in:
2025-12-08 14:17:35 +08:00
parent 9012caa7aa
commit 3a31ea1ba6
6 changed files with 447 additions and 65 deletions

View File

@@ -133,3 +133,44 @@ export function getGroupNotifications(params?: {
}>('/group-notifications', { params })
}
// ========== 预留 API 接口(需后端支持) ==========
/**
* 禁言成员
* @param roomId 群ID
* @param userId 用户ID
* @param duration 禁言时长0 表示永久禁言,-1 表示解除禁言
*/
export function muteGroupMember(roomId: string, userId: string, duration: number) {
return request.post<void>(`/groups/${roomId}/members/${userId}/mute`, { duration })
}
/**
* 更新群设置(扩展版本,包含简介和邀请权限)
* @param roomId 群ID
* @param data 群设置数据
*/
export function updateGroupSettings(roomId: string, data: {
name?: string
avatar?: string
description?: string // 群简介
invite_permission?: number // 邀请权限0-所有人 1-仅管理员 2-仅群主
}) {
return request.post<void>(`/groups/${roomId}/settings`, data)
}
/**
* 获取群设置
* @param roomId 群ID
*/
export function getGroupSettings(roomId: string) {
return request.get<{
name: string
avatar: string
description: string
invite_permission: number
created_at: string
updated_at: string
}>(`/groups/${roomId}/settings`)
}

View File

@@ -55,10 +55,10 @@
:style="gridStyle"
>
<!-- A. 自己的画面 -->
<div class="relative bg-gray-800 rounded-xl overflow-hidden shadow-lg group border border-white/5">
<div class="relative bg-gray-800 rounded-xl overflow-hidden shadow-lg group border border-white/5 min-h-0">
<video
ref="localVideoRef"
class="w-full h-full object-cover transform -scale-x-100"
class="w-full h-full object-contain transform -scale-x-100"
autoplay
muted
playsinline
@@ -79,12 +79,12 @@
<div
v-for="user in participants"
:key="user.userId"
class="relative bg-gray-800 rounded-xl overflow-hidden shadow-lg border border-white/5 transition-all"
class="relative bg-gray-800 rounded-xl overflow-hidden shadow-lg border border-white/5 transition-all min-h-0"
>
<video
v-if="user.stream && !user.isCamOff"
:srcObject="user.stream"
class="w-full h-full object-cover"
class="w-full h-full object-contain"
autoplay
playsinline
></video>
@@ -180,12 +180,23 @@ const gridStyle = computed(() => {
if (count === 2) { cols = 1; rows = 2 }
else if (count <= 4) { cols = 2; rows = 2 }
else if (count <= 6) { cols = 2; rows = 3 }
else { cols = 3; rows = 3 }
else if (count <= 9) { cols = 3; rows = 3 }
else { cols = 4; rows = Math.ceil(count / 4) }
// 桌面端优化布局
if (window.innerWidth > 768 && count === 2) { cols = 2; rows = 1 }
return { gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }
// 计算每个格子的最大高度,确保不超出可视范围
// 可用高度 = 80vh - 顶部栏(3rem) - 底部控制栏(6rem) - padding(1rem)
const maxCellHeight = `calc((80vh - 10rem) / ${rows})`
return {
gridTemplateColumns: `repeat(${cols}, 1fr)`,
gridTemplateRows: `repeat(${rows}, minmax(0, ${maxCellHeight}))`,
}
})
const windowClasses = computed(() => callState.minimized ? 'w-32 h-48 right-4 top-20 rounded-lg' : 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[90vw] h-[80vh] rounded-2xl')
const windowClasses = computed(() => callState.minimized ? 'w-32 h-48 right-4 top-20 rounded-lg' : 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[90vw] max-w-[1200px] h-[80vh] max-h-[800px] rounded-2xl')
const drag = reactive({ isDragging: false, startX: 0, startY: 0, left: 0, top: 0, initialized: false })
const dragStyle = computed(() => drag.initialized ? { left: `${drag.left}px`, top: `${drag.top}px`, transform: 'none' } : {})

View File

@@ -139,13 +139,19 @@
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-1">
<span class="text-sm font-medium text-gray-200 group-hover:text-white truncate">
{{ member.nickname || member.user?.name || '未知用户' }}
</span>
<!-- 身份标识 -->
<span v-if="member.role === 2" class="px-1.5 py-0.5 rounded text-[10px] bg-yellow-500/20 text-yellow-400 font-bold border border-yellow-500/20">群主</span>
<span v-else-if="member.role === 1" class="px-1.5 py-0.5 rounded text-[10px] bg-blue-500/20 text-blue-400 font-bold border border-blue-500/20">管理</span>
<div class="flex items-center gap-1 shrink-0">
<!-- 禁言标识 -->
<span v-if="isMemberMuted(member)" class="px-1.5 py-0.5 rounded text-[10px] bg-orange-500/20 text-orange-400 font-bold border border-orange-500/20" title="已被禁言">
<i class="fas fa-volume-mute"></i>
</span>
<!-- 身份标识 -->
<span v-if="member.role === 2" class="px-1.5 py-0.5 rounded text-[10px] bg-yellow-500/20 text-yellow-400 font-bold border border-yellow-500/20">群主</span>
<span v-else-if="member.role === 1" class="px-1.5 py-0.5 rounded text-[10px] bg-blue-500/20 text-blue-400 font-bold border border-blue-500/20">管理</span>
</div>
</div>
<p class="text-xs text-gray-500 truncate mt-0.5 group-hover:text-gray-400">
{{ member.user?.email || '无签名' }}
@@ -176,59 +182,124 @@
/>
<!-- 弹窗 3公告详情/编辑 -->
<div v-if="showAnnouncementModal" class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm animate-fade-in">
<div class="bg-gray-800 rounded-xl w-full max-w-sm border border-gray-700 shadow-2xl overflow-hidden">
<div class="px-4 py-3 border-b border-gray-700 flex justify-between items-center bg-gray-900/50">
<h3 class="text-white font-bold text-sm">群公告</h3>
<button class="text-gray-400 hover:text-white" @click="showAnnouncementModal = false"><i class="fas fa-times"></i></button>
</div>
<div class="p-4">
<textarea
v-if="isEditingAnnouncement"
v-model="editAnnouncementText"
class="w-full bg-black/20 border border-gray-600 rounded-lg p-3 text-sm text-white focus:border-indigo-500 focus:outline-none h-40 resize-none"
placeholder="请输入群公告..."
></textarea>
<div v-else class="min-h-[100px] text-sm text-gray-300 whitespace-pre-wrap leading-relaxed">
{{ announcement || '暂无公告' }}
<Teleport to="body">
<div v-if="showAnnouncementModal" class="fixed inset-0 bg-black/80 z-[10000] flex items-center justify-center p-4 backdrop-blur-sm animate-fade-in">
<div class="bg-gray-800 rounded-xl w-full max-w-sm border border-gray-700 shadow-2xl overflow-hidden">
<div class="px-4 py-3 border-b border-gray-700 flex justify-between items-center bg-gray-900/50">
<h3 class="text-white font-bold text-sm">群公告</h3>
<button class="text-gray-400 hover:text-white" @click="showAnnouncementModal = false"><i class="fas fa-times"></i></button>
</div>
<div class="p-4">
<textarea
v-if="isEditingAnnouncement"
v-model="editAnnouncementText"
class="w-full bg-black/20 border border-gray-600 rounded-lg p-3 text-sm text-white focus:border-indigo-500 focus:outline-none h-40 resize-none"
placeholder="请输入群公告..."
></textarea>
<div v-else class="min-h-[100px] text-sm text-gray-300 whitespace-pre-wrap leading-relaxed">
{{ announcement || '暂无公告' }}
</div>
</div>
<div class="px-4 py-3 bg-gray-900/50 border-t border-gray-700 flex justify-end gap-2" v-if="canEditAnnouncement">
<template v-if="isEditingAnnouncement">
<button class="px-3 py-1.5 text-xs text-gray-300 hover:text-white" @click="isEditingAnnouncement = false">取消</button>
<button class="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-500" @click="saveAnnouncement">保存</button>
</template>
<button v-else class="px-3 py-1.5 text-xs bg-gray-700 text-white rounded hover:bg-gray-600" @click="startEditAnnouncement">编辑公告</button>
</div>
</div>
<div class="px-4 py-3 bg-gray-900/50 border-t border-gray-700 flex justify-end gap-2" v-if="canEditAnnouncement">
<template v-if="isEditingAnnouncement">
<button class="px-3 py-1.5 text-xs text-gray-300 hover:text-white" @click="isEditingAnnouncement = false">取消</button>
<button class="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-500" @click="saveAnnouncement">保存</button>
</template>
<button v-else class="px-3 py-1.5 text-xs bg-gray-700 text-white rounded hover:bg-gray-600" @click="startEditAnnouncement">编辑公告</button>
</div>
</div>
</div>
</Teleport>
<ContextMenu />
<!-- 弹窗 4移除成员确认 -->
<div v-if="removingMember" class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm animate-fade-in">
<div class="bg-gray-800 rounded-xl p-6 w-80 text-center border border-gray-700 shadow-2xl">
<div class="flex justify-center mb-4">
<Avatar
:name="removingMember.user?.name || removingMember.nickname"
:avatar="removingMember.user?.avatar"
size="lg"
rounded="full"
/>
</div>
<h3 class="text-white font-bold mb-2">移除成员</h3>
<p class="text-gray-400 text-sm mb-6">
确定要将 <span class="text-white font-medium">{{ removingMember.nickname || removingMember.user?.name || '该成员' }}</span> 移出群聊吗
</p>
<div class="flex gap-3">
<button class="flex-1 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 text-sm" @click="removingMember = null">取消</button>
<button class="flex-1 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm" :disabled="removing" @click="handleRemoveMember">
<i v-if="removing" class="fas fa-circle-notch fa-spin mr-1"></i>
确定移除
</button>
<Teleport to="body">
<div v-if="removingMember" class="fixed inset-0 bg-black/80 z-[10000] flex items-center justify-center p-4 backdrop-blur-sm animate-fade-in">
<div class="bg-gray-800 rounded-xl p-6 w-80 text-center border border-gray-700 shadow-2xl">
<div class="flex justify-center mb-4">
<Avatar
:name="removingMember.user?.name || removingMember.nickname"
:avatar="removingMember.user?.avatar"
size="lg"
rounded="full"
/>
</div>
<h3 class="text-white font-bold mb-2">移除成员</h3>
<p class="text-gray-400 text-sm mb-6">
确定要将 <span class="text-white font-medium">{{ removingMember.nickname || removingMember.user?.name || '该成员' }}</span> 移出群聊吗
</p>
<div class="flex gap-3">
<button class="flex-1 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 text-sm" @click="removingMember = null">取消</button>
<button class="flex-1 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm" :disabled="removing" @click="handleRemoveMember">
<i v-if="removing" class="fas fa-circle-notch fa-spin mr-1"></i>
确定移除
</button>
</div>
</div>
</div>
</div>
</Teleport>
<!-- 弹窗 5禁言时长选择 -->
<Teleport to="body">
<div v-if="mutingMember" class="fixed inset-0 bg-black/80 z-[10000] flex items-center justify-center p-4 backdrop-blur-sm animate-fade-in">
<div class="bg-gray-800 rounded-xl p-6 w-80 border border-gray-700 shadow-2xl">
<div class="flex justify-center mb-4">
<div class="relative">
<Avatar
:name="mutingMember.user?.name || mutingMember.nickname"
:avatar="mutingMember.user?.avatar"
size="lg"
rounded="full"
/>
<div class="absolute -bottom-1 -right-1 bg-orange-500 text-white text-[10px] p-1 rounded-full w-5 h-5 flex items-center justify-center shadow-sm">
<i class="fas fa-volume-mute"></i>
</div>
</div>
</div>
<h3 class="text-white font-bold mb-2 text-center">禁言成员</h3>
<p class="text-gray-400 text-sm mb-4 text-center">
选择禁言 <span class="text-orange-400 font-medium">{{ mutingMember.nickname || mutingMember.user?.name || '该成员' }}</span> 的时长
</p>
<div class="grid grid-cols-3 gap-2 mb-4">
<button
class="py-2 px-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition"
:disabled="muting"
@click="handleMuteMember(600)"
>10分钟</button>
<button
class="py-2 px-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition"
:disabled="muting"
@click="handleMuteMember(3600)"
>1小时</button>
<button
class="py-2 px-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition"
:disabled="muting"
@click="handleMuteMember(43200)"
>12小时</button>
<button
class="py-2 px-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition"
:disabled="muting"
@click="handleMuteMember(86400)"
>1</button>
<button
class="py-2 px-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg text-sm transition"
:disabled="muting"
@click="handleMuteMember(604800)"
>7</button>
<button
class="py-2 px-3 bg-orange-600 hover:bg-orange-500 text-white rounded-lg text-sm transition"
:disabled="muting"
@click="handleMuteMember(0)"
>永久</button>
</div>
<button
class="w-full py-2 bg-gray-700 text-gray-300 rounded-lg hover:bg-gray-600 text-sm"
@click="mutingMember = null"
>取消</button>
</div>
</div>
</Teleport>
</div>
</template>
@@ -271,6 +342,8 @@ const isEditingAnnouncement = ref(false)
const editAnnouncementText = ref('')
const removingMember = ref<GroupMember | null>(null)
const removing = ref(false)
const mutingMember = ref<GroupMember | null>(null)
const muting = ref(false)
// Computed
const currentUserRole = computed(() => members.value.find(m => m.user_id === authStore.user?.id)?.role ?? 0)
@@ -295,6 +368,25 @@ function canRemoveMember(member: GroupMember): boolean {
return false
}
// 判断是否可以禁言某个成员
function canMuteMember(member: GroupMember): boolean {
// 不能禁言自己
if (member.user_id === authStore.user?.id) return false
// 不能禁言群主
if (member.role === 2) return false
// 群主可以禁言任何人
if (isOwner.value) return true
// 管理员只能禁言普通成员
if (isAdmin.value && member.role === 0) return true
return false
}
// 检查成员是否被禁言
function isMemberMuted(member: GroupMember): boolean {
if (!member.muted_until) return false
return new Date(member.muted_until) > new Date()
}
// 过滤出可通话的候选人(排除自己)
const callCandidates = computed(() => {
return members.value
@@ -392,7 +484,24 @@ function showMemberContextMenu(event: MouseEvent, member: GroupMember) {
}
}
// 4. 移除成员 - 群主可移除任何人,管理员只能移除普通成员
// 4. 禁言/解除禁言 - 群主和管理员可操作
if (canMuteMember(member)) {
if (isMemberMuted(member)) {
items.push({
label: '解除禁言',
icon: 'fas fa-volume-up',
action: () => handleUnmuteMember(member)
})
} else {
items.push({
label: '禁言',
icon: 'fas fa-volume-mute',
action: () => { mutingMember.value = member }
})
}
}
// 5. 移除成员 - 群主可移除任何人,管理员只能移除普通成员
if (canRemoveMember(member)) {
items.push({
label: '移除成员',
@@ -455,6 +564,33 @@ async function handleRemoveMember() {
}
}
// --- 禁言逻辑 ---
async function handleMuteMember(duration: number) {
if (!mutingMember.value) return
muting.value = true
try {
await groupApi.muteGroupMember(props.roomId, mutingMember.value.user_id, duration)
const durationText = duration === 0 ? '永久' : duration < 3600 ? `${duration / 60}分钟` : duration < 86400 ? `${duration / 3600}小时` : `${duration / 86400}`
toastStore.success(`已禁言 ${durationText}`)
mutingMember.value = null
loadData()
} catch (e: any) {
toastStore.error(e?.response?.data?.message || e?.message || '禁言失败')
} finally {
muting.value = false
}
}
async function handleUnmuteMember(member: GroupMember) {
try {
await groupApi.muteGroupMember(props.roomId, member.user_id, -1)
toastStore.success('已解除禁言')
loadData()
} catch (e: any) {
toastStore.error(e?.response?.data?.message || e?.message || '解除禁言失败')
}
}
// --- 通话逻辑 ---
function handleStartCallClick() {
if (callCandidates.value.length === 0) {

View File

@@ -75,7 +75,15 @@
<span class="text-xs text-gray-400 group-hover:text-white mt-2">编辑</span>
</button>
<!-- 4. 退出按钮 -->
<!-- 4. 管理员设置仅群主可见 -->
<button v-if="isOwner" class="info-action-btn group" @click="openAdminModal">
<div class="icon-box bg-purple-500/10 text-purple-400 group-hover:bg-purple-500 group-hover:text-white border border-purple-500/20">
<i class="fas fa-user-shield"></i>
</div>
<span class="text-xs text-gray-400 group-hover:text-white mt-2">管理员</span>
</button>
<!-- 5. 退出按钮 -->
<button v-if="!isOwner" class="info-action-btn group" @click="showQuitConfirmDialog">
<div class="icon-box bg-red-500/10 text-red-400 group-hover:bg-red-500 group-hover:text-white border border-red-500/20">
<i class="fas fa-sign-out-alt"></i>
@@ -83,7 +91,7 @@
<span class="text-xs text-gray-400 group-hover:text-white mt-2">退出</span>
</button>
<!-- 5. 解散按钮 -->
<!-- 6. 解散按钮 -->
<button v-if="isOwner" class="info-action-btn group" @click="showDissolveConfirmDialog">
<div class="icon-box bg-red-600/10 text-red-500 group-hover:bg-red-600 group-hover:text-white border border-red-600/20">
<i class="fas fa-trash-alt"></i>
@@ -143,6 +151,15 @@
>
<i class="fas fa-minus text-[8px]"></i>
</button>
<!-- 转让群主按钮仅群主可见且不能转让给自己 -->
<button
v-if="isOwner && member.user_id !== authStore.user?.id"
class="absolute -bottom-1 -left-1 w-4 h-4 bg-yellow-500 hover:bg-yellow-600 text-black rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-10"
@click.stop="showTransferConfirm(member)"
title="转让群主"
>
<i class="fas fa-crown text-[8px]"></i>
</button>
</div>
<span class="text-[10px] text-gray-400 mt-1 truncate w-14 text-center group-hover:text-white transition-colors">
{{ member.nickname || member.user?.name || '未知' }}
@@ -224,6 +241,96 @@
</div>
</div>
<!-- 弹窗 6: 转让群主确认 -->
<div v-if="transferringTo" class="fixed inset-0 bg-black/80 z-[1000] flex items-center justify-center p-4">
<div class="bg-gray-800 rounded-xl p-6 w-80 text-center border border-gray-700 shadow-2xl">
<div class="flex justify-center mb-4">
<div class="relative">
<Avatar
:name="transferringTo.user?.name || transferringTo.nickname"
:avatar="transferringTo.user?.avatar"
size="lg"
rounded="full"
/>
<div class="absolute -top-1 -right-1 bg-yellow-500 text-black text-[10px] p-1 rounded-full w-5 h-5 flex items-center justify-center shadow-sm">
<i class="fas fa-crown"></i>
</div>
</div>
</div>
<h3 class="text-white font-bold mb-2">转让群主</h3>
<p class="text-gray-400 text-sm mb-6">
确定要将群主转让给 <span class="text-yellow-400 font-medium">{{ transferringTo.nickname || transferringTo.user?.name || '该成员' }}</span> <br/>
<span class="text-xs text-gray-500">转让后您将成为普通成员</span>
</p>
<div class="flex gap-3">
<button class="flex-1 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 text-sm" @click="transferringTo = null">取消</button>
<button class="flex-1 py-2 bg-yellow-500 text-black rounded-lg hover:bg-yellow-400 text-sm font-bold" :disabled="transferring" @click="handleTransferOwner">
<i v-if="transferring" class="fas fa-circle-notch fa-spin mr-1"></i>
确定转让
</button>
</div>
</div>
</div>
<!-- 弹窗 7: 管理员设置 -->
<div v-if="showAdminModal" class="fixed inset-0 bg-black/80 z-[1000] flex items-center justify-center p-4 backdrop-blur-sm">
<div class="bg-gray-800 rounded-xl w-full max-w-md border border-gray-700 shadow-2xl overflow-hidden max-h-[80vh] flex flex-col">
<div class="px-4 py-3 border-b border-gray-700 flex justify-between items-center bg-gray-900/50">
<div>
<h3 class="text-white font-bold text-sm">管理员设置</h3>
<p class="text-xs text-gray-400 mt-0.5">已选择 {{ selectedAdminIds.size }} </p>
</div>
<button class="text-gray-400 hover:text-white" @click="showAdminModal = false"><i class="fas fa-times"></i></button>
</div>
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar">
<div class="space-y-2">
<div
v-for="member in adminCandidates"
:key="member.user_id"
class="flex items-center gap-3 p-3 rounded-xl hover:bg-white/5 cursor-pointer transition"
:class="{ 'bg-purple-500/10': selectedAdminIds.has(member.user_id) }"
@click="toggleAdminSelection(member.user_id)"
>
<div class="relative">
<Avatar
:name="member.user?.name || member.nickname"
:avatar="member.user?.avatar"
size="sm"
rounded="full"
/>
<div v-if="member.role === 1" class="absolute -top-1 -right-1 bg-blue-500 text-white text-[8px] p-0.5 rounded-full w-3 h-3 flex items-center justify-center">
<i class="fas fa-star"></i>
</div>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm text-white truncate">{{ member.nickname || member.user?.name || '未知' }}</p>
<p class="text-xs text-gray-500">{{ member.role === 1 ? '当前管理员' : '普通成员' }}</p>
</div>
<div
class="w-5 h-5 rounded border-2 transition flex items-center justify-center"
:class="selectedAdminIds.has(member.user_id) ? 'bg-purple-500 border-purple-500' : 'border-gray-600'"
>
<i v-if="selectedAdminIds.has(member.user_id)" class="fas fa-check text-white text-xs"></i>
</div>
</div>
</div>
</div>
<div class="px-4 py-3 border-t border-gray-700 bg-gray-900/50 flex justify-end gap-2">
<button class="px-4 py-2 text-gray-300 hover:text-white text-sm" @click="showAdminModal = false">取消</button>
<button
class="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-500 disabled:opacity-50"
:disabled="savingAdmins"
@click="handleSaveAdmins"
>
<i v-if="savingAdmins" class="fas fa-circle-notch fa-spin mr-1"></i>
保存设置
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
@@ -261,6 +368,11 @@ const showDissolveConfirm = ref(false)
const showCallModal = ref(false)
const removingMember = ref<GroupMember | null>(null)
const removing = ref(false)
const transferringTo = ref<GroupMember | null>(null)
const transferring = ref(false)
const showAdminModal = ref(false)
const selectedAdminIds = ref<Set<string>>(new Set())
const savingAdmins = ref(false)
const editForm = ref({ name: '', avatar: '' })
@@ -296,6 +408,11 @@ const callCandidates = computed(() => {
} as unknown as Contact))
})
// 管理员候选人(排除群主)
const adminCandidates = computed(() => {
return members.value.filter(m => m.user_id !== authStore.user?.id && m.role !== 2)
})
// Methods
async function loadGroupInfo() {
if (!props.roomId) return
@@ -411,6 +528,80 @@ async function handleRemoveMember() {
}
}
// 转让群主相关
function showTransferConfirm(member: GroupMember) {
transferringTo.value = member
}
async function handleTransferOwner() {
if (!transferringTo.value) return
transferring.value = true
try {
// 调用 updateMemberRole 将目标成员设置为群主 (role=2)
await groupApi.updateMemberRole(props.roomId, transferringTo.value.user_id, { role: 2 })
toastStore.success('群主已转让')
transferringTo.value = null
loadGroupInfo()
emit('updated')
} catch (e: any) {
toastStore.error(e?.response?.data?.message || e?.message || '转让失败')
} finally {
transferring.value = false
}
}
// 管理员设置相关
function openAdminModal() {
// 初始化选中状态,当前管理员默认选中
selectedAdminIds.value = new Set(
members.value.filter(m => m.role === 1).map(m => m.user_id)
)
showAdminModal.value = true
}
function toggleAdminSelection(userId: string) {
if (selectedAdminIds.value.has(userId)) {
selectedAdminIds.value.delete(userId)
} else {
selectedAdminIds.value.add(userId)
}
// 触发响应式更新
selectedAdminIds.value = new Set(selectedAdminIds.value)
}
async function handleSaveAdmins() {
savingAdmins.value = true
try {
// 获取当前管理员列表
const currentAdminIds = new Set(members.value.filter(m => m.role === 1).map(m => m.user_id))
// 需要设置为管理员的(新增)
const toAdd = [...selectedAdminIds.value].filter(id => !currentAdminIds.has(id))
// 需要取消管理员的(移除)
const toRemove = [...currentAdminIds].filter(id => !selectedAdminIds.value.has(id))
// 批量设置
const promises: Promise<void>[] = []
for (const userId of toAdd) {
promises.push(groupApi.updateMemberRole(props.roomId, userId, { role: 1 }))
}
for (const userId of toRemove) {
promises.push(groupApi.updateMemberRole(props.roomId, userId, { role: 0 }))
}
await Promise.all(promises)
toastStore.success('管理员设置已保存')
showAdminModal.value = false
loadGroupInfo()
emit('updated')
} catch (e: any) {
toastStore.error(e?.response?.data?.message || e?.message || '保存失败')
} finally {
savingAdmins.value = false
}
}
watch(() => props.show, (val) => {
if (val) loadGroupInfo()
})

View File

@@ -1,10 +1,11 @@
<template>
<div
v-if="show"
class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="bg-panel rounded-2xl w-full max-w-md shadow-2xl border border-gray-700 overflow-hidden animate-fade-in max-h-[80vh] flex flex-col">
<Teleport to="body">
<div
v-if="show"
class="fixed inset-0 bg-black/80 z-[10000] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="$emit('close')"
>
<div class="bg-panel rounded-2xl w-full max-w-md shadow-2xl border border-gray-700 overflow-hidden animate-fade-in max-h-[80vh] flex flex-col">
<!-- 头部 -->
<div class="px-6 py-4 border-b border-gray-800 flex justify-between items-center">
<h2 class="text-xl font-bold text-white">{{ mode === 'invite' ? '邀请好友加入群聊' : '选择联系人创建群聊' }}</h2>
@@ -115,10 +116,11 @@
>
{{ mode === 'invite' ? '邀请' : '创建群聊' }}
</button>
</div>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">

View File

@@ -158,6 +158,7 @@ export interface GroupMember {
role: number // 0:成员 1:管理员 2:群主
nickname?: string // 群名片
joined_at?: string
muted_until?: string // 禁言到期时间
user?: User // 关联的用户信息
}