feat(im): 优化输入框的样式

im
YunaiV 2026-05-01 06:59:14 +08:00
parent 384a0c134a
commit dfbae06afa
8 changed files with 160 additions and 121 deletions

View File

@ -12,6 +12,7 @@ export interface ImManagerGroupMessageVO {
content: string
status: number
atUserIds?: number[]
atUserNicknames?: string[]
receiptStatus?: number
sendTime: Date
createTime: Date

View File

@ -5,6 +5,7 @@ export interface ImManagerSensitiveWordVO {
word: string
status: number
creator?: string
creatorName?: string
createTime?: Date
}

View File

@ -254,7 +254,8 @@ export enum DICT_TYPE {
// ========== IM - 即时通讯模块 ==========
IM_MESSAGE_TYPE = 'im_message_type', // IM 消息类型
IM_MESSAGE_STATUS = 'im_message_status', // IM 消息状态
IM_PRIVATE_MESSAGE_STATUS = 'im_private_message_status', // IM 私聊消息状态0=未读 / 2=已撤回 / 3=已读
IM_GROUP_MESSAGE_STATUS = 'im_group_message_status', // IM 群聊消息状态0=正常 / 2=已撤回
IM_GROUP_MESSAGE_RECEIPT_STATUS = 'im_group_message_receipt_status', // IM 群消息回执状态
IM_FRIEND_STATUS = 'im_friend_status', // IM 好友状态
IM_GROUP_STATUS = 'im_group_status' // IM 群状态

View File

@ -5,7 +5,7 @@
- 拖拽区在右边缘鼠标变 col-resize
-->
<aside
class="relative flex flex-col shrink-0 bg-[var(--el-bg-color)] border-r border-[var(--el-border-color-lighter)] shadow-[2px_0_8px_rgba(0,0,0,0.05)]"
class="relative flex flex-col shrink-0 bg-[var(--el-fill-color-light)] border-r border-[var(--el-border-color-lighter)] shadow-[2px_0_8px_rgba(0,0,0,0.05)]"
:style="{ width: asideWidth + 'px' }"
>
<slot></slot>

View File

@ -1,98 +1,111 @@
<template>
<div
class="relative flex flex-col bg-[var(--el-bg-color)] border-t border-[var(--el-border-color-lighter)]"
>
<!--
外层底色与消息流bg-color-page保持一致"消息 → 输入"无色差过渡
padding 给内层白卡片呼吸空间卡片自带边框就够区分输入区不再需要一条 border-t
-->
<div class="relative bg-[var(--el-bg-color-page)] px-3 pt-2 pb-3">
<!--
输入区在上contenteditable div取代 textarea对齐微信 PC输入区在上操作在下
- @ 浮层能拿到真实光标 recttextarea 拿不到
- @ 成员以 <span data-id> token 节点存在 token 即删 id避免 stale atUserIds
- placeholder 通过 data-empty + ::before 模拟contenteditable 没有原生 placeholder
内层白色圆角卡片 = editor + 工具栏border + rounded 模拟微信"输入框"边界
避免之前"无框 Web 输入"的散开感border scoped CSSUnoCSS 不带 border-style preflight
-->
<div
ref="editorRef"
class="message-input__editor"
contenteditable="true"
data-placeholder="按 Enter 发送Shift+Enter 换行"
data-empty=""
role="textbox"
@keydown="onKeydown"
@input="onInput"
@scroll.passive="onEditorScroll"
@paste.prevent="onPaste"
></div>
<div class="message-input__card relative flex flex-col bg-[var(--el-bg-color)] rounded-lg">
<!--
输入区在上contenteditable div取代 textarea对齐微信 PC输入区在上操作在下
- @ 浮层能拿到真实光标 recttextarea 拿不到
- @ 成员以 <span data-id> token 节点存在 token 即删 id避免 stale atUserIds
- placeholder 通过 data-empty + ::before 模拟contenteditable 没有原生 placeholder
-->
<div
ref="editorRef"
class="message-input__editor"
contenteditable="true"
data-placeholder="按 Enter 发送Shift+Enter 换行"
data-empty=""
role="textbox"
@keydown="onKeydown"
@input="onInput"
@scroll.passive="onEditorScroll"
@paste.prevent="onPaste"
></div>
<!--
底部工具栏左侧操作图标 + 右侧发送按钮对齐微信 PC操作图标统一放底部
- relative EmojiPicker 提供 absolute 锚点picker bottom-full 向上弹出
- 图标统一 30×30 点击区18px icon + p-1.5gap-1 让间距贴合微信观感
-->
<div class="relative flex items-center justify-between gap-2 px-3 pb-2">
<div class="flex items-center gap-1">
<!--
所有 icon 统一走 Iconifyant-design outlined 系列
- 视觉风格更接近微信 PC线性圆角 Element Plus 内置的更轻量
- 笑脸 / 图片 / 文件夹 / 麦克风 同源避免一个走 ep 一个走 antd 视觉割裂
- 外层 span 复用 .message-input__tool padding / hover 样式scoped CSS :deep(svg) 仍能命中
-->
<el-tooltip content="表情" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click.stop="toggleEmoji"
>
<Icon icon="ant-design:smile-outlined" :size="18" />
</span>
</el-tooltip>
<el-tooltip content="发送图片" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click="imageInputRef?.click()"
>
<Icon icon="ant-design:picture-outlined" :size="18" />
</span>
</el-tooltip>
<el-tooltip content="发送文件" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click="fileInputRef?.click()"
>
<Icon icon="ant-design:folder-outlined" :size="18" />
</span>
</el-tooltip>
<el-tooltip content="语音消息" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click="voiceVisible = true"
>
<Icon icon="ant-design:audio-outlined" :size="18" />
</span>
</el-tooltip>
</div>
<!-- 群聊发送按钮 + 下拉菜单点主按钮普通发送 / 发送回执消息对齐微信 PC -->
<el-dropdown
v-if="isGroup"
split-button
type="primary"
:disabled="!canSend"
@click="handleSend()"
@command="handleSendCommand"
<!--
底部工具栏左侧操作图标 + 右侧发送按钮对齐微信 PC操作图标统一放底部
- relative EmojiPicker 提供 absolute 锚点picker bottom-full 向上弹出
- 图标统一 30×30 点击区18px icon + p-1.5gap-1 让间距贴合微信观感
- border-t 在编辑区与工具栏之间画一条与 card 边框同色的细线scoped CSS 避绕 UnoCSS preflight 缺失
-->
<div
class="message-input__toolbar relative flex items-center justify-between gap-2 px-3 py-2"
>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="receipt">发送回执消息</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 私聊普通发送按钮私聊没有群回执概念 -->
<el-button v-else type="primary" :disabled="!canSend" @click="handleSend()"> </el-button>
<div class="flex items-center gap-1">
<!--
所有 icon 统一走 Iconifyant-design outlined 系列
- 视觉风格更接近微信 PC线性圆角 Element Plus 内置的更轻量
- 笑脸 / 图片 / 文件夹 / 麦克风 同源避免一个走 ep 一个走 antd 视觉割裂
- 外层 span 复用 .message-input__tool padding / hover 样式scoped CSS :deep(svg) 仍能命中
-->
<el-tooltip content="表情" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click.stop="toggleEmoji"
>
<Icon icon="ant-design:smile-outlined" :size="18" />
</span>
</el-tooltip>
<el-tooltip content="发送图片" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click="imageInputRef?.click()"
>
<Icon icon="ant-design:picture-outlined" :size="18" />
</span>
</el-tooltip>
<el-tooltip content="发送文件" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click="fileInputRef?.click()"
>
<Icon icon="ant-design:folder-outlined" :size="18" />
</span>
</el-tooltip>
<el-tooltip content="语音消息" placement="top">
<span
class="message-input__tool inline-flex items-center justify-center box-content p-1.5 cursor-pointer rounded transition-colors hover:bg-[var(--el-fill-color)]"
@click="voiceVisible = true"
>
<Icon icon="ant-design:audio-outlined" :size="18" />
</span>
</el-tooltip>
</div>
<!-- 表情面板bottom-full picker 下沿贴工具栏顶部向上弹出对齐工具栏左侧首图标 -->
<EmojiPicker
v-model:visible="emojiVisible"
class="bottom-full left-3 mb-2"
@select="insertText"
/>
<!-- 群聊发送按钮 + 下拉菜单点主按钮普通发送 / 发送回执消息对齐微信 PC -->
<el-dropdown
v-if="isGroup"
split-button
type="primary"
:disabled="!canSend"
@click="handleSend()"
@command="handleSendCommand"
>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="receipt">发送回执消息</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 私聊普通发送按钮私聊没有群回执概念 -->
<el-button v-else type="primary" :disabled="!canSend" @click="handleSend()">
</el-button>
<!-- 表情面板bottom-full picker 下沿贴工具栏顶部向上弹出对齐工具栏左侧首图标 -->
<EmojiPicker
v-model:visible="emojiVisible"
class="bottom-full left-3 mb-2"
@select="insertText"
/>
</div>
</div>
<!-- @ 选择浮层群聊才启用 -->
@ -686,6 +699,15 @@ async function onVoiceSend(payload: { blob: Blob; duration: number }) {
</script>
<style scoped>
/* + 线UnoCSS border-style preflight
border-* 类只设色 / 宽不出线统一走 scoped 显式 shorthand 兜底 */
.message-input__card {
border: 1px solid var(--el-border-color-lighter);
}
.message-input__toolbar {
border-top: 1px solid var(--el-border-color-lighter);
}
/* el-icon .el-icon{color:var(--color,inherit); font-size:inherit; width:1em; height:1em}
会盖过 UnoCSS 原子类用字面选择器 + !important 兜底
颜色取 Element Plus 主题变量暗色自动切到浅灰 */
@ -700,14 +722,14 @@ async function onVoiceSend(payload: { blob: Blob; duration: number }) {
color: var(--el-color-primary) !important;
}
/* ""min-height
max-height 不再无增长超过则内部滚动避免聊天列表被挤太短 */
/* 输入区在上、工具栏在下时,编辑区视觉上承担"主体"min-height / padding 都比早期版本撑大
贴近微信 PC "大输入框"观感max-height 限内部滚动避免聊天列表被挤太短 */
.message-input__editor {
position: relative;
min-height: 100px;
max-height: 160px;
min-height: 120px;
max-height: 200px;
overflow-y: auto;
padding: 10px 14px;
padding: 14px 16px;
font-size: 14px;
line-height: 1.5;
outline: none;

View File

@ -1,12 +1,22 @@
<template>
<div class="flex flex-1 flex-col min-w-0 bg-[var(--el-fill-color-light)]">
<template v-if="conversationStore.activeConversation">
<!-- 顶部会话名 + 右侧功能图标 -->
<!-- 顶部会话名群聊带人数+ 右侧功能图标border scoped CSS项目 UnoCSS 不带 border-style preflight -->
<div
class="flex items-center justify-between h-14 px-5 bg-[var(--el-fill-color-light)] border-b border-[var(--el-border-color-light)]"
class="message-panel__header flex items-center justify-between h-14 px-5 bg-[var(--el-fill-color-light)]"
>
<span class="text-base font-medium text-[var(--el-text-color-primary)]">
{{ conversationStore.activeConversation?.name || '' }}
<span class="flex items-baseline gap-1.5 min-w-0">
<span
class="overflow-hidden text-base font-medium truncate text-[var(--el-text-color-primary)]"
>
{{ conversationStore.activeConversation?.name || '' }}
</span>
<span
v-if="isGroup && headerMemberCount > 0"
class="flex-shrink-0 text-sm text-[var(--el-text-color-secondary)]"
>
({{ headerMemberCount }})
</span>
</span>
<div class="flex gap-3 items-center">
<!-- 聊天历史从输入区底部工具栏挪到顶部右上角对齐微信 PC点击弹窗承接历史消息 -->
@ -140,6 +150,21 @@ const isGroup = computed(
() => conversationStore.activeConversation?.type === ImConversationType.GROUP
)
/**
* 群聊 header 显示的人数优先 groupStore.memberCount无需等成员列表无值再回退 members.length
*
* 之所以不直接用 groupMembers.value.length成员列表是按需懒加载的刚切到群时未加载完
* groupInfo.memberCount 跟群信息一起来能更早显示人数避免"先空再蹦"
*/
const headerMemberCount = computed(() => {
const conversation = conversationStore.activeConversation
if (!conversation || conversation.type !== ImConversationType.GROUP) {
return 0
}
const group = groupStore.getGroup(conversation.targetId)
return group?.memberCount ?? group?.members?.length ?? 0
})
/**
* MessageInput :key 切群时强制 unmount + remount editor / mention range /
* 上一会话草稿全部归零 fallback 'none' 避开 activeConversation 短暂为 null 的窗口
@ -149,13 +174,9 @@ const messageInputKey = computed(() => {
return conv ? getConversationKey(conv) : 'none'
})
/** "是否停留在底部"的阈值:距离底部 < 80px 视为底部 */
const BOTTOM_THRESHOLD = 80
/** 当前是否已不在底部(显示"回到底部"按钮) */
const showJumpToBottom = ref(false)
/** 不在底部期间累计的新消息数 */
const newMessageCount = ref(0)
const BOTTOM_THRESHOLD = 80 // "" < 80px
const showJumpToBottom = ref(false) // ""
const newMessageCount = ref(0) //
/**
* 当前激活的群详情优先 groupStore带详细字段未加载完时用 activeConversation 兜底
@ -376,6 +397,11 @@ watch(
</script>
<style scoped>
/* 顶部分隔线UnoCSS 不带 border-style preflightclass 写法只设色 / 宽不出线,走 scoped 显式 shorthand */
.message-panel__header {
border-bottom: 1px solid var(--el-border-color-light);
}
/* el-icon .el-icon{color:var(--color,inherit)} UnoCSS :deep + !important
颜色直接引用 Element Plus 主题变量暗色模式自动切到更亮的灰 */
.message-panel__header-icon,

View File

@ -3,9 +3,9 @@
<div class="flex flex-1 min-w-0 h-full">
<!-- 左侧会话列表可拖拽宽度 -->
<ResizableAside :default-width="260" :storage-key="StorageKeys.asideWidth">
<!-- 顶部搜索框 + "+" 号下拉对齐微信 PC发起群聊 / 添加朋友 -->
<!-- 顶部搜索框 + "+" 号下拉对齐微信 PC发起群聊 / 添加朋友h-14 与右侧 MessagePanel 头部对齐 -->
<div
class="flex flex-shrink-0 gap-2 items-center px-4 py-2 border-b border-[var(--el-border-color-lighter)]"
class="flex flex-shrink-0 gap-2 items-center h-14 px-4 border-b border-[var(--el-border-color-lighter)]"
>
<el-input v-model="keyword" placeholder="搜索" clearable class="flex-1">
<template #prefix>

View File

@ -154,18 +154,6 @@ export const playAudioTip = () => {
// ==================== 管理后台展示工具 ====================
/** 消息内容JSON取首层 content 字段做列表预览,解析失败时回退原文 */
export const getContentPreview = (content?: string): string => {
if (!content) return ''
try {
const parsed = JSON.parse(content)
if (typeof parsed === 'object' && parsed.content) return String(parsed.content)
return content
} catch {
return content
}
}
/** 详情弹窗里把 content JSON 美化成 2 缩进 */
export const formatJson = (content?: string): string => {
if (!content) return ''