feat(im): 将 style 尽量多的改成 unocss,ai 友好

im
YunaiV 2026-05-20 13:56:46 +08:00
parent fc812aef26
commit 0a07d4a2e4
48 changed files with 402 additions and 1346 deletions

View File

@ -16,7 +16,7 @@
:style="{ left: adjustedPosition.x + 'px', top: adjustedPosition.y + 'px' }" :style="{ left: adjustedPosition.x + 'px', top: adjustedPosition.y + 'px' }"
> >
<template v-for="(item, index) in contextMenu.items" :key="item.key"> <template v-for="(item, index) in contextMenu.items" :key="item.key">
<!-- divided 项上方插一条分割线首项跳过避免空白 bg+h-[1px] 而非 borderUnoCSS 不带 border-style preflight --> <!-- divided 项上方插一条分割线首项跳过避免空白 -->
<div <div
v-if="item.divided && index > 0" v-if="item.divided && index > 0"
class="my-1 mx-2 h-[1px] bg-[var(--el-border-color-lighter)]" class="my-1 mx-2 h-[1px] bg-[var(--el-border-color-lighter)]"

View File

@ -5,7 +5,7 @@
- 拖拽区在右边缘鼠标变 col-resize - 拖拽区在右边缘鼠标变 col-resize
--> -->
<aside <aside
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)]" class="relative flex flex-col shrink-0 bg-[var(--el-fill-color-light)] border-r border-r-solid border-[var(--el-border-color-lighter)] shadow-[2px_0_8px_rgba(0,0,0,0.05)]"
:style="{ width: asideWidth + 'px' }" :style="{ width: asideWidth + 'px' }"
> >
<slot></slot> <slot></slot>
@ -102,8 +102,7 @@ function stopResize() {
</script> </script>
<style scoped> <style scoped>
/* 拖拽手柄的 hover / 拖拽中变色UnoCSS 同时控制"handle 状态 → 子 line 样式"的选择器链比较绕 /* hover / 拖拽中 把内部 line 加粗变深,提示手柄可拖;状态在父 handle 上 → 通过后代选择器联动子 line */
scoped CSS 直接描述更清晰 */
.im-resizable-aside__handle:hover .im-resizable-aside__line, .im-resizable-aside__handle:hover .im-resizable-aside__line,
.im-resizable-aside__handle.is-resizing .im-resizable-aside__line { .im-resizable-aside__handle.is-resizing .im-resizable-aside__line {
width: 3px; width: 3px;

View File

@ -98,7 +98,7 @@ const goProfile = () => router.push({ name: 'Profile' })
</script> </script>
<style scoped> <style scoped>
/* el-badge 子组件内部类 UnoCSS 够不到,单独贴一条 :deep 覆盖 */ /* :deep 穿透 el-badge 子组件内部 .el-badge__content右上角红点位置 + 去掉描边 */
.tool-bar__badge :deep(.el-badge__content) { .tool-bar__badge :deep(.el-badge__content) {
top: 4px; top: 4px;
right: 8px; right: 8px;

View File

@ -6,7 +6,7 @@
- 整卡 click 由调用方监听@click组件不内嵌业务逻辑 - 整卡 click 由调用方监听@click组件不内嵌业务逻辑
--> -->
<div <div
class="flex flex-col w-[240px] rounded-md overflow-hidden bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]" class="flex flex-col w-[240px] rounded-md overflow-hidden bg-[var(--el-bg-color)] border border-solid border-[var(--el-border-color-lighter)]"
:class="{ 'cursor-pointer': clickable }" :class="{ 'cursor-pointer': clickable }"
> >
<div class="flex gap-2.5 items-center px-3 py-2.5"> <div class="flex gap-2.5 items-center px-3 py-2.5">
@ -30,7 +30,7 @@
</div> </div>
</div> </div>
<div <div
class="px-3 py-1 text-12px border-t text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]" class="px-3 py-1 text-12px border-t border-t-solid text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
> >
{{ labelInfo.label }} {{ labelInfo.label }}
</div> </div>

View File

@ -28,7 +28,7 @@
<div <div
v-for="user in visibleUsers" v-for="user in visibleUsers"
:key="user.id" :key="user.id"
class="flex gap-3 items-center px-2 py-2.5 border-b border-[var(--el-border-color-lighter)]" class="flex gap-3 items-center px-2 py-2.5 border-b border-b-solid border-[var(--el-border-color-lighter)]"
> >
<UserAvatar <UserAvatar
:id="user.id" :id="user.id"

View File

@ -115,6 +115,7 @@ async function handleOk() {
<style scoped lang="scss"> <style scoped lang="scss">
@use '../picker/picker-dialog' as picker; @use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog { .im-picker-dialog {
@include picker.styles; @include picker.styles;
} }

View File

@ -135,6 +135,7 @@ async function handleOk() {
<style scoped lang="scss"> <style scoped lang="scss">
@use '../picker/picker-dialog' as picker; @use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog { .im-picker-dialog {
@include picker.styles; @include picker.styles;
} }

View File

@ -142,6 +142,7 @@ async function handleOk() {
<style scoped lang="scss"> <style scoped lang="scss">
@use '../picker/picker-dialog' as picker; @use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog { .im-picker-dialog {
@include picker.styles; @include picker.styles;
} }

View File

@ -97,6 +97,7 @@ async function handleOk() {
<style scoped lang="scss"> <style scoped lang="scss">
@use '../picker/picker-dialog' as picker; @use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog { .im-picker-dialog {
@include picker.styles; @include picker.styles;
} }

View File

@ -116,6 +116,7 @@ async function handleOk() {
<style scoped lang="scss"> <style scoped lang="scss">
@use '../picker/picker-dialog' as picker; @use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog { .im-picker-dialog {
@include picker.styles; @include picker.styles;
} }

View File

@ -12,27 +12,33 @@
:close-on-click-modal="false" :close-on-click-modal="false"
class="im-group-request-list__dialog" class="im-group-request-list__dialog"
> >
<div v-loading="loading" class="im-group-request-list__body"> <div
v-loading="loading"
class="flex flex-col gap-3 max-h-[60vh] overflow-y-auto pr-1"
>
<!-- 空态 --> <!-- 空态 -->
<el-empty v-if="!loading && list.length === 0" description="暂无进群申请" /> <el-empty v-if="!loading && list.length === 0" description="暂无进群申请" />
<!-- 顶部卡片最新一条 --> <!-- 顶部卡片最新一条 -->
<div v-if="latest" class="im-group-request-list__card"> <div
<div class="im-group-request-list__row"> v-if="latest"
class="flex flex-col gap-2.5 p-3.5 rounded-[10px] border border-solid border-[var(--el-border-color-lighter)] bg-[var(--el-bg-color)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]"
>
<div class="flex items-center gap-3">
<UserAvatar <UserAvatar
:url="latest.userAvatar" :url="latest.userAvatar"
:name="latest.userNickname" :name="latest.userNickname"
:size="44" :size="44"
:clickable="false" :clickable="false"
/> />
<div class="im-group-request-list__main"> <div class="flex-1 min-w-0">
<div class="im-group-request-list__name truncate"> <div class="truncate text-sm font-medium leading-[1.4] text-[var(--el-text-color-primary)]">
{{ latest.userNickname || `用户 ${latest.userId}` }} {{ latest.userNickname || `用户 ${latest.userId}` }}
</div> </div>
<div class="im-group-request-list__source truncate"> <div class="truncate mt-[2px] text-xs leading-[1.5] text-[var(--el-text-color-secondary)]">
<template v-if="latest.inviterUserId"> <template v-if="latest.inviterUserId">
通过 通过
<span class="im-group-request-list__inviter"> <span class="text-[var(--el-color-primary)]">
{{ latest.inviterNickname || `用户 ${latest.inviterUserId}` }} {{ latest.inviterNickname || `用户 ${latest.inviterUserId}` }}
</span> </span>
的邀请进群 的邀请进群
@ -42,17 +48,17 @@
</div> </div>
<span <span
v-if="latest.handleResult === ImGroupRequestHandleResult.AGREED" v-if="latest.handleResult === ImGroupRequestHandleResult.AGREED"
class="im-group-request-list__done" class="flex-shrink-0 text-[13px] text-[var(--el-text-color-placeholder)]"
> >
已同意 已同意
</span> </span>
<span <span
v-else-if="latest.handleResult === ImGroupRequestHandleResult.REFUSED" v-else-if="latest.handleResult === ImGroupRequestHandleResult.REFUSED"
class="im-group-request-list__done" class="flex-shrink-0 text-[13px] text-[var(--el-text-color-placeholder)]"
> >
已拒绝 已拒绝
</span> </span>
<div v-else class="im-group-request-list__actions"> <div v-else class="flex gap-1.5 flex-shrink-0">
<button <button
class="im-group-request-list__btn im-group-request-list__btn--primary" class="im-group-request-list__btn im-group-request-list__btn--primary"
:disabled="actingId === latest.id" :disabled="actingId === latest.id"
@ -70,8 +76,11 @@
</div> </div>
</div> </div>
<!-- 申请理由邀请场景显示邀请人 + 留言主动申请显示申请人 + 留言 --> <!-- 申请理由邀请场景显示邀请人 + 留言主动申请显示申请人 + 留言 -->
<div v-if="latest.applyContent" class="im-group-request-list__quote"> <div
<span class="im-group-request-list__quote-name"> v-if="latest.applyContent"
class="px-3 py-2 rounded-md text-[13px] leading-[1.5] break-all bg-[var(--el-fill-color-light)] text-[var(--el-text-color-regular)]"
>
<span class="text-[var(--el-color-primary)]">
{{ {{
latest.inviterUserId latest.inviterUserId
? latest.inviterNickname || `用户 ${latest.inviterUserId}` ? latest.inviterNickname || `用户 ${latest.inviterUserId}`
@ -83,7 +92,10 @@
</div> </div>
<!-- 分割线仅在有更早申请时出现 --> <!-- 分割线仅在有更早申请时出现 -->
<div v-if="histories.length > 0" class="im-group-request-list__divider"> <div
v-if="histories.length > 0"
class="flex items-center justify-center mt-1.5 -mb-0.5 text-xs text-[var(--el-text-color-placeholder)]"
>
<span>以下为更早的申请</span> <span>以下为更早的申请</span>
</div> </div>
@ -91,23 +103,23 @@
<div <div
v-for="item in histories" v-for="item in histories"
:key="item.id" :key="item.id"
class="im-group-request-list__card im-group-request-list__card--compact" class="flex flex-col gap-2.5 px-3.5 py-2.5 rounded-[10px] border border-solid border-[var(--el-border-color-lighter)] bg-[var(--el-bg-color)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]"
> >
<div class="im-group-request-list__row"> <div class="flex items-center gap-3">
<UserAvatar <UserAvatar
:url="item.userAvatar" :url="item.userAvatar"
:name="item.userNickname" :name="item.userNickname"
:size="40" :size="40"
:clickable="false" :clickable="false"
/> />
<div class="im-group-request-list__main"> <div class="flex-1 min-w-0">
<div class="im-group-request-list__name truncate"> <div class="truncate text-sm font-medium leading-[1.4] text-[var(--el-text-color-primary)]">
{{ item.userNickname || `用户 ${item.userId}` }} {{ item.userNickname || `用户 ${item.userId}` }}
</div> </div>
<div class="im-group-request-list__source truncate"> <div class="truncate mt-[2px] text-xs leading-[1.5] text-[var(--el-text-color-secondary)]">
<template v-if="item.inviterUserId"> <template v-if="item.inviterUserId">
通过 通过
<span class="im-group-request-list__inviter"> <span class="text-[var(--el-color-primary)]">
{{ item.inviterNickname || `用户 ${item.inviterUserId}` }} {{ item.inviterNickname || `用户 ${item.inviterUserId}` }}
</span> </span>
的邀请进群 的邀请进群
@ -117,17 +129,17 @@
</div> </div>
<span <span
v-if="item.handleResult === ImGroupRequestHandleResult.AGREED" v-if="item.handleResult === ImGroupRequestHandleResult.AGREED"
class="im-group-request-list__done" class="flex-shrink-0 text-[13px] text-[var(--el-text-color-placeholder)]"
> >
已同意 已同意
</span> </span>
<span <span
v-else-if="item.handleResult === ImGroupRequestHandleResult.REFUSED" v-else-if="item.handleResult === ImGroupRequestHandleResult.REFUSED"
class="im-group-request-list__done" class="flex-shrink-0 text-[13px] text-[var(--el-text-color-placeholder)]"
> >
已拒绝 已拒绝
</span> </span>
<div v-else class="im-group-request-list__actions"> <div v-else class="flex gap-1.5 flex-shrink-0">
<button <button
class="im-group-request-list__btn im-group-request-list__btn--primary" class="im-group-request-list__btn im-group-request-list__btn--primary"
:disabled="actingId === item.id" :disabled="actingId === item.id"
@ -281,88 +293,7 @@ function updateLocalResult(id: number, handleResult: number) {
</script> </script>
<style scoped> <style scoped>
.im-group-request-list__body { /* 自绘按钮:贴近微信小药丸样式;与 :disabled、:hover:not(:disabled) 等伪类叠加 modifier 类的组合选择器写在 class 里成本高,留 SCSS */
display: flex;
flex-direction: column;
gap: 12px;
max-height: 60vh;
overflow-y: auto;
padding-right: 4px;
}
.im-group-request-list__card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px;
background-color: var(--el-bg-color);
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
border: 1px solid var(--el-border-color-lighter);
}
.im-group-request-list__card--compact {
padding: 10px 14px;
}
.im-group-request-list__row {
display: flex;
align-items: center;
gap: 12px;
}
.im-group-request-list__main {
flex: 1;
min-width: 0;
}
.im-group-request-list__name {
font-size: 14px;
font-weight: 500;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.im-group-request-list__source {
margin-top: 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.5;
}
.im-group-request-list__inviter {
color: var(--el-color-primary);
}
.im-group-request-list__quote {
padding: 8px 12px;
background-color: var(--el-fill-color-light);
border-radius: 6px;
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.5;
word-break: break-all;
}
.im-group-request-list__quote-name {
color: var(--el-color-primary);
}
.im-group-request-list__divider {
display: flex;
align-items: center;
justify-content: center;
margin: 6px 0 -2px;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.im-group-request-list__actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
/* 自绘按钮贴近微信小药丸样式el-button 默认尺寸偏大、圆角偏方 */
.im-group-request-list__btn { .im-group-request-list__btn {
flex-shrink: 0; flex-shrink: 0;
min-width: 56px; min-width: 56px;
@ -401,15 +332,10 @@ function updateLocalResult(id: number, handleResult: number) {
color: var(--el-color-primary); color: var(--el-color-primary);
border-color: var(--el-color-primary); border-color: var(--el-color-primary);
} }
.im-group-request-list__done {
flex-shrink: 0;
font-size: 13px;
color: var(--el-text-color-placeholder);
}
</style> </style>
<style> <style>
/* el-dialog 内部 body 通过 teleport 渲染到 bodyscoped 选不到,留非 scoped 全局覆盖 */
.im-group-request-list__dialog .el-dialog__body { .im-group-request-list__dialog .el-dialog__body {
padding: 12px 20px 8px; padding: 12px 20px 8px;
background-color: var(--el-fill-color-light); background-color: var(--el-fill-color-light);

View File

@ -6,10 +6,10 @@
- Panel 不带 el-dialog dialog 由业务壳持有 - Panel 不带 el-dialog dialog 由业务壳持有
- footer slot 渲染在右栏已选列表下方业务壳放预览卡 / 留言 / 提交按钮 - footer slot 渲染在右栏已选列表下方业务壳放预览卡 / 留言 / 提交按钮
--> -->
<div class="flex h-full im-conversation-picker"> <div class="flex h-full">
<!-- 左栏 --> <!-- 左栏 -->
<div <div
class="flex flex-col w-[280px] border-r border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]" class="flex flex-col w-[280px] border-r border-r-solid border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]"
> >
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="flex-shrink-0 px-3 py-2"> <div class="flex-shrink-0 px-3 py-2">
@ -60,7 +60,7 @@
<!-- 移除模式右上角 × 圆角标点击把这条 key recentForwardConversationKeys 删掉 --> <!-- 移除模式右上角 × 圆角标点击把这条 key recentForwardConversationKeys 删掉 -->
<span <span
v-if="recentRemoveMode" v-if="recentRemoveMode"
class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full cursor-pointer im-conversation-picker__recent-remove" class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full cursor-pointer bg-[var(--el-fill-color-dark)] text-[var(--el-text-color-primary)]"
@click.stop="emit('remove-recent', getConversationKey(conversation))" @click.stop="emit('remove-recent', getConversationKey(conversation))"
> >
<Icon icon="ant-design:close-outlined" :size="10" /> <Icon icon="ant-design:close-outlined" :size="10" />
@ -71,8 +71,8 @@
class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full transition-colors" class="flex absolute -top-1 -right-1 justify-center items-center w-4 h-4 rounded-full transition-colors"
:class=" :class="
isSelected(conversation) isSelected(conversation)
? 'im-conversation-picker__recent-badge' ? 'bg-[#07c160] border border-solid border-[#07c160]'
: 'border border-[var(--el-border-color)] bg-[var(--el-bg-color)]' : 'border border-solid border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
" "
> >
<Icon <Icon
@ -121,8 +121,8 @@
class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors" class="flex flex-shrink-0 justify-center items-center w-5 h-5 rounded-full transition-colors"
:class=" :class="
isSelected(conversation) isSelected(conversation)
? 'im-conversation-picker__check--checked' ? 'bg-[#07c160] border border-solid border-[#07c160]'
: 'border border-[var(--el-border-color)] bg-[var(--el-bg-color)]' : 'border border-solid border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
" "
> >
<Icon <Icon
@ -167,7 +167,7 @@
<div class="flex flex-col flex-1 min-w-0"> <div class="flex flex-col flex-1 min-w-0">
<!-- 标题 0/1发送给多个分别发送给与微信文案一致 --> <!-- 标题 0/1发送给多个分别发送给与微信文案一致 -->
<div <div
class="flex-shrink-0 px-4 py-3 border-b text-13px text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]" class="flex-shrink-0 px-4 py-3 border-b border-b-solid text-13px text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]"
> >
{{ sendTitle }} {{ sendTitle }}
</div> </div>
@ -201,7 +201,7 @@
<Icon <Icon
icon="ant-design:close-outlined" icon="ant-design:close-outlined"
:size="14" :size="14"
class="flex-shrink-0 cursor-pointer im-conversation-picker__remove" class="flex-shrink-0 cursor-pointer transition-colors text-[var(--el-text-color-placeholder)] hover:text-[var(--el-color-danger)]"
@click="handleToggle(conversation)" @click="handleToggle(conversation)"
/> />
</div> </div>
@ -216,7 +216,7 @@
<!-- 业务壳塞预览卡 / 留言 / 提交按钮的位置 --> <!-- 业务壳塞预览卡 / 留言 / 提交按钮的位置 -->
<div <div
v-if="$slots.footer" v-if="$slots.footer"
class="flex-shrink-0 border-t border-[var(--el-border-color-lighter)]" class="flex-shrink-0 border-t border-t-solid border-[var(--el-border-color-lighter)]"
> >
<slot name="footer"></slot> <slot name="footer"></slot>
</div> </div>
@ -352,20 +352,7 @@ function handleToggle(conversation: Conversation) {
</script> </script>
<style scoped> <style scoped>
/* 选中态圆形指示器:微信绿底色 + 白对勾,不污染主题色 */ /* 横向滚动条做窄一点避免占视觉;走 ::-webkit-scrollbar 浏览器伪元素 */
.im-conversation-picker__check--checked,
.im-conversation-picker__recent-badge {
background-color: #07c160;
border: 1px solid #07c160;
}
/* 最近转发移除模式的 × 角标:浅灰底 + 次要字色 */
.im-conversation-picker__recent-remove {
background-color: var(--el-fill-color-dark);
color: var(--el-text-color-primary);
}
/* 最近转发头像横向滚动条做窄一点,避免占视觉 */
.im-conversation-picker__recent::-webkit-scrollbar { .im-conversation-picker__recent::-webkit-scrollbar {
height: 4px; height: 4px;
} }
@ -373,13 +360,4 @@ function handleToggle(conversation: Conversation) {
background-color: var(--el-border-color); background-color: var(--el-border-color);
border-radius: 2px; border-radius: 2px;
} }
/* 已选行 × 移除常驻浅灰hover 转危险色 */
.im-conversation-picker__remove {
color: var(--el-text-color-placeholder);
transition: color 0.15s;
}
.im-conversation-picker__remove:hover {
color: var(--el-color-danger);
}
</style> </style>

View File

@ -6,10 +6,10 @@
- Panel 不带 el-dialog dialog 由业务壳持有 - Panel 不带 el-dialog dialog 由业务壳持有
- 三态语义hide > locked > disabled详见 contract - 三态语义hide > locked > disabled详见 contract
--> -->
<div class="flex h-full im-friend-picker"> <div class="flex h-full">
<!-- 左栏 --> <!-- 左栏 -->
<div <div
class="flex flex-col flex-1 min-w-0 border-r border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]" class="flex flex-col flex-1 min-w-0 border-r border-r-solid border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]"
> >
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="flex-shrink-0 px-3 py-2"> <div class="flex-shrink-0 px-3 py-2">
@ -79,7 +79,7 @@
<div class="flex flex-col flex-1 min-w-0"> <div class="flex flex-col flex-1 min-w-0">
<!-- 标题已选数高度对齐左侧 input default32px保证两侧第一项起点同水平 --> <!-- 标题已选数高度对齐左侧 input default32px保证两侧第一项起点同水平 -->
<div <div
class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b text-13px text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]" class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b border-b-solid text-13px text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]"
> >
已选择 {{ selectedCount }} 个好友 已选择 {{ selectedCount }} 个好友
</div> </div>
@ -108,7 +108,7 @@
v-if="!isLocked(friend)" v-if="!isLocked(friend)"
icon="ant-design:close-outlined" icon="ant-design:close-outlined"
:size="14" :size="14"
class="flex-shrink-0 cursor-pointer im-friend-picker__remove" class="flex-shrink-0 cursor-pointer transition-colors text-[var(--el-text-color-placeholder)] hover:text-[var(--el-color-danger)]"
@click="handleToggle(friend)" @click="handleToggle(friend)"
/> />
</div> </div>
@ -123,7 +123,7 @@
<!-- 业务壳塞额外内容的位置FriendPickerPanel 主流场景不需要 footer --> <!-- 业务壳塞额外内容的位置FriendPickerPanel 主流场景不需要 footer -->
<div <div
v-if="$slots.footer" v-if="$slots.footer"
class="flex-shrink-0 border-t border-[var(--el-border-color-lighter)]" class="flex-shrink-0 border-t border-t-solid border-[var(--el-border-color-lighter)]"
> >
<slot name="footer"></slot> <slot name="footer"></slot>
</div> </div>
@ -224,12 +224,12 @@ function isSelected(friend: FriendLite): boolean {
/** 圆形勾选指示器的 class选中 / 锁定走绿底,禁用灰底,未选空心圆 */ /** 圆形勾选指示器的 class选中 / 锁定走绿底,禁用灰底,未选空心圆 */
function getCheckClass(friend: FriendLite): string { function getCheckClass(friend: FriendLite): string {
if (isLocked(friend) || isSelected(friend)) { if (isLocked(friend) || isSelected(friend)) {
return 'im-friend-picker__check--checked' return 'bg-[#07c160] border border-solid border-[#07c160]'
} }
if (isDisabled(friend)) { if (isDisabled(friend)) {
return 'im-friend-picker__check--disabled' return 'bg-[var(--el-fill-color)] border border-solid border-[var(--el-border-color)]'
} }
return 'border border-[var(--el-border-color)] bg-[var(--el-bg-color)]' return 'border border-solid border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
} }
/** 切换选中态locked / disabled 不响应;右栏 × 移除 / 行 click 都走这里 */ /** 切换选中态locked / disabled 不响应;右栏 × 移除 / 行 click 都走这里 */
@ -252,25 +252,3 @@ function handleToggle(friend: FriendLite) {
} }
</script> </script>
<style scoped>
/* 选中 / 锁定圆形指示器:微信绿底 + 白对勾,不污染主题色 */
.im-friend-picker__check--checked {
background-color: #07c160;
border: 1px solid #07c160;
}
/* 禁用项:浅灰底 + 浅灰边,提示「不可选」 */
.im-friend-picker__check--disabled {
background-color: var(--el-fill-color);
border: 1px solid var(--el-border-color);
}
/* 已选行 × 移除常驻浅灰hover 转危险色 */
.im-friend-picker__remove {
color: var(--el-text-color-placeholder);
transition: color 0.15s;
}
.im-friend-picker__remove:hover {
color: var(--el-color-danger);
}
</style>

View File

@ -6,10 +6,10 @@
- Panel 不带 el-dialog dialog 由业务壳持有 - Panel 不带 el-dialog dialog 由业务壳持有
- 三态语义hide > locked > disabled - 三态语义hide > locked > disabled
--> -->
<div class="flex h-full im-group-member-picker"> <div class="flex h-full">
<!-- 左栏 --> <!-- 左栏 -->
<div <div
class="flex flex-col flex-1 min-w-0 border-r border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]" class="flex flex-col flex-1 min-w-0 border-r border-r-solid border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-light)]"
> >
<!-- 搜索框 --> <!-- 搜索框 -->
<div class="flex-shrink-0 px-3 py-2"> <div class="flex-shrink-0 px-3 py-2">
@ -49,7 +49,7 @@
<div class="flex flex-col flex-1 min-w-0"> <div class="flex flex-col flex-1 min-w-0">
<!-- 标题已选数高度对齐左侧 input default32px --> <!-- 标题已选数高度对齐左侧 input default32px -->
<div <div
class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b text-13px text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]" class="flex-shrink-0 h-12 px-4 leading-[3rem] border-b border-b-solid text-13px text-[var(--el-text-color-secondary)] border-[var(--el-border-color-lighter)]"
> >
已选择 {{ selectedCount }} 位成员 已选择 {{ selectedCount }} 位成员
</div> </div>
@ -78,7 +78,7 @@
v-if="!isLocked(member)" v-if="!isLocked(member)"
icon="ant-design:close-outlined" icon="ant-design:close-outlined"
:size="14" :size="14"
class="flex-shrink-0 cursor-pointer im-group-member-picker__remove" class="flex-shrink-0 cursor-pointer transition-colors text-[var(--el-text-color-placeholder)] hover:text-[var(--el-color-danger)]"
@click="handleToggle(member)" @click="handleToggle(member)"
/> />
</div> </div>
@ -205,12 +205,12 @@ function isSelected(member: GroupMemberLite): boolean {
/** 圆形勾选指示器的 class */ /** 圆形勾选指示器的 class */
function getCheckClass(member: GroupMemberLite): string { function getCheckClass(member: GroupMemberLite): string {
if (isLocked(member) || isSelected(member)) { if (isLocked(member) || isSelected(member)) {
return 'im-group-member-picker__check--checked' return 'bg-[#07c160] border border-solid border-[#07c160]'
} }
if (isDisabled(member)) { if (isDisabled(member)) {
return 'im-group-member-picker__check--disabled' return 'bg-[var(--el-fill-color)] border border-solid border-[var(--el-border-color)]'
} }
return 'border border-[var(--el-border-color)] bg-[var(--el-bg-color)]' return 'border border-solid border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
} }
/** 切换选中态locked / disabled 不响应;右栏 × / 行 click 都走这里 */ /** 切换选中态locked / disabled 不响应;右栏 × / 行 click 都走这里 */
@ -233,22 +233,3 @@ function handleToggle(member: GroupMemberLite) {
} }
</script> </script>
<style scoped>
.im-group-member-picker__check--checked {
background-color: #07c160;
border: 1px solid #07c160;
}
.im-group-member-picker__check--disabled {
background-color: var(--el-fill-color);
border: 1px solid var(--el-border-color);
}
.im-group-member-picker__remove {
color: var(--el-text-color-placeholder);
transition: color 0.15s;
}
.im-group-member-picker__remove:hover {
color: var(--el-color-danger);
}
</style>

View File

@ -102,6 +102,7 @@ function handleOk() {
<style scoped lang="scss"> <style scoped lang="scss">
@use '../picker/picker-dialog' as picker; @use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog { .im-picker-dialog {
@include picker.styles; @include picker.styles;
} }

View File

@ -93,7 +93,7 @@ const audioRef = useMediaStreamElement<HTMLAudioElement>(() => props.participant
</script> </script>
<style scoped> <style scoped>
/* 三点淡入淡出动画;@keyframes 必须 CSS 定义,再由 UnoCSS 之外的类名引用 */ /* 三点淡入淡出动画;@keyframes 必须 CSS 定义 */
.tile-dot { .tile-dot {
animation: tile-dot 1.4s infinite ease-in-out both; animation: tile-dot 1.4s infinite ease-in-out both;
} }

View File

@ -266,7 +266,7 @@ const formattedDuration = computed(() =>
</script> </script>
<style scoped> <style scoped>
/* 重连小点淡入淡出;@keyframes 必须 CSS 定义,再由非 UnoCSS 类名引用 */ /* 重连小点淡入淡出;@keyframes 必须 CSS 定义 */
.reconnect-dot { .reconnect-dot {
animation: reconnect-pulse 1s ease-in-out infinite; animation: reconnect-pulse 1s ease-in-out infinite;
} }

View File

@ -20,7 +20,7 @@
v-if="view === 'contact'" v-if="view === 'contact'"
icon="ant-design:arrow-left-outlined" icon="ant-design:arrow-left-outlined"
:size="16" :size="16"
class="cursor-pointer im-recommend-dialog__back" class="cursor-pointer text-[var(--el-text-color-secondary)] transition-colors duration-150 hover:text-[var(--el-color-primary)]"
@click="view = 'conversation'" @click="view = 'conversation'"
/> />
<span class="text-base text-[var(--el-text-color-primary)]"> <span class="text-base text-[var(--el-text-color-primary)]">
@ -315,16 +315,8 @@ async function handleCreateGroupAndSend() {
<style scoped lang="scss"> <style scoped lang="scss">
@use '../picker/picker-dialog' as picker; @use '../picker/picker-dialog' as picker;
/* :deep 穿透 el-dialog 内部类;复用 picker 公共 mixin */
.im-picker-dialog { .im-picker-dialog {
@include picker.styles; @include picker.styles;
} }
/* 返回箭头 hover 高亮,提示可点击 */
.im-recommend-dialog__back {
color: var(--el-text-color-secondary);
transition: color 0.15s;
}
.im-recommend-dialog__back:hover {
color: var(--el-color-primary);
}
</style> </style>

View File

@ -9,7 +9,7 @@
<ResizableAside :default-width="260" :storage-key="StorageKeys.asideWidth"> <ResizableAside :default-width="260" :storage-key="StorageKeys.asideWidth">
<!-- 顶部仅搜索框h-14 与消息 Tab 顶部对齐避免切换时搜索框上下抖动 --> <!-- 顶部仅搜索框h-14 与消息 Tab 顶部对齐避免切换时搜索框上下抖动 -->
<div <div
class="flex flex-shrink-0 items-center h-14 px-4 border-b border-[var(--el-border-color-lighter)]" class="flex flex-shrink-0 items-center h-14 px-4 border-b border-b-solid border-[var(--el-border-color-lighter)]"
> >
<el-input v-model="keyword" placeholder="搜索" clearable class="flex-1"> <el-input v-model="keyword" placeholder="搜索" clearable class="flex-1">
<template #prefix> <template #prefix>

View File

@ -8,11 +8,11 @@
append-to-body append-to-body
modal-class="im-conversation-group-side__modal" modal-class="im-conversation-group-side__modal"
> >
<div v-if="group" class="im-conversation-group-side flex flex-col h-full"> <div v-if="group" class="flex flex-col h-full bg-[var(--el-bg-color)]">
<!-- 上部可滚动内容区 --> <!-- 上部可滚动内容区 -->
<div class="im-conversation-group-side__scroll flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto bg-[var(--el-fill-color-light)]">
<!-- ==================== 群成员区 ==================== --> <!-- ==================== 群成员区 ==================== -->
<div class="im-conversation-group-side__section im-conversation-group-side__members"> <div class="px-4 pt-4 pb-[10px] bg-[var(--el-bg-color)]">
<el-input v-model="searchText" placeholder="搜索群成员" clearable> <el-input v-model="searchText" placeholder="搜索群成员" clearable>
<template #prefix> <template #prefix>
<Icon <Icon
@ -22,7 +22,7 @@
</template> </template>
</el-input> </el-input>
<div class="im-conversation-group-side__grid"> <div class="flex flex-wrap gap-x-1 gap-y-[14px] mt-[14px]">
<GroupMemberGrid <GroupMemberGrid
v-for="member in displayMembers" v-for="member in displayMembers"
:key="member.userId" :key="member.userId"
@ -34,34 +34,34 @@
<!-- 添加任何成员都能邀请 --> <!-- 添加任何成员都能邀请 -->
<div <div
class="im-conversation-group-side__tile-wrap" class="im-conversation-group-side__tile-wrap flex flex-col items-center w-[66px] cursor-pointer"
title="邀请好友入群" title="邀请好友入群"
@click="handleOpenInvite" @click="handleOpenInvite"
> >
<div class="im-conversation-group-side__icon-tile"> <div class="im-conversation-group-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--el-text-color-regular)] bg-[var(--el-fill-color-lighter)] border border-dashed border-[var(--el-border-color)] rounded-md transition-colors duration-200">
<Icon icon="ant-design:plus-outlined" /> <Icon icon="ant-design:plus-outlined" />
</div> </div>
<div class="im-conversation-group-side__tile-label">添加</div> <div class="mt-1.5 text-12px leading-[1.5] text-[var(--el-text-color-regular)] text-center">添加</div>
</div> </div>
<!-- 移出群主或管理员管理员只能移出普通成员由后端校验 --> <!-- 移出群主或管理员管理员只能移出普通成员由后端校验 -->
<div <div
v-if="isOwnerOrAdmin" v-if="isOwnerOrAdmin"
class="im-conversation-group-side__tile-wrap" class="im-conversation-group-side__tile-wrap flex flex-col items-center w-[66px] cursor-pointer"
title="移出群成员" title="移出群成员"
@click="handleOpenRemove" @click="handleOpenRemove"
> >
<div class="im-conversation-group-side__icon-tile"> <div class="im-conversation-group-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--el-text-color-regular)] bg-[var(--el-fill-color-lighter)] border border-dashed border-[var(--el-border-color)] rounded-md transition-colors duration-200">
<Icon icon="ant-design:minus-outlined" /> <Icon icon="ant-design:minus-outlined" />
</div> </div>
<div class="im-conversation-group-side__tile-label">移出</div> <div class="mt-1.5 text-12px leading-[1.5] text-[var(--el-text-color-regular)] text-center">移出</div>
</div> </div>
</div> </div>
<!-- 大群折叠默认只展示前 N "查看更多" 全展开搜索时不折叠 --> <!-- 大群折叠默认只展示前 N "查看更多" 全展开搜索时不折叠 -->
<div <div
v-if="moreMembersHidden" v-if="moreMembersHidden"
class="im-conversation-group-side__more" class="flex items-center justify-center gap-1 mt-[10px] pt-1.5 pb-0.5 text-12px text-[var(--el-text-color-secondary)] cursor-pointer transition-colors duration-150 hover:text-[var(--el-color-primary)]"
@click="showAllMembers = true" @click="showAllMembers = true"
> >
查看更多 查看更多
@ -69,11 +69,11 @@
</div> </div>
</div> </div>
<div class="im-conversation-group-side__spacer"></div> <div class="flex-shrink-0 h-[10px]"></div>
<!-- ==================== 群信息 ==================== --> <!-- ==================== 群信息 ==================== -->
<!-- label 在上value 在下纵向堆叠对齐微信 PC 设计只有 "群公告" 因为内容长加 > chevron --> <!-- label 在上value 在下纵向堆叠对齐微信 PC 设计只有 "群公告" 因为内容长加 > chevron -->
<div class="im-conversation-group-side__section"> <div class="bg-[var(--el-bg-color)]">
<!-- 群聊名称群主可改 --> <!-- 群聊名称群主可改 -->
<el-popover <el-popover
v-if="isOwner" v-if="isOwner"
@ -84,10 +84,10 @@
> >
<template #reference> <template #reference>
<div <div
class="im-conversation-group-side__row im-conversation-group-side__row--vertical im-conversation-group-side__row--clickable" class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--el-fill-color-lighter)]"
> >
<span class="im-conversation-group-side__label">群聊名称</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">群聊名称</span>
<span class="im-conversation-group-side__value truncate">{{ group.name }}</span> <span class="text-13px text-[var(--el-text-color-regular)] break-all leading-[1.6] truncate">{{ group.name }}</span>
</div> </div>
</template> </template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@ -100,10 +100,10 @@
</el-popover> </el-popover>
<div <div
v-else v-else
class="im-conversation-group-side__row im-conversation-group-side__row--vertical" class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 transition-colors duration-150"
> >
<span class="im-conversation-group-side__label">群聊名称</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">群聊名称</span>
<span class="im-conversation-group-side__value truncate">{{ group.name }}</span> <span class="text-13px text-[var(--el-text-color-regular)] break-all leading-[1.6] truncate">{{ group.name }}</span>
</div> </div>
<!-- 群公告群主可改内容可能很长 > chevron 表示可展开编辑 --> <!-- 群公告群主可改内容可能很长 > chevron 表示可展开编辑 -->
@ -116,23 +116,23 @@
> >
<template #reference> <template #reference>
<div <div
class="im-conversation-group-side__row im-conversation-group-side__row--vertical im-conversation-group-side__row--clickable" class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--el-fill-color-lighter)]"
> >
<div class="im-conversation-group-side__row-header"> <div class="flex items-center justify-between gap-2">
<span class="im-conversation-group-side__label">群公告</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">群公告</span>
<Icon <Icon
icon="ant-design:right-outlined" icon="ant-design:right-outlined"
:size="11" :size="11"
class="im-conversation-group-side__chevron" class="text-[var(--el-text-color-placeholder)]"
/> />
</div> </div>
<span <span
v-if="group.notice" v-if="group.notice"
class="im-conversation-group-side__value im-conversation-group-side__value--clamp" class="text-13px text-[var(--el-text-color-regular)] break-all leading-[1.6] line-clamp-2"
> >
{{ group.notice }} {{ group.notice }}
</span> </span>
<span v-else class="im-conversation-group-side__value-placeholder">未设置</span> <span v-else class="text-13px text-[var(--el-text-color-placeholder)] leading-[1.6]">未设置</span>
</div> </div>
</template> </template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@ -152,16 +152,16 @@
</el-popover> </el-popover>
<div <div
v-else v-else
class="im-conversation-group-side__row im-conversation-group-side__row--vertical" class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 transition-colors duration-150"
> >
<span class="im-conversation-group-side__label">群公告</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">群公告</span>
<span <span
v-if="group.notice" v-if="group.notice"
class="im-conversation-group-side__value im-conversation-group-side__value--clamp" class="text-13px text-[var(--el-text-color-regular)] break-all leading-[1.6] line-clamp-2"
> >
{{ group.notice }} {{ group.notice }}
</span> </span>
<span v-else class="im-conversation-group-side__value-placeholder">未设置</span> <span v-else class="text-13px text-[var(--el-text-color-placeholder)] leading-[1.6]">未设置</span>
</div> </div>
<!-- 备注仅自己可见保存后会替换会话列表 / 顶部群名展示 --> <!-- 备注仅自己可见保存后会替换会话列表 / 顶部群名展示 -->
@ -173,16 +173,16 @@
> >
<template #reference> <template #reference>
<div <div
class="im-conversation-group-side__row im-conversation-group-side__row--vertical im-conversation-group-side__row--clickable" class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--el-fill-color-lighter)]"
> >
<span class="im-conversation-group-side__label">备注</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">备注</span>
<span <span
v-if="group.groupRemark" v-if="group.groupRemark"
class="im-conversation-group-side__value im-conversation-group-side__value--clamp" class="text-13px text-[var(--el-text-color-regular)] break-all leading-[1.6] line-clamp-2"
> >
{{ group.groupRemark }} {{ group.groupRemark }}
</span> </span>
<span v-else class="im-conversation-group-side__value-placeholder"> <span v-else class="text-13px text-[var(--el-text-color-placeholder)] leading-[1.6]">
群聊的备注仅自己可见 群聊的备注仅自己可见
</span> </span>
</div> </div>
@ -212,16 +212,16 @@
> >
<template #reference> <template #reference>
<div <div
class="im-conversation-group-side__row im-conversation-group-side__row--vertical im-conversation-group-side__row--clickable" class="im-conversation-group-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--el-fill-color-lighter)]"
> >
<span class="im-conversation-group-side__label">我在本群的昵称</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">我在本群的昵称</span>
<span <span
v-if="group.remarkNickName" v-if="group.remarkNickName"
class="im-conversation-group-side__value truncate" class="text-13px text-[var(--el-text-color-regular)] break-all leading-[1.6] truncate"
> >
{{ group.remarkNickName }} {{ group.remarkNickName }}
</span> </span>
<span v-else class="im-conversation-group-side__value-placeholder">点击设置</span> <span v-else class="text-13px text-[var(--el-text-color-placeholder)] leading-[1.6]">点击设置</span>
</div> </div>
</template> </template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@ -234,55 +234,55 @@
</el-popover> </el-popover>
</div> </div>
<div class="im-conversation-group-side__spacer"></div> <div class="flex-shrink-0 h-[10px]"></div>
<!-- ==================== 查找聊天内容 ==================== --> <!-- ==================== 查找聊天内容 ==================== -->
<!-- 点击 父组件打开 MessageHistory 弹窗 --> <!-- 点击 父组件打开 MessageHistory 弹窗 -->
<div class="im-conversation-group-side__spacer"></div> <div class="flex-shrink-0 h-[10px]"></div>
<div class="im-conversation-group-side__section"> <div class="bg-[var(--el-bg-color)]">
<div <div
class="im-conversation-group-side__row im-conversation-group-side__row--clickable" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--el-fill-color-lighter)]"
@click="emit('open-history')" @click="emit('open-history')"
> >
<span class="im-conversation-group-side__label">查找聊天内容</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">查找聊天内容</span>
<Icon <Icon
icon="ant-design:right-outlined" icon="ant-design:right-outlined"
:size="11" :size="11"
class="im-conversation-group-side__chevron" class="text-[var(--el-text-color-placeholder)]"
/> />
</div> </div>
<!-- 分享群名片 RecommendCardDialog把当前群作为名片消息推荐给其他会话 --> <!-- 分享群名片 RecommendCardDialog把当前群作为名片消息推荐给其他会话 -->
<div <div
v-if="group" v-if="group"
class="im-conversation-group-side__row im-conversation-group-side__row--clickable" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--el-fill-color-lighter)]"
@click="handleShareGroupCard" @click="handleShareGroupCard"
> >
<span class="im-conversation-group-side__label">分享群名片</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">分享群名片</span>
<Icon <Icon
icon="ant-design:right-outlined" icon="ant-design:right-outlined"
:size="11" :size="11"
class="im-conversation-group-side__chevron" class="text-[var(--el-text-color-placeholder)]"
/> />
</div> </div>
</div> </div>
<div class="im-conversation-group-side__spacer"></div> <div class="flex-shrink-0 h-[10px]"></div>
<!-- ==================== 开关项 ==================== --> <!-- ==================== 开关项 ==================== -->
<div class="im-conversation-group-side__section"> <div class="bg-[var(--el-bg-color)]">
<div class="im-conversation-group-side__row"> <div class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
<span class="im-conversation-group-side__label">消息免打扰</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">消息免打扰</span>
<el-switch :model-value="!!conversation?.silent" @change="onMutedChange" /> <el-switch :model-value="!!conversation?.silent" @change="onMutedChange" />
</div> </div>
<div class="im-conversation-group-side__row"> <div class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
<span class="im-conversation-group-side__label">置顶聊天</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">置顶聊天</span>
<el-switch :model-value="!!conversation?.top" @change="onTopChange" /> <el-switch :model-value="!!conversation?.top" @change="onTopChange" />
</div> </div>
<!-- 全群禁言仅群主或管理员可操作 --> <!-- 全群禁言仅群主或管理员可操作 -->
<div v-if="isOwnerOrAdmin" class="im-conversation-group-side__row"> <div v-if="isOwnerOrAdmin" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
<span class="im-conversation-group-side__label">全群禁言</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">全群禁言</span>
<el-switch :model-value="!!currentMutedAll" @change="onMuteAllChange" /> <el-switch :model-value="!!currentMutedAll" @change="onMuteAllChange" />
</div> </div>
</div> </div>
@ -290,24 +290,24 @@
<!-- ==================== 进群审批 ==================== --> <!-- ==================== 进群审批 ==================== -->
<!-- 单独一段群主开关 + 紧跟- 进群申请子项与微信群管理布局对齐 --> <!-- 单独一段群主开关 + 紧跟- 进群申请子项与微信群管理布局对齐 -->
<template v-if="isOwner || (isOwnerOrAdmin && !!group.joinApproval)"> <template v-if="isOwner || (isOwnerOrAdmin && !!group.joinApproval)">
<div class="im-conversation-group-side__spacer"></div> <div class="flex-shrink-0 h-[10px]"></div>
<div class="im-conversation-group-side__section"> <div class="bg-[var(--el-bg-color)]">
<!-- 进群审批仅群主可操作开启后普通成员的申请邀请路径都需群主 / 管理员同意群主 / 管理员邀请直进 --> <!-- 进群审批仅群主可操作开启后普通成员的申请邀请路径都需群主 / 管理员同意群主 / 管理员邀请直进 -->
<div v-if="isOwner" class="im-conversation-group-side__row"> <div v-if="isOwner" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
<span class="im-conversation-group-side__label">进群需要群主 / 群管理确认</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">进群需要群主 / 群管理确认</span>
<el-switch :model-value="!!group.joinApproval" @change="onJoinApprovalChange" /> <el-switch :model-value="!!group.joinApproval" @change="onJoinApprovalChange" />
</div> </div>
<!-- 进群申请子项仅当开启审批 + 当前用户是 owner / admin 时出现点击进列表 dialog --> <!-- 进群申请子项仅当开启审批 + 当前用户是 owner / admin 时出现点击进列表 dialog -->
<div <div
v-if="isOwnerOrAdmin && !!group.joinApproval" v-if="isOwnerOrAdmin && !!group.joinApproval"
class="im-conversation-group-side__row im-conversation-group-side__row--clickable" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--el-fill-color-lighter)]"
@click="handleOpenRequestList" @click="handleOpenRequestList"
> >
<span class="im-conversation-group-side__label">- 进群申请</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">- 进群申请</span>
<Icon <Icon
icon="ant-design:right-outlined" icon="ant-design:right-outlined"
:size="11" :size="11"
class="im-conversation-group-side__chevron" class="text-[var(--el-text-color-placeholder)]"
/> />
</div> </div>
</div> </div>
@ -316,28 +316,28 @@
<!-- ==================== 群主操作 ==================== --> <!-- ==================== 群主操作 ==================== -->
<!-- 仅群主可见含管理员设置 + 群主管理权转让 --> <!-- 仅群主可见含管理员设置 + 群主管理权转让 -->
<template v-if="isOwner"> <template v-if="isOwner">
<div class="im-conversation-group-side__spacer"></div> <div class="flex-shrink-0 h-[10px]"></div>
<div class="im-conversation-group-side__section"> <div class="bg-[var(--el-bg-color)]">
<div <div
class="im-conversation-group-side__row im-conversation-group-side__row--clickable" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--el-fill-color-lighter)]"
@click="handleOpenAdminSet" @click="handleOpenAdminSet"
> >
<span class="im-conversation-group-side__label">群管理员</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">群管理员</span>
<Icon <Icon
icon="ant-design:right-outlined" icon="ant-design:right-outlined"
:size="11" :size="11"
class="im-conversation-group-side__chevron" class="text-[var(--el-text-color-placeholder)]"
/> />
</div> </div>
<div <div
class="im-conversation-group-side__row im-conversation-group-side__row--clickable" class="im-conversation-group-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--el-fill-color-lighter)]"
@click="handleOpenTransferOwner" @click="handleOpenTransferOwner"
> >
<span class="im-conversation-group-side__label">群主管理权转让</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">群主管理权转让</span>
<Icon <Icon
icon="ant-design:right-outlined" icon="ant-design:right-outlined"
:size="11" :size="11"
class="im-conversation-group-side__chevron" class="text-[var(--el-text-color-placeholder)]"
/> />
</div> </div>
</div> </div>
@ -345,11 +345,11 @@
</div> </div>
<!-- ==================== 底部退出 / 解散群聊 ==================== --> <!-- ==================== 底部退出 / 解散群聊 ==================== -->
<div class="im-conversation-group-side__footer"> <div class="flex-shrink-0 px-4 pt-[14px] pb-[18px] bg-[var(--el-bg-color)] border-t border-t-solid border-[var(--el-border-color-lighter)]">
<!-- 群主解散群聊 --> <!-- 群主解散群聊 -->
<el-button <el-button
v-if="isOwner" v-if="isOwner"
class="im-conversation-group-side__quit-btn" class="w-full !h-9 text-14px"
type="danger" type="danger"
plain plain
@click="handleDissolve" @click="handleDissolve"
@ -359,7 +359,7 @@
<!-- 非群主退出群聊 --> <!-- 非群主退出群聊 -->
<el-button <el-button
v-else v-else
class="im-conversation-group-side__quit-btn" class="w-full !h-9 text-14px"
type="danger" type="danger"
plain plain
@click="handleQuit" @click="handleQuit"
@ -796,174 +796,22 @@ function handleOpenTransferOwner() {
</script> </script>
<style scoped> <style scoped>
.im-conversation-group-side { /* 「添加 / 移出」瓦片hover 时联动内部 icon-tile 走主色,跨子元素的 hover 联动无法用单元素工具类表达 */
background-color: var(--el-bg-color);
}
/* 滚动区底色用浅灰,和 section 的白色形成 “块” 视觉spacer 自动融入背景就不用单独画线 */
.im-conversation-group-side__scroll {
background-color: var(--el-fill-color-light);
}
.im-conversation-group-side__section {
background-color: var(--el-bg-color);
}
/* 群成员区:搜索 + 宫格 + 查看更多 */
.im-conversation-group-side__members {
padding: 16px 16px 10px;
}
.im-conversation-group-side__grid {
display: flex;
flex-wrap: wrap;
gap: 14px 4px;
margin-top: 14px;
}
/* 添加 / 移出 宫格 wrapper */
.im-conversation-group-side__tile-wrap {
display: flex;
flex-direction: column;
align-items: center;
width: 66px;
cursor: pointer;
}
/* 添加 / 移出 的方块按钮:用浅底 + 虚线边hover 走主色让交互可读 */
.im-conversation-group-side__icon-tile {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
font-size: 20px;
color: var(--el-text-color-regular);
background-color: var(--el-fill-color-lighter);
border: 1px dashed var(--el-border-color);
border-radius: 6px;
transition:
color 0.18s,
border-color 0.18s,
background-color 0.18s;
}
.im-conversation-group-side__tile-wrap:hover .im-conversation-group-side__icon-tile { .im-conversation-group-side__tile-wrap:hover .im-conversation-group-side__icon-tile {
color: var(--el-color-primary); color: var(--el-color-primary);
border-color: var(--el-color-primary); border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9); background-color: var(--el-color-primary-light-9);
} }
.im-conversation-group-side__tile-label {
margin-top: 6px; /* :deep 穿透 Icon 内部 svg el-icon 全局 color 在暗色模式下被主题盖过,锁 fill 到当前色 */
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-regular);
text-align: center;
}
/* el-icon 全局 color 在暗色模式下被主题盖过;:deep(svg) 锁 fill 到当前色 */
.im-conversation-group-side__icon-tile :deep(svg) { .im-conversation-group-side__icon-tile :deep(svg) {
fill: currentColor !important; fill: currentColor !important;
} }
/* 查看更多 */ /* 相邻信息行加分隔线; 相邻兄弟选择器无法用工具类表达 */
.im-conversation-group-side__more {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin-top: 10px;
padding: 6px 0 2px;
font-size: 12px;
color: var(--el-text-color-secondary);
cursor: pointer;
transition: color 0.15s;
}
.im-conversation-group-side__more:hover {
color: var(--el-color-primary);
}
/* section 之间的灰条靠滚动区底色透出来即可spacer 高度决定块间距 */
.im-conversation-group-side__spacer {
flex-shrink: 0;
height: 10px;
}
/* 信息行:默认左 label 右 value开关行__row--vertical 切到 label 在上、value 在下(编辑字段行) */
.im-conversation-group-side__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 16px;
font-size: 14px;
min-height: 24px;
transition: background-color 0.15s;
}
.im-conversation-group-side__row + .im-conversation-group-side__row { .im-conversation-group-side__row + .im-conversation-group-side__row {
border-top: 1px solid var(--el-border-color-lighter); border-top: 1px solid var(--el-border-color-lighter);
} }
.im-conversation-group-side__row--clickable {
cursor: pointer;
}
.im-conversation-group-side__row--clickable:hover {
background-color: var(--el-fill-color-lighter);
}
.im-conversation-group-side__row--vertical {
flex-direction: column;
align-items: stretch;
gap: 6px;
padding: 14px 16px;
}
.im-conversation-group-side__label {
flex-shrink: 0;
font-size: 14px;
color: var(--el-text-color-primary);
}
/* 编辑字段行的 headerlabel 居左,可选 chevron 居右 */
.im-conversation-group-side__row-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.im-conversation-group-side__value {
font-size: 13px;
color: var(--el-text-color-regular);
word-break: break-all;
line-height: 1.6;
}
.im-conversation-group-side__value--clamp {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.im-conversation-group-side__value-placeholder {
font-size: 13px;
color: var(--el-text-color-placeholder);
line-height: 1.6;
}
/* chevron 统一调成 placeholder 灰,避免 opacity hack 在暗色下偏色 */
.im-conversation-group-side__chevron {
color: var(--el-text-color-placeholder);
}
/* 底部退出群聊:浅灰分隔条 + 内边距,给按钮喘气空间 */
.im-conversation-group-side__footer {
flex-shrink: 0;
padding: 14px 16px 18px;
background-color: var(--el-bg-color);
border-top: 1px solid var(--el-border-color-lighter);
}
.im-conversation-group-side__quit-btn {
width: 100%;
height: 36px;
font-size: 14px;
}
</style> </style>
<!-- el-drawer append-to-body 后被传送出当前 scoped 边界scoped CSS data-v 不会落到 body <!-- el-drawer append-to-body 后被传送出当前 scoped 边界scoped CSS data-v 不会落到 body

View File

@ -23,7 +23,7 @@
/> />
<span <span
v-show="!conversation.silent && conversation.unreadCount > 0" v-show="!conversation.silent && conversation.unreadCount > 0"
class="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1.5 text-11px leading-[18px] text-white text-center bg-[#f56c6c] border border-white dark:border-[var(--el-bg-color)] rounded-full box-border whitespace-nowrap" class="absolute -top-1.5 -right-1.5 min-w-[18px] h-[18px] px-1.5 text-11px leading-[18px] text-white text-center bg-[#f56c6c] border border-solid border-white dark:border-[var(--el-bg-color)] rounded-full box-border whitespace-nowrap"
> >
{{ conversation.unreadCount > 99 ? '99+' : conversation.unreadCount }} {{ conversation.unreadCount > 99 ? '99+' : conversation.unreadCount }}
</span> </span>
@ -40,7 +40,7 @@
type="primary" type="primary"
size="small" size="small"
effect="plain" effect="plain"
class="conversation-item__tag" class="conversation-item__tag flex-shrink-0 !h-[18px] !px-1 !leading-4"
> >
</el-tag> </el-tag>
@ -269,13 +269,8 @@ function handleContextMenu(e: MouseEvent) {
</script> </script>
<style scoped> <style scoped>
/* el-tag 内部尺寸走 CSS 变量UnoCSS 的高度/内边距会被 el-tag 自身的样式覆盖,用 :deep 微调 */ /* 消掉 el-tag 切会话时 active 底色变化的渐变(看起来像闪烁); :deep 穿透 el-tag 自身样式 */
/* transition:none 是为了消掉 el-tag 切会话时 active 底色变化的渐变(看起来像闪烁) */
.conversation-item__tag { .conversation-item__tag {
flex-shrink: 0;
height: 18px;
padding: 0 4px;
line-height: 16px;
transition: none !important; transition: none !important;
} }

View File

@ -13,39 +13,39 @@
append-to-body append-to-body
modal-class="im-conversation-private-side__modal" modal-class="im-conversation-private-side__modal"
> >
<div v-if="friend" class="im-conversation-private-side flex flex-col h-full"> <div v-if="friend" class="flex flex-col h-full bg-[var(--el-bg-color)]">
<div class="im-conversation-private-side__scroll flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto bg-[var(--el-fill-color-light)]">
<!-- 好友宫格 tile + "+" tile对齐 GroupSide 视觉让两种抽屉看起来是一家的 --> <!-- 好友宫格 tile + "+" tile对齐 GroupSide 视觉让两种抽屉看起来是一家的 -->
<div class="im-conversation-private-side__section im-conversation-private-side__friend"> <div class="flex flex-wrap gap-1 px-4 pt-4 pb-[14px] bg-[var(--el-bg-color)]">
<div class="im-conversation-private-side__tile-wrap"> <div class="flex flex-col items-center w-[66px]">
<UserAvatar <UserAvatar
:id="friend.friendUserId" :id="friend.friendUserId"
:url="friend.avatar" :url="friend.avatar"
:name="friend.nickname" :name="friend.nickname"
:size="50" :size="50"
/> />
<div class="im-conversation-private-side__tile-label"> <div class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--el-text-color-regular)] text-center truncate">
{{ displayName }} {{ displayName }}
</div> </div>
</div> </div>
<!-- + tile点击调起 GroupCreateDialog把对方 id 作为 lockedIds 传入 --> <!-- + tile点击调起 GroupCreateDialog把对方 id 作为 lockedIds 传入 -->
<div <div
class="im-conversation-private-side__tile-wrap im-conversation-private-side__tile-wrap--clickable" class="im-conversation-private-side__tile-wrap-clickable flex flex-col items-center w-[66px] cursor-pointer"
title="发起群聊" title="发起群聊"
@click="handleOpenCreateGroup" @click="handleOpenCreateGroup"
> >
<div class="im-conversation-private-side__icon-tile"> <div class="im-conversation-private-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--el-text-color-regular)] bg-[var(--el-fill-color-lighter)] border border-dashed border-[var(--el-border-color)] rounded-md transition-colors duration-200">
<Icon icon="ant-design:plus-outlined" /> <Icon icon="ant-design:plus-outlined" />
</div> </div>
<div class="im-conversation-private-side__tile-label">添加</div> <div class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--el-text-color-regular)] text-center truncate">添加</div>
</div> </div>
</div> </div>
<div class="im-conversation-private-side__spacer"></div> <div class="flex-shrink-0 h-[10px]"></div>
<!-- 备注仅自己可见点击弹 popover 编辑保存后立即刷新本抽屉 + 会话列表展示名 --> <!-- 备注仅自己可见点击弹 popover 编辑保存后立即刷新本抽屉 + 会话列表展示名 -->
<div class="im-conversation-private-side__section"> <div class="bg-[var(--el-bg-color)]">
<el-popover <el-popover
v-model:visible="displayNamePopoverVisible" v-model:visible="displayNamePopoverVisible"
trigger="click" trigger="click"
@ -54,16 +54,16 @@
> >
<template #reference> <template #reference>
<div <div
class="im-conversation-private-side__row im-conversation-private-side__row--vertical im-conversation-private-side__row--clickable" class="im-conversation-private-side__row flex flex-col items-stretch gap-1.5 px-4 py-[14px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--el-fill-color-lighter)]"
> >
<span class="im-conversation-private-side__label">备注</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">备注</span>
<span <span
v-if="friend.displayName" v-if="friend.displayName"
class="im-conversation-private-side__value im-conversation-private-side__value--clamp" class="text-13px leading-[1.6] text-[var(--el-text-color-regular)] break-all line-clamp-2"
> >
{{ friend.displayName }} {{ friend.displayName }}
</span> </span>
<span v-else class="im-conversation-private-side__value-placeholder"> <span v-else class="text-13px leading-[1.6] text-[var(--el-text-color-placeholder)]">
好友备注仅自己可见 好友备注仅自己可见
</span> </span>
</div> </div>
@ -85,33 +85,33 @@
</el-popover> </el-popover>
</div> </div>
<div class="im-conversation-private-side__spacer"></div> <div class="flex-shrink-0 h-[10px]"></div>
<!-- 查找聊天内容 --> <!-- 查找聊天内容 -->
<div class="im-conversation-private-side__section"> <div class="bg-[var(--el-bg-color)]">
<div <div
class="im-conversation-private-side__row im-conversation-private-side__row--clickable" class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 cursor-pointer transition-colors duration-150 hover:bg-[var(--el-fill-color-lighter)]"
@click="emit('open-history')" @click="emit('open-history')"
> >
<span class="im-conversation-private-side__label">查找聊天内容</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">查找聊天内容</span>
<Icon <Icon
icon="ant-design:right-outlined" icon="ant-design:right-outlined"
:size="11" :size="11"
class="im-conversation-private-side__chevron" class="text-[var(--el-text-color-placeholder)]"
/> />
</div> </div>
</div> </div>
<div class="im-conversation-private-side__spacer"></div> <div class="flex-shrink-0 h-[10px]"></div>
<!-- 开关项 --> <!-- 开关项 -->
<div class="im-conversation-private-side__section"> <div class="bg-[var(--el-bg-color)]">
<div class="im-conversation-private-side__row"> <div class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
<span class="im-conversation-private-side__label">消息免打扰</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">消息免打扰</span>
<el-switch :model-value="!!conversation?.silent" @change="handleMutedChange" /> <el-switch :model-value="!!conversation?.silent" @change="handleMutedChange" />
</div> </div>
<div class="im-conversation-private-side__row"> <div class="im-conversation-private-side__row flex items-center justify-between gap-3 px-4 py-[13px] text-14px min-h-6 transition-colors duration-150">
<span class="im-conversation-private-side__label">置顶聊天</span> <span class="flex-shrink-0 text-14px text-[var(--el-text-color-primary)]">置顶聊天</span>
<el-switch :model-value="!!conversation?.top" @change="handleTopChange" /> <el-switch :model-value="!!conversation?.top" @change="handleTopChange" />
</div> </div>
</div> </div>
@ -250,137 +250,22 @@ function handleGroupCreated(groupId: number) {
</script> </script>
<style scoped> <style scoped>
.im-conversation-private-side { /* 「+」 tile hover 时联动内部 icon-tile 走主色; 跨子元素的 hover 联动无法用单元素工具类表达 */
background-color: var(--el-bg-color); .im-conversation-private-side__tile-wrap-clickable:hover .im-conversation-private-side__icon-tile {
}
/* 滚动区底色用浅灰,配合 section 的白色形成 “块” 视觉spacer 自动透出来当分隔条 */
.im-conversation-private-side__scroll {
background-color: var(--el-fill-color-light);
}
.im-conversation-private-side__section {
background-color: var(--el-bg-color);
}
/* 好友宫格区:留白和 GroupSide__members 对齐friend tile + "+" tile 横排 */
.im-conversation-private-side__friend {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 16px 16px 14px;
}
.im-conversation-private-side__tile-wrap {
display: flex;
flex-direction: column;
align-items: center;
width: 66px;
}
.im-conversation-private-side__tile-wrap--clickable {
cursor: pointer;
}
.im-conversation-private-side__tile-label {
width: 100%;
margin-top: 6px;
overflow: hidden;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-regular);
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
/* "+" 加号 tile浅底 + 虚线边hover 走主色让交互可读,与 GroupSide 一致 */
.im-conversation-private-side__icon-tile {
display: flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
font-size: 20px;
color: var(--el-text-color-regular);
background-color: var(--el-fill-color-lighter);
border: 1px dashed var(--el-border-color);
border-radius: 6px;
transition:
color 0.18s,
border-color 0.18s,
background-color 0.18s;
}
.im-conversation-private-side__tile-wrap--clickable:hover .im-conversation-private-side__icon-tile {
color: var(--el-color-primary); color: var(--el-color-primary);
border-color: var(--el-color-primary); border-color: var(--el-color-primary);
background-color: var(--el-color-primary-light-9); background-color: var(--el-color-primary-light-9);
} }
/* el-icon 全局 color 在暗色模式下被主题盖过;:deep(svg) 锁 fill 到当前色 */
/* :deep 穿透 Icon 内部 svg el-icon 全局 color 在暗色模式下被主题盖过,锁 fill 到当前色 */
.im-conversation-private-side__icon-tile :deep(svg) { .im-conversation-private-side__icon-tile :deep(svg) {
fill: currentColor !important; fill: currentColor !important;
} }
/* section 间隔条 */ /* 相邻信息行加分隔线; 相邻兄弟选择器无法用工具类表达 */
.im-conversation-private-side__spacer {
flex-shrink: 0;
height: 10px;
}
/* 信息行:和 GroupSide 完全一致的尺寸 / hover避免两个抽屉切换时 jitter */
.im-conversation-private-side__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 16px;
font-size: 14px;
min-height: 24px;
transition: background-color 0.15s;
}
.im-conversation-private-side__row + .im-conversation-private-side__row { .im-conversation-private-side__row + .im-conversation-private-side__row {
border-top: 1px solid var(--el-border-color-lighter); border-top: 1px solid var(--el-border-color-lighter);
} }
.im-conversation-private-side__row--clickable {
cursor: pointer;
}
.im-conversation-private-side__row--clickable:hover {
background-color: var(--el-fill-color-lighter);
}
/* 备注行label 在上、value 在下,编辑场景才用 */
.im-conversation-private-side__row--vertical {
flex-direction: column;
align-items: stretch;
gap: 6px;
padding: 14px 16px;
}
.im-conversation-private-side__label {
flex-shrink: 0;
font-size: 14px;
color: var(--el-text-color-primary);
}
.im-conversation-private-side__value {
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-regular);
word-break: break-all;
}
.im-conversation-private-side__value--clamp {
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.im-conversation-private-side__value-placeholder {
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-placeholder);
}
.im-conversation-private-side__chevron {
color: var(--el-text-color-placeholder);
}
</style> </style>
<!-- GroupSideel-drawer append-to-body scoped CSS data-v 不会落到 body modal-class 作祖先选择器写一段全局规则 --> <!-- GroupSideel-drawer append-to-body scoped CSS data-v 不会落到 body modal-class 作祖先选择器写一段全局规则 -->

View File

@ -34,7 +34,7 @@
<div class="grid grid-cols-5 gap-2 p-3"> <div class="grid grid-cols-5 gap-2 p-3">
<!-- 上传入口固定放第一格dashed border 与表情格子区分视觉语义对齐 el-upload 观感 --> <!-- 上传入口固定放第一格dashed border 与表情格子区分视觉语义对齐 el-upload 观感 -->
<button <button
class="im-face-upload-btn aspect-square flex items-center justify-center rounded-md cursor-pointer transition-colors" class="aspect-square flex items-center justify-center rounded-md cursor-pointer transition-colors border border-dashed border-[var(--el-border-color)] bg-transparent text-[var(--el-text-color-placeholder)] hover:border-[var(--el-color-primary)] hover:text-[var(--el-color-primary)] disabled:cursor-not-allowed disabled:text-[var(--el-text-color-disabled)]"
type="button" type="button"
:disabled="uploading" :disabled="uploading"
:title="uploading ? '上传中…' : '上传图片到个人表情'" :title="uploading ? '上传中…' : '上传图片到个人表情'"
@ -115,12 +115,16 @@
<!-- 底部 tab [ emoji / 个人 / 系统包 1..N ]mode='emoji' 时隐藏 --> <!-- 底部 tab [ emoji / 个人 / 系统包 1..N ]mode='emoji' 时隐藏 -->
<div <div
v-if="isFullMode" v-if="isFullMode"
class="flex flex-shrink-0 items-center gap-1 px-2 py-1.5 border-t border-[var(--el-border-color-lighter)]" class="flex flex-shrink-0 items-center gap-1 px-2 py-1.5 border-t border-t-solid border-[var(--el-border-color-lighter)]"
> >
<el-tooltip content="Emoji 表情" placement="top" :show-after="300"> <el-tooltip content="Emoji 表情" placement="top" :show-after="300">
<button <button
class="im-face-tab" class="inline-flex items-center justify-center w-[30px] h-[30px] border-none rounded bg-transparent cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
:class="{ 'im-face-tab--active': activeTab === FACE_TAB.EMOJI }" :class="
activeTab === FACE_TAB.EMOJI
? 'bg-[var(--el-fill-color)] text-[var(--el-color-primary)]'
: 'text-[var(--el-text-color-regular)]'
"
type="button" type="button"
@click="activeTab = FACE_TAB.EMOJI" @click="activeTab = FACE_TAB.EMOJI"
> >
@ -129,8 +133,12 @@
</el-tooltip> </el-tooltip>
<el-tooltip content="个人表情" placement="top" :show-after="300"> <el-tooltip content="个人表情" placement="top" :show-after="300">
<button <button
class="im-face-tab" class="inline-flex items-center justify-center w-[30px] h-[30px] border-none rounded bg-transparent cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
:class="{ 'im-face-tab--active': activeTab === FACE_TAB.MINE }" :class="
activeTab === FACE_TAB.MINE
? 'bg-[var(--el-fill-color)] text-[var(--el-color-primary)]'
: 'text-[var(--el-text-color-regular)]'
"
type="button" type="button"
@click="activeTab = FACE_TAB.MINE" @click="activeTab = FACE_TAB.MINE"
> >
@ -145,8 +153,12 @@
:show-after="300" :show-after="300"
> >
<button <button
class="im-face-tab" class="inline-flex items-center justify-center w-[30px] h-[30px] border-none rounded bg-transparent cursor-pointer transition-colors hover:bg-[var(--el-fill-color)]"
:class="{ 'im-face-tab--active': activeTab === packTabKey(pack.id) }" :class="
activeTab === packTabKey(pack.id)
? 'bg-[var(--el-fill-color)] text-[var(--el-color-primary)]'
: 'text-[var(--el-text-color-regular)]'
"
type="button" type="button"
@click="activeTab = packTabKey(pack.id)" @click="activeTab = packTabKey(pack.id)"
> >
@ -339,42 +351,4 @@ onUnmounted(() => {
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08)); filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
} }
/* tab 按钮样式:被选中走主色高亮,鼠标悬停浅底 */
.im-face-tab {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 4px;
background-color: transparent;
color: var(--el-text-color-regular);
cursor: pointer;
transition:
background-color 0.15s,
color 0.15s;
}
.im-face-tab:hover {
background-color: var(--el-fill-color);
}
.im-face-tab--active {
background-color: var(--el-fill-color);
color: var(--el-color-primary);
}
/* 个人表情上传按钮dashed border 区分视觉语义,对齐 el-upload */
.im-face-upload-btn {
border: 1px dashed var(--el-border-color);
background-color: transparent;
color: var(--el-text-color-placeholder);
}
.im-face-upload-btn:hover {
border-color: var(--el-color-primary);
color: var(--el-color-primary);
}
.im-face-upload-btn:disabled {
cursor: not-allowed;
color: var(--el-text-color-disabled);
}
</style> </style>

