🐛 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
YunaiV 2026-04-27 19:59:56 +08:00
parent 9e8d04249c
commit 8fd21da555
18 changed files with 1818 additions and 0 deletions

Binary file not shown.

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

View File

@ -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 })
}