543 lines
20 KiB
Vue
543 lines
20 KiB
Vue
<template>
|
||
<div class="w-80 bg-[#171a21] border-l border-gray-800 flex flex-col h-full shadow-2xl relative z-10 font-sans select-none">
|
||
|
||
<!-- 1. 顶部:群信息卡片 -->
|
||
<div class="relative overflow-hidden shrink-0 pb-4">
|
||
<!-- 动态背景 -->
|
||
<div class="absolute inset-0 bg-gradient-to-br from-indigo-900/30 via-slate-900/50 to-[#171a21] z-0 pointer-events-none"></div>
|
||
|
||
<div class="relative z-10 px-5 pt-6">
|
||
<div class="flex items-start gap-4 mb-5">
|
||
<!-- 群头像 -->
|
||
<div class="relative group cursor-pointer transition-transform active:scale-95" @click="emit('show-info')">
|
||
<Avatar
|
||
:name="groupName"
|
||
:avatar="groupAvatar"
|
||
size="lg"
|
||
rounded="2xl"
|
||
class="shadow-xl ring-2 ring-white/10 group-hover:ring-indigo-500/50 transition-all"
|
||
/>
|
||
<div class="absolute -bottom-1 -right-1 bg-gray-800 rounded-full p-1 border border-gray-700 shadow-sm">
|
||
<i class="fas fa-crown text-[10px] text-yellow-500" v-if="isOwner"></i>
|
||
<i class="fas fa-shield-alt text-[10px] text-blue-400" v-else-if="isAdmin"></i>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex-1 min-w-0 pt-1">
|
||
<h2 class="text-lg font-bold text-white tracking-tight truncate leading-snug mb-1 cursor-pointer hover:text-indigo-300 transition" @click="emit('show-info')">
|
||
{{ groupName || '加载中...' }}
|
||
</h2>
|
||
<div class="flex items-center gap-2 text-xs text-gray-400">
|
||
<span class="bg-white/5 px-1.5 py-0.5 rounded text-[10px] font-mono border border-white/5">ID: {{ roomId.slice(-4) }}</span>
|
||
<span>{{ members.length }} 成员</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 2. 快捷操作栏:核心功能入口 -->
|
||
<div class="grid grid-cols-4 gap-2">
|
||
<!-- A. 发起群通话 -->
|
||
<button
|
||
class="action-btn-col bg-green-600/10 text-green-400 hover:bg-green-600/20 border border-green-500/20"
|
||
@click="handleStartCallClick"
|
||
title="语音/视频通话"
|
||
>
|
||
<i class="fas fa-video mb-1.5 text-sm"></i>
|
||
<span>通话</span>
|
||
</button>
|
||
|
||
<!-- B. 邀请成员 -->
|
||
<button
|
||
class="action-btn-col bg-blue-600/10 text-blue-400 hover:bg-blue-600/20 border border-blue-500/20"
|
||
@click="showInviteModal = true"
|
||
v-if="canInvite"
|
||
>
|
||
<i class="fas fa-user-plus mb-1.5 text-sm"></i>
|
||
<span>邀请</span>
|
||
</button>
|
||
|
||
<!-- C. 群公告 -->
|
||
<button
|
||
class="action-btn-col bg-purple-600/10 text-purple-400 hover:bg-purple-600/20 border border-purple-500/20"
|
||
@click="openAnnouncementModal"
|
||
>
|
||
<i class="fas fa-bullhorn mb-1.5 text-sm"></i>
|
||
<span>公告</span>
|
||
</button>
|
||
|
||
<!-- D. 管理/设置 -->
|
||
<button
|
||
class="action-btn-col bg-gray-600/10 text-gray-400 hover:bg-gray-600/20 border border-gray-500/20"
|
||
@click="emit('show-info')"
|
||
>
|
||
<i class="fas fa-cog mb-1.5 text-sm"></i>
|
||
<span>管理</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 3. 公告预览 (折叠式) -->
|
||
<div
|
||
class="px-5 py-3 border-y border-white/5 bg-white/[0.02] cursor-pointer hover:bg-white/[0.04] transition"
|
||
v-if="announcement"
|
||
@click="openAnnouncementModal"
|
||
>
|
||
<div class="flex items-center gap-2 mb-1.5">
|
||
<i class="fas fa-quote-left text-[10px] text-indigo-400"></i>
|
||
<span class="text-xs font-bold text-gray-400">群公告</span>
|
||
</div>
|
||
<p class="text-xs text-gray-300 line-clamp-2 leading-relaxed opacity-80">
|
||
{{ announcement }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 4. 成员列表 -->
|
||
<div class="flex-1 overflow-hidden flex flex-col bg-[#171a21]">
|
||
<div class="px-5 py-3 flex items-center justify-between">
|
||
<h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider">成员列表</h3>
|
||
<button class="text-gray-500 hover:text-white transition" @click="toggleSearch">
|
||
<i class="fas fa-search text-xs"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 搜索栏 -->
|
||
<div v-if="showSearch" class="px-4 mb-2 animate-fade-in-down">
|
||
<div class="relative">
|
||
<input
|
||
v-model="memberSearchQuery"
|
||
type="text"
|
||
class="w-full bg-black/20 border border-gray-700 rounded-lg py-1.5 pl-8 pr-3 text-xs text-white focus:border-indigo-500 focus:outline-none placeholder-gray-600"
|
||
placeholder="搜索成员..."
|
||
@input="handleMemberSearch"
|
||
/>
|
||
<i class="fas fa-search absolute left-2.5 top-2 text-gray-600 text-[10px]"></i>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex-1 overflow-y-auto custom-scrollbar px-3 space-y-1 pb-4">
|
||
<div
|
||
v-for="member in members"
|
||
:key="member.user_id"
|
||
class="flex items-center gap-3 p-2 rounded-xl hover:bg-white/5 transition-colors cursor-pointer group relative"
|
||
@click="handleMemberClick(member)"
|
||
@contextmenu.stop="showMemberContextMenu($event, member)"
|
||
>
|
||
<!-- 头像状态圈 -->
|
||
<div class="relative shrink-0">
|
||
<Avatar
|
||
:name="member.user?.name || member.nickname"
|
||
:avatar="member.user?.avatar"
|
||
size="sm"
|
||
rounded="full"
|
||
/>
|
||
<!-- 在线点 -->
|
||
<div
|
||
v-if="member.user?.is_online"
|
||
class="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-[#171a21]"
|
||
></div>
|
||
</div>
|
||
|
||
<div class="flex-1 min-w-0">
|
||
<div class="flex items-center justify-between">
|
||
<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>
|
||
<p class="text-xs text-gray-500 truncate mt-0.5 group-hover:text-gray-400">
|
||
{{ member.user?.email || '无签名' }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 弹窗 1:邀请好友 (Mode=invite) -->
|
||
<SelectContactsModal
|
||
v-if="showInviteModal"
|
||
:show="showInviteModal"
|
||
:contacts="availableContacts"
|
||
mode="invite"
|
||
title="邀请好友加入群聊"
|
||
@close="showInviteModal = false"
|
||
@invite="handleInviteMembers"
|
||
/>
|
||
|
||
<!-- 弹窗 2:发起通话 (替换为全新的 GroupCallModal) -->
|
||
<GroupCallModal
|
||
v-if="showCallModal"
|
||
:show="showCallModal"
|
||
:members="callCandidates"
|
||
@close="showCallModal = false"
|
||
@start-call="confirmStartCall"
|
||
/>
|
||
|
||
<!-- 弹窗 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 || '暂无公告' }}
|
||
</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>
|
||
|
||
<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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, watch } from 'vue'
|
||
import Avatar from '@/components/common/Avatar.vue'
|
||
import SelectContactsModal from '@/components/chat/SelectContactsModal.vue'
|
||
import GroupCallModal from '@/components/chat/GroupCallModal.vue' // 引入新组件
|
||
import ContextMenu from '@/components/common/ContextMenu.vue'
|
||
import * as groupApi from '@/api/modules/room'
|
||
import * as contactApi from '@/api/modules/contact'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { useChatStore } from '@/stores/chat'
|
||
import { useToastStore } from '@/stores/toast'
|
||
import { useContextMenu } from '@/composables/useContextMenu'
|
||
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
|
||
import type { GroupMember, Contact } from '@/types/api'
|
||
import type { MenuItem } from '@/stores/contextMenu'
|
||
|
||
const props = defineProps<{ roomId: string }>()
|
||
const emit = defineEmits(['member-clicked', 'member-removed', 'group-updated', 'show-info', 'show-notice'])
|
||
|
||
const authStore = useAuthStore()
|
||
const chatStore = useChatStore()
|
||
const toastStore = useToastStore()
|
||
const { showContextMenu } = useContextMenu()
|
||
const groupWebRTC = useGroupWebRTC()
|
||
|
||
// State
|
||
const members = ref<GroupMember[]>([])
|
||
const groupName = ref('')
|
||
const groupAvatar = ref('')
|
||
const announcement = ref('')
|
||
const memberSearchQuery = ref('')
|
||
const showSearch = ref(false)
|
||
const showInviteModal = ref(false)
|
||
const showCallModal = ref(false)
|
||
const showAnnouncementModal = ref(false)
|
||
const isEditingAnnouncement = ref(false)
|
||
const editAnnouncementText = ref('')
|
||
const removingMember = ref<GroupMember | null>(null)
|
||
const removing = ref(false)
|
||
|
||
// Computed
|
||
const currentUserRole = computed(() => members.value.find(m => m.user_id === authStore.user?.id)?.role ?? 0)
|
||
const isOwner = computed(() => currentUserRole.value === 2)
|
||
const isAdmin = computed(() => currentUserRole.value === 1)
|
||
const canInvite = computed(() => isOwner.value || isAdmin.value)
|
||
const canEditAnnouncement = computed(() => isOwner.value || isAdmin.value)
|
||
|
||
// 判断是否为好友
|
||
function isFriendCheck(userId: string): boolean {
|
||
return chatStore.contacts.some(c => c.user_id === userId || c.id === userId)
|
||
}
|
||
|
||
// 判断是否可以移除某个成员
|
||
function canRemoveMember(member: GroupMember): boolean {
|
||
// 不能移除自己
|
||
if (member.user_id === authStore.user?.id) return false
|
||
// 群主可以移除任何人
|
||
if (isOwner.value) return true
|
||
// 管理员只能移除普通成员
|
||
if (isAdmin.value && member.role === 0) return true
|
||
return false
|
||
}
|
||
|
||
// 过滤出可通话的候选人(排除自己)
|
||
const callCandidates = computed(() => {
|
||
return members.value
|
||
.filter(m => m.user_id !== authStore.user?.id)
|
||
.map(m => ({
|
||
id: m.user_id,
|
||
user_id: m.user_id,
|
||
remark_name: m.nickname || m.user?.name || '未知',
|
||
user: m.user,
|
||
is_online: m.user?.is_online
|
||
} as unknown as Contact))
|
||
})
|
||
|
||
// 可邀请的好友(排除已在群里的)
|
||
const availableContacts = computed(() => {
|
||
const memberIds = new Set(members.value.map(m => m.user_id))
|
||
return chatStore.contacts.filter(c => !memberIds.has(c.user_id || c.id))
|
||
})
|
||
|
||
// Methods
|
||
function toggleSearch() {
|
||
showSearch.value = !showSearch.value
|
||
if (!showSearch.value) memberSearchQuery.value = ''
|
||
}
|
||
|
||
async function loadData() {
|
||
if (!props.roomId) return
|
||
try {
|
||
const [info, memberList, ann] = await Promise.all([
|
||
groupApi.getGroup(props.roomId),
|
||
groupApi.getGroupMembers(props.roomId),
|
||
groupApi.getGroupAnnouncement(props.roomId).catch(() => ({ announcement: '' }))
|
||
])
|
||
groupName.value = info.name || '未命名群聊'
|
||
groupAvatar.value = info.avatar || ''
|
||
members.value = memberList
|
||
announcement.value = ann.announcement || ''
|
||
} catch (e) {
|
||
console.error(e)
|
||
}
|
||
}
|
||
|
||
async function handleMemberSearch() {
|
||
if (!memberSearchQuery.value) {
|
||
loadData()
|
||
return
|
||
}
|
||
try {
|
||
const res = await groupApi.getGroupMembers(props.roomId, memberSearchQuery.value)
|
||
members.value = res
|
||
} catch(e) {}
|
||
}
|
||
|
||
function handleMemberClick(member: GroupMember) {
|
||
emit('member-clicked', member)
|
||
}
|
||
|
||
function showMemberContextMenu(event: MouseEvent, member: GroupMember) {
|
||
const items: MenuItem[] = []
|
||
|
||
// 1. 查看资料 - 始终显示
|
||
items.push({
|
||
label: '查看资料',
|
||
icon: 'fas fa-user',
|
||
action: () => handleMemberClick(member)
|
||
})
|
||
|
||
// 不是自己时才显示以下选项
|
||
if (member.user_id !== authStore.user?.id) {
|
||
// 2. 添加好友 - 非好友时显示
|
||
if (!isFriendCheck(member.user_id)) {
|
||
items.push({
|
||
label: '添加好友',
|
||
icon: 'fas fa-user-plus',
|
||
action: () => handleAddFriendFromMenu(member)
|
||
})
|
||
}
|
||
|
||
// 3. 设置/取消管理员 - 仅群主可操作
|
||
if (isOwner.value) {
|
||
if (member.role === 0) {
|
||
// 普通成员 -> 设置为管理员
|
||
items.push({
|
||
label: '设置为管理员',
|
||
icon: 'fas fa-user-shield',
|
||
action: () => handleSetAdmin(member)
|
||
})
|
||
} else if (member.role === 1) {
|
||
// 管理员 -> 取消管理员
|
||
items.push({
|
||
label: '取消管理员',
|
||
icon: 'fas fa-user-minus',
|
||
action: () => handleRemoveAdmin(member)
|
||
})
|
||
}
|
||
}
|
||
|
||
// 4. 移除成员 - 群主可移除任何人,管理员只能移除普通成员
|
||
if (canRemoveMember(member)) {
|
||
items.push({
|
||
label: '移除成员',
|
||
icon: 'fas fa-user-times',
|
||
danger: true,
|
||
action: () => { removingMember.value = member }
|
||
})
|
||
}
|
||
}
|
||
|
||
showContextMenu(event, items)
|
||
}
|
||
|
||
// --- 成员管理逻辑 ---
|
||
async function handleAddFriendFromMenu(member: GroupMember) {
|
||
try {
|
||
await contactApi.addFriend({ to_user_id: member.user_id })
|
||
toastStore.success('好友申请已发送')
|
||
} catch (e: any) {
|
||
toastStore.error(e?.response?.data?.message || e?.message || '添加好友失败')
|
||
}
|
||
}
|
||
|
||
async function handleSetAdmin(member: GroupMember) {
|
||
try {
|
||
await groupApi.updateMemberRole(props.roomId, member.user_id, { role: 1 })
|
||
toastStore.success('已设置为管理员')
|
||
loadData()
|
||
emit('group-updated')
|
||
} catch (e: any) {
|
||
toastStore.error(e?.response?.data?.message || e?.message || '设置失败')
|
||
}
|
||
}
|
||
|
||
async function handleRemoveAdmin(member: GroupMember) {
|
||
try {
|
||
await groupApi.updateMemberRole(props.roomId, member.user_id, { role: 0 })
|
||
toastStore.success('已取消管理员')
|
||
loadData()
|
||
emit('group-updated')
|
||
} catch (e: any) {
|
||
toastStore.error(e?.response?.data?.message || e?.message || '取消失败')
|
||
}
|
||
}
|
||
|
||
async function handleRemoveMember() {
|
||
if (!removingMember.value) return
|
||
removing.value = true
|
||
try {
|
||
await groupApi.removeGroupMember(props.roomId, removingMember.value.user_id)
|
||
toastStore.success('已移除该成员')
|
||
removingMember.value = null
|
||
loadData()
|
||
emit('member-removed')
|
||
emit('group-updated')
|
||
} catch (e: any) {
|
||
toastStore.error(e?.response?.data?.message || e?.message || '移除失败')
|
||
} finally {
|
||
removing.value = false
|
||
}
|
||
}
|
||
|
||
// --- 通话逻辑 ---
|
||
function handleStartCallClick() {
|
||
if (callCandidates.value.length === 0) {
|
||
toastStore.info('群里只有你一个人,无法通话')
|
||
return
|
||
}
|
||
showCallModal.value = true
|
||
}
|
||
|
||
// 确认发起通话
|
||
function confirmStartCall(type: 'audio' | 'video', selectedIds: string[]) {
|
||
if (!selectedIds.length) {
|
||
toastStore.warning('请选择至少一名成员')
|
||
return
|
||
}
|
||
groupWebRTC.startGroupCall(props.roomId, selectedIds, type)
|
||
showCallModal.value = false
|
||
}
|
||
|
||
// --- 邀请逻辑 ---
|
||
async function handleInviteMembers(data: { member_ids: string[] }) {
|
||
if (!data.member_ids?.length) return
|
||
try {
|
||
await groupApi.inviteGroupMembers(props.roomId, { member_ids: data.member_ids })
|
||
toastStore.success('邀请成功')
|
||
showInviteModal.value = false
|
||
loadData()
|
||
emit('group-updated')
|
||
} catch(e: any) {
|
||
toastStore.error(e.message || '邀请失败')
|
||
}
|
||
}
|
||
|
||
// --- 公告逻辑 ---
|
||
function openAnnouncementModal() {
|
||
isEditingAnnouncement.value = false
|
||
showAnnouncementModal.value = true
|
||
}
|
||
|
||
function startEditAnnouncement() {
|
||
editAnnouncementText.value = announcement.value
|
||
isEditingAnnouncement.value = true
|
||
}
|
||
|
||
async function saveAnnouncement() {
|
||
try {
|
||
await groupApi.updateGroupAnnouncement(props.roomId, editAnnouncementText.value)
|
||
announcement.value = editAnnouncementText.value
|
||
isEditingAnnouncement.value = false
|
||
toastStore.success('公告已更新')
|
||
} catch(e) {
|
||
toastStore.error('更新失败')
|
||
}
|
||
}
|
||
|
||
watch(() => props.roomId, loadData, { immediate: true })
|
||
|
||
onMounted(() => {
|
||
if (chatStore.contacts.length === 0) {
|
||
contactApi.getContacts().then(res => chatStore.setContacts(res))
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
|
||
.action-btn-col {
|
||
@apply flex flex-col items-center justify-center py-2.5 rounded-lg transition-all active:scale-95 text-xs font-medium cursor-pointer backdrop-blur-sm;
|
||
}
|
||
.animate-fade-in-down {
|
||
animation: fadeInDown 0.2s ease-out;
|
||
}
|
||
@keyframes fadeInDown {
|
||
from { opacity: 0; transform: translateY(-5px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
.animate-fade-in {
|
||
animation: fadeIn 0.2s ease-out;
|
||
}
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; }
|
||
to { opacity: 1; }
|
||
}
|
||
</style>
|