View File

@ -7,20 +7,19 @@
<!-- 禁言 / 封禁覆盖层优先级 封禁 > 全群禁言 > 成员禁言 --> <!-- 禁言 / 封禁覆盖层优先级 封禁 > 全群禁言 > 成员禁言 -->
<div <div
v-if="muteOverlay" v-if="muteOverlay"
class="message-input__mute-overlay" class="absolute top-2 right-3 bottom-3 left-3 z-10 flex items-center justify-center gap-2 rounded-lg border border-solid text-sm"
:class="{ :class="
'message-input__mute-overlay--banned': muteOverlay.icon === 'ant-design:stop-outlined' muteOverlay.icon === 'ant-design:stop-outlined'
}" ? 'text-[var(--el-color-danger-dark-2)] bg-[var(--el-color-danger-light-9)] border-[var(--el-color-danger-light-5)]'
: 'text-[var(--el-color-warning-dark-2)] bg-[var(--el-color-warning-light-9)] border-[var(--el-color-warning-light-5)]'
"
> >
<Icon :icon="muteOverlay.icon" :size="18" /> <Icon :icon="muteOverlay.icon" :size="18" />
<span>{{ muteOverlay.text }}</span> <span>{{ muteOverlay.text }}</span>
</div> </div>
<!-- <!-- 内层白色圆角卡片 = editor + 工具栏border + rounded 模拟微信输入框边界 -->
内层白色圆角卡片 = editor + 工具栏border + rounded 模拟微信"输入框"边界
避免之前"无框 Web 输入"的散开感border scoped CSSUnoCSS 不带 border-style preflight
-->
<div <div
class="relative flex flex-col bg-[var(--el-bg-color)] rounded-lg border border-[var(--el-border-color-lighter)]" class="relative flex flex-col bg-[var(--el-bg-color)] rounded-lg border border-solid border-[var(--el-border-color-lighter)]"
> >
<!-- <!--
输入区在上contenteditable div取代 textarea对齐微信 PC输入区在上操作在下 输入区在上contenteditable div取代 textarea对齐微信 PC输入区在上操作在下
@ -54,10 +53,10 @@
底部工具栏左侧操作图标 + 右侧发送按钮对齐微信 PC操作图标统一放底部 底部工具栏左侧操作图标 + 右侧发送按钮对齐微信 PC操作图标统一放底部
- relative FacePicker 提供 absolute 锚点picker bottom-full 向上弹出 - relative FacePicker 提供 absolute 锚点picker bottom-full 向上弹出
- 图标统一 30×30 点击区18px icon + p-1.5gap-1 让间距贴合微信观感 - 图标统一 30×30 点击区18px icon + p-1.5gap-1 让间距贴合微信观感
- border-t 在编辑区与工具栏之间画一条与 card 边框同色的细线scoped CSS 避绕 UnoCSS preflight 缺失 - border-t 在编辑区与工具栏之间画一条与 card 边框同色的细线
--> -->
<div <div
class="relative flex items-center justify-between gap-2 px-3 py-2 border-t border-[var(--el-border-color-lighter)]" class="relative flex items-center justify-between gap-2 px-3 py-2 border-t border-t-solid border-[var(--el-border-color-lighter)]"
> >
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- <!--
@ -1117,9 +1116,8 @@ async function onVideoPicked(e: Event) {
</script> </script>
<style scoped> <style scoped>
/* el-icon .el-icon{color:var(--color,inherit); font-size:inherit; width:1em; height:1em} /* el-icon .el-icon{color:var(--color,inherit); font-size:inherit; width:1em; height:1em}
会盖过 UnoCSS 原子类用字面选择器 + !important 兜底 用字面选择器 + !important 锁死颜色取 Element Plus 主题变量暗色自动切到浅灰 */
颜色取 Element Plus 主题变量暗色自动切到浅灰 */
.message-input__tool, .message-input__tool,
.message-input__tool:deep(svg) { .message-input__tool:deep(svg) {
font-size: 18px !important; font-size: 18px !important;
@ -1143,26 +1141,4 @@ async function onVideoPicked(e: Event) {
.message-input__editor :deep(.mention-token) { .message-input__editor :deep(.mention-token) {
color: var(--el-color-primary); color: var(--el-color-primary);
} }
/* 禁言 / 封禁覆盖层:绝对定位在外层容器上,遮挡整个输入卡片 */
.message-input__mute-overlay {
position: absolute;
inset: 8px 12px 12px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 8px;
font-size: 14px;
color: var(--el-color-warning-dark-2);
background-color: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-5);
}
/* 封禁态:红底,区别于禁言的橙底 */
.message-input__mute-overlay--banned {
color: var(--el-color-danger-dark-2);
background-color: var(--el-color-danger-light-9);
border-color: var(--el-color-danger-light-5);
}
</style> </style>

View File

@ -1,7 +1,7 @@
<template> <template>
<div <div
v-if="multiSelect.state.active" v-if="multiSelect.state.active"
class="im-multi-select-bar flex items-center justify-center gap-12 px-5 w-full h-full border-t bg-[var(--el-bg-color)] border-[var(--el-border-color-lighter)]" class="flex items-center justify-center gap-12 px-5 w-full h-full border-t border-t-solid bg-[var(--el-bg-color)] border-[var(--el-border-color-lighter)]"
> >
<span <span
class="absolute left-5 top-1/2 -translate-y-1/2 text-12px text-[var(--el-text-color-secondary)]" class="absolute left-5 top-1/2 -translate-y-1/2 text-12px text-[var(--el-text-color-secondary)]"
@ -10,7 +10,7 @@
</span> </span>
<button <button
class="im-multi-select-bar__action" class="inline-flex flex-col items-center gap-1 px-3 py-1 text-12px rounded-md border-0 bg-transparent cursor-pointer transition-colors text-[var(--el-text-color-primary)] hover:text-[var(--el-color-primary)] hover:bg-[var(--el-fill-color)] disabled:text-[var(--el-text-color-disabled)] disabled:cursor-not-allowed disabled:bg-transparent"
:disabled="selectedCount === 0" :disabled="selectedCount === 0"
@click="handleForwardOneByOne" @click="handleForwardOneByOne"
> >
@ -19,7 +19,7 @@
</button> </button>
<button <button
class="im-multi-select-bar__action" class="inline-flex flex-col items-center gap-1 px-3 py-1 text-12px rounded-md border-0 bg-transparent cursor-pointer transition-colors text-[var(--el-text-color-primary)] hover:text-[var(--el-color-primary)] hover:bg-[var(--el-fill-color)] disabled:text-[var(--el-text-color-disabled)] disabled:cursor-not-allowed disabled:bg-transparent"
:disabled="selectedCount === 0" :disabled="selectedCount === 0"
@click="handleForwardMerged" @click="handleForwardMerged"
> >
@ -28,7 +28,7 @@
</button> </button>
<button <button
class="im-multi-select-bar__action im-multi-select-bar__action--danger" class="inline-flex flex-col items-center gap-1 px-3 py-1 text-12px rounded-md border-0 bg-transparent cursor-pointer transition-colors text-[var(--el-text-color-primary)] hover:bg-[var(--el-fill-color)] hover:text-[var(--el-color-danger)] disabled:text-[var(--el-text-color-disabled)] disabled:cursor-not-allowed disabled:bg-transparent"
:disabled="selectedCount === 0" :disabled="selectedCount === 0"
@click="handleDelete" @click="handleDelete"
> >
@ -37,7 +37,7 @@
</button> </button>
<button <button
class="im-multi-select-bar__close absolute right-5 top-1/2 -translate-y-1/2" class="absolute right-5 top-1/2 -translate-y-1/2 inline-flex items-center justify-center w-7 h-7 rounded-full border-0 bg-transparent cursor-pointer transition-colors text-[var(--el-text-color-secondary)] hover:text-[var(--el-text-color-primary)] hover:bg-[var(--el-fill-color)]"
@click="handleCancel" @click="handleCancel"
> >
<Icon icon="ant-design:close-outlined" :size="20" /> <Icon icon="ant-design:close-outlined" :size="20" />
@ -140,56 +140,3 @@ function handleCancel() {
} }
</script> </script>
<style scoped>
.im-multi-select-bar__action {
display: inline-flex;
flex-direction: column;
gap: 4px;
align-items: center;
padding: 4px 12px;
font-size: 12px;
color: var(--el-text-color-primary);
background: transparent;
border: 0;
border-radius: 6px;
cursor: pointer;
transition:
color 0.15s,
background 0.15s;
}
.im-multi-select-bar__action:hover:not(:disabled) {
color: var(--el-color-primary);
background: var(--el-fill-color);
}
.im-multi-select-bar__action:disabled {
color: var(--el-text-color-disabled);
cursor: not-allowed;
}
.im-multi-select-bar__action--danger:hover:not(:disabled) {
color: var(--el-color-danger);
}
.im-multi-select-bar__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: var(--el-text-color-secondary);
background: transparent;
border: 0;
border-radius: 50%;
cursor: pointer;
transition:
color 0.15s,
background 0.15s;
}
.im-multi-select-bar__close:hover {
color: var(--el-text-color-primary);
background: var(--el-fill-color);
}
</style>

View File

@ -265,7 +265,7 @@ onUnmounted(() => {
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08)); filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.08));
} }
/* 脉冲呼吸动画keyframes 在 UnoCSS 原子类里不好表达,保留 scoped */ /* 录音中的脉冲呼吸动画;@keyframes 必须 CSS 定义 */
.im-voice-recorder__pulse { .im-voice-recorder__pulse {
animation: im-voice-pulse 1s infinite; animation: im-voice-pulse 1s infinite;
} }

