🐛 fix(im): TIP_TEXT 系统提示不再显示空白
群解散 / 退群 / 踢人 等系统提示后端发的是裸字符串,之前按 TextMessage JSON
解析 → 主聊天窗显示空行、会话列表摘要变空。
- message.ts:新增 resolveTipText helper,兼容裸字符串 + {"content":"..."}
- MessageItem / conversationStore.resolveLastContent 把 TIP_TEXT 从 TEXT
分支拆出来,统一走 resolveTipText(TEXT 仍按 JSON 解析,没有裸字符串可能)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
im
parent
9e8d04249c
commit
8fd21da555
Binary file not shown.
|
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<!--
|
||||
群成员单行(对应 boxim chat/ChatGroupMember.vue)
|
||||
跨子域复用:@候选 (MentionPicker) / 已读列表 (MessageReadStatus) / 群成员宫格 (ChatGroupSide)
|
||||
-->
|
||||
<div
|
||||
class="im-chat-group-member"
|
||||
:class="{ 'is-active': active }"
|
||||
:style="{ height: height + 'px' }"
|
||||
>
|
||||
<UserAvatar
|
||||
:size="avatarSize"
|
||||
:name="member.showNickName"
|
||||
:url="member.headImage"
|
||||
:clickable="clickable"
|
||||
:id="member.userId"
|
||||
/>
|
||||
<div class="im-chat-group-member__name" :style="{ lineHeight: height + 'px' }">
|
||||
{{ member.showNickName }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import UserAvatar from '../../../components/UserAvatar.vue'
|
||||
|
||||
defineOptions({ name: 'ImChatGroupMember' })
|
||||
|
||||
/** 群成员结构(跨多处使用,放这里做窄接口;独立于 types/index.ts) */
|
||||
export interface GroupMemberLite {
|
||||
/** 用户 id,特殊值 -1 表示「全体成员」 */
|
||||
userId: number | string
|
||||
/** 展示昵称:优先群备注,再群昵称,再用户昵称 */
|
||||
showNickName: string
|
||||
/** 头像 URL */
|
||||
headImage?: string
|
||||
/** 是否已退群 */
|
||||
quit?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
member: GroupMemberLite
|
||||
/** 行高(px),影响头像大小 */
|
||||
height?: number
|
||||
/** 选中态(@候选键盘高亮等) */
|
||||
active?: boolean
|
||||
/** 头像点击是否弹 UserInfoCard;@候选场景通常禁用(避免嵌套交互) */
|
||||
clickable?: boolean
|
||||
}>(),
|
||||
{
|
||||
height: 50,
|
||||
active: false,
|
||||
clickable: false
|
||||
}
|
||||
)
|
||||
|
||||
const avatarSize = computed(() => Math.ceil(props.height * 0.75))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-chat-group-member {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 5px;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.im-chat-group-member.is-active {
|
||||
background-color: #e1eaf7;
|
||||
}
|
||||
|
||||
.im-chat-group-member__name {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
padding-left: 10px;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
<template>
|
||||
<!--
|
||||
聊天面板右侧信息抽屉(对应 boxim chat/ChatGroupSide.vue)
|
||||
- 抽屉形态:当 uiStore.chatGroupSide.show=true 时滑入
|
||||
- 群成员宫格 + 邀请 / 移除按钮(仅群主) + 群信息表单 + 退群按钮
|
||||
- 跨 page 引用 pages/group/components/ 下的 Dialog 组件
|
||||
- TODO 群模块后端 API 对接后替换 saveGroup / quit / remove
|
||||
-->
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:with-header="false"
|
||||
direction="rtl"
|
||||
size="360px"
|
||||
append-to-body
|
||||
modal-class="im-chat-group-side__modal"
|
||||
>
|
||||
<div class="im-chat-group-side">
|
||||
<!-- 群成员区 -->
|
||||
<div v-if="group" class="im-chat-group-side__block">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="搜索群成员"
|
||||
clearable
|
||||
size="small"
|
||||
class="im-chat-group-side__search"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<div class="im-chat-group-side__members">
|
||||
<!-- 邀请按钮 -->
|
||||
<div class="im-chat-group-side__tool" title="邀请好友入群" @click="inviteVisible = true">
|
||||
<div class="im-chat-group-side__tool-btn">
|
||||
<el-icon><Plus /></el-icon>
|
||||
</div>
|
||||
<div class="im-chat-group-side__tool-text">邀请</div>
|
||||
</div>
|
||||
|
||||
<!-- 移除按钮(仅群主) -->
|
||||
<div
|
||||
v-if="isOwner"
|
||||
class="im-chat-group-side__tool"
|
||||
title="移出成员"
|
||||
@click="removeVisible = true"
|
||||
>
|
||||
<div class="im-chat-group-side__tool-btn">
|
||||
<el-icon><Minus /></el-icon>
|
||||
</div>
|
||||
<div class="im-chat-group-side__tool-text">移除</div>
|
||||
</div>
|
||||
|
||||
<!-- 成员宫格 -->
|
||||
<GroupMemberGrid
|
||||
v-for="m in filteredMembers"
|
||||
:key="m.userId"
|
||||
:member="m"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<!-- 群信息表单 -->
|
||||
<div v-if="group" class="im-chat-group-side__form">
|
||||
<el-form label-position="top" size="small">
|
||||
<el-form-item label="群聊名称">
|
||||
<el-input v-model="group.name" :disabled="!editing" maxlength="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="群主">
|
||||
<el-input :model-value="ownerName" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="群公告">
|
||||
<el-input
|
||||
v-model="group.notice"
|
||||
:disabled="!editing"
|
||||
type="textarea"
|
||||
maxlength="1024"
|
||||
:rows="3"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="我在本群的昵称">
|
||||
<el-input v-model="group.remarkNickName" :disabled="!editing" maxlength="20" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="im-chat-group-side__actions">
|
||||
<el-button v-if="editing" type="success" @click="handleSave">保存</el-button>
|
||||
<el-button v-else type="primary" @click="editing = true">编辑</el-button>
|
||||
<el-button v-if="!isOwner" type="danger" @click="handleQuit">退出群聊</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子对话框(跨 page 引用 pages/group/ 下的组件) -->
|
||||
<AddGroupMemberDialog
|
||||
v-model="inviteVisible"
|
||||
:group-id="group?.id"
|
||||
:members="members"
|
||||
:friends="friends"
|
||||
@reload="$emit('reload')"
|
||||
/>
|
||||
<GroupMemberSelector
|
||||
v-model="removeVisible"
|
||||
title="选择成员进行移除"
|
||||
:members="members"
|
||||
:hide-ids="group?.ownerId ? [group.ownerId] : []"
|
||||
:max-size="50"
|
||||
@complete="handleRemoveComplete"
|
||||
/>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Plus, Minus } from '@element-plus/icons-vue'
|
||||
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useImUiStore } from '../../../store/uiStore'
|
||||
import GroupMemberGrid from '../../group/components/GroupMemberGrid.vue'
|
||||
import AddGroupMemberDialog from '../../group/components/AddGroupMemberDialog.vue'
|
||||
import GroupMemberSelector, {
|
||||
type GroupMemberFlag
|
||||
} from '../../group/components/GroupMemberSelector.vue'
|
||||
import type { GroupLite } from '../../group/components/GroupItem.vue'
|
||||
import type { GroupMemberLite } from './ChatGroupMember.vue'
|
||||
import type { FriendLite } from '../../friend/components/FriendItem.vue'
|
||||
|
||||
defineOptions({ name: 'ImChatGroupSide' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 当前群信息(可空:无激活群会话时) */
|
||||
group?: GroupLite & { notice?: string; remarkNickName?: string }
|
||||
members?: GroupMemberLite[]
|
||||
friends?: FriendLite[]
|
||||
}>(),
|
||||
{
|
||||
members: () => [],
|
||||
friends: () => []
|
||||
}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
/** 邀请 / 移除 / 修改群资料后,父组件重新拉群数据 */
|
||||
reload: []
|
||||
}>()
|
||||
|
||||
const uiStore = useImUiStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const visible = computed({
|
||||
get: () => uiStore.chatGroupSide.show,
|
||||
set: (v) => uiStore.toggleChatGroupSide(v)
|
||||
})
|
||||
|
||||
const searchText = ref('')
|
||||
const editing = ref(false)
|
||||
const inviteVisible = ref(false)
|
||||
const removeVisible = ref(false)
|
||||
|
||||
const myId = computed(() => userStore.getUser?.id?.toString() || '')
|
||||
|
||||
const isOwner = computed(
|
||||
() => props.group != null && String(props.group.ownerId) === myId.value
|
||||
)
|
||||
|
||||
const ownerName = computed(() => {
|
||||
if (!props.group) return ''
|
||||
const owner = props.members.find(
|
||||
(m) => String(m.userId) === String(props.group!.ownerId)
|
||||
)
|
||||
return owner?.showNickName || '-'
|
||||
})
|
||||
|
||||
const filteredMembers = computed(() =>
|
||||
props.members.filter(
|
||||
(m) => !m.quit && (m.showNickName || '').includes(searchText.value)
|
||||
)
|
||||
)
|
||||
|
||||
// TODO 接入 /im/group/modify
|
||||
async function handleSave() {
|
||||
ElMessage.info('群信息保存接口待接入,当前为占位实现')
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
// TODO 接入 /im/group/quit
|
||||
async function handleQuit() {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'退出群聊后将不再接受群里的消息,确认退出吗?',
|
||||
'确认退出',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
ElMessage.info('退出群聊接口待接入,当前为占位实现')
|
||||
} catch {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// TODO 接入 /im/group/member/remove
|
||||
function handleRemoveComplete(members: GroupMemberFlag[]) {
|
||||
if (members.length === 0) return
|
||||
ElMessage.info(`移除成员接口待接入,选择了 ${members.length} 位成员`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-chat-group-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.im-chat-group-side__block {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.im-chat-group-side__search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.im-chat-group-side__members {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.im-chat-group-side__tool {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 54px;
|
||||
}
|
||||
|
||||
.im-chat-group-side__tool-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 18px;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
border: 1px dashed #dcdfe6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.im-chat-group-side__tool-btn:hover {
|
||||
color: #409eff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.im-chat-group-side__tool-text {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.im-chat-group-side__form {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.im-chat-group-side__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
<template>
|
||||
<div class="chat-panel">
|
||||
<template v-if="chatStore.activeChat">
|
||||
<ChatHeader />
|
||||
<MessageList />
|
||||
<InputBox />
|
||||
</template>
|
||||
<div v-else class="chat-panel__empty">
|
||||
<span>选择一个会话开始聊天</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useChatStore } from '../store/chatStore'
|
||||
import ChatHeader from './ChatHeader.vue'
|
||||
import MessageList from './MessageList.vue'
|
||||
import InputBox from './InputBox.vue'
|
||||
|
||||
defineOptions({ name: 'ImChatPanel' })
|
||||
|
||||
const chatStore = useChatStore()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.chat-panel__empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
<template>
|
||||
<!--
|
||||
私聊侧边抽屉
|
||||
- 抽屉形态:受 v-model 控制,由父组件 ChatPanel 管理开关
|
||||
- 顶部:好友头像 + 昵称
|
||||
- 操作:消息免打扰 / 置顶聊天
|
||||
- 与会话列表右键菜单同语义:免打扰联动 friendStore.setMuted
|
||||
-->
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
:with-header="false"
|
||||
direction="rtl"
|
||||
size="320px"
|
||||
append-to-body
|
||||
>
|
||||
<div class="flex flex-col h-full p-2.5">
|
||||
<!-- 头像 + 昵称 -->
|
||||
<div v-if="friend" class="flex flex-col gap-1.5 items-start">
|
||||
<UserAvatar
|
||||
:id="friend.friendUserId"
|
||||
:url="friend.avatar"
|
||||
:name="friend.nickname"
|
||||
:size="56"
|
||||
radius="10%"
|
||||
:clickable="false"
|
||||
/>
|
||||
<div class="overflow-hidden text-sm font-medium truncate text-[var(--el-text-color-primary)] max-w-full">
|
||||
{{ friend.nickname }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider class="im-chat-private-side__divider" />
|
||||
|
||||
<!-- 操作项 -->
|
||||
<div class="flex flex-col gap-3.5 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[var(--el-text-color-primary)]">消息免打扰</span>
|
||||
<el-switch :model-value="!!conversation?.muted" @change="onMutedChange" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[var(--el-text-color-primary)]">置顶聊天</span>
|
||||
<el-switch :model-value="!!conversation?.top" @change="onTopChange" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useConversationStore } from '../../../store/conversationStore'
|
||||
import { useFriendStore } from '../../../store/friendStore'
|
||||
import { ImConversationType } from '../../../../utils/constants'
|
||||
import type { Conversation, Friend } from '../../../types'
|
||||
import UserAvatar from '../../../components/UserAvatar.vue'
|
||||
|
||||
defineOptions({ name: 'ImChatPrivateSide' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 抽屉开关(v-model) */
|
||||
modelValue?: boolean
|
||||
/** 当前会话(取置顶 / 免打扰态) */
|
||||
conversation?: Conversation | null
|
||||
/** 对方好友信息(取头像 / 昵称) */
|
||||
friend?: Friend
|
||||
}>(),
|
||||
{
|
||||
modelValue: false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const conversationStore = useConversationStore()
|
||||
const friendStore = useFriendStore()
|
||||
|
||||
function onMutedChange(value: boolean | string | number) {
|
||||
if (!props.conversation) {
|
||||
return
|
||||
}
|
||||
const next = !!value
|
||||
conversationStore.setMuted(props.conversation.type, props.conversation.targetId, next)
|
||||
if (props.conversation.type === ImConversationType.PRIVATE) {
|
||||
friendStore.setMuted(props.conversation.targetId, next)
|
||||
}
|
||||
}
|
||||
|
||||
function onTopChange(value: boolean | string | number) {
|
||||
if (!props.conversation) {
|
||||
return
|
||||
}
|
||||
conversationStore.setTop(props.conversation.type, props.conversation.targetId, !!value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* el-divider 默认 margin 较大,压成 8px,和群聊抽屉视觉对齐 */
|
||||
.im-chat-private-side__divider {
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
<template>
|
||||
<!--
|
||||
历史消息抽屉(对应 boxim chat/ChatHistory.vue)
|
||||
- 从输入框工具栏触发,展示当前会话的全部历史消息
|
||||
- 简化实现:基于本地缓存的 activeChat.messages 全量展示 + 关键词搜索
|
||||
- 未来可接入后端「按时间段 / 发送人」查询,并用 PagedScroller 做增量加载
|
||||
-->
|
||||
<el-drawer
|
||||
v-model="visible"
|
||||
title="历史消息"
|
||||
direction="rtl"
|
||||
size="420px"
|
||||
append-to-body
|
||||
>
|
||||
<div class="im-message-history">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索消息内容"
|
||||
clearable
|
||||
class="im-message-history__search"
|
||||
/>
|
||||
|
||||
<div class="im-message-history__count">
|
||||
共 {{ filtered.length }} 条{{ keyword ? '(过滤后)' : '' }}
|
||||
</div>
|
||||
|
||||
<div class="im-message-history__list">
|
||||
<div
|
||||
v-for="msg in filtered"
|
||||
:key="msg.id || msg.tmpId"
|
||||
class="im-message-history__item"
|
||||
>
|
||||
<div class="im-message-history__meta">
|
||||
<span class="im-message-history__sender">{{ msg.selfSend ? '我' : (msg.sendNickName || '对方') }}</span>
|
||||
<span class="im-message-history__time">{{ formatTime(msg.sendTime) }}</span>
|
||||
</div>
|
||||
<div class="im-message-history__content">{{ renderContent(msg) }}</div>
|
||||
</div>
|
||||
<div v-if="filtered.length === 0" class="im-message-history__empty">
|
||||
{{ keyword ? '没有匹配的消息' : '暂无历史消息' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useChatStore } from '../../../../store/chatStore'
|
||||
import { ImMessageType } from '../../../../../utils/constants'
|
||||
import { parseTextContent, buildRecallTip } from '../../../../../utils'
|
||||
|
||||
defineOptions({ name: 'ImMessageHistory' })
|
||||
|
||||
const props = defineProps<{
|
||||
/** v-model 控制抽屉显隐 */
|
||||
modelValue: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
const chatStore = useChatStore()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const keyword = ref('')
|
||||
|
||||
const filtered = computed(() => {
|
||||
const all = chatStore.activeChat?.messages || []
|
||||
const kw = keyword.value.trim()
|
||||
if (!kw) return [...all].reverse()
|
||||
return all.filter((m) => renderContent(m).includes(kw)).reverse()
|
||||
})
|
||||
|
||||
function renderContent(msg: { type: number; content: string; sendNickName?: string; selfSend?: boolean }): string {
|
||||
switch (msg.type) {
|
||||
case ImMessageType.TEXT:
|
||||
case ImMessageType.TIP_TEXT:
|
||||
return parseTextContent(msg.content)
|
||||
case ImMessageType.IMAGE:
|
||||
return '[图片]'
|
||||
case ImMessageType.FILE:
|
||||
return '[文件]'
|
||||
case ImMessageType.VOICE:
|
||||
return '[语音]'
|
||||
case ImMessageType.VIDEO:
|
||||
return '[视频]'
|
||||
case ImMessageType.RECALL:
|
||||
return buildRecallTip(msg.sendNickName || '', !!msg.selfSend)
|
||||
default:
|
||||
return '[不支持的消息类型]'
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
if (!ts) return ''
|
||||
const d = new Date(ts)
|
||||
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-message-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.im-message-history__search {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.im-message-history__count {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.im-message-history__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.im-message-history__item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.im-message-history__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.im-message-history__sender {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.im-message-history__content {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.im-message-history__empty {
|
||||
padding: 40px 0;
|
||||
font-size: 13px;
|
||||
color: #c0c4cc;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<template>
|
||||
<!--
|
||||
群消息已读状态(对应 boxim chat/ChatGroupReaded.vue)
|
||||
- 标签形态:展示「N 已读」或「全部已读」;点击弹出 tab 列出具体成员
|
||||
- 仅在群聊、自己发送、已送达的消息下使用
|
||||
- 依赖 getGroupReadUsers API 拉已读人列表;未读列表由群成员减去已读得出
|
||||
-->
|
||||
<el-popover
|
||||
v-model:visible="popVisible"
|
||||
placement="left"
|
||||
trigger="click"
|
||||
:width="320"
|
||||
@show="loadReadUsers"
|
||||
>
|
||||
<template #reference>
|
||||
<span class="im-message-read-status">
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="activeTab" stretch>
|
||||
<el-tab-pane :label="`已读(${readMembers.length})`" name="read">
|
||||
<PagedScroller :items="readMembers" :page-size="20" class="im-message-read-status__scroll">
|
||||
<template #default="{ item }">
|
||||
<ChatGroupMember :member="(item as GroupMemberLite)" :height="40" :clickable="false" />
|
||||
</template>
|
||||
</PagedScroller>
|
||||
<div v-if="readMembers.length === 0" class="im-message-read-status__empty">暂无已读</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane :label="`未读(${unreadMembers.length})`" name="unread">
|
||||
<PagedScroller :items="unreadMembers" :page-size="20" class="im-message-read-status__scroll">
|
||||
<template #default="{ item }">
|
||||
<ChatGroupMember :member="(item as GroupMemberLite)" :height="40" :clickable="false" />
|
||||
</template>
|
||||
</PagedScroller>
|
||||
<div v-if="unreadMembers.length === 0" class="im-message-read-status__empty">全部已读</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-popover>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { getGroupReadUsers } from '@/api/im/message/group'
|
||||
import { ImGroupReceiptStatus } from '../../../../../utils/constants'
|
||||
import type { MessageInfo } from '../../../../types'
|
||||
import ChatGroupMember, { type GroupMemberLite } from '../ChatGroupMember.vue'
|
||||
import PagedScroller from '../../../../components/PagedScroller.vue'
|
||||
|
||||
defineOptions({ name: 'ImMessageReadStatus' })
|
||||
|
||||
const props = defineProps<{
|
||||
message: MessageInfo
|
||||
/** 当前群所有成员(第一期外部传入;没有就传空数组,未读列表会空) */
|
||||
groupMembers: GroupMemberLite[]
|
||||
/** 当前群 id */
|
||||
groupId: string
|
||||
}>()
|
||||
|
||||
const popVisible = ref(false)
|
||||
const activeTab = ref<'read' | 'unread'>('read')
|
||||
const readUserIds = ref<string[]>([])
|
||||
|
||||
const label = computed(() => {
|
||||
if (props.message.receiptStatus === ImGroupReceiptStatus.DONE) return '全部已读'
|
||||
const n = props.message.readCount || 0
|
||||
return n > 0 ? `${n} 人已读` : '未读'
|
||||
})
|
||||
|
||||
const readMembers = computed(() =>
|
||||
props.groupMembers.filter(
|
||||
(m) =>
|
||||
!m.quit &&
|
||||
String(m.userId) !== String(props.message.sendId) &&
|
||||
readUserIds.value.includes(String(m.userId))
|
||||
)
|
||||
)
|
||||
|
||||
const unreadMembers = computed(() =>
|
||||
props.groupMembers.filter(
|
||||
(m) =>
|
||||
!m.quit &&
|
||||
String(m.userId) !== String(props.message.sendId) &&
|
||||
!readUserIds.value.includes(String(m.userId))
|
||||
)
|
||||
)
|
||||
|
||||
async function loadReadUsers() {
|
||||
if (!props.message.id) return
|
||||
try {
|
||||
const res = await getGroupReadUsers({
|
||||
groupId: props.groupId,
|
||||
messageId: props.message.id
|
||||
})
|
||||
readUserIds.value = (res || []).map(String)
|
||||
} catch (e) {
|
||||
console.error('[IM] 拉取群已读列表失败:', e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-message-read-status {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.im-message-read-status:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.im-message-read-status__scroll {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.im-message-read-status__empty {
|
||||
padding: 20px 0;
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<!-- 好友 Tab 占位页,对齐 boxim /home/friend;后续接入好友申请、分组、黑名单等功能 -->
|
||||
<div class="im-placeholder-page">
|
||||
<el-empty description="好友功能敬请期待" :image-size="120" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'ImFriendPage' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-placeholder-page {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
<template>
|
||||
<!--
|
||||
好友单行项(对应 boxim friend/FriendItem.vue)
|
||||
- 头像 + 昵称 + 在线标识
|
||||
- 选中态 active
|
||||
- 右键菜单(发消息 / 删除好友)由全局 ContextMenu 承接
|
||||
-->
|
||||
<div
|
||||
class="im-friend-item"
|
||||
:class="{ 'is-active': active }"
|
||||
@click="$emit('click', friend)"
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
>
|
||||
<UserAvatar
|
||||
:id="friend.id"
|
||||
:url="friend.headImage"
|
||||
:name="friend.nickName"
|
||||
:online="friend.online"
|
||||
:size="42"
|
||||
:clickable="false"
|
||||
/>
|
||||
<div class="im-friend-item__info">
|
||||
<div class="im-friend-item__name">{{ friend.nickName }}</div>
|
||||
<div class="im-friend-item__online">
|
||||
<span v-if="friend.onlineWeb" class="im-friend-item__dot" title="Web 在线">💻</span>
|
||||
<span v-if="friend.onlineApp" class="im-friend-item__dot" title="App 在线">📱</span>
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useImUiStore } from '../../../store/uiStore'
|
||||
import UserAvatar from '../../../components/UserAvatar.vue'
|
||||
|
||||
defineOptions({ name: 'ImFriendItem' })
|
||||
|
||||
export interface FriendLite {
|
||||
id: string | number
|
||||
nickName: string
|
||||
headImage?: string
|
||||
online?: boolean
|
||||
onlineWeb?: boolean
|
||||
onlineApp?: boolean
|
||||
deleted?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
friend: FriendLite
|
||||
active?: boolean
|
||||
/** 是否启用右键菜单;在选择器弹窗里一般关闭 */
|
||||
menu?: boolean
|
||||
}>(),
|
||||
{
|
||||
active: false,
|
||||
menu: true
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [friend: FriendLite]
|
||||
chat: [friend: FriendLite]
|
||||
delete: [friend: FriendLite]
|
||||
}>()
|
||||
|
||||
const uiStore = useImUiStore()
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
if (!props.menu) return
|
||||
uiStore.openContextMenu(
|
||||
{ x: e.clientX, y: e.clientY },
|
||||
[
|
||||
{ key: 'chat', name: '发送消息' },
|
||||
{ key: 'delete', name: '删除好友' }
|
||||
],
|
||||
(item) => {
|
||||
if (item.key === 'chat') emit('chat', props.friend)
|
||||
else if (item.key === 'delete') emit('delete', props.friend)
|
||||
}
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-friend-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
margin: 0 3px;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.im-friend-item:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.im-friend-item.is-active {
|
||||
background-color: #e1eaf7;
|
||||
}
|
||||
|
||||
.im-friend-item__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.im-friend-item__name {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.im-friend-item__online {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<!-- 群聊 Tab 占位页,对齐 boxim /home/group;后续接入我的群、创建群、群管理等功能 -->
|
||||
<div class="im-placeholder-page">
|
||||
<el-empty description="群聊功能敬请期待" :image-size="120" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'ImGroupPage' })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-placeholder-page {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
<template>
|
||||
<!--
|
||||
邀请好友入群对话框(对应 boxim group/AddGroupMember.vue)
|
||||
- 左:好友列表(带 checkbox)
|
||||
- 右:已勾选预览
|
||||
- 已在群内的好友标记为 disabled
|
||||
- TODO 接入 /im/group/invite
|
||||
-->
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="邀请好友"
|
||||
width="620px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="im-add-group-member">
|
||||
<div class="im-add-group-member__left">
|
||||
<el-input v-model="searchText" placeholder="搜索好友" size="small" clearable>
|
||||
<template #suffix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-scrollbar class="im-add-group-member__scroll">
|
||||
<FriendItem
|
||||
v-for="f in shownFriends"
|
||||
:key="f.id"
|
||||
:friend="f"
|
||||
:menu="false"
|
||||
:active="false"
|
||||
@click="toggleCheck(f)"
|
||||
>
|
||||
<el-checkbox
|
||||
:model-value="f.isCheck"
|
||||
:disabled="f.disabled"
|
||||
@click.stop
|
||||
@change="(v: boolean) => (f.isCheck = v)"
|
||||
/>
|
||||
</FriendItem>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<div class="im-add-group-member__arrow">
|
||||
<el-icon><DArrowRight /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="im-add-group-member__right">
|
||||
<div class="im-add-group-member__tip">已勾选 {{ checkCount }} 位好友</div>
|
||||
<el-scrollbar class="im-add-group-member__scroll">
|
||||
<FriendItem
|
||||
v-for="f in checkedFriends"
|
||||
:key="f.id"
|
||||
:friend="f"
|
||||
:menu="false"
|
||||
:active="false"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="handleOk">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Search, DArrowRight } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import FriendItem, { type FriendLite } from '../../friend/components/FriendItem.vue'
|
||||
import type { GroupMemberLite } from '../../chat/components/ChatGroupMember.vue'
|
||||
|
||||
defineOptions({ name: 'ImAddGroupMemberDialog' })
|
||||
|
||||
interface FriendCheckable extends FriendLite {
|
||||
isCheck?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
groupId?: string | number
|
||||
/** 本群现有成员,用来判断好友是否已在群里 */
|
||||
members?: GroupMemberLite[]
|
||||
/** 全量好友(由调用方从 friendStore 传入) */
|
||||
friends?: FriendLite[]
|
||||
}>(),
|
||||
{
|
||||
members: () => [],
|
||||
friends: () => []
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** 邀请完成,携带被邀请的好友 id 列表 */
|
||||
reload: [friendIds: (string | number)[]]
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const searchText = ref('')
|
||||
const friends = ref<FriendCheckable[]>([])
|
||||
|
||||
watch(
|
||||
visible,
|
||||
(v) => {
|
||||
if (!v) return
|
||||
friends.value = props.friends
|
||||
.filter((f) => !f.deleted)
|
||||
.map((f) => {
|
||||
const inGroup = props.members.some(
|
||||
(m) => !m.quit && String(m.userId) === String(f.id)
|
||||
)
|
||||
return {
|
||||
...f,
|
||||
disabled: inGroup,
|
||||
isCheck: inGroup
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const shownFriends = computed(() =>
|
||||
friends.value.filter((f) => f.nickName.includes(searchText.value))
|
||||
)
|
||||
|
||||
const checkedFriends = computed(() =>
|
||||
friends.value.filter((f) => f.isCheck && !f.disabled)
|
||||
)
|
||||
|
||||
const checkCount = computed(() => checkedFriends.value.length)
|
||||
|
||||
function toggleCheck(f: FriendCheckable) {
|
||||
if (!f.disabled) f.isCheck = !f.isCheck
|
||||
}
|
||||
|
||||
// TODO 接入 /im/group/invite
|
||||
async function handleOk() {
|
||||
const ids = checkedFriends.value.map((f) => f.id)
|
||||
if (ids.length === 0) {
|
||||
ElMessage.warning('请选择至少一个好友')
|
||||
return
|
||||
}
|
||||
ElMessage.info('邀请入群接口待接入,当前为占位实现')
|
||||
emit('reload', ids)
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-add-group-member {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.im-add-group-member__left,
|
||||
.im-add-group-member__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.im-add-group-member__scroll {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.im-add-group-member__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.im-add-group-member__tip {
|
||||
height: 40px;
|
||||
padding-left: 10px;
|
||||
line-height: 40px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
<template>
|
||||
<!--
|
||||
新建群聊对话框
|
||||
- 顶部:群名称输入
|
||||
- 左:好友列表(带 checkbox)
|
||||
- 右:已勾选预览
|
||||
- 提交:先 createGroup 再 inviteGroupMember,最后让父页 reload
|
||||
-->
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="新建群聊"
|
||||
width="620px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<el-input
|
||||
v-model="groupName"
|
||||
placeholder="请输入群名称"
|
||||
maxlength="20"
|
||||
show-word-limit
|
||||
/>
|
||||
|
||||
<div class="flex gap-2.5">
|
||||
<div class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]">
|
||||
<el-input v-model="searchText" placeholder="搜索好友" size="small" clearable>
|
||||
<template #suffix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-scrollbar class="h-[400px]">
|
||||
<FriendItem
|
||||
v-for="f in shownFriends"
|
||||
:key="f.id"
|
||||
:friend="f"
|
||||
:menu="false"
|
||||
:active="false"
|
||||
@click="toggleCheck(f)"
|
||||
>
|
||||
<el-checkbox
|
||||
:model-value="f.isCheck"
|
||||
@click.stop
|
||||
@change="(v) => (f.isCheck = !!v)"
|
||||
/>
|
||||
</FriendItem>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-lg text-[#409eff]">
|
||||
<el-icon><DArrowRight /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col flex-1 overflow-hidden rounded border border-[var(--el-border-color)]">
|
||||
<div
|
||||
class="h-10 pl-2.5 leading-10 text-13px text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]"
|
||||
>
|
||||
已勾选 {{ checkedFriends.length }} 位好友
|
||||
</div>
|
||||
<el-scrollbar class="h-[400px]">
|
||||
<FriendItem
|
||||
v-for="f in checkedFriends"
|
||||
:key="f.id"
|
||||
:friend="f"
|
||||
:menu="false"
|
||||
:active="false"
|
||||
/>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取 消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleOk">创 建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Search, DArrowRight } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { createGroup } from '@/api/im/group'
|
||||
import { inviteGroupMember } from '@/api/im/group/member'
|
||||
import FriendItem, { type FriendLite } from '../../friend/components/FriendItem.vue'
|
||||
|
||||
defineOptions({ name: 'ImCreateGroupDialog' })
|
||||
|
||||
interface FriendCheckable extends FriendLite {
|
||||
isCheck?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
/** 全量好友(由调用方从 friendStore 传入) */
|
||||
friends?: FriendLite[]
|
||||
}>(),
|
||||
{
|
||||
friends: () => []
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** 创建成功,携带新群编号 */
|
||||
created: [groupId: number]
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const groupName = ref('')
|
||||
const searchText = ref('')
|
||||
const submitting = ref(false)
|
||||
/** 工作副本(带 isCheck 标记),与 prop 隔离 */
|
||||
const workingFriends = ref<FriendCheckable[]>([])
|
||||
|
||||
watch(
|
||||
visible,
|
||||
(v) => {
|
||||
if (!v) {
|
||||
return
|
||||
}
|
||||
groupName.value = ''
|
||||
searchText.value = ''
|
||||
workingFriends.value = props.friends
|
||||
.filter((f) => !f.deleted)
|
||||
.map((f) => ({ ...f, isCheck: false }))
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const shownFriends = computed(() =>
|
||||
workingFriends.value.filter((f) => f.nickname.includes(searchText.value))
|
||||
)
|
||||
|
||||
const checkedFriends = computed(() => workingFriends.value.filter((f) => f.isCheck))
|
||||
|
||||
function toggleCheck(f: FriendCheckable) {
|
||||
f.isCheck = !f.isCheck
|
||||
}
|
||||
|
||||
async function handleOk() {
|
||||
const name = groupName.value.trim()
|
||||
if (!name) {
|
||||
ElMessage.warning('请输入群名称')
|
||||
return
|
||||
}
|
||||
const memberUserIds = checkedFriends.value.map((f) => f.id)
|
||||
if (memberUserIds.length === 0) {
|
||||
ElMessage.warning('请至少选择一位好友')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const group = await createGroup({ name })
|
||||
if (!group?.id) {
|
||||
throw new Error('创建群失败:未返回群编号')
|
||||
}
|
||||
await inviteGroupMember({ groupId: group.id, memberUserIds })
|
||||
ElMessage.success('群聊创建成功')
|
||||
emit('created', group.id)
|
||||
visible.value = false
|
||||
} catch (e: any) {
|
||||
console.error('[IM] 创建群失败', e)
|
||||
ElMessage.error(e?.message || '创建群失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<template>
|
||||
<!--
|
||||
群单行项(对应 boxim group/GroupItem.vue)
|
||||
- 头像 + 群名
|
||||
- 选中态 active
|
||||
-->
|
||||
<div
|
||||
class="im-group-item"
|
||||
:class="{ 'is-active': active }"
|
||||
@click="$emit('click', group)"
|
||||
>
|
||||
<UserAvatar
|
||||
:url="group.headImage || group.headImageThumb"
|
||||
:name="group.showGroupName || group.name"
|
||||
:size="42"
|
||||
:clickable="false"
|
||||
/>
|
||||
<div class="im-group-item__info">
|
||||
<div class="im-group-item__name">{{ group.showGroupName || group.name }}</div>
|
||||
<div v-if="group.memberCount != null" class="im-group-item__desc">
|
||||
{{ group.memberCount }} 位成员
|
||||
</div>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UserAvatar from '../../../components/UserAvatar.vue'
|
||||
|
||||
defineOptions({ name: 'ImGroupItem' })
|
||||
|
||||
export interface GroupLite {
|
||||
id: string | number
|
||||
name?: string
|
||||
/** 带备注的展示名(如"我在群里的昵称") */
|
||||
showGroupName?: string
|
||||
headImage?: string
|
||||
headImageThumb?: string
|
||||
memberCount?: number
|
||||
ownerId?: string | number
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
group: GroupLite
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
click: [group: GroupLite]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-group-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
margin: 0 3px;
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.im-group-item:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.im-group-item.is-active {
|
||||
background-color: #e1eaf7;
|
||||
}
|
||||
|
||||
.im-group-item__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.im-group-item__name {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.im-group-item__desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
<template>
|
||||
<!--
|
||||
群成员宫格单元(对应 boxim group/GroupMember.vue)
|
||||
- 宫格展示的最小单位:50px 窄列,头像在上、名字在下
|
||||
- 被 GroupMemberSelector 右侧已选区、ChatGroupSide 群成员区循环使用
|
||||
-->
|
||||
<div class="im-group-member-grid">
|
||||
<UserAvatar
|
||||
:id="member.userId"
|
||||
:url="member.headImage"
|
||||
:name="member.showNickName"
|
||||
:size="38"
|
||||
:clickable="false"
|
||||
/>
|
||||
<div class="im-group-member-grid__name">{{ member.showNickName }}</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import UserAvatar from '../../../components/UserAvatar.vue'
|
||||
import type { GroupMemberLite } from '../../chat/components/ChatGroupMember.vue'
|
||||
|
||||
defineOptions({ name: 'ImGroupMemberGrid' })
|
||||
|
||||
defineProps<{
|
||||
member: GroupMemberLite
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-group-member-grid {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 54px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.im-group-member-grid__name {
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #606266;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
<template>
|
||||
<!--
|
||||
群成员行形态(对应 boxim group/GroupMemberItem.vue)
|
||||
- 横排、带 hover 态;slot 放 checkbox / 操作按钮等
|
||||
- 与 ChatGroupMember 的差别:带 hover 态 + slot 扩展点,适合 selector / admin 列表
|
||||
-->
|
||||
<div
|
||||
class="im-group-member-item"
|
||||
:class="{ 'is-active': active }"
|
||||
:style="{ height: height + 'px' }"
|
||||
@click="$emit('click', member)"
|
||||
>
|
||||
<UserAvatar
|
||||
:id="member.userId"
|
||||
:url="member.headImage"
|
||||
:name="member.showNickName"
|
||||
:size="avatarSize"
|
||||
:clickable="false"
|
||||
/>
|
||||
<div class="im-group-member-item__name" :style="{ lineHeight: height + 'px' }">
|
||||
{{ member.showNickName }}
|
||||
</div>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import UserAvatar from '../../../components/UserAvatar.vue'
|
||||
import type { GroupMemberLite } from '../../chat/components/ChatGroupMember.vue'
|
||||
|
||||
defineOptions({ name: 'ImGroupMemberItem' })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
member: GroupMemberLite
|
||||
height?: number
|
||||
active?: boolean
|
||||
}>(),
|
||||
{
|
||||
height: 50,
|
||||
active: false
|
||||
}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
click: [member: GroupMemberLite]
|
||||
}>()
|
||||
|
||||
const avatarSize = computed(() => Math.ceil(props.height * 0.75))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-group-member-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin: 0 1px;
|
||||
padding: 0 15px;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.im-group-member-item:hover {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.im-group-member-item.is-active {
|
||||
background-color: #e1eaf7;
|
||||
}
|
||||
|
||||
.im-group-member-item__name {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
padding-left: 4px;
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
<template>
|
||||
<!--
|
||||
群成员选择器(对应 boxim group/GroupMemberSelector.vue)
|
||||
- 左:搜索 + 群成员列表(带 checkbox)
|
||||
- 右:已勾选的成员(宫格预览)
|
||||
- 确定时 emit complete,抛出选中的成员列表
|
||||
-->
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
width="700px"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div class="im-group-member-selector">
|
||||
<div class="im-group-member-selector__left">
|
||||
<el-input v-model="searchText" placeholder="搜索" clearable>
|
||||
<template #suffix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<div class="im-group-member-selector__scroll">
|
||||
<PagedScroller :items="showMembers" :page-size="30">
|
||||
<template #default="{ item }">
|
||||
<GroupMemberItem
|
||||
:member="(item as GroupMemberFlag)"
|
||||
:height="46"
|
||||
@click="toggleCheck(item as GroupMemberFlag)"
|
||||
>
|
||||
<el-checkbox
|
||||
:model-value="(item as GroupMemberFlag).checked"
|
||||
:disabled="(item as GroupMemberFlag).locked"
|
||||
@click.stop
|
||||
@change="(val: boolean) => onCheckChange(item as GroupMemberFlag, val)"
|
||||
/>
|
||||
</GroupMemberItem>
|
||||
</template>
|
||||
</PagedScroller>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="im-group-member-selector__arrow">
|
||||
<el-icon><DArrowRight /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="im-group-member-selector__right">
|
||||
<div class="im-group-member-selector__tip">已勾选 {{ checkedMembers.length }} 位成员</div>
|
||||
<el-scrollbar class="im-group-member-selector__scroll">
|
||||
<div class="im-group-member-selector__grid">
|
||||
<GroupMemberGrid
|
||||
v-for="m in checkedMembers"
|
||||
:key="m.userId"
|
||||
:member="m"
|
||||
/>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取 消</el-button>
|
||||
<el-button type="primary" @click="handleOk">确 定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Search, DArrowRight } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import GroupMemberItem from './GroupMemberItem.vue'
|
||||
import GroupMemberGrid from './GroupMemberGrid.vue'
|
||||
import PagedScroller from '../../../components/PagedScroller.vue'
|
||||
import type { GroupMemberLite } from '../../chat/components/ChatGroupMember.vue'
|
||||
|
||||
defineOptions({ name: 'ImGroupMemberSelector' })
|
||||
|
||||
/** 选择器内部扩展:加上 checked / locked / hide 标记 */
|
||||
export interface GroupMemberFlag extends GroupMemberLite {
|
||||
checked?: boolean
|
||||
locked?: boolean
|
||||
hide?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
title?: string
|
||||
/** 传入的群成员列表(已经有 quit/headImage 等基础字段) */
|
||||
members?: GroupMemberLite[]
|
||||
/** 默认选中的 userId 列表 */
|
||||
checkedIds?: (string | number)[]
|
||||
/** 锁定的 userId 列表(不能取消) */
|
||||
lockedIds?: (string | number)[]
|
||||
/** 隐藏的 userId 列表(不展示) */
|
||||
hideIds?: (string | number)[]
|
||||
/** 最多可选数量,-1 表示不限制 */
|
||||
maxSize?: number
|
||||
}>(),
|
||||
{
|
||||
title: '选择成员',
|
||||
members: () => [],
|
||||
checkedIds: () => [],
|
||||
lockedIds: () => [],
|
||||
hideIds: () => [],
|
||||
maxSize: -1
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
/** 点击"确定"时抛出被勾选的成员列表 */
|
||||
complete: [members: GroupMemberFlag[]]
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const searchText = ref('')
|
||||
const workingMembers = ref<GroupMemberFlag[]>([])
|
||||
|
||||
watch(
|
||||
visible,
|
||||
(v) => {
|
||||
if (v) rebuild()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function rebuild() {
|
||||
workingMembers.value = props.members.map((m) => ({
|
||||
...m,
|
||||
checked: props.checkedIds.some((id) => String(id) === String(m.userId)),
|
||||
locked: props.lockedIds.some((id) => String(id) === String(m.userId)),
|
||||
hide: props.hideIds.some((id) => String(id) === String(m.userId))
|
||||
}))
|
||||
}
|
||||
|
||||
const showMembers = computed(() =>
|
||||
workingMembers.value.filter(
|
||||
(m) => !m.hide && !m.quit && m.showNickName.includes(searchText.value)
|
||||
)
|
||||
)
|
||||
|
||||
const checkedMembers = computed(() => workingMembers.value.filter((m) => m.checked))
|
||||
|
||||
function toggleCheck(m: GroupMemberFlag) {
|
||||
if (m.locked) return
|
||||
m.checked = !m.checked
|
||||
if (props.maxSize > 0 && checkedMembers.value.length > props.maxSize) {
|
||||
ElMessage.error(`最多选择 ${props.maxSize} 位成员`)
|
||||
m.checked = false
|
||||
}
|
||||
}
|
||||
|
||||
function onCheckChange(m: GroupMemberFlag, val: boolean) {
|
||||
m.checked = val
|
||||
if (props.maxSize > 0 && checkedMembers.value.length > props.maxSize) {
|
||||
ElMessage.error(`最多选择 ${props.maxSize} 位成员`)
|
||||
m.checked = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleOk() {
|
||||
emit('complete', checkedMembers.value)
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.im-group-member-selector {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.im-group-member-selector__left,
|
||||
.im-group-member-selector__right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.im-group-member-selector__scroll {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.im-group-member-selector__arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 20px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.im-group-member-selector__tip {
|
||||
height: 40px;
|
||||
padding-left: 10px;
|
||||
line-height: 40px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
}
|
||||
|
||||
.im-group-member-selector__grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/** 生成客户端消息 ID(时间戳 + UUID) */
|
||||
export const generateClientMessageId = (): string => {
|
||||
const timestamp = Date.now().toString()
|
||||
const randomPart = 'xxxx-xxxx-4xxx-yxxx-xxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8
|
||||
return v.toString(16)
|
||||
})
|
||||
return `${timestamp}-${randomPart}`
|
||||
}
|
||||
|
||||
/** 生成存储 key(对齐 boxim key 命名 chats-{userId}) */
|
||||
export const buildMetaKey = (userId: string): string => {
|
||||
return `chats-${userId}`
|
||||
}
|
||||
|
||||
/** 解析文本消息 content JSON */
|
||||
export const parseTextContent = (content: string): string => {
|
||||
try {
|
||||
const parsed = JSON.parse(content)
|
||||
return parsed.content || ''
|
||||
} catch {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
/** 序列化文本消息 content JSON */
|
||||
export const serializeTextContent = (text: string): string => {
|
||||
return JSON.stringify({ content: text })
|
||||
}
|
||||
Loading…
Reference in New Issue