391 lines
12 KiB
Vue
391 lines
12 KiB
Vue
|
|
<template>
|
|||
|
|
<div class="w-80 bg-[#1e293b] border-l border-gray-800 flex flex-col h-full">
|
|||
|
|
<!-- 群名称区域 -->
|
|||
|
|
<div class="border-b border-gray-800 p-4">
|
|||
|
|
<div class="flex items-center justify-between mb-2">
|
|||
|
|
<h3 class="text-sm font-semibold text-white">群名称</h3>
|
|||
|
|
<button
|
|||
|
|
v-if="canEditGroupName && !isEditingGroupName"
|
|||
|
|
class="text-xs text-primary hover:text-primary/80 transition"
|
|||
|
|
@click="startEditGroupName"
|
|||
|
|
>
|
|||
|
|
<i class="fas fa-edit mr-1"></i>编辑
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="!isEditingGroupName" class="min-h-[40px]">
|
|||
|
|
<p v-if="groupName" class="text-base font-medium text-white">
|
|||
|
|
{{ groupName }}
|
|||
|
|
</p>
|
|||
|
|
<p v-else class="text-sm text-gray-500 italic">
|
|||
|
|
暂无群名称
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else class="space-y-2">
|
|||
|
|
<input
|
|||
|
|
v-model="editGroupNameText"
|
|||
|
|
type="text"
|
|||
|
|
class="w-full bg-gray-800 text-white text-sm rounded-lg p-2 border border-gray-700 focus:border-primary focus:outline-none"
|
|||
|
|
placeholder="请输入群名称..."
|
|||
|
|
maxlength="20"
|
|||
|
|
/>
|
|||
|
|
<div class="flex gap-2">
|
|||
|
|
<button
|
|||
|
|
class="flex-1 py-1.5 bg-primary text-white text-xs rounded-lg hover:bg-primary/80 transition"
|
|||
|
|
@click="saveGroupName"
|
|||
|
|
>
|
|||
|
|
保存
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="flex-1 py-1.5 bg-gray-700 text-gray-300 text-xs rounded-lg hover:bg-gray-600 transition"
|
|||
|
|
@click="cancelEditGroupName"
|
|||
|
|
>
|
|||
|
|
取消
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 群公告区域 -->
|
|||
|
|
<div class="border-b border-gray-800 p-4">
|
|||
|
|
<div class="flex items-center justify-between mb-3">
|
|||
|
|
<h3 class="text-sm font-semibold text-white">群公告</h3>
|
|||
|
|
<button
|
|||
|
|
v-if="canEditAnnouncement && !isEditingAnnouncement"
|
|||
|
|
class="text-xs text-primary hover:text-primary/80 transition"
|
|||
|
|
@click="startEditAnnouncement"
|
|||
|
|
>
|
|||
|
|
<i class="fas fa-edit mr-1"></i>编辑
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="!isEditingAnnouncement" class="min-h-[60px]">
|
|||
|
|
<p v-if="announcement" class="text-sm text-gray-300 whitespace-pre-wrap">
|
|||
|
|
{{ announcement }}
|
|||
|
|
</p>
|
|||
|
|
<p v-else class="text-sm text-gray-500 italic">
|
|||
|
|
暂无群公告
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else class="space-y-2">
|
|||
|
|
<textarea
|
|||
|
|
v-model="editAnnouncementText"
|
|||
|
|
class="w-full bg-gray-800 text-white text-sm rounded-lg p-2 border border-gray-700 focus:border-primary focus:outline-none resize-none"
|
|||
|
|
rows="4"
|
|||
|
|
placeholder="请输入群公告内容..."
|
|||
|
|
></textarea>
|
|||
|
|
<div class="flex gap-2">
|
|||
|
|
<button
|
|||
|
|
class="flex-1 py-1.5 bg-primary text-white text-xs rounded-lg hover:bg-primary/80 transition"
|
|||
|
|
@click="saveAnnouncement"
|
|||
|
|
>
|
|||
|
|
保存
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
class="flex-1 py-1.5 bg-gray-700 text-gray-300 text-xs rounded-lg hover:bg-gray-600 transition"
|
|||
|
|
@click="cancelEditAnnouncement"
|
|||
|
|
>
|
|||
|
|
取消
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 群成员列表 -->
|
|||
|
|
<div class="flex-1 overflow-y-auto custom-scrollbar p-4">
|
|||
|
|
<div class="flex items-center justify-between mb-3">
|
|||
|
|
<h3 class="text-sm font-semibold text-white">群成员 ({{ members.length }})</h3>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-if="loadingMembers" class="text-center py-8 text-gray-500 text-xs">
|
|||
|
|
<i class="fas fa-spinner fa-spin mr-2"></i>
|
|||
|
|
加载中...
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else-if="members.length === 0" class="text-center py-8 text-gray-500 text-xs">
|
|||
|
|
暂无成员
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else class="space-y-2">
|
|||
|
|
<div
|
|||
|
|
v-for="member in members"
|
|||
|
|
:key="member.user_id"
|
|||
|
|
class="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 transition cursor-pointer group"
|
|||
|
|
@click="handleMemberClick(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.5 -right-0.5 w-3 h-3 bg-emerald-500 rounded-full border-2 border-[#1e293b]"
|
|||
|
|
></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="flex-1 min-w-0">
|
|||
|
|
<p class="text-sm font-medium text-white truncate">
|
|||
|
|
{{ member.user?.name || member.nickname || '未知' }}
|
|||
|
|
</p>
|
|||
|
|
<p class="text-xs text-gray-500">
|
|||
|
|
<span v-if="member.role === 2" class="text-yellow-400">群主</span>
|
|||
|
|
<span v-else-if="member.role === 1" class="text-blue-400">管理员</span>
|
|||
|
|
<span v-else>成员</span>
|
|||
|
|
<span v-if="member.user?.is_online" class="ml-2 text-emerald-400">在线</span>
|
|||
|
|
<span v-else class="ml-2 text-gray-500">离线</span>
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 成员操作按钮(仅群主/管理员可见) -->
|
|||
|
|
<div
|
|||
|
|
v-if="canManageMember(member)"
|
|||
|
|
class="opacity-0 group-hover:opacity-100 transition"
|
|||
|
|
>
|
|||
|
|
<button
|
|||
|
|
v-if="member.role !== 2"
|
|||
|
|
class="text-gray-400 hover:text-red-400 transition text-sm p-1"
|
|||
|
|
@click.stop="handleRemoveMember(member)"
|
|||
|
|
title="移除成员"
|
|||
|
|
>
|
|||
|
|
<i class="fas fa-user-minus"></i>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, computed, onMounted, watch } from 'vue'
|
|||
|
|
import Avatar from '@/components/common/Avatar.vue'
|
|||
|
|
import * as groupApi from '@/api/modules/room'
|
|||
|
|
import { useAuthStore } from '@/stores/auth'
|
|||
|
|
import { useToastStore } from '@/stores/toast'
|
|||
|
|
import type { GroupMember } from '@/types/api'
|
|||
|
|
|
|||
|
|
interface Props {
|
|||
|
|
roomId: string
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const props = defineProps<Props>()
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
'member-removed': []
|
|||
|
|
'member-clicked': [member: GroupMember]
|
|||
|
|
'group-updated': []
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const authStore = useAuthStore()
|
|||
|
|
const toastStore = useToastStore()
|
|||
|
|
|
|||
|
|
const loadingMembers = ref(false)
|
|||
|
|
const members = ref<GroupMember[]>([])
|
|||
|
|
const announcement = ref('')
|
|||
|
|
const isEditingAnnouncement = ref(false)
|
|||
|
|
const editAnnouncementText = ref('')
|
|||
|
|
const loadingAnnouncement = ref(false)
|
|||
|
|
const groupName = ref('')
|
|||
|
|
const isEditingGroupName = ref(false)
|
|||
|
|
const editGroupNameText = ref('')
|
|||
|
|
const loadingGroupName = ref(false)
|
|||
|
|
|
|||
|
|
// 当前用户角色
|
|||
|
|
const currentUserRole = computed(() => {
|
|||
|
|
if (!authStore.user) return -1
|
|||
|
|
const member = members.value.find(m => m.user_id === authStore.user!.id)
|
|||
|
|
return member?.role ?? -1
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// 是否为群主
|
|||
|
|
const isOwner = computed(() => currentUserRole.value === 2)
|
|||
|
|
|
|||
|
|
// 是否为管理员
|
|||
|
|
const isAdmin = computed(() => currentUserRole.value === 1)
|
|||
|
|
|
|||
|
|
// 是否可以编辑群公告
|
|||
|
|
const canEditAnnouncement = computed(() => isOwner.value || isAdmin.value)
|
|||
|
|
|
|||
|
|
// 是否可以编辑群名
|
|||
|
|
const canEditGroupName = computed(() => isOwner.value || isAdmin.value)
|
|||
|
|
|
|||
|
|
// 判断是否可以管理某个成员
|
|||
|
|
function canManageMember(member: GroupMember): boolean {
|
|||
|
|
// 群主可以管理所有成员(除了自己)
|
|||
|
|
if (isOwner.value && member.user_id !== authStore.user?.id) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
// 管理员可以管理普通成员
|
|||
|
|
if (isAdmin.value && member.role === 0) {
|
|||
|
|
return true
|
|||
|
|
}
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 加载群成员
|
|||
|
|
async function loadMembers() {
|
|||
|
|
if (loadingMembers.value) return
|
|||
|
|
|
|||
|
|
loadingMembers.value = true
|
|||
|
|
try {
|
|||
|
|
const memberList = await groupApi.getGroupMembers(props.roomId)
|
|||
|
|
members.value = memberList as unknown as GroupMember[]
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to load group members:', error)
|
|||
|
|
toastStore.error('加载群成员失败')
|
|||
|
|
members.value = []
|
|||
|
|
} finally {
|
|||
|
|
loadingMembers.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 加载群信息(包括群名和公告)
|
|||
|
|
async function loadGroupInfo() {
|
|||
|
|
if (loadingGroupName.value) return
|
|||
|
|
|
|||
|
|
loadingGroupName.value = true
|
|||
|
|
try {
|
|||
|
|
const [groupInfo, annRes] = await Promise.all([
|
|||
|
|
groupApi.getGroup(props.roomId),
|
|||
|
|
groupApi.getGroupAnnouncement(props.roomId).catch(() => ({ announcement: '' }))
|
|||
|
|
])
|
|||
|
|
groupName.value = groupInfo.name || groupInfo.room_name || ''
|
|||
|
|
announcement.value = annRes.announcement || ''
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to load group info:', error)
|
|||
|
|
// 加载失败不影响使用
|
|||
|
|
} finally {
|
|||
|
|
loadingGroupName.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 加载群公告
|
|||
|
|
async function loadAnnouncement() {
|
|||
|
|
if (loadingAnnouncement.value) return
|
|||
|
|
|
|||
|
|
loadingAnnouncement.value = true
|
|||
|
|
try {
|
|||
|
|
const res = await groupApi.getGroupAnnouncement(props.roomId)
|
|||
|
|
announcement.value = res.announcement || ''
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('Failed to load announcement:', error)
|
|||
|
|
// 公告加载失败不影响使用
|
|||
|
|
} finally {
|
|||
|
|
loadingAnnouncement.value = false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 开始编辑群公告
|
|||
|
|
function startEditAnnouncement() {
|
|||
|
|
editAnnouncementText.value = announcement.value
|
|||
|
|
isEditingAnnouncement.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 取消编辑
|
|||
|
|
function cancelEditAnnouncement() {
|
|||
|
|
isEditingAnnouncement.value = false
|
|||
|
|
editAnnouncementText.value = ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 保存群公告
|
|||
|
|
async function saveAnnouncement() {
|
|||
|
|
try {
|
|||
|
|
await groupApi.updateGroupAnnouncement(props.roomId, editAnnouncementText.value)
|
|||
|
|
announcement.value = editAnnouncementText.value
|
|||
|
|
isEditingAnnouncement.value = false
|
|||
|
|
toastStore.success('群公告已更新')
|
|||
|
|
} catch (error: any) {
|
|||
|
|
console.error('Failed to update announcement:', error)
|
|||
|
|
toastStore.error(error.message || '更新群公告失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 开始编辑群名
|
|||
|
|
function startEditGroupName() {
|
|||
|
|
editGroupNameText.value = groupName.value
|
|||
|
|
isEditingGroupName.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 取消编辑群名
|
|||
|
|
function cancelEditGroupName() {
|
|||
|
|
isEditingGroupName.value = false
|
|||
|
|
editGroupNameText.value = ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 保存群名
|
|||
|
|
async function saveGroupName() {
|
|||
|
|
if (!editGroupNameText.value.trim()) {
|
|||
|
|
toastStore.warning('群名称不能为空')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await groupApi.updateGroup(props.roomId, {
|
|||
|
|
name: editGroupNameText.value.trim()
|
|||
|
|
})
|
|||
|
|
groupName.value = editGroupNameText.value.trim()
|
|||
|
|
isEditingGroupName.value = false
|
|||
|
|
toastStore.success('群名称已更新')
|
|||
|
|
// 触发更新事件,让父组件刷新
|
|||
|
|
emit('group-updated')
|
|||
|
|
} catch (error: any) {
|
|||
|
|
console.error('Failed to update group name:', error)
|
|||
|
|
toastStore.error(error.message || '更新群名称失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理成员点击
|
|||
|
|
function handleMemberClick(member: GroupMember) {
|
|||
|
|
emit('member-clicked', member)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 移除成员
|
|||
|
|
async function handleRemoveMember(member: GroupMember) {
|
|||
|
|
if (!confirm(`确定要移除成员 "${member.user?.name || member.nickname || '未知'}" 吗?`)) {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await groupApi.removeGroupMember(props.roomId, member.user_id)
|
|||
|
|
toastStore.success('成员已移除')
|
|||
|
|
// 重新加载成员列表
|
|||
|
|
await loadMembers()
|
|||
|
|
emit('member-removed')
|
|||
|
|
} catch (error: any) {
|
|||
|
|
console.error('Failed to remove member:', error)
|
|||
|
|
toastStore.error(error.message || '移除成员失败')
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 监听 roomId 变化
|
|||
|
|
watch(() => props.roomId, () => {
|
|||
|
|
if (props.roomId) {
|
|||
|
|
loadMembers()
|
|||
|
|
loadGroupInfo()
|
|||
|
|
}
|
|||
|
|
}, { immediate: true })
|
|||
|
|
|
|||
|
|
onMounted(() => {
|
|||
|
|
if (props.roomId) {
|
|||
|
|
loadMembers()
|
|||
|
|
loadGroupInfo()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.custom-scrollbar::-webkit-scrollbar {
|
|||
|
|
width: 4px;
|
|||
|
|
}
|
|||
|
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|||
|
|
@apply bg-gray-700 rounded-full;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
|