View File

@ -1,48 +1,55 @@
<template> <template>
<!-- 群聊置顶消息仅群聊 + 有置顶时显示悬挂在群聊头部下方左上角不占整行对齐微信 PC --> <!-- 群聊置顶消息仅群聊 + 有置顶时显示悬挂在群聊头部下方左上角不占整行对齐微信 PC -->
<div v-if="pinnedMessages.length > 0" class="im-group-pinned-message"> <div
v-if="pinnedMessages.length > 0"
class="im-group-pinned-message relative flex flex-shrink-0 flex-col items-start px-4 pt-1.5 pb-2 bg-[var(--el-fill-color-light)]"
>
<!-- 顶部胶囊单条点击跳转多条折叠点击展开多条展开点击折叠 --> <!-- 顶部胶囊单条点击跳转多条折叠点击展开多条展开点击折叠 -->
<div <div
class="im-group-pinned-message__row im-group-pinned-message__row--clickable" class="flex items-center gap-1.5 w-[360px] px-3 py-1.5 rounded-[10px] text-13px text-[var(--el-text-color-primary)] bg-[var(--el-bg-color)] shadow-[0_1px_2px_rgba(0,0,0,0.04)] cursor-pointer hover:bg-[var(--el-fill-color-lighter)]"
@click="handleTopClick" @click="handleTopClick"
> >
<Icon icon="ant-design:pushpin-outlined" :size="14" class="im-group-pinned-message__icon" /> <Icon icon="ant-design:pushpin-outlined" :size="14" class="flex-shrink-0 text-[var(--el-color-warning)]" />
<span class="im-group-pinned-message__sender">{{ getSenderName(latest) }}</span> <span class="flex-shrink-0 text-[var(--el-text-color-secondary)]">{{ getSenderName(latest) }}</span>
<span class="im-group-pinned-message__text">{{ getPreview(latest) }}</span> <span class="flex-1 min-w-0 truncate">{{ getPreview(latest) }}</span>
<!-- 单条移除按钮多条折叠 N 多条展开收起箭头 --> <!-- 单条移除按钮多条折叠 N 多条展开收起箭头 -->
<span <span
v-if="pinnedMessages.length === 1 && canManage" v-if="pinnedMessages.length === 1 && canManage"
v-loading="removingId === latest.id" v-loading="removingId === latest.id"
class="im-group-pinned-message__remove" class="flex-shrink-0 text-[var(--el-color-primary)] cursor-pointer text-13px hover:text-[var(--el-color-primary-light-3)]"
@click.stop="handleRemove(latest)" @click.stop="handleRemove(latest)"
> >
移除 移除
</span> </span>
<template v-else-if="pinnedMessages.length > 1"> <template v-else-if="pinnedMessages.length > 1">
<span class="im-group-pinned-message__count"> {{ pinnedMessages.length }} </span> <span class="flex-shrink-0 text-[var(--el-text-color-secondary)] text-12px"> {{ pinnedMessages.length }} </span>
<Icon <Icon
:icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'" :icon="expanded ? 'ant-design:up-outlined' : 'ant-design:down-outlined'"
:size="11" :size="11"
class="im-group-pinned-message__chevron" class="flex-shrink-0 text-[var(--el-text-color-placeholder)]"
/> />
</template> </template>
</div> </div>
<!-- 多条展开浅色面板包裹完整列表每条独立胶囊点击跳转到对应消息位置 --> <!-- 多条展开浅色面板包裹完整列表每条独立胶囊点击跳转到对应消息位置 -->
<div v-if="pinnedMessages.length > 1 && expanded" class="im-group-pinned-message__list"> <div
v-if="pinnedMessages.length > 1 && expanded"
class="im-group-pinned-message__list absolute top-full left-1.5 z-10 flex flex-col gap-2.5 w-[380px] p-3 rounded-xl bg-[var(--el-bg-color)] shadow-[0_6px_16px_rgba(0,0,0,0.12)]"
style="margin-top: -1px;"
>
<div <div
v-for="msg in pinnedMessages" v-for="msg in pinnedMessages"
:key="msg.id" :key="msg.id"
class="im-group-pinned-message__row im-group-pinned-message__row--list im-group-pinned-message__row--clickable" class="flex items-center gap-1.5 w-full px-3 py-1.5 rounded-[10px] text-13px text-[var(--el-text-color-primary)] bg-[var(--el-fill-color-light)] cursor-pointer hover:bg-[var(--el-bg-color)]"
@click="handleLocate(msg)" @click="handleLocate(msg)"
> >
<Icon icon="ant-design:pushpin-outlined" :size="14" class="im-group-pinned-message__icon" /> <Icon icon="ant-design:pushpin-outlined" :size="14" class="flex-shrink-0 text-[var(--el-color-warning)]" />
<span class="im-group-pinned-message__sender">{{ getSenderName(msg) }}</span> <span class="flex-shrink-0 text-[var(--el-text-color-secondary)]">{{ getSenderName(msg) }}</span>
<span class="im-group-pinned-message__text">{{ getPreview(msg) }}</span> <span class="flex-1 min-w-0 truncate">{{ getPreview(msg) }}</span>
<span <span
v-if="canManage" v-if="canManage"
v-loading="removingId === msg.id" v-loading="removingId === msg.id"
class="im-group-pinned-message__remove" class="flex-shrink-0 text-[var(--el-color-primary)] cursor-pointer text-13px hover:text-[var(--el-color-primary-light-3)]"
@click.stop="handleRemove(msg)" @click.stop="handleRemove(msg)"
> >
移除 移除
@ -140,35 +147,7 @@ async function handleRemove(msg: Message) {
</script> </script>
<style scoped> <style scoped>
/* 容器:左对齐悬浮在 header 下方不占整行relative 让展开列表绝对定位贴顶部胶囊下方 */ /* 弹出层朝上的三角箭头;走 ::before + 4 边 border 配色画,颜色跟弹出层 background 一致 */
.im-group-pinned-message {
position: relative;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 6px 16px 8px;
background-color: var(--el-fill-color-light);
}
/* + header
margin-top: -1px 让弹出层往上盖住 header bottom 1px 分隔线避免视觉上有横向空隙 */
.im-group-pinned-message__list {
position: absolute;
top: 100%;
margin-top: -1px;
left: 6px;
z-index: 10;
display: flex;
flex-direction: column;
gap: 10px;
width: 380px;
padding: 12px;
border-radius: 12px;
background-color: var(--el-bg-color);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
/* 弹出层箭头:朝上的三角,颜色跟弹出层 background 一致(白色),跟 header 浅灰强对比 */
.im-group-pinned-message__list::before { .im-group-pinned-message__list::before {
content: ''; content: '';
position: absolute; position: absolute;
@ -181,71 +160,4 @@ async function handleRemove(msg: Message) {
border-bottom: 8px solid var(--el-bg-color); border-bottom: 8px solid var(--el-bg-color);
filter: drop-shadow(0 -2px 1px rgba(0, 0, 0, 0.04)); filter: drop-shadow(0 -2px 1px rgba(0, 0, 0, 0.04));
} }
/* 胶囊基础样式:顶部固定 width弹出层里的胶囊撑满外框宽度 */
.im-group-pinned-message__row {
display: flex;
align-items: center;
gap: 6px;
width: 360px;
padding: 6px 12px;
background-color: var(--el-bg-color);
border-radius: 10px;
font-size: 13px;
color: var(--el-text-color-primary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.im-group-pinned-message__row--clickable {
cursor: pointer;
}
.im-group-pinned-message__row--clickable:hover {
background-color: var(--el-fill-color-lighter);
}
/* 列表里的胶囊撑满弹出层宽度浅灰背景跟弹出层白色区分hover 反相白色 */
.im-group-pinned-message__row--list {
width: 100%;
background-color: var(--el-fill-color-light);
box-shadow: none;
}
.im-group-pinned-message__row--list:hover {
background-color: var(--el-bg-color);
}
.im-group-pinned-message__icon {
flex-shrink: 0;
color: var(--el-color-warning);
}
.im-group-pinned-message__sender {
flex-shrink: 0;
color: var(--el-text-color-secondary);
}
.im-group-pinned-message__text {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.im-group-pinned-message__count {
flex-shrink: 0;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.im-group-pinned-message__chevron {
flex-shrink: 0;
color: var(--el-text-color-placeholder);
}
.im-group-pinned-message__remove {
flex-shrink: 0;
color: var(--el-color-primary);
cursor: pointer;
font-size: 13px;
}
.im-group-pinned-message__remove:hover {
color: var(--el-color-primary-light-3);
}
</style> </style>

View File

@ -5,18 +5,24 @@
- count groupRequestStore 派生全局存本端处理 / WS 通知到达后 store 自动更新 - count groupRequestStore 派生全局存本端处理 / WS 通知到达后 store 自动更新
- 点击横幅打开 GroupRequestListDialog含历史已处理记录不再就地展开 - 点击横幅打开 GroupRequestListDialog含历史已处理记录不再就地展开
--> -->
<div v-if="canManage && pendingCount > 0" class="im-group-request-pending"> <div
<div class="im-group-request-pending__row" @click="handleOpen"> v-if="canManage && pendingCount > 0"
class="flex flex-shrink-0 flex-col items-start px-4 pt-1.5 pb-2 bg-[var(--el-fill-color-light)]"
>
<div
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-13px text-[var(--el-text-color-primary)] bg-[var(--el-bg-color)] cursor-pointer shadow-[0_1px_2px_rgba(0,0,0,0.04)] transition-colors hover:bg-[var(--el-fill-color-lighter)]"
@click="handleOpen"
>
<Icon <Icon
icon="ant-design:user-add-outlined" icon="ant-design:user-add-outlined"
:size="14" :size="14"
class="im-group-request-pending__icon" class="im-group-request-pending__icon flex-shrink-0 text-[var(--el-color-success)]"
/> />
<span class="im-group-request-pending__text"> 新进群申请{{ pendingCount }} </span> <span class="flex-1 min-w-0 truncate"> 新进群申请{{ pendingCount }} </span>
<Icon <Icon
icon="ant-design:right-outlined" icon="ant-design:right-outlined"
:size="11" :size="11"
class="im-group-request-pending__chevron" class="flex-shrink-0 text-[var(--el-text-color-placeholder)]"
/> />
</div> </div>
@ -75,53 +81,8 @@ const pendingCount = computed(() => groupRequestStore.getUnhandledCountByGroupId
</script> </script>
<style scoped> <style scoped>
/* 容器align-items flex-start 让胶囊靠左、不占整行;高度由内容撑开,与置顶消息横幅节奏对齐 */ /* :deep 穿透 Icon 子组件 DOM强制 svg 走 currentColor 应对暗色模式 el-icon 全局色覆盖 */
.im-group-request-pending {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 6px 16px 8px;
background-color: var(--el-fill-color-light);
}
/* 胶囊本体内容自适应宽度padding / 圆角 / 阴影对齐 GroupPinnedMessage 的 __row */
.im-group-request-pending__row {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background-color: var(--el-bg-color);
border-radius: 10px;
font-size: 13px;
color: var(--el-text-color-primary);
cursor: pointer;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
transition: background-color 0.15s;
}
.im-group-request-pending__row:hover {
background-color: var(--el-fill-color-lighter);
}
/* 绿色「加好友」icon与置顶消息黄色 pushpin 同节奏仅换色调svg 强制 currentColor 应对暗色覆盖 */
.im-group-request-pending__icon {
flex-shrink: 0;
color: var(--el-color-success);
}
.im-group-request-pending__icon :deep(svg) { .im-group-request-pending__icon :deep(svg) {
fill: currentColor !important; fill: currentColor !important;
} }
.im-group-request-pending__text {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.im-group-request-pending__chevron {
flex-shrink: 0;
color: var(--el-text-color-placeholder);
}
</style> </style>

View File

@ -2,30 +2,47 @@
<!-- 公众号会话内大卡片对齐微信公众号单图文卡封面 9:5 + 下方白底加粗标题条 --> <!-- 公众号会话内大卡片对齐微信公众号单图文卡封面 9:5 + 下方白底加粗标题条 -->
<div <div
v-if="isChannelView" v-if="isChannelView"
class="material-card channel-card cursor-pointer" class="material-card cursor-pointer w-full overflow-hidden rounded-lg bg-[var(--el-bg-color)] border border-solid border-[var(--el-border-color-lighter)]"
@click="onClick" @click="onClick"
> >
<img v-if="payload.coverUrl" class="channel-cover" :src="payload.coverUrl" /> <img v-if="payload.coverUrl" class="block w-full h-[200px] object-cover" :src="payload.coverUrl" />
<div class="channel-title">{{ payload.title || '(无标题)' }}</div> <div class="px-3.5 py-3 text-15px font-600 leading-[1.4] text-[var(--el-text-color-primary)] line-clamp-2">
{{ payload.title || '(无标题)' }}
</div>
</div> </div>
<!-- 私聊 / 群聊里被转发的素材紧凑卡片标题 + 摘要在左小封面在右底部频道头像 + 名称对齐微信公众号转发卡 --> <!-- 私聊 / 群聊里被转发的素材紧凑卡片标题 + 摘要在左小封面在右底部频道头像 + 名称对齐微信公众号转发卡 -->
<div v-else class="material-card forward-card cursor-pointer" @click="onClick"> <div
<div class="forward-body"> v-else
<div class="forward-text"> class="material-card cursor-pointer flex flex-col w-[260px] px-3.5 pt-3 pb-2.5 rounded-lg bg-[var(--el-bg-color)] border border-solid border-[var(--el-border-color-lighter)]"
<div class="forward-title">{{ payload.title || '(无标题)' }}</div> @click="onClick"
<div v-if="payload.summary" class="forward-summary">{{ payload.summary }}</div> >
<div class="flex gap-2.5 items-start">
<div class="flex flex-1 flex-col gap-1.5 min-w-0">
<div class="text-15px font-600 leading-[1.4] text-[var(--el-text-color-primary)] line-clamp-2 break-all">
{{ payload.title || '(无标题)' }}
</div>
<div
v-if="payload.summary"
class="text-12px leading-[1.5] text-[var(--el-text-color-secondary)] line-clamp-2 break-all"
>
{{ payload.summary }}
</div>
</div> </div>
<img v-if="payload.coverUrl" class="forward-cover" :src="payload.coverUrl" /> <img
v-if="payload.coverUrl"
class="flex-shrink-0 w-[60px] h-[60px] object-cover rounded bg-[var(--el-fill-color-light)]"
:src="payload.coverUrl"
/>
</div> </div>
<div class="forward-footer"> <div class="flex items-center gap-1.5 mt-2.5 pt-2 border-t border-t-solid border-[var(--el-border-color-lighter)] text-12px text-[var(--el-text-color-secondary)]">
<img <img
v-if="sourceChannel?.avatar" v-if="sourceChannel?.avatar"
class="forward-channel-avatar" class="w-4 h-4 rounded-full object-cover flex-shrink-0"
:src="sourceChannel.avatar" :src="sourceChannel.avatar"
/> />
<Icon v-else icon="ep:promotion" :size="14" /> <Icon v-else icon="ep:promotion" :size="14" />
<span class="forward-channel-name">{{ sourceChannel?.name || '频道消息' }}</span> <span class="truncate">{{ sourceChannel?.name || '频道消息' }}</span>
</div> </div>
</div> </div>
@ -36,10 +53,21 @@
fullscreen fullscreen
destroy-on-close destroy-on-close
> >
<div v-loading="detailLoading" class="material-detail-body"> <div v-loading="detailLoading" class="material-detail-body max-w-[720px] mx-auto px-5 pt-6 pb-20">
<div class="article-title">{{ payload.title || '' }}</div> <div class="text-[22px] font-600 leading-[1.4] text-[var(--el-text-color-primary)] mb-5">
<div v-if="detailHtml" class="article-content" v-dompurify-html="detailHtml"></div> {{ payload.title || '' }}
<div v-else-if="!detailLoading" class="article-empty">暂无正文</div> </div>
<div
v-if="detailHtml"
class="article-content text-15px leading-[1.75] text-[var(--el-text-color-primary)]"
v-dompurify-html="detailHtml"
></div>
<div
v-else-if="!detailLoading"
class="text-center text-[var(--el-text-color-secondary)] mt-20"
>
暂无正文
</div>
</div> </div>
</Dialog> </Dialog>
</template> </template>
@ -104,7 +132,7 @@ const onClick = async () => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/* hover 阴影 + transition 用 SCSS 写更紧凑unocss 写成 hover: 一行还行,但 transition 缓动还得带类,反而散 */ /* hover 阴影 + transition:合写一处,写法更紧凑 */
.material-card { .material-card {
transition: box-shadow 0.15s ease; transition: box-shadow 0.15s ease;
@ -113,156 +141,22 @@ const onClick = async () => {
} }
} }
/* 公众号大卡片:封面 9:5 + 下方加粗标题条;纯 SCSS 写避免 unocss 偶发 arbitrary value 漏生成 */ /* :deep 穿透 v-dompurify-html 渲染的内嵌 DOM统一控制富文本里的 img / p / hN 排版 */
.channel-card { .article-content {
width: 100%; :deep(img) {
background: var(--el-bg-color); max-width: 100%;
border: 1px solid var(--el-border-color-lighter); height: auto;
border-radius: 8px;
overflow: hidden;
.channel-cover {
display: block;
width: 100%;
height: 200px;
object-fit: cover;
} }
.channel-title { :deep(p) {
padding: 12px 14px; margin: 12px 0;
font-size: 15px; }
:deep(h1),
:deep(h2),
:deep(h3) {
margin: 20px 0 12px;
font-weight: 600; font-weight: 600;
line-height: 1.4;
color: var(--el-text-color-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
/* 私聊 / 群聊转发卡片:标题 + 摘要左、封面右、底部频道头像 + 名称 */
.forward-card {
display: flex;
flex-direction: column;
width: 260px;
padding: 12px 14px 10px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
.forward-body {
display: flex;
gap: 10px;
align-items: flex-start;
.forward-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.forward-title {
font-size: 15px;
font-weight: 600;
line-height: 1.4;
color: var(--el-text-color-primary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.forward-summary {
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
.forward-cover {
flex-shrink: 0;
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
background: var(--el-fill-color-light);
}
}
.forward-footer {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
.forward-channel-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.forward-channel-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
/* 富文本详情article-content 内置 img / p / hN 用 :deep 全局生效unocss 无法穿透 scoped 边界 */
.material-detail-body {
max-width: 720px;
margin: 0 auto;
padding: 24px 20px 80px;
.article-title {
font-size: 22px;
font-weight: 600;
line-height: 1.4;
color: var(--el-text-color-primary);
margin-bottom: 20px;
}
.article-content {
font-size: 15px;
line-height: 1.75;
color: var(--el-text-color-primary);
:deep(img) {
max-width: 100%;
height: auto;
}
:deep(p) {
margin: 12px 0;
}
:deep(h1),
:deep(h2),
:deep(h3) {
margin: 20px 0 12px;
font-weight: 600;
}
}
.article-empty {
text-align: center;
color: var(--el-text-color-secondary);
margin-top: 80px;
} }
} }
</style> </style>

View File

@ -28,7 +28,7 @@
<!-- 文件文件名 + 大小靠左彩色大图标贴右上传中插一条进度条 --> <!-- 文件文件名 + 大小靠左彩色大图标贴右上传中插一条进度条 -->
<div <div
v-else-if="isFile && filePayload" v-else-if="isFile && filePayload"
class="relative flex gap-3 items-center min-w-[260px] max-w-[340px] px-3.5 py-3 border rounded transition-colors" class="relative flex gap-3 items-center min-w-[260px] max-w-[340px] px-3.5 py-3 border border-solid rounded transition-colors"
:class="[bubbleClass('file'), isUploading ? 'cursor-default' : 'cursor-pointer']" :class="[bubbleClass('file'), isUploading ? 'cursor-default' : 'cursor-pointer']"
@click="handleFileClick" @click="handleFileClick"
> >
@ -120,7 +120,7 @@
<!-- 合并转发气泡title + 摘要预览 + 底部聊天记录标签 --> <!-- 合并转发气泡title + 摘要预览 + 底部聊天记录标签 -->
<div <div
v-else-if="isMerge && mergePayload" v-else-if="isMerge && mergePayload"
class="flex flex-col w-[260px] rounded-md overflow-hidden cursor-pointer bg-[var(--el-bg-color)] border border-[var(--el-border-color)] hover:border-[#409eff]" class="flex flex-col w-[260px] rounded-md overflow-hidden cursor-pointer bg-[var(--el-bg-color)] border border-solid border-[var(--el-border-color)] hover:border-[#409eff]"
@click="emit('open-merge', content)" @click="emit('open-merge', content)"
> >
<div class="px-3 py-2 text-sm font-medium text-[var(--el-text-color-primary)] truncate"> <div class="px-3 py-2 text-sm font-medium text-[var(--el-text-color-primary)] truncate">
@ -136,7 +136,7 @@
</div> </div>
</div> </div>
<div <div
class="px-3 py-1 text-12px border-t text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]" class="px-3 py-1 text-12px border-t border-t-solid text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
> >
聊天记录 聊天记录
</div> </div>
@ -350,8 +350,8 @@ onBeforeUnmount(() => {
</script> </script>
<style scoped> <style scoped>
/* /* ::before + 4 border 3 + 1
border 4 边色画三角透明 3 + 实色 1 省一张图片颜色与气泡背景对应 1px 视觉吃进去 */ 颜色与气泡背景对应 1px 视觉吃进去省一张图片 */
.message-bubble--other::before, .message-bubble--other::before,
.message-bubble--self::before { .message-bubble--self::before {
content: ''; content: '';
@ -372,7 +372,7 @@ onBeforeUnmount(() => {
border-color: transparent transparent transparent #95ec69; border-color: transparent transparent transparent #95ec69;
} }
/* el-icon 在暗色模式下全局 color 被 .el-icon{color:var(--color)} 干扰,把 voice 图标 fill 锁死 */ /* :deep 穿透 scoped 子组件 DOMel-icon 在暗色模式下全局 color 被 .el-icon{color:var(--color)} 干扰,把 voice 图标 fill 锁死 */
.message-bubble__voice-icon :deep(svg) { .message-bubble__voice-icon :deep(svg) {
fill: #606266 !important; fill: #606266 !important;
} }
@ -380,7 +380,7 @@ onBeforeUnmount(() => {
fill: #409eff !important; fill: #409eff !important;
} }
/* 播放中的脉冲动画 */ /* @keyframes 需要 SCSS 声明;播放中的脉冲动画 */
.im-voice-playing { .im-voice-playing {
animation: im-voice-icon-pulse 0.8s infinite; animation: im-voice-icon-pulse 0.8s infinite;
} }

View File

@ -42,10 +42,9 @@
/> />
</div> </div>
<!-- Tab 文件 / 图片 / 语音 / 日期(popover) / 群成员(popover, 仅群聊) <!-- Tab 文件 / 图片 / 语音 / 日期(popover) / 群成员(popover, 仅群聊)底部一条分割线把 tab 区跟消息列表分开对齐微信观感 -->
底部一条分割线把 tab 区跟消息列表分开对齐微信观感border scoped CSS 走主题变量 -->
<div <div
class="im-message-history__tabs flex gap-5 px-2 pb-2 text-sm flex-shrink-0 text-[#1989fa]" class="flex gap-5 px-2 pb-2 text-sm flex-shrink-0 text-[#1989fa] border-b border-b-solid border-[var(--el-border-color-lighter)]"
> >
<span <span
class="im-message-history__tab cursor-pointer" class="im-message-history__tab cursor-pointer"
@ -144,7 +143,7 @@
跟主聊天面板里 MessageItem 的渲染语义对齐 --> 跟主聊天面板里 MessageItem 的渲染语义对齐 -->
<div <div
v-if="isFriendChatTip(message.type)" v-if="isFriendChatTip(message.type)"
class="px-4 py-3 text-12px text-center italic text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]" class="px-4 py-3 text-12px text-center italic text-[var(--el-text-color-secondary)] border-b border-b-solid border-[var(--el-border-color-lighter)]"
> >
<TipSegments :segments="resolveFriendNotificationSegments(message)" /> <TipSegments :segments="resolveFriendNotificationSegments(message)" />
</div> </div>
@ -152,7 +151,7 @@
<!-- 群广播事件跟好友事件同灰色样式mention 段挂点击弹 UserInfoCard --> <!-- 群广播事件跟好友事件同灰色样式mention 段挂点击弹 UserInfoCard -->
<div <div
v-else-if="isGroupNotification(message.type)" v-else-if="isGroupNotification(message.type)"
class="px-4 py-3 text-12px text-center italic text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]" class="px-4 py-3 text-12px text-center italic text-[var(--el-text-color-secondary)] border-b border-b-solid border-[var(--el-border-color-lighter)]"
> >
<TipSegments :segments="resolveGroupNotificationSegments(message, resolveGroupMemberName(message))" /> <TipSegments :segments="resolveGroupNotificationSegments(message, resolveGroupMemberName(message))" />
</div> </div>
@ -160,7 +159,7 @@
<!-- 普通消息行 --> <!-- 普通消息行 -->
<div <div
v-else v-else
class="im-message-history__row flex gap-3 items-start px-1 py-3 border-b border-[var(--el-border-color-lighter)]" class="im-message-history__row flex gap-3 items-start px-1 py-3 border-b border-b-solid border-[var(--el-border-color-lighter)]"
> >
<UserAvatar <UserAvatar
:url="getAvatar(message)" :url="getAvatar(message)"
@ -210,7 +209,10 @@
</div> </div>
</template> </template>
<div v-if="currentList.length === 0" class="im-message-history__empty"> <div
v-if="currentList.length === 0"
class="py-10 text-center text-13px text-[var(--el-text-color-disabled)]"
>
{{ keyword || activeFilter ? '没有匹配的消息' : '暂无消息' }} {{ keyword || activeFilter ? '没有匹配的消息' : '暂无消息' }}
</div> </div>
@ -218,7 +220,7 @@
filter 命中 0 条时仍保留 加载更早可能带回匹配内容 --> filter 命中 0 条时仍保留 加载更早可能带回匹配内容 -->
<div <div
v-if="hasMore && allMessages.length > 0" v-if="hasMore && allMessages.length > 0"
class="py-3 text-center border-t border-[var(--el-border-color-lighter)]" class="py-3 text-center border-t border-t-solid border-[var(--el-border-color-lighter)]"
> >
<el-button :loading="loadingMore" link type="primary" @click="loadEarlier"> <el-button :loading="loadingMore" link type="primary" @click="loadEarlier">
加载更早消息 加载更早消息
@ -669,24 +671,14 @@ function locateMessage(messageId: number) {
</script> </script>
<style scoped> <style scoped>
/* 空态文案 */ /* :deep 穿透 el-tag 子组件 DOM搜索区 chip 禁掉 hover 颜色过渡 / × 图标动效,避免在搜索区里有抖动感 */
.im-message-history__empty {
padding: 40px 0;
text-align: center;
font-size: 13px;
color: var(--el-text-color-disabled);
}
/* 搜索区 chip禁掉 el-tag 默认的 hover 颜色过渡 / × 图标动效,避免在搜索区里有抖动感 */
.im-message-history__chip, .im-message-history__chip,
.im-message-history__chip :deep(.el-tag__close) { .im-message-history__chip :deep(.el-tag__close) {
transition: none !important; transition: none !important;
animation: none !important; animation: none !important;
} }
/* "" /* 「定位到聊天位置」按钮:父行 hover 才显示,走 .parent:hover .child 跨元素状态联动 */
- absolute 定位浮在时间下方不参与右侧栏 flex 排版隐藏时不占位 "我"和内容之间不留空隙
- 默认 display:none hover block颜色对齐 tab 同款蓝按钮自身 hover 再深一档 */
.im-message-history__locate { .im-message-history__locate {
position: absolute; position: absolute;
top: 100%; top: 100%;
@ -705,13 +697,7 @@ function locateMessage(messageId: number) {
color: #146fc7; color: #146fc7;
} }
/* tab 线 tab UnoCSS border-[var(--*)] /* ::after 伪元素画激活下划线tab 默认蓝色行内文字,激活态加下划线 */
直接用 scoped CSS 引主题变量更稳 */
.im-message-history__tabs {
border-bottom: 1px solid var(--el-border-color-lighter);
}
/* tab 默认 -> 蓝色行内文字;激活态加下划线 */
.im-message-history__tab { .im-message-history__tab {
position: relative; position: relative;
padding: 4px 2px; padding: 4px 2px;
@ -732,7 +718,7 @@ function locateMessage(messageId: number) {
border-radius: 1px; border-radius: 1px;
} }
/* el-calendar 默认偏大压一压让它能塞进 320 popover */ /* :deep 穿透 el-calendar 子组件 DOM默认偏大压一压让它能塞进 320 popover */
.im-message-history__calendar :deep(.el-calendar) { .im-message-history__calendar :deep(.el-calendar) {
--el-calendar-cell-width: 36px; --el-calendar-cell-width: 36px;
} }

View File

@ -81,7 +81,7 @@
:class=" :class="
isMessageChecked isMessageChecked
? 'bg-[#07c160]' ? 'bg-[#07c160]'
: 'border border-[var(--el-border-color)] bg-[var(--el-bg-color)]' : 'border border-solid border-[var(--el-border-color)] bg-[var(--el-bg-color)]'
" "
> >
<Icon v-if="isMessageChecked" icon="ant-design:check-outlined" :size="12" color="#fff" /> <Icon v-if="isMessageChecked" icon="ant-design:check-outlined" :size="12" color="#fff" />
@ -1049,7 +1049,7 @@ function handleDelete() {
</script> </script>
<style scoped> <style scoped>
/* SENDING 状态的转圈动画 */ /* @keyframes 需要 SCSS 声明;SENDING 状态的转圈动画 */
.im-loading-spin { .im-loading-spin {
animation: im-loading-spin 1s linear infinite; animation: im-loading-spin 1s linear infinite;
} }

View File

@ -1,8 +1,8 @@
<template> <template>
<div class="flex flex-1 flex-col min-w-0 bg-[var(--el-fill-color-light)]"> <div class="flex flex-1 flex-col min-w-0 bg-[var(--el-fill-color-light)]">
<template v-if="conversationStore.activeConversation"> <template v-if="conversationStore.activeConversation">
<!-- 顶部 header第一行群名 + 右侧图标第二行嵌入置顶气泡仅群聊 + 有置顶border scoped CSS --> <!-- 顶部 header第一行群名 + 右侧图标第二行嵌入置顶气泡仅群聊 + 有置顶 -->
<div class="message-panel__header flex flex-col bg-[var(--el-fill-color-light)]"> <div class="flex flex-shrink-0 flex-col bg-[var(--el-fill-color-light)] border-b border-b-solid border-[var(--el-border-color-light)]">
<div class="flex items-center justify-between h-14 px-5"> <div class="flex items-center justify-between h-14 px-5">
<span class="flex flex-col min-w-0"> <span class="flex flex-col min-w-0">
<span class="flex items-baseline gap-1.5 min-w-0"> <span class="flex items-baseline gap-1.5 min-w-0">
@ -52,16 +52,16 @@
class="message-panel__header-icon cursor-pointer" class="message-panel__header-icon cursor-pointer"
/> />
</template> </template>
<div class="message-panel__call-menu"> <div class="flex flex-col gap-0.5">
<div <div
class="message-panel__call-menu-item" class="flex items-center gap-2.5 px-3 py-2 rounded cursor-pointer text-sm text-[var(--el-text-color-primary)] hover:bg-[var(--el-fill-color-light)]"
@click="startPrivateCall(ImRtcCallMediaType.VOICE)" @click="startPrivateCall(ImRtcCallMediaType.VOICE)"
> >
<Icon icon="ant-design:phone-outlined" :size="16" /> <Icon icon="ant-design:phone-outlined" :size="16" />
<span>语音通话</span> <span>语音通话</span>
</div> </div>
<div <div
class="message-panel__call-menu-item" class="flex items-center gap-2.5 px-3 py-2 rounded cursor-pointer text-sm text-[var(--el-text-color-primary)] hover:bg-[var(--el-fill-color-light)]"
@click="startPrivateCall(ImRtcCallMediaType.VIDEO)" @click="startPrivateCall(ImRtcCallMediaType.VIDEO)"
> >
<Icon icon="ant-design:video-camera-outlined" :size="16" /> <Icon icon="ant-design:video-camera-outlined" :size="16" />
@ -107,9 +107,15 @@
:group-id="conversationStore.activeConversation.targetId" :group-id="conversationStore.activeConversation.targetId"
/> />
<!-- 私聊对方不再是有效好友我删了对方 / 从未加过单边设计下被对方删除本端 friendStore 不更新故不会触发胶囊嵌在 header 跟群置顶同级点击弹 UserInfoCard --> <!-- 私聊对方不再是有效好友我删了对方 / 从未加过单边设计下被对方删除本端 friendStore 不更新故不会触发胶囊嵌在 header 跟群置顶同级点击弹 UserInfoCard -->
<div v-if="showNotFriendBanner" class="message-panel__not-friend-container"> <div
<div class="message-panel__not-friend" @click="handleNotFriendClick"> v-if="showNotFriendBanner"
<span class="message-panel__not-friend-icon"> class="flex flex-shrink-0 items-start px-4 pb-2 bg-[var(--el-fill-color-light)]"
>
<div
class="inline-flex items-center gap-2 px-2.5 py-1 rounded-full text-13px cursor-pointer text-[var(--el-text-color-primary)] bg-[var(--el-color-warning-light-9)] transition-colors hover:bg-[var(--el-color-warning-light-8)]"
@click="handleNotFriendClick"
>
<span class="inline-flex items-center justify-center w-4 h-4 rounded-full text-white bg-[var(--el-color-warning)] flex-shrink-0">
<Icon icon="ant-design:user-outlined" :size="11" /> <Icon icon="ant-design:user-outlined" :size="11" />
</span> </span>
<span>对方还不是你的朋友</span> <span>对方还不是你的朋友</span>
@ -700,14 +706,8 @@ watch(
</script> </script>
<style scoped> <style scoped>
/* 顶部分隔线UnoCSS 不带 border-style preflightclass 写法只设色 / 宽不出线,走 scoped 显式 shorthand */ /* :deep 穿 el-icon svgel-icon .el-icon{color:var(--color,inherit)}
.message-panel__header { :deep + !important 锁色颜色取 Element Plus 主题变量暗色模式自动切到更亮的灰 */
flex-shrink: 0;
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, .message-panel__header-icon,
.message-panel__header-icon :deep(svg) { .message-panel__header-icon :deep(svg) {
color: var(--el-text-color-regular) !important; color: var(--el-text-color-regular) !important;
@ -719,44 +719,7 @@ watch(
color: var(--el-color-primary) !important; color: var(--el-color-primary) !important;
} }
/* 「对方还不是你的朋友」胶囊:嵌在 header 内跟群置顶同级不占整行padding 跟群置顶 padding 对齐 */ /* sticky + translate 居中fit-content 宽度不会撑满transform 水平 -50% 偏移;同时 transition opacity 和 transform 两个属性 */
.message-panel__not-friend-container {
flex-shrink: 0;
display: flex;
align-items: flex-start;
padding: 0 16px 8px;
background-color: var(--el-fill-color-light);
}
.message-panel__not-friend {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 4px 10px;
border-radius: 999px;
font-size: 13px;
cursor: pointer;
color: var(--el-text-color-primary);
background-color: var(--el-color-warning-light-9);
transition: background-color 0.15s;
}
.message-panel__not-friend:hover {
background-color: var(--el-color-warning-light-8);
}
/* 圆形小图标,深黄底色配白色 icon —— 跟微信一致的视觉锤 */
.message-panel__not-friend-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
color: #fff;
background-color: var(--el-color-warning);
flex-shrink: 0;
}
/* sticky + translate fit-content transform -50%
UnoCSS 表达 transform+transition value 不太方便这里用最小的 scoped CSS 承接 */
.message-panel__jump-bottom { .message-panel__jump-bottom {
transform: translateX(-50%); transform: translateX(-50%);
transition: transition:
@ -764,7 +727,7 @@ watch(
transform 0.2s; transform 0.2s;
} }
/* MessageHistory "定位" 跳过来时短暂高亮1.6s 后由 JS 移除 class配合 transition 缓出黄底 */ /* JS 切换 highlight class + background-color transition 联动MessageHistory「定位」跳过来时短暂高亮 1.6s */
.message-panel__message-anchor { .message-panel__message-anchor {
transition: background-color 0.6s ease; transition: background-color 0.6s ease;
} }
@ -772,7 +735,7 @@ watch(
background-color: var(--el-color-warning-light-9); background-color: var(--el-color-warning-light-9);
} }
/* 回到底部按钮的 Vue transition 钩子类名 */ /* Vue <transition> 钩子类名固定,必须 SCSS 声明;回到底部按钮淡入淡出 */
.message-panel__jump-fade-enter-active, .message-panel__jump-fade-enter-active,
.message-panel__jump-fade-leave-active { .message-panel__jump-fade-leave-active {
transition: transition:
@ -785,27 +748,6 @@ watch(
opacity: 0; opacity: 0;
transform: translate(-50%, 20px); transform: translate(-50%, 20px);
} }
.message-panel__call-menu {
display: flex;
flex-direction: column;
gap: 2px;
}
.message-panel__call-menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: var(--el-text-color-primary);
}
.message-panel__call-menu-item:hover {
background-color: var(--el-fill-color-light);
}
</style> </style>
<style> <style>

View File

@ -12,9 +12,9 @@
- 撤回命中本地缓存且 type === RECALL原消息已撤回斜体灰字 - 撤回命中本地缓存且 type === RECALL原消息已撤回斜体灰字
--> -->
<div <div
class="im-reply-preview flex w-fit gap-1.5 items-center min-w-0 py-0.5 text-12px text-[var(--el-text-color-secondary)] rounded transition-colors" class="flex w-fit gap-1.5 items-center min-w-0 py-0.5 text-12px text-[var(--el-text-color-secondary)] rounded transition-colors"
:class="[ :class="[
mirrored ? 'im-reply-preview--end pl-1 pr-2' : 'pl-2 pr-1', mirrored ? 'pl-1 pr-2 border-r-2 border-r-solid border-r-[var(--el-border-color)]' : 'pl-2 pr-1 border-l-2 border-l-solid border-l-[var(--el-border-color)]',
{ {
'cursor-pointer hover:text-[var(--el-text-color-primary)]': clickable && !isRecalled, 'cursor-pointer hover:text-[var(--el-text-color-primary)]': clickable && !isRecalled,
'hover:bg-[var(--el-fill-color-light)]': (clickable && !isRecalled) || closable 'hover:bg-[var(--el-fill-color-light)]': (clickable && !isRecalled) || closable
@ -28,7 +28,7 @@
<span v-if="isRecalled" class="italic"></span> <span v-if="isRecalled" class="italic"></span>
<!-- 文本 --> <!-- 文本 -->
<span v-else-if="isText" class="im-reply-preview__text min-w-0">{{ textPreview }}</span> <span v-else-if="isText" class="min-w-0 line-clamp-2 break-words">{{ textPreview }}</span>
<!-- 文件icon + 文件名 + 大小 --> <!-- 文件icon + 文件名 + 大小 -->
<template v-else-if="isFile"> <template v-else-if="isFile">
@ -38,7 +38,7 @@
:size="14" :size="14"
class="flex-shrink-0" class="flex-shrink-0"
/> />
<span v-if="parsedPayload?.name" class="im-reply-preview__text min-w-0"> <span v-if="parsedPayload?.name" class="min-w-0 line-clamp-2 break-words">
{{ parsedPayload.name }} {{ parsedPayload.name }}
</span> </span>
<span <span
@ -58,12 +58,12 @@
</template> </template>
<!-- 名片 --> <!-- 名片 -->
<CardLineLabel v-else-if="isCard" :card="parsedPayload" class="im-reply-preview__text min-w-0" /> <CardLineLabel v-else-if="isCard" :card="parsedPayload" class="min-w-0 line-clamp-2 break-words" />
<!-- 表情贴图缩略图 + name name 仅显示 [表情] --> <!-- 表情贴图缩略图 + name name 仅显示 [表情] -->
<template v-else-if="isFace"> <template v-else-if="isFace">
<span class="flex-shrink-0">[表情]</span> <span class="flex-shrink-0">[表情]</span>
<span v-if="parsedPayload?.name" class="im-reply-preview__text min-w-0"> <span v-if="parsedPayload?.name" class="min-w-0 line-clamp-2 break-words">
{{ parsedPayload.name }} {{ parsedPayload.name }}
</span> </span>
</template> </template>
@ -71,7 +71,7 @@
<!-- 频道素材[频道] + 标题 + 封面缩略图 --> <!-- 频道素材[频道] + 标题 + 封面缩略图 -->
<template v-else-if="isMaterial"> <template v-else-if="isMaterial">
<span class="flex-shrink-0">[频道]</span> <span class="flex-shrink-0">[频道]</span>
<span v-if="parsedPayload?.title" class="im-reply-preview__text min-w-0"> <span v-if="parsedPayload?.title" class="min-w-0 line-clamp-2 break-words">
{{ parsedPayload.title }} {{ parsedPayload.title }}
</span> </span>
</template> </template>
@ -88,7 +88,7 @@
<button <button
v-if="closable" v-if="closable"
type="button" type="button"
class="im-reply-preview__close flex-shrink-0 inline-flex items-center justify-center w-4 h-4 cursor-pointer rounded-full bg-transparent border-none text-[var(--el-text-color-secondary)] hover:bg-[var(--el-fill-color)] hover:text-[var(--el-text-color-primary)]" class="flex-shrink-0 inline-flex items-center justify-center w-4 h-4 cursor-pointer rounded-full bg-transparent border-none text-[var(--el-text-color-secondary)] hover:bg-[var(--el-fill-color)] hover:text-[var(--el-text-color-primary)]"
@click.stop="emit('close')" @click.stop="emit('close')"
> >
<Icon icon="ant-design:close-outlined" :size="10" /> <Icon icon="ant-design:close-outlined" :size="10" />
@ -228,22 +228,3 @@ function onClick() {
} }
</script> </script>
<style scoped>
/* 默认左侧 2px 竖线作为「引用」视觉标识mirrored 时镜像到右侧 */
.im-reply-preview {
border-left: 2px solid var(--el-border-color);
}
.im-reply-preview--end {
border-left: 0;
border-right: 2px solid var(--el-border-color);
}
/* 文字超过 2 行截断,避免长引用把输入条 / 气泡撑高UnoCSS 的 line-clamp 工具类在本项目未启用,走 scoped CSS */
.im-reply-preview__text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
</style>

View File

@ -44,7 +44,7 @@
<!-- 合并模式预览[聊天记录] 标题 + 摘要列表预览卡 --> <!-- 合并模式预览[聊天记录] 标题 + 摘要列表预览卡 -->
<div <div
v-if="state.mode === ImForwardMode.MERGE && mergePreview" v-if="state.mode === ImForwardMode.MERGE && mergePreview"
class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]" class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--el-bg-color)] border border-solid border-[var(--el-border-color-lighter)]"
> >
<div <div
class="px-3 py-2 text-sm font-medium truncate text-[var(--el-text-color-primary)]" class="px-3 py-2 text-sm font-medium truncate text-[var(--el-text-color-primary)]"
@ -61,7 +61,7 @@
</div> </div>
</div> </div>
<div <div
class="px-3 py-1 text-12px border-t text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]" class="px-3 py-1 text-12px border-t border-t-solid text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
> >
聊天记录 聊天记录
</div> </div>
@ -70,7 +70,7 @@
<!-- 逐条模式预览消息数 + 首条摘要 --> <!-- 逐条模式预览消息数 + 首条摘要 -->
<div <div
v-else-if="state.mode === ImForwardMode.SINGLE && singlePreviewLines.length > 0" v-else-if="state.mode === ImForwardMode.SINGLE && singlePreviewLines.length > 0"
class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--el-bg-color)] border border-[var(--el-border-color-lighter)]" class="flex flex-col w-full overflow-hidden rounded-md bg-[var(--el-bg-color)] border border-solid border-[var(--el-border-color-lighter)]"
> >
<div class="flex flex-col px-3 py-2 gap-0.5"> <div class="flex flex-col px-3 py-2 gap-0.5">
<div <div
@ -82,7 +82,7 @@
</div> </div>
</div> </div>
<div <div
class="px-3 py-1 text-12px border-t text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]" class="px-3 py-1 text-12px border-t border-t-solid text-[var(--el-text-color-placeholder)] border-[var(--el-border-color-lighter)] bg-[var(--el-fill-color-lighter)]"
> >
{{ state.messages.length }} 条消息 {{ state.messages.length }} 条消息
</div> </div>
@ -429,6 +429,7 @@ async function handleCreateGroupAndSend() {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/* 复用选择类弹窗的公共 mixin 内部是 :deep 穿透 el-dialog 内部 header / body 的样式,无法替换为工具类 */
@use '@/views/im/home/components/picker/picker-dialog' as picker; @use '@/views/im/home/components/picker/picker-dialog' as picker;
.im-picker-dialog { .im-picker-dialog {

View File

@ -21,7 +21,7 @@
<div v-if="currentPayload" class="flex flex-col h-[480px]"> <div v-if="currentPayload" class="flex flex-col h-[480px]">
<div <div
class="px-4 py-2 text-12px text-[var(--el-text-color-secondary)] border-b border-[var(--el-border-color-lighter)]" class="px-4 py-2 text-12px text-[var(--el-text-color-secondary)] border-b border-b-solid border-[var(--el-border-color-lighter)]"
> >
以下是 {{ currentPayload.messages.length }} 条消息 以下是 {{ currentPayload.messages.length }} 条消息
</div> </div>
@ -29,7 +29,7 @@
<div <div
v-for="(item, idx) in currentPayload.messages" v-for="(item, idx) in currentPayload.messages"
:key="idx" :key="idx"
class="flex gap-3 items-start px-4 py-3 border-b border-[var(--el-border-color-lighter)]" class="flex gap-3 items-start px-4 py-3 border-b border-b-solid border-[var(--el-border-color-lighter)]"
> >
<UserAvatar <UserAvatar
:url="item.senderAvatar" :url="item.senderAvatar"
@ -117,6 +117,7 @@ function handleClose() {
</script> </script>
<style scoped> <style scoped>
/* :deep 穿透 el-dialog 内部 body去掉默认内边距让滚动区贴边 */
.im-merge-detail-dialog :deep(.el-dialog__body) { .im-merge-detail-dialog :deep(.el-dialog__body) {
padding: 0; padding: 0;
} }

View File

@ -5,7 +5,7 @@
<ResizableAside :default-width="260" :storage-key="StorageKeys.asideWidth"> <ResizableAside :default-width="260" :storage-key="StorageKeys.asideWidth">
<!-- 顶部搜索框 + "+" 号下拉对齐微信 PC发起群聊 / 添加朋友h-14 与右侧 MessagePanel 头部对齐 --> <!-- 顶部搜索框 + "+" 号下拉对齐微信 PC发起群聊 / 添加朋友h-14 与右侧 MessagePanel 头部对齐 -->
<div <div
class="flex flex-shrink-0 gap-2 items-center h-14 px-4 border-b border-[var(--el-border-color-lighter)]" class="flex flex-shrink-0 gap-2 items-center h-14 px-4 border-b border-b-solid border-[var(--el-border-color-lighter)]"
> >
<el-input v-model="keyword" placeholder="搜索" clearable class="flex-1"> <el-input v-model="keyword" placeholder="搜索" clearable class="flex-1">
<template #prefix> <template #prefix>
@ -51,7 +51,7 @@
<!-- 折叠头放在置顶区底部对齐 WeChat mac展开 / 折叠态共用仅在还有"可折叠"内容或当前已展开时出现 --> <!-- 折叠头放在置顶区底部对齐 WeChat mac展开 / 折叠态共用仅在还有"可折叠"内容或当前已展开时出现 -->
<div <div
v-if="pinnedGroups.foldable.length > 0 || pinnedExpanded" v-if="pinnedGroups.foldable.length > 0 || pinnedExpanded"
class="flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors text-13px text-[var(--el-text-color-regular)] border-y border-[var(--el-border-color-lighter)] hover:bg-[var(--el-fill-color)]" class="flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors text-13px text-[var(--el-text-color-regular)] border-y border-y-solid border-[var(--el-border-color-lighter)] hover:bg-[var(--el-fill-color)]"
@click="togglePinnedExpanded" @click="togglePinnedExpanded"
> >
<span class="flex items-center gap-1.5"> <span class="flex items-center gap-1.5">

View File

@ -154,7 +154,7 @@ const handleSelected = (rows: ManagerGroupApi.ImManagerGroupVO[]) => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* :deep 用于穿透 el-input 内部元素的 cursor 样式UnoCSS 无法直接处理组件内部 DOM */ /* :deep 穿透 el-input 内部 wrapper / inner 元素改 cursor */
.is-select-clickable { .is-select-clickable {
:deep(.el-input__wrapper), :deep(.el-input__wrapper),
:deep(.el-input__inner) { :deep(.el-input__inner) {

View File

@ -281,7 +281,7 @@ defineExpose({ open })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* 隐藏 radio 的 label 文字,只保留圆圈 */ /* :deep 穿透 el-radio 内部 label 元素,让单选只渲染圆圈不渲染文字 */
.radio-no-label { .radio-no-label {
:deep(.el-radio__label) { :deep(.el-radio__label) {
display: none; display: none;

View File

@ -39,7 +39,7 @@
/> />
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="原始 JSON" :span="2"> <el-descriptions-item label="原始 JSON" :span="2">
<pre class="content-pre">{{ formatJson(detail.content) }}</pre> <pre class="m-0 whitespace-pre-wrap break-all font-mono text-12px bg-[#f5f5f5] p-8px rounded-4px">{{ formatJson(detail.content) }}</pre>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-dialog> </el-dialog>
@ -68,16 +68,3 @@ const open = (row: ManagerGroupMessageApi.ImManagerGroupMessageVO) => {
} }
defineExpose({ open }) // open defineExpose({ open }) // open
</script> </script>
<style scoped>
.content-pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-family: 'Menlo', 'Consolas', monospace;
font-size: 12px;
background: #f5f5f5;
padding: 8px;
border-radius: 4px;
}
</style>

View File

@ -28,7 +28,7 @@
/> />
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="原始 JSON" :span="2"> <el-descriptions-item label="原始 JSON" :span="2">
<pre class="content-pre">{{ formatJson(detail.content) }}</pre> <pre class="m-0 whitespace-pre-wrap break-all font-mono text-12px bg-[#f5f5f5] p-8px rounded-4px">{{ formatJson(detail.content) }}</pre>
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-dialog> </el-dialog>
@ -56,16 +56,3 @@ const open = (row: ManagerPrivateMessageApi.ImManagerPrivateMessageVO) => {
} }
defineExpose({ open }) // open defineExpose({ open }) // open
</script> </script>
<style scoped>
.content-pre {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-family: 'Menlo', 'Consolas', monospace;
font-size: 12px;
background: #f5f5f5;
padding: 8px;
border-radius: 4px;
}
</style>

View File

@ -1,5 +1,5 @@
<template> <template>
<el-card shadow="never" class="chart-card"> <el-card shadow="never" class="!rounded-8px mb-16px">
<template #header>群规模分布</template> <template #header>群规模分布</template>
<div ref="chartRef" v-loading="loading" style="width: 100%; height: 320px"></div> <div ref="chartRef" v-loading="loading" style="width: 100%; height: 320px"></div>
</el-card> </el-card>
@ -53,10 +53,3 @@ onMounted(async () => {
}) })
onUnmounted(() => chart?.dispose()) onUnmounted(() => chart?.dispose())
</script> </script>
<style scoped>
.chart-card {
border-radius: 8px;
margin-bottom: 16px;
}
</style>

View File

@ -1,7 +1,7 @@
<template> <template>
<el-card shadow="never" class="chart-card"> <el-card shadow="never" class="!rounded-8px mb-16px">
<template #header> <template #header>
<div class="chart-header"> <div class="flex justify-between items-center">
<span>消息趋势私聊 + 群聊</span> <span>消息趋势私聊 + 群聊</span>
<el-select v-model="days" @change="loadData" style="width: 100px" size="small"> <el-select v-model="days" @change="loadData" style="width: 100px" size="small">
<el-option label="近 7 天" :value="7" /> <el-option label="近 7 天" :value="7" />
@ -84,8 +84,3 @@ onMounted(async () => {
}) })
onUnmounted(() => chart?.dispose()) onUnmounted(() => chart?.dispose())
</script> </script>
<style scoped>
.chart-card { border-radius: 8px; margin-bottom: 16px; }
.chart-header { display: flex; justify-content: space-between; align-items: center; }
</style>

View File

@ -1,5 +1,5 @@
<template> <template>
<el-card shadow="never" class="chart-card"> <el-card shadow="never" class="!rounded-8px mb-16px">
<template #header>消息类型分布</template> <template #header>消息类型分布</template>
<div ref="chartRef" v-loading="loading" style="width: 100%; height: 320px"></div> <div ref="chartRef" v-loading="loading" style="width: 100%; height: 320px"></div>
</el-card> </el-card>
@ -58,10 +58,3 @@ onMounted(async () => {
}) })
onUnmounted(() => chart?.dispose()) onUnmounted(() => chart?.dispose())
</script> </script>
<style scoped>
.chart-card {
border-radius: 8px;
margin-bottom: 16px;
}
</style>

View File

@ -1,18 +1,21 @@
<template> <template>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col v-for="card in cards" :key="card.title" :xl="6" :lg="6" :md="12" :sm="24" :xs="24"> <el-col v-for="card in cards" :key="card.title" :xl="6" :lg="6" :md="12" :sm="24" :xs="24">
<el-card shadow="never" class="kpi-card"> <el-card shadow="never" class="!rounded-8px mb-16px">
<div class="kpi-row"> <div class="flex items-center">
<div class="kpi-icon" :style="{ backgroundColor: card.color }"> <div
class="w-48px h-48px rounded-8px flex items-center justify-center mr-12px flex-shrink-0"
:style="{ backgroundColor: card.color }"
>
<Icon :icon="card.icon" :size="24" color="#fff" /> <Icon :icon="card.icon" :size="24" color="#fff" />
</div> </div>
<div class="kpi-body"> <div class="flex-1 min-w-0">
<div class="kpi-title">{{ card.title }}</div> <div class="text-13px text-[var(--el-text-color-secondary)] mb-4px">{{ card.title }}</div>
<div class="kpi-value"> <div class="text-22px font-600 text-[var(--el-text-color-primary)] leading-none">
<CountTo :start-val="0" :end-val="card.value" :duration="1500" /> <CountTo :start-val="0" :end-val="card.value" :duration="1500" />
<span v-if="card.suffix" class="kpi-suffix">{{ card.suffix }}</span> <span v-if="card.suffix" class="text-12px text-[var(--el-text-color-placeholder)] ml-6px font-normal">{{ card.suffix }}</span>
</div> </div>
<div class="kpi-meta">{{ card.metaLabel }} <div class="text-12px text-[var(--el-text-color-placeholder)] mt-6px">{{ card.metaLabel }}
<span :class="card.metaClass">{{ card.metaValue }}</span> <span :class="card.metaClass">{{ card.metaValue }}</span>
</div> </div>
</div> </div>
@ -85,50 +88,3 @@ const cards = computed(() => {
] ]
}) })
</script> </script>
<style scoped>
.kpi-card {
border-radius: 8px;
margin-bottom: 16px;
}
.kpi-row {
display: flex;
align-items: center;
}
.kpi-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
flex-shrink: 0;
}
.kpi-body {
flex: 1;
min-width: 0;
}
.kpi-title {
font-size: 13px;
color: #8c8c8c;
margin-bottom: 4px;
}
.kpi-value {
font-size: 22px;
font-weight: 600;
color: #262626;
line-height: 1;
}
.kpi-suffix {
font-size: 12px;
color: #909399;
margin-left: 6px;
font-weight: normal;
}
.kpi-meta {
font-size: 12px;
color: #909399;
margin-top: 6px;
}
</style>

View File

@ -1,5 +1,5 @@
<template> <template>
<el-card shadow="never" class="chart-card"> <el-card shadow="never" class="!rounded-8px mb-16px">
<template #header>消息发送 TOP 10</template> <template #header>消息发送 TOP 10</template>
<div ref="chartRef" v-loading="loading" style="width: 100%; height: 320px"></div> <div ref="chartRef" v-loading="loading" style="width: 100%; height: 320px"></div>
</el-card> </el-card>
@ -58,10 +58,3 @@ onMounted(async () => {
}) })
onUnmounted(() => chart?.dispose()) onUnmounted(() => chart?.dispose())
</script> </script>
<style scoped>
.chart-card {
border-radius: 8px;
margin-bottom: 16px;
}
</style>

View File

@ -1,7 +1,7 @@
<template> <template>
<el-card shadow="never" class="chart-card"> <el-card shadow="never" class="!rounded-8px mb-16px">
<template #header> <template #header>
<div class="chart-header"> <div class="flex justify-between items-center">
<span>用户趋势新增注册 + 日活</span> <span>用户趋势新增注册 + 日活</span>
<el-select v-model="days" @change="loadData" style="width: 100px" size="small"> <el-select v-model="days" @change="loadData" style="width: 100px" size="small">
<el-option label="近 7 天" :value="7" /> <el-option label="近 7 天" :value="7" />
@ -63,8 +63,3 @@ onMounted(async () => {
}) })
onUnmounted(() => chart?.dispose()) onUnmounted(() => chart?.dispose())
</script> </script>
<style scoped>
.chart-card { border-radius: 8px; margin-bottom: 16px; }
.chart-header { display: flex; justify-content: space-between; align-items: center; }
</style>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="dashboard"> <div class="p-16px">
<!-- 概览卡片 --> <!-- 概览卡片 -->
<OverviewCards v-if="overview" :overview="overview" /> <OverviewCards v-if="overview" :overview="overview" />
@ -46,9 +46,3 @@ onMounted(async () => {
overview.value = await StatisticsApi.getStatisticsOverview() overview.value = await StatisticsApi.getStatisticsOverview()
}) })
</script> </script>
<style scoped>
.dashboard {
padding: 16px;
}
</style>