This commit is contained in:
2025-12-05 13:13:12 +08:00
parent 8010d1d8a5
commit c259092b5a
18 changed files with 2800 additions and 124 deletions

View File

@@ -0,0 +1,390 @@
<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>

View File

@@ -0,0 +1,408 @@
<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-2xl shadow-2xl border border-gray-700 overflow-hidden animate-fade-in max-h-[90vh] 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">群资料</h2>
<button
class="text-gray-400 hover:text-white transition text-xl"
@click="$emit('close')"
>
<i class="fas fa-times"></i>
</button>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="flex-1 flex items-center justify-center py-20">
<div class="text-center">
<i class="fas fa-circle-notch fa-spin text-4xl text-primary mb-4"></i>
<p class="text-gray-400">加载中...</p>
</div>
</div>
<!-- 内容区域 -->
<div v-else-if="groupInfo" class="flex-1 overflow-y-auto custom-scrollbar">
<!-- 群基本信息 -->
<div class="px-6 py-6 border-b border-gray-800">
<div class="flex flex-col items-center mb-6">
<Avatar
:name="groupInfo.name"
:avatar="groupInfo.avatar || groupInfo.name?.charAt(0)"
size="xl"
rounded="full"
class="mb-4 border-4 border-gray-700"
/>
<h3 class="text-2xl font-bold text-white mb-2">{{ groupInfo.name }}</h3>
<p class="text-sm text-gray-400">群ID: {{ groupInfo.room_id }}</p>
<p class="text-sm text-gray-400 mt-1">成员数: {{ groupInfo.member_count || members.length }}</p>
</div>
<!-- 群操作按钮 -->
<div class="flex gap-3 justify-center">
<button
v-if="canInvite"
class="px-4 py-2 rounded-xl bg-primary hover:bg-indigo-600 text-white transition flex items-center gap-2"
@click="showInviteModal = true"
>
<i class="fas fa-user-plus"></i>
<span>邀请成员</span>
</button>
<button
v-if="canEdit"
class="px-4 py-2 rounded-xl bg-gray-700 hover:bg-gray-600 text-white transition flex items-center gap-2"
@click="showEditModal = true"
>
<i class="fas fa-edit"></i>
<span>编辑群信息</span>
</button>
<button
v-if="isOwner"
class="px-4 py-2 rounded-xl bg-red-600 hover:bg-red-700 text-white transition flex items-center gap-2"
@click="handleDissolve"
>
<i class="fas fa-trash"></i>
<span>解散群聊</span>
</button>
<button
v-else
class="px-4 py-2 rounded-xl bg-red-600 hover:bg-red-700 text-white transition flex items-center gap-2"
@click="handleQuit"
>
<i class="fas fa-sign-out-alt"></i>
<span>退出群聊</span>
</button>
</div>
</div>
<!-- 成员列表 -->
<div class="px-6 py-4">
<h4 class="text-lg font-semibold text-white mb-4">群成员 ({{ members.length }})</h4>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
<div
v-for="member in members"
:key="member.user_id"
class="flex items-center gap-3 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer group"
@click="handleMemberClick(member)"
>
<Avatar
:name="member.user?.name || member.nickname || '未知'"
:avatar="member.user?.avatar || (member.user?.name || member.nickname)?.charAt(0)"
size="sm"
rounded="full"
/>
<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>
</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"
@click.stop="handleRemoveMember(member)"
>
<i class="fas fa-user-minus"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 错误状态 -->
<div v-else class="flex-1 flex items-center justify-center py-20">
<div class="text-center">
<i class="fas fa-exclamation-circle text-4xl text-red-500 mb-4"></i>
<p class="text-gray-400">加载失败</p>
</div>
</div>
</div>
<!-- 邀请成员弹窗 -->
<SelectContactsModal
v-if="showInviteModal"
:show="showInviteModal"
:contacts="availableContacts"
@close="showInviteModal = false"
@create="handleInviteMembers"
/>
<!-- 编辑群信息弹窗 -->
<div
v-if="showEditModal"
class="fixed inset-0 bg-black/80 z-[70] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="showEditModal = false"
>
<div class="bg-panel rounded-2xl w-full max-w-md shadow-2xl border border-gray-700 overflow-hidden animate-fade-in">
<div class="px-6 py-4 border-b border-gray-800 flex justify-between items-center">
<h3 class="text-lg font-bold text-white">编辑群信息</h3>
<button class="text-gray-400 hover:text-white transition" @click="showEditModal = false">
<i class="fas fa-times"></i>
</button>
</div>
<div class="px-6 py-4 space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-2">群名称</label>
<input
v-model="editForm.name"
type="text"
class="w-full bg-input rounded-xl py-2 px-4 text-sm text-white focus:ring-2 ring-primary outline-none"
placeholder="请输入群名称"
maxlength="20"
/>
</div>
<div>
<label class="block text-sm text-gray-400 mb-2">群头像(首字母)</label>
<input
v-model="editForm.avatar"
type="text"
class="w-full bg-input rounded-xl py-2 px-4 text-sm text-white focus:ring-2 ring-primary outline-none"
placeholder="输入单个字符作为头像"
maxlength="1"
/>
</div>
</div>
<div class="px-6 py-4 border-t border-gray-800 flex justify-end gap-3">
<button
class="px-4 py-2 rounded-xl bg-gray-700 hover:bg-gray-600 text-white transition"
@click="showEditModal = false"
>
取消
</button>
<button
class="px-4 py-2 rounded-xl bg-primary hover:bg-indigo-600 text-white transition"
@click="handleUpdateGroup"
>
保存
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { GroupInfo, GroupMember, Contact } from '@/types/api'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
import { useToastStore } from '@/stores/toast'
import * as groupApi from '@/api/modules/room'
import * as contactApi from '@/api/modules/contact'
import Avatar from '@/components/common/Avatar.vue'
interface Props {
show: boolean
roomId: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'close': []
'updated': []
'quit': []
'dissolve': []
}>()
const authStore = useAuthStore()
const chatStore = useChatStore()
const toastStore = useToastStore()
const loading = ref(false)
const groupInfo = ref<GroupInfo | null>(null)
const members = ref<GroupMember[]>([])
const availableContacts = ref<Contact[]>([])
const showInviteModal = ref(false)
const showEditModal = ref(false)
const editForm = ref({ name: '', avatar: '' })
// 当前用户角色
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 canInvite = computed(() => isOwner.value || isAdmin.value)
// 是否可以编辑群信息
const canEdit = computed(() => isOwner.value)
// 加载群信息
async function loadGroupInfo() {
if (!props.roomId) return
loading.value = true
try {
const [group, memberList] = await Promise.all([
groupApi.getGroup(props.roomId),
groupApi.getGroupMembers(props.roomId)
])
groupInfo.value = group
members.value = memberList
// 加载可用联系人(用于邀请)
const contacts = await contactApi.getContacts()
// 过滤掉已经是群成员的联系人
const memberIds = new Set(memberList.map(m => m.user_id))
availableContacts.value = contacts.filter(c => !memberIds.has(c.user_id))
// 初始化编辑表单
editForm.value = {
name: group.name || '',
avatar: group.avatar || ''
}
} catch (error: any) {
console.error('Failed to load group info:', error)
toastStore.error('加载群信息失败')
} finally {
loading.value = false
}
}
// 判断是否可以管理某个成员
function canManageMember(member: GroupMember): boolean {
if (!isOwner.value && !isAdmin.value) return false
if (member.user_id === authStore.user?.id) return false // 不能管理自己
if (member.role === 2) return false // 不能管理群主
if (isAdmin.value && member.role === 1) return false // 管理员不能管理其他管理员
return true
}
// 处理成员点击
function handleMemberClick(member: GroupMember) {
// 可以扩展为显示成员详情
}
// 移除成员
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 loadGroupInfo()
emit('updated')
} catch (error: any) {
toastStore.error('移除成员失败')
}
}
// 邀请单个联系人
async function handleInviteContact(contact: Contact) {
try {
await groupApi.inviteGroupMembers(props.roomId, { member_ids: [contact.user_id || contact.id] })
toastStore.success('邀请成功')
showInviteModal.value = false
await loadGroupInfo()
emit('updated')
} catch (error: any) {
toastStore.error('邀请失败')
}
}
// 更新群信息
async function handleUpdateGroup() {
if (!editForm.value.name.trim()) {
toastStore.warning('群名称不能为空')
return
}
try {
await groupApi.updateGroup(props.roomId, {
name: editForm.value.name.trim(),
avatar: editForm.value.avatar || editForm.value.name.charAt(0).toUpperCase()
})
toastStore.success('更新成功')
showEditModal.value = false
await loadGroupInfo()
emit('updated')
} catch (error: any) {
toastStore.error('更新失败')
}
}
// 退出群聊
async function handleQuit() {
if (!confirm('确定要退出该群聊吗?')) {
return
}
try {
await groupApi.quitGroup(props.roomId)
toastStore.success('已退出群聊')
emit('quit')
emit('close')
} catch (error: any) {
toastStore.error('退出失败')
}
}
// 解散群聊
async function handleDissolve() {
if (!confirm('确定要解散该群聊吗?此操作不可恢复!')) {
return
}
try {
await groupApi.dissolveGroup(props.roomId)
toastStore.success('群聊已解散')
emit('dissolve')
emit('close')
} catch (error: any) {
toastStore.error('解散失败')
}
}
// 监听 show 变化,加载数据
watch(() => props.show, (newVal) => {
if (newVal) {
loadGroupInfo()
}
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-700 rounded-full;
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>

View File

@@ -1,5 +1,14 @@
<template>
<div class="flex flex-col" :class="message.isSelf ? 'items-end' : 'items-start'">
<!-- 系统/通知消息:居中灰色提示条样式 -->
<div v-if="isSystemOrNotify" class="flex justify-center my-2">
<div class="bg-gray-700/50 text-gray-400 text-xs px-4 py-2 rounded-full border border-gray-600/30 max-w-[80%] text-center">
<i :class="systemIcon" class="mr-1.5"></i>
<span>{{ systemContent }}</span>
</div>
</div>
<!-- 普通消息:原有样式 -->
<div v-else class="flex flex-col" :class="message.isSelf ? 'items-end' : 'items-start'">
<!-- 气泡主体 -->
<div
class="relative text-sm shadow-md transition-all duration-300 hover:shadow-lg group/bubble overflow-hidden"
@@ -24,6 +33,11 @@
<!-- 文件消息 -->
<FileBubble v-else-if="message.message_type === 8" :message="message" class="px-3 py-2" />
<!-- 未知类型消息 -->
<div v-else class="px-4 py-3">
<span class="text-gray-400 text-xs">[未知消息类型]</span>
</div>
</div>
<!-- 底部时间显示 (格式化) -->
@@ -40,6 +54,8 @@
import { computed } from 'vue'
import type { ChatMessage } from '@/types/api'
import { formatTime } from '@/utils/format'
import { isSystemOrNotifyMessage, getMessageTypeIcon, getMessageSummary } from '@/utils/messageTypes'
import { MessageType } from '@/types/message'
import TextBubble from './bubble/Text.vue'
import ImageBubble from './bubble/Image.vue'
import AudioBubble from './bubble/Audio.vue'
@@ -50,6 +66,15 @@ const props = defineProps<{
message: ChatMessage
}>()
// 判断是否为系统/通知消息
const isSystemOrNotify = computed(() => isSystemOrNotifyMessage(props.message.message_type))
// 系统消息图标
const systemIcon = computed(() => getMessageTypeIcon(props.message.message_type))
// 系统消息内容
const systemContent = computed(() => getMessageSummary(props.message))
// 更现代的圆角逻辑
const bubbleShapeClass = computed(() => {
if (props.message.isSelf) {

View File

@@ -43,9 +43,9 @@
>
<!-- 头像 -->
<Avatar
:name="msg.isSelf ? currentUser.name : (target.user?.name || target.remark_name)"
:avatar="msg.isSelf ? currentUser.avatar : (target.user?.avatar || target.remark_name?.charAt(0))"
:color="msg.isSelf ? generateColor(currentUser.id) : target.color"
:name="getSenderName(msg)"
:avatar="getSenderAvatar(msg)"
:color="msg.isSelf ? generateColor(currentUser.id) : (getMemberColor(msg.sender_user_id) || target.color)"
size="sm"
rounded="lg"
class="cursor-pointer hover:opacity-80 transition self-start mt-1 shadow-md"
@@ -54,7 +54,16 @@
<!-- 消息内容区 -->
<div class="flex flex-col max-w-[75%] md:max-w-[60%]">
<!-- 群聊中显示发送者昵称(非自己发送的消息) -->
<div
v-if="isGroupChat && !msg.isSelf"
class="text-[10px] text-gray-500 mb-1 px-1"
>
{{ getSenderDisplayName(msg) }}
</div>
<!-- 单聊或自己发送的消息 -->
<div
v-else-if="!isGroupChat || msg.isSelf"
class="text-[10px] text-gray-500 mb-1 px-1"
:class="{ 'text-right': msg.isSelf }"
>
@@ -69,7 +78,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { generateColor } from '@/utils/format'
import Avatar from '@/components/common/Avatar.vue'
import MessageBubble from '@/components/chat/MessageBubble.vue'
@@ -81,6 +90,7 @@ interface Props {
target: Contact
loadingHistory?: boolean
hasMoreHistory?: boolean
roomMembers?: Record<string, { name: string; avatar?: string }> // 群成员映射 roomId -> { name, avatar }
}
const props = defineProps<Props>()
@@ -94,6 +104,50 @@ const emit = defineEmits<{
const containerRef = ref<HTMLElement | null>(null)
// 判断是否为群聊
const isGroupChat = computed(() => props.target.is_group || props.target.room_type === 'group')
// 获取发送者名称
function getSenderName(msg: ChatMessage): string {
if (msg.isSelf) {
return props.currentUser.name
}
if (isGroupChat.value && props.roomMembers?.[msg.sender_user_id]) {
return props.roomMembers[msg.sender_user_id].name
}
return props.target.user?.name || props.target.remark_name || '未知用户'
}
// 获取发送者头像
function getSenderAvatar(msg: ChatMessage): string {
if (msg.isSelf) {
return props.currentUser.avatar
}
if (isGroupChat.value && props.roomMembers?.[msg.sender_user_id]) {
return props.roomMembers[msg.sender_user_id].avatar || getSenderName(msg).charAt(0)
}
return props.target.user?.avatar || props.target.remark_name?.charAt(0) || '?'
}
// 获取发送者显示名称(群聊中显示)
function getSenderDisplayName(msg: ChatMessage): string {
if (msg.isSelf) {
return 'Me'
}
if (isGroupChat.value && props.roomMembers?.[msg.sender_user_id]) {
return props.roomMembers[msg.sender_user_id].name
}
return props.target.user?.name || props.target.remark_name || '未知用户'
}
// 获取成员颜色
function getMemberColor(userId: string): string | undefined {
if (props.roomMembers?.[userId]) {
return generateColor(userId)
}
return undefined
}
// 节流函数
function throttle<T extends (...args: any[]) => any>(func: T, delay: number): T {
let lastCall = 0

View File

@@ -0,0 +1,224 @@
<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">
<!-- 头部 -->
<div class="px-6 py-4 border-b border-gray-800 flex justify-between items-center">
<h2 class="text-xl font-bold text-white">选择联系人创建群聊</h2>
<button
class="text-gray-400 hover:text-white transition text-xl"
@click="$emit('close')"
>
<i class="fas fa-times"></i>
</button>
</div>
<!-- 搜索框 -->
<div class="px-6 py-4 border-b border-gray-800">
<div class="relative">
<i class="fas fa-search absolute left-3 top-3 text-gray-500"></i>
<input
v-model="searchQuery"
type="text"
class="w-full bg-input rounded-xl py-2 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="搜索联系人..."
/>
</div>
</div>
<!-- 已选联系人 -->
<div v-if="selectedContacts.length > 0" class="px-6 py-3 border-b border-gray-800">
<div class="flex flex-wrap gap-2">
<div
v-for="contact in selectedContacts"
:key="contact.id"
class="flex items-center gap-2 bg-primary/20 text-primary px-3 py-1.5 rounded-full text-sm"
>
<span>{{ contact.user?.name || contact.remark_name }}</span>
<button
class="hover:text-red-400 transition"
@click="removeContact(contact.id)"
>
<i class="fas fa-times text-xs"></i>
</button>
</div>
</div>
</div>
<!-- 联系人列表 -->
<div class="flex-1 overflow-y-auto px-4 py-4 custom-scrollbar">
<div v-if="filteredContacts.length === 0" class="text-center py-10 text-gray-500">
<i class="fas fa-users-slash text-4xl mb-3 opacity-50"></i>
<p class="text-sm">暂无联系人</p>
</div>
<div
v-for="contact in filteredContacts"
:key="contact.id"
class="flex items-center gap-3 p-3 rounded-xl hover:bg-white/5 cursor-pointer transition"
:class="{ 'bg-primary/10': isSelected(contact.id) }"
@click="toggleContact(contact)"
>
<Avatar
:name="contact.user?.name || contact.remark_name"
:avatar="contact.user?.avatar || contact.remark_name?.charAt(0)"
:color="contact.color"
size="sm"
rounded="full"
/>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-white truncate">
{{ contact.user?.name || contact.remark_name }}
</p>
<p v-if="contact.user?.desc" class="text-xs text-gray-500 truncate">
{{ contact.user.desc }}
</p>
</div>
<div
class="w-5 h-5 rounded-full border-2 transition"
:class="isSelected(contact.id) ? 'bg-primary border-primary' : 'border-gray-600'"
>
<i v-if="isSelected(contact.id)" class="fas fa-check text-white text-xs flex items-center justify-center h-full"></i>
</div>
</div>
</div>
<!-- 底部操作区 -->
<div class="px-6 py-4 border-t border-gray-800 flex justify-between items-center">
<div class="text-sm text-gray-400">
已选择 <span class="text-primary font-bold">{{ selectedContacts.length }}</span> 人
</div>
<div class="flex gap-3">
<button
class="px-4 py-2 rounded-xl bg-gray-700 hover:bg-gray-600 text-white transition"
@click="$emit('close')"
>
取消
</button>
<button
class="px-4 py-2 rounded-xl bg-primary hover:bg-indigo-600 text-white transition disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="selectedContacts.length < 1 || !groupName.trim()"
@click="handleCreate"
>
创建群聊
</button>
</div>
</div>
<!-- 群名称输入(在底部操作区上方) -->
<div class="px-6 py-3 border-t border-gray-800">
<input
v-model="groupName"
type="text"
class="w-full bg-input rounded-xl py-2 px-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="请输入群名称(必填)"
maxlength="20"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Contact } from '@/types/api'
import Avatar from '@/components/common/Avatar.vue'
interface Props {
show: boolean
contacts: Contact[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
'close': []
'create': [data: { member_ids: string[]; name: string; avatar?: string }]
}>()
const searchQuery = ref('')
const groupName = ref('')
const selectedContacts = ref<Contact[]>([])
// 过滤联系人
const filteredContacts = computed(() => {
const query = searchQuery.value.toLowerCase()
return props.contacts.filter(c => {
const name = (c.user?.name || c.remark_name || '').toLowerCase()
return name.includes(query)
})
})
// 判断联系人是否已选中
function isSelected(contactId: string): boolean {
return selectedContacts.value.some(c => c.id === contactId)
}
// 切换联系人选择状态
function toggleContact(contact: Contact) {
const index = selectedContacts.value.findIndex(c => c.id === contact.id)
if (index > -1) {
selectedContacts.value.splice(index, 1)
} else {
selectedContacts.value.push(contact)
}
}
// 移除已选联系人
function removeContact(contactId: string) {
const index = selectedContacts.value.findIndex(c => c.id === contactId)
if (index > -1) {
selectedContacts.value.splice(index, 1)
}
}
// 创建群聊
function handleCreate() {
if (selectedContacts.value.length < 1 || !groupName.value.trim()) {
return
}
const memberIds = selectedContacts.value.map(c => c.user_id || c.id)
const avatar = groupName.value.charAt(0).toUpperCase() // 使用群名称首字母作为头像
emit('create', {
member_ids: memberIds,
name: groupName.value.trim(),
avatar
})
// 重置状态
selectedContacts.value = []
groupName.value = ''
searchQuery.value = ''
}
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-700 rounded-full;
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>

View File

@@ -0,0 +1,203 @@
<template>
<div class="w-full">
<!-- 群聊基本信息 -->
<div
class="flex items-center p-2.5 rounded-xl transition-all duration-200 select-none cursor-pointer"
:class="isSelected ? 'bg-primary text-white shadow-lg shadow-primary/20' : 'hover:bg-white/5 text-gray-300'"
@click="handleCardClick"
>
<div class="relative shrink-0 mr-3">
<Avatar :name="group.room_name" :avatar="group.room_avatar" size="sm" rounded="xl" />
</div>
<div class="flex-1 min-w-0">
<div class="flex justify-between items-center mb-0.5">
<span class="font-bold text-sm truncate" :class="isSelected ? 'text-white' : 'text-gray-200'">
{{ group.room_name || '未知群聊' }}
</span>
</div>
<!-- 未展开时显示群公告 -->
<div v-if="!isExpanded" class="mt-1">
<p v-if="loadingAnnouncement" class="text-xs opacity-50" :class="isSelected ? 'text-white/60' : 'text-gray-500'">
<i class="fas fa-spinner fa-spin mr-1"></i>加载中...
</p>
<p v-else-if="announcement" class="text-xs truncate opacity-70 line-clamp-1" :class="isSelected ? 'text-white/80' : 'text-gray-500'" :title="announcement">
<i class="fas fa-bullhorn mr-1 opacity-60"></i>{{ announcement }}
</p>
<p v-else class="text-xs opacity-50" :class="isSelected ? 'text-white/60' : 'text-gray-500'">
<i class="fas fa-bullhorn mr-1 opacity-60"></i>暂无群公告
</p>
</div>
<!-- 展开时显示最后一条消息 -->
<p v-else class="text-xs truncate opacity-70" :class="isSelected ? 'text-white/80' : 'text-gray-500'">
{{ group.last_message || '暂无消息' }}
</p>
</div>
<div class="flex items-center gap-1 ml-2">
<button
class="p-1 hover:bg-white/10 rounded transition"
@click.stop="toggleExpand"
>
<i
class="fas transition-transform duration-200 text-xs"
:class="[
isExpanded ? 'fa-chevron-down' : 'fa-chevron-right',
isSelected ? 'text-white' : 'text-gray-500'
]"
></i>
</button>
</div>
</div>
<!-- 成员列表(展开时显示) -->
<Transition name="slide-down">
<div v-if="isExpanded" class="pl-2 mt-2 space-y-2 max-h-96 overflow-y-auto custom-scrollbar">
<div v-if="loadingMembers" class="text-center py-4 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-4 text-gray-500 text-xs">
暂无成员
</div>
<div v-else class="space-y-1">
<div
v-for="member in displayMembers"
:key="member.user_id"
class="flex items-center gap-2 p-2 rounded-lg hover:bg-white/5 transition"
>
<Avatar
:name="member.user?.name || member.nickname || '未知'"
:avatar="member.user?.avatar"
size="sm"
rounded="full"
/>
<span class="text-xs text-gray-300 truncate flex-1">
{{ member.user?.name || member.nickname || '未知' }}
</span>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import Avatar from '@/components/common/Avatar.vue'
import * as groupApi from '@/api/modules/room'
import type { GroupMember } from '@/types/api'
interface Props {
group: {
room_id: string
room_name: string
room_avatar?: string
last_message?: string
}
isSelected?: boolean
}
const props = withDefaults(defineProps<Props>(), {
isSelected: false
})
const emit = defineEmits<{
(e: 'click', group: Props['group']): void
}>()
const isExpanded = ref(false)
const loadingMembers = ref(false)
const loadingAnnouncement = ref(false)
const members = ref<GroupMember[]>([])
const announcement = ref<string>('')
// 显示成员(展开时显示全部)
const displayMembers = computed(() => {
return members.value
})
// 切换展开/收起
function toggleExpand() {
isExpanded.value = !isExpanded.value
// 展开时加载成员列表
if (isExpanded.value && members.value.length === 0) {
loadMembers()
}
}
// 加载群成员
async function loadMembers() {
if (loadingMembers.value) return
loadingMembers.value = true
try {
const memberList = await groupApi.getGroupMembers(props.group.room_id)
members.value = memberList as unknown as GroupMember[]
} catch (error) {
console.error('Failed to load group members:', error)
members.value = []
} finally {
loadingMembers.value = false
}
}
// 监听 group 变化,重置展开状态并重新加载数据
watch(() => props.group.room_id, () => {
isExpanded.value = false
members.value = []
announcement.value = ''
loadAnnouncement()
})
// 处理卡片点击
function handleCardClick() {
emit('click', props.group)
}
// 加载群公告
async function loadAnnouncement() {
if (loadingAnnouncement.value) return
loadingAnnouncement.value = true
try {
const res = await groupApi.getGroupAnnouncement(props.group.room_id)
announcement.value = res?.announcement || ''
} catch (error) {
console.error('Failed to load group announcement:', error)
announcement.value = ''
} finally {
loadingAnnouncement.value = false
}
}
// 组件挂载时加载群公告
onMounted(() => {
// 延迟加载,避免同时加载太多请求
setTimeout(() => {
loadAnnouncement()
}, 100)
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-gray-700 rounded-full;
}
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
max-height: 500px;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
max-height: 0;
transform: translateY(-10px);
}
</style>