禁言
This commit is contained in:
@@ -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`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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' } : {})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -158,6 +158,7 @@ export interface GroupMember {
|
||||
role: number // 0:成员 1:管理员 2:群主
|
||||
nickname?: string // 群名片
|
||||
joined_at?: string
|
||||
muted_until?: string // 禁言到期时间
|
||||
user?: User // 关联的用户信息
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user