Merge from vben

This commit is contained in:
xingyu4j
2026-06-26 16:02:30 +08:00
56 changed files with 3937 additions and 3743 deletions

View File

@@ -1,59 +1,59 @@
<script lang="ts" setup>
import type { ImGroupRequestApi } from '#/api/im/group/request'
import type { ImGroupRequestApi } from '#/api/im/group/request';
import { computed, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue';
import { prompt } from '@vben/common-ui'
import { prompt } from '@vben/common-ui';
import { ElDialog, ElEmpty, ElInput, ElMessage } from 'element-plus'
import { ElDialog, ElEmpty, ElInput, ElMessage } from 'element-plus';
import { getGroupRequestListByGroupId } from '#/api/im/group/request'
import { ImGroupRequestHandleResult } from '#/views/im/utils/constants'
import { getGroupRequestListByGroupId } from '#/api/im/group/request';
import { ImGroupRequestHandleResult } from '#/views/im/utils/constants';
import { useGroupRequestStore } from '../../store/groupRequestStore'
import { UserAvatar } from '../user'
import { useGroupRequestStore } from '../../store/groupRequestStore';
import { UserAvatar } from '../user';
defineOptions({ name: 'ImGroupRequestListDialog' })
defineOptions({ name: 'ImGroupRequestListDialog' });
const groupRequestStore = useGroupRequestStore()
const groupRequestStore = useGroupRequestStore();
const visible = ref(false)
const groupId = ref<number | undefined>() // 当前展示的群编号undefined 时走全局未处理列表store.unhandledList
const loading = ref(false)
const groupList = ref<ImGroupRequestApi.GroupRequestRespVO[]>([])
const actingId = ref<null | number>(null)
const visible = ref(false);
const groupId = ref<number | undefined>(); // 当前展示的群编号undefined 时走全局未处理列表store.unhandledList
const loading = ref(false);
const groupList = ref<ImGroupRequestApi.GroupRequestRespVO[]>([]);
const actingId = ref<null | number>(null);
defineExpose({
/** 打开进群申请弹窗reset → 灌参 → visible=true不传 groupId 走全局未处理列表 */
open(opts?: { groupId?: number }) {
groupId.value = opts?.groupId
actingId.value = null
visible.value = true
}
})
groupId.value = opts?.groupId;
actingId.value = null;
visible.value = true;
},
});
/** 数据源:单群模式用 fetch 回来的 groupList全局模式直接读 store.unhandledList处理后 store 自动 reactive 同步 */
const list = computed<ImGroupRequestApi.GroupRequestRespVO[]>(() =>
groupId.value ? groupList.value : groupRequestStore.unhandledList
)
groupId.value ? groupList.value : groupRequestStore.unhandledList,
);
/** 顶部卡片:最新一条;空数组时为 null */
const latest = computed(() => list.value[0] || null)
const latest = computed(() => list.value[0] || null);
/** 历史列表:除最新一条外的其余 */
const histories = computed(() => list.value.slice(1))
const histories = computed(() => list.value.slice(1));
/** 打开 dialog 时拉数据:单群拉 API全局直接读 store关闭时清掉单群缓存 */
watch(
[visible, groupId],
([isVisible, currentGroupId]) => {
if (isVisible && currentGroupId) {
void fetchList(currentGroupId)
void fetchList(currentGroupId);
} else if (!isVisible) {
groupList.value = []
groupList.value = [];
}
},
{ immediate: true }
)
{ immediate: true },
);
/**
* 单群模式下订阅 store 中归属本群的未处理列表变化远端事件WS 1503 新申请 / 其他管理员处理)触发时 refetch
@@ -68,89 +68,92 @@ watch(
.filter((request) => request.groupId === groupId.value)
.map(
(request) =>
`${request.id}:${request.inviterUserId ?? ''}:${request.applyContent ?? ''}`
`${request.id}:${request.inviterUserId ?? ''}:${request.applyContent ?? ''}`,
)
.join(',')
: null,
(current, previous) => {
if (current === null || previous === undefined || current === previous) {
return
return;
}
if (actingId.value !== null) {
return
return;
}
if (groupId.value) {
void fetchList(groupId.value)
void fetchList(groupId.value);
}
}
)
},
);
let fetchSeq = 0 // 单调递增请求序号;同群也会因为 WS 1503 推送触发额外 fetch乱序返回时旧响应不能覆盖新数据
let fetchSeq = 0; // 单调递增请求序号;同群也会因为 WS 1503 推送触发额外 fetch乱序返回时旧响应不能覆盖新数据
async function fetchList(targetGroupId: number) {
const seq = ++fetchSeq
loading.value = true
const seq = ++fetchSeq;
loading.value = true;
try {
const data = (await getGroupRequestListByGroupId(targetGroupId)) || []
const data = (await getGroupRequestListByGroupId(targetGroupId)) || [];
// 期间切群 / 关弹窗 / 又触发更新 fetch丢响应
if (seq !== fetchSeq || !visible.value || groupId.value !== targetGroupId) {
return
return;
}
groupList.value = data
groupList.value = data;
} finally {
// 旧请求 finally 命中时新请求仍在跑,跳过避免提前关 loading
if (seq === fetchSeq) {
loading.value = false
loading.value = false;
}
}
}
/** 同意:走 store 同步全局未处理列表 + 本地更新 handleResult 让按钮变灰 */
async function handleAgree(item: ImGroupRequestApi.GroupRequestRespVO) {
if (actingId.value !== null) return
actingId.value = item.id
if (actingId.value !== null) return;
actingId.value = item.id;
try {
await groupRequestStore.agreeGroupRequest(item.id)
updateLocalResult(item.id, ImGroupRequestHandleResult.AGREED)
ElMessage.success('已同意')
await groupRequestStore.agreeGroupRequest(item.id);
updateLocalResult(item.id, ImGroupRequestHandleResult.AGREED);
ElMessage.success('已同意');
} finally {
actingId.value = null
actingId.value = null;
}
}
/** 拒绝:弹理由输入框;为空则不带 handleContent */
async function handleRefuse(item: ImGroupRequestApi.GroupRequestRespVO) {
if (actingId.value !== null) return
let handleContent: string
if (actingId.value !== null) return;
let handleContent: string;
try {
const result = await prompt<string>({
component: ElInput,
componentProps: {
clearable: true,
placeholder: '请输入拒绝理由(可选)'
placeholder: '请输入拒绝理由(可选)',
},
content: '',
modelPropName: 'value',
title: '拒绝申请'
})
handleContent = result || ''
title: '拒绝申请',
});
handleContent = result || '';
} catch {
return
return;
}
actingId.value = item.id
actingId.value = item.id;
try {
await groupRequestStore.refuseGroupRequest(item.id, handleContent || undefined)
updateLocalResult(item.id, ImGroupRequestHandleResult.REFUSED)
ElMessage.success('已拒绝')
await groupRequestStore.refuseGroupRequest(
item.id,
handleContent || undefined,
);
updateLocalResult(item.id, ImGroupRequestHandleResult.REFUSED);
ElMessage.success('已拒绝');
} finally {
actingId.value = null
actingId.value = null;
}
}
/** 单群模式下处理后更新 groupList 里的 handleResult按钮转「已同意 / 已拒绝」灰态;全局模式 store 直接移除该项无需更新 */
function updateLocalResult(id: number, handleResult: number) {
const target = groupList.value.find((r) => r.id === id)
const target = groupList.value.find((r) => r.id === id);
if (target) {
target.handleResult = handleResult
target.handleResult = handleResult;
}
}
</script>
@@ -166,92 +169,98 @@ function updateLocalResult(id: number, handleResult: number) {
v-model="visible"
title="进群申请"
width="560px"
:close-on-click-modal="false"
class="im-group-request-list__dialog"
>
<div v-loading="loading" class="w-full">
<div class="flex flex-col gap-3 max-h-[60vh] overflow-y-auto pr-1">
<!-- 空态 -->
<ElEmpty v-if="!loading && list.length === 0" description="暂无进群申请" />
<ElEmpty
v-if="!loading && list.length === 0"
description="暂无进群申请"
/>
<!-- 顶部卡片最新一条 -->
<!-- 顶部卡片最新一条 -->
<div
v-if="latest"
class="flex flex-col gap-2.5 p-3.5 rounded-[10px] border border-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-bg-container)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]"
>
<div class="flex items-center gap-3">
<UserAvatar
:url="latest.userAvatar"
:name="latest.userNickname"
:size="44"
:clickable="false"
/>
<div class="flex-1 min-w-0">
<div
class="truncate text-sm font-medium leading-[1.4] text-[var(--ant-color-text)]"
>
{{ latest.userNickname || `用户 ${latest.userId}` }}
<div class="flex items-center gap-3">
<UserAvatar
:url="latest.userAvatar"
:name="latest.userNickname"
:size="44"
:clickable="false"
/>
<div class="flex-1 min-w-0">
<div
class="truncate text-sm font-medium leading-[1.4] text-[var(--ant-color-text)]"
>
{{ latest.userNickname || `用户 ${latest.userId}` }}
</div>
<div
class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]"
>
<template v-if="latest.inviterUserId">
通过
<span class="text-[var(--ant-color-primary)]">
{{
latest.inviterNickname || `用户 ${latest.inviterUserId}`
}}
</span>
的邀请进群
</template>
<template v-else>申请加入</template>
</div>
</div>
<div
class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]"
<span
v-if="latest.handleResult === ImGroupRequestHandleResult.AGREED"
class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
>
<template v-if="latest.inviterUserId">
通过
<span class="text-[var(--ant-color-primary)]">
{{ latest.inviterNickname || `用户 ${latest.inviterUserId}` }}
</span>
的邀请进群
</template>
<template v-else>申请加入</template>
已同意
</span>
<span
v-else-if="
latest.handleResult === ImGroupRequestHandleResult.REFUSED
"
class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
>
已拒绝
</span>
<div v-else class="flex gap-1.5 flex-shrink-0">
<button
class="im-group-request-list__btn im-group-request-list__btn--primary"
:disabled="actingId === latest.id"
@click="handleAgree(latest)"
>
确认
</button>
<button
class="im-group-request-list__btn im-group-request-list__btn--ghost"
:disabled="actingId === latest.id"
@click="handleRefuse(latest)"
>
拒绝
</button>
</div>
</div>
<span
v-if="latest.handleResult === ImGroupRequestHandleResult.AGREED"
class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
<!-- 申请理由:邀请场景显示邀请人 + 留言;主动申请显示申请人 + 留言 -->
<div
v-if="latest.applyContent"
class="px-3 py-2 rounded-md text-[13px] leading-[1.5] break-all bg-[var(--ant-color-fill-secondary)] text-[var(--ant-color-text)]"
>
已同意
</span>
<span
v-else-if="latest.handleResult === ImGroupRequestHandleResult.REFUSED"
class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
>
已拒绝
</span>
<div v-else class="flex gap-1.5 flex-shrink-0">
<button
class="im-group-request-list__btn im-group-request-list__btn--primary"
:disabled="actingId === latest.id"
@click="handleAgree(latest)"
>
确认
</button>
<button
class="im-group-request-list__btn im-group-request-list__btn--ghost"
:disabled="actingId === latest.id"
@click="handleRefuse(latest)"
>
拒绝
</button>
<span class="text-[var(--ant-color-primary)]">
{{
latest.inviterUserId
? latest.inviterNickname || `用户 ${latest.inviterUserId}`
: latest.userNickname || `用户 ${latest.userId}`
}}
</span>
{{ latest.applyContent }}
</div>
</div>
<!-- 申请理由邀请场景显示邀请人 + 留言主动申请显示申请人 + 留言 -->
<div
v-if="latest.applyContent"
class="px-3 py-2 rounded-md text-[13px] leading-[1.5] break-all bg-[var(--ant-color-fill-secondary)] text-[var(--ant-color-text)]"
>
<span class="text-[var(--ant-color-primary)]">
{{
latest.inviterUserId
? latest.inviterNickname || `用户 ${latest.inviterUserId}`
: latest.userNickname || `用户 ${latest.userId}`
}}
</span>
{{ latest.applyContent }}
</div>
</div>
<!-- 分割线仅在有更早申请时出现 -->
<!-- 分割线:仅在有更早申请时出现 -->
<div
v-if="histories.length > 0"
class="flex items-center justify-center mt-1.5 -mb-0.5 text-12px text-[var(--ant-color-text-placeholder)]"
@@ -265,61 +274,63 @@ function updateLocalResult(id: number, handleResult: number) {
:key="item.id"
class="flex flex-col gap-2.5 px-3.5 py-2.5 rounded-[10px] border border-solid border-[var(--ant-color-border-secondary)] bg-[var(--ant-color-bg-container)] shadow-[0_1px_3px_rgba(0,0,0,0.04)]"
>
<div class="flex items-center gap-3">
<UserAvatar
:url="item.userAvatar"
:name="item.userNickname"
:size="40"
:clickable="false"
/>
<div class="flex-1 min-w-0">
<div
class="truncate text-sm font-medium leading-[1.4] text-[var(--ant-color-text)]"
>
{{ item.userNickname || `用户 ${item.userId}` }}
<div class="flex items-center gap-3">
<UserAvatar
:url="item.userAvatar"
:name="item.userNickname"
:size="40"
:clickable="false"
/>
<div class="flex-1 min-w-0">
<div
class="truncate text-sm font-medium leading-[1.4] text-[var(--ant-color-text)]"
>
{{ item.userNickname || `用户 ${item.userId}` }}
</div>
<div
class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]"
>
<template v-if="item.inviterUserId">
通过
<span class="text-[var(--ant-color-primary)]">
{{ item.inviterNickname || `用户 ${item.inviterUserId}` }}
</span>
的邀请进群
</template>
<template v-else>申请加入</template>
</div>
</div>
<div
class="truncate mt-[2px] text-12px leading-[1.5] text-[var(--ant-color-text-secondary)]"
<span
v-if="item.handleResult === ImGroupRequestHandleResult.AGREED"
class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
>
<template v-if="item.inviterUserId">
通过
<span class="text-[var(--ant-color-primary)]">
{{ item.inviterNickname || `用户 ${item.inviterUserId}` }}
</span>
的邀请进群
</template>
<template v-else>申请加入</template>
已同意
</span>
<span
v-else-if="
item.handleResult === ImGroupRequestHandleResult.REFUSED
"
class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
>
已拒绝
</span>
<div v-else class="flex gap-1.5 flex-shrink-0">
<button
class="im-group-request-list__btn im-group-request-list__btn--primary"
:disabled="actingId === item.id"
@click="handleAgree(item)"
>
确认
</button>
<button
class="im-group-request-list__btn im-group-request-list__btn--ghost"
:disabled="actingId === item.id"
@click="handleRefuse(item)"
>
拒绝
</button>
</div>
</div>
<span
v-if="item.handleResult === ImGroupRequestHandleResult.AGREED"
class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
>
已同意
</span>
<span
v-else-if="item.handleResult === ImGroupRequestHandleResult.REFUSED"
class="flex-shrink-0 text-[13px] text-[var(--ant-color-text-placeholder)]"
>
已拒绝
</span>
<div v-else class="flex gap-1.5 flex-shrink-0">
<button
class="im-group-request-list__btn im-group-request-list__btn--primary"
:disabled="actingId === item.id"
@click="handleAgree(item)"
>
确认
</button>
<button
class="im-group-request-list__btn im-group-request-list__btn--ghost"
:disabled="actingId === item.id"
@click="handleRefuse(item)"
>
拒绝
</button>
</div>
</div>
</div>
</div>
</div>
@@ -334,17 +345,18 @@ function updateLocalResult(id: number, handleResult: number) {
height: 28px;
padding: 0 12px;
font-size: 13px;
border-radius: 4px;
cursor: pointer;
border: 1px solid transparent;
border-radius: 4px;
transition:
background-color 0.15s,
border-color 0.15s,
color 0.15s;
}
.im-group-request-list__btn:disabled {
opacity: 0.6;
cursor: not-allowed;
opacity: 0.6;
}
.im-group-request-list__btn--primary {
@@ -352,6 +364,7 @@ function updateLocalResult(id: number, handleResult: number) {
background-color: var(--ant-color-primary);
border-color: var(--ant-color-primary);
}
.im-group-request-list__btn--primary:hover:not(:disabled) {
background-color: var(--ant-color-primary-hover);
border-color: var(--ant-color-primary-hover);
@@ -362,6 +375,7 @@ function updateLocalResult(id: number, handleResult: number) {
background-color: var(--ant-color-bg-container);
border-color: var(--ant-color-border);
}
.im-group-request-list__btn--ghost:hover:not(:disabled) {
color: var(--ant-color-primary);
border-color: var(--ant-color-primary);

View File

@@ -1,5 +1,7 @@
// IM 选择类弹窗的公共样式 mixin
// 每个业务壳在自己的 <style scoped lang="scss"> 内 @use + @include 即可,避免全局污染
/**
* IM 选择类弹窗的公共样式 mixin
* 每个业务壳在自己的 <style scoped lang="scss"> 内 @use + @include 即可,避免全局污染
*/
@mixin styles {
:deep(.el-dialog__body) {
padding: 0;
@@ -7,7 +9,7 @@
}
:deep(.el-dialog__header) {
margin-right: 0;
padding-bottom: 16px;
margin-right: 0;
}
}

View File

@@ -1,44 +1,49 @@
<script lang="ts" setup>
import type { Conversation } from '../../../../types'
import type { Conversation } from '../../../../types';
import { computed } from 'vue'
import { computed } from 'vue';
import { confirm } from '@vben/common-ui'
import { IconifyIcon as Icon } from '@vben/icons'
import { confirm } from '@vben/common-ui';
import { IconifyIcon as Icon } from '@vben/icons';
import { ElTag } from 'element-plus'
import { ElTag } from 'element-plus';
import { buildRecallTip } from '#/views/im/utils/conversation'
import { formatConversationTime } from '#/views/im/utils/time'
import { getSenderDisplayName } from '#/views/im/utils/user'
import { buildRecallTip } from '#/views/im/utils/conversation';
import { formatConversationTime } from '#/views/im/utils/time';
import { getSenderDisplayName } from '#/views/im/utils/user';
import { ImContentType, ImConversationType, isNormalMessage } from '../../../../../utils/constants'
import { GroupAvatar } from '../../../../components/group'
import { UserAvatar } from '../../../../components/user'
import { useConversationStore } from '../../../../store/conversationStore'
import { useFriendStore } from '../../../../store/friendStore'
import { useGroupRequestStore } from '../../../../store/groupRequestStore'
import { useGroupStore } from '../../../../store/groupStore'
import { useImUiStore } from '../../../../store/uiStore'
import {
ImContentType,
ImConversationType,
isNormalMessage,
} from '../../../../../utils/constants';
import { GroupAvatar } from '../../../../components/group';
import { UserAvatar } from '../../../../components/user';
import { useConversationStore } from '../../../../store/conversationStore';
import { useFriendStore } from '../../../../store/friendStore';
import { useGroupRequestStore } from '../../../../store/groupRequestStore';
import { useGroupStore } from '../../../../store/groupStore';
import { useImUiStore } from '../../../../store/uiStore';
defineOptions({ name: 'ImConversationItem' })
defineOptions({ name: 'ImConversationItem' });
// 周中文名dayjs 的 day() 返回 0-60=周日);项目没全局装 dayjs/locale/zh-cn本地映射避免引副作用
const props = defineProps<{
conversation: Conversation
}>()
conversation: Conversation;
}>();
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const groupRequestStore = useGroupRequestStore()
const uiStore = useImUiStore()
const conversationStore = useConversationStore();
const friendStore = useFriendStore();
const groupStore = useGroupStore();
const groupRequestStore = useGroupRequestStore();
const uiStore = useImUiStore();
const isActive = computed(
() =>
conversationStore.activeConversation?.targetId === props.conversation.targetId &&
conversationStore.activeConversation?.type === props.conversation.type
)
conversationStore.activeConversation?.targetId ===
props.conversation.targetId &&
conversationStore.activeConversation?.type === props.conversation.type,
);
/**
* 当前会话的草稿快照:存在时列表显示 [草稿] 前缀 + 文本,盖掉 sender 前缀和 @我 红字
@@ -46,97 +51,105 @@ const isActive = computed(
*/
const draft = computed(() => {
if (isActive.value) {
return undefined
return undefined;
}
return conversationStore.getConversationDraft(props.conversation)
})
return conversationStore.getConversationDraft(props.conversation);
});
const isGroup = computed(() => props.conversation.type === ImConversationType.GROUP)
const isGroup = computed(
() => props.conversation.type === ImConversationType.GROUP,
);
/** 最后一条消息发送者的展示名:实时算 + 快照 fallbackgetSenderDisplayName 算不出时兜底) */
const lastSenderDisplayName = computed(() => {
const senderId = props.conversation.lastSenderId
const senderId = props.conversation.lastSenderId;
if (!senderId) {
return ''
return '';
}
return getSenderDisplayName(
senderId,
props.conversation.type,
props.conversation.targetId,
props.conversation.lastSenderDisplayName
)
})
props.conversation.lastSenderDisplayName,
);
});
/** 群聊 + 有最后发送者 + 最后一条是普通消息时显示发送者前缀FRIEND_* / GROUP_* / RECALL / 草稿态不带前缀) */
const showSendName = computed(() => {
if (draft.value) {
return false
return false;
}
if (!isGroup.value) {
return false
return false;
}
if (!props.conversation.lastSenderId) {
return false
return false;
}
const lastType = props.conversation.lastMessageType
return lastType != null && isNormalMessage(lastType)
})
const lastType = props.conversation.lastMessageType;
return lastType != null && isNormalMessage(lastType);
});
/** 列表展示文案:草稿优先(对齐微信 PC有草稿时盖掉最后一条预览→ 撤回实时算 → lastContent 兜底 */
const lastContentDisplay = computed(() => {
if (draft.value) {
return draft.value.plain
return draft.value.plain;
}
if (
props.conversation.lastMessageType === ImContentType.RECALL &&
props.conversation.lastSenderId != null
props.conversation.lastSenderId !== null
) {
return buildRecallTip(
props.conversation.lastSenderId,
!!props.conversation.lastSelfSend,
props.conversation.type,
props.conversation.targetId,
props.conversation.lastSenderDisplayName
)
props.conversation.lastSenderDisplayName,
);
}
return props.conversation.lastContent
})
return props.conversation.lastContent;
});
/** 会话列表 "[草稿]" / "@ 我" / "@ 全体成员" 红字提示;草稿优先(对齐微信 PC */
const atText = computed(() => {
if (draft.value) {
return '[草稿]'
return '[草稿]';
}
if (props.conversation.atMe) {
return '[有人@我]'
return '[有人@我]';
}
if (props.conversation.atAll) {
return '[@全体成员]'
return '[@全体成员]';
}
return ''
})
return '';
});
/** 免打扰会话未读条数文案 */
const mutedUnreadText = computed(() => {
if (!props.conversation.silent || props.conversation.unreadCount <= 0) {
return ''
return '';
}
const count = props.conversation.unreadCount > 99 ? '99+' : props.conversation.unreadCount
return `[${count}条]`
})
const count =
props.conversation.unreadCount > 99
? '99+'
: props.conversation.unreadCount;
return `[${count}条]`;
});
/** 群聊未处理加群申请红字前缀store 已经按「我管理的群」过滤过count > 0 即可显示 */
const requestText = computed(() => {
if (!isGroup.value) {
return ''
return '';
}
const count = groupRequestStore.getUnhandledGroupRequestCountMap.get(props.conversation.targetId) ?? 0
return count > 0 ? `[${count}条进群申请]` : ''
})
const count =
groupRequestStore.getUnhandledGroupRequestCountMap.get(
props.conversation.targetId,
) ?? 0;
return count > 0 ? `[${count}条进群申请]` : '';
});
/** 点击切会话 */
function handleClick() {
conversationStore.setActiveConversation(props.conversation)
conversationStore.setActiveConversation(props.conversation);
}
/** 切换置顶 */
@@ -144,30 +157,36 @@ function handleTop() {
conversationStore.setConversationTop(
props.conversation.type,
props.conversation.targetId,
!props.conversation.top
)
!props.conversation.top,
);
}
/** 切换免打扰:乐观 UI先本地切换菜单立即关后端失败回滚 conversation 状态) */
function handleMuted() {
const next = !props.conversation.silent
const { type, targetId } = props.conversation
conversationStore.setConversationSilent(type, targetId, next)
const next = !props.conversation.silent;
const { type, targetId } = props.conversation;
conversationStore.setConversationSilent(type, targetId, next);
const sync =
type === ImConversationType.PRIVATE
? friendStore.setFriendSilent(targetId, next)
: groupStore.setGroupSilent(targetId, next)
: groupStore.setGroupSilent(targetId, next);
sync.catch((error) => {
console.error('[IM] 切换免打扰失败', error)
conversationStore.setConversationSilent(type, targetId, !next)
})
console.error('[IM] 切换免打扰失败', error);
conversationStore.setConversationSilent(type, targetId, !next);
});
}
/** 删除会话:二次确认后软删 */
async function handleDelete() {
try {
await confirm(`确定删除与「${props.conversation.name}」的会话吗?`, '删除会话')
conversationStore.removeConversation(props.conversation.type, props.conversation.targetId)
await confirm(
`确定删除与「${props.conversation.name}」的会话吗?`,
'删除会话',
);
conversationStore.removeConversation(
props.conversation.type,
props.conversation.targetId,
);
} catch {}
}
@@ -177,38 +196,42 @@ function handleContextMenu(e: MouseEvent) {
{ x: e.clientX, y: e.clientY },
[
{ key: 'TOP', name: props.conversation.top ? '取消置顶' : '置顶' },
{ key: 'MUTED', name: props.conversation.silent ? '允许消息通知' : '消息免打扰' },
{ key: 'DELETE', name: '删除', divided: true, danger: true }
{
key: 'MUTED',
name: props.conversation.silent ? '允许消息通知' : '消息免打扰',
},
{ key: 'DELETE', name: '删除', divided: true, danger: true },
],
(item) => {
switch (item.key) {
case 'DELETE': {
void handleDelete()
case 'DELETE': {
void handleDelete();
break;
}
case 'MUTED': {
handleMuted()
break;
}
case 'MUTED': {
handleMuted();
break;
}
case 'TOP': {
handleTop()
break;
}
case 'TOP': {
handleTop();
break;
break;
}
// No default
}
// No default
}
}
)
},
);
}
</script>
<template>
<div
class="relative flex items-center gap-2.5 px-4 py-3 cursor-pointer transition-colors hover:bg-[var(--ant-color-fill)]"
:class="{ '!bg-[#d9ecff] dark:!bg-[var(--ant-color-primary-bg-hover)]': isActive }"
:class="{
'!bg-[#d9ecff] dark:!bg-[var(--ant-color-primary-bg-hover)]': isActive,
}"
:data-conversation-key="`${conversation.type}-${conversation.targetId}`"
@click="handleClick"
@contextmenu.prevent="handleContextMenu"
@@ -246,7 +269,9 @@ function handleContextMenu(e: MouseEvent) {
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<span class="flex flex-1 items-center gap-1 min-w-0">
<span class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]">
<span
class="overflow-hidden text-sm truncate text-[var(--ant-color-text)]"
>
{{ conversation.name }}
</span>
<ElTag
@@ -258,7 +283,9 @@ function handleContextMenu(e: MouseEvent) {
</ElTag>
</span>
<span class="flex-shrink-0 ml-1 text-12px text-[var(--ant-color-text-secondary)]">
<span
class="flex-shrink-0 ml-1 text-12px text-[var(--ant-color-text-secondary)]"
>
{{ formatConversationTime(conversation.lastSendTime) }}
</span>
</div>
@@ -317,7 +344,7 @@ function handleContextMenu(e: MouseEvent) {
/* el-icon 的全局 color:var(--color) 在暗色模式下会渲染成白色,这里用 :deep + !important 锁定 */
.conversation-item__silent :deep(svg) {
fill: currentColor !important;
fill: currentcolor !important;
}
.conversation-item__prefix {

View File

@@ -1,86 +1,101 @@
<script lang="ts" setup>
import type { Conversation, Friend } from '../../../../types'
import type { Conversation, Friend } from '../../../../types';
import { computed, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue';
import { IconifyIcon as Icon } from '@vben/icons'
import { IconifyIcon as Icon } from '@vben/icons';
import { ElButton, ElDrawer, ElInput, ElMessage, ElPopover, ElSwitch } from 'element-plus'
import {
ElButton,
ElDrawer,
ElInput,
ElMessage,
ElPopover,
ElSwitch,
} from 'element-plus';
import { useConversationStore } from '#/views/im/home/store/conversationStore'
import { useFriendStore } from '#/views/im/home/store/friendStore'
import { useGroupStore } from '#/views/im/home/store/groupStore'
import { ImConversationType } from '#/views/im/utils/constants'
import { getFriendDisplayName, getGroupDisplayName } from '#/views/im/utils/user'
import { useConversationStore } from '#/views/im/home/store/conversationStore';
import { useFriendStore } from '#/views/im/home/store/friendStore';
import { useGroupStore } from '#/views/im/home/store/groupStore';
import { ImConversationType } from '#/views/im/utils/constants';
import {
getFriendDisplayName,
getGroupDisplayName,
} from '#/views/im/utils/user';
import { GroupCreateDialog } from '../../../../components/group'
import { UserAvatar } from '../../../../components/user'
import { GroupCreateDialog } from '../../../../components/group';
import { UserAvatar } from '../../../../components/user';
defineOptions({ name: 'ImConversationPrivateSide' })
defineOptions({ name: 'ImConversationPrivateSide' });
const props = withDefaults(
defineProps<{
conversation?: Conversation | null // 当前会话(取置顶 / 免打扰态)
friend?: Friend // 对方好友信息(取头像 / 昵称)
modelValue?: boolean // 抽屉开关v-model
conversation?: Conversation | null; // 当前会话(取置顶 / 免打扰态)
friend?: Friend; // 对方好友信息(取头像 / 昵称)
modelValue?: boolean; // 抽屉开关v-model
}>(),
{
conversation: null,
friend: undefined,
modelValue: false
}
)
modelValue: false,
},
);
const emit = defineEmits<{
openHistory: [] // 点击 "查找聊天内容" 行 → 父组件打开 MessageHistory 弹窗
'update:modelValue': [value: boolean]
}>()
openHistory: []; // 点击 "查找聊天内容" 行 → 父组件打开 MessageHistory 弹窗
'update:modelValue': [value: boolean];
}>();
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
set: (value) => emit('update:modelValue', value),
});
const conversationStore = useConversationStore()
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const conversationStore = useConversationStore();
const friendStore = useFriendStore();
const groupStore = useGroupStore();
/** tile 标签 / 后续聊天界面用的展示名:备注优先 */
const displayName = computed(() => (props.friend ? getFriendDisplayName(props.friend) : ''))
const displayName = computed(() =>
props.friend ? getFriendDisplayName(props.friend) : '',
);
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>() // 发起群聊弹窗 refhandleOpenCreateGroup 调 open({ lockedIds }) 锁定对方
const createGroupDialogRef = ref<InstanceType<typeof GroupCreateDialog>>(); // 发起群聊弹窗 refhandleOpenCreateGroup 调 open({ lockedIds }) 锁定对方
/** 打开发起群聊弹窗:把对方默认勾上且不可取消,对应微信"基于私聊发起群聊" */
function handleOpenCreateGroup() {
const lockedIds = props.friend ? [props.friend.friendUserId] : []
createGroupDialogRef.value?.open({ lockedIds })
const lockedIds = props.friend ? [props.friend.friendUserId] : [];
createGroupDialogRef.value?.open({ lockedIds });
}
const displayNamePopoverVisible = ref(false)
const editDisplayName = ref('')
const displayNamePopoverVisible = ref(false);
const editDisplayName = ref('');
// popover 弹出时把当前备注灌进编辑态,避免上次未保存的脏值
watch(displayNamePopoverVisible, (open) => {
if (open) {
editDisplayName.value = props.friend?.displayName || ''
editDisplayName.value = props.friend?.displayName || '';
}
})
});
// 抽屉关闭时把还没收掉的 popover 一并清掉,避免下次打开闪一下
watch(visible, (open) => {
if (!open) {
displayNamePopoverVisible.value = false
displayNamePopoverVisible.value = false;
}
})
});
/** 备注 popover 点击保存 */
async function handleSaveDisplayName() {
if (!props.friend) {
return
return;
}
await friendStore.setFriendDisplayName(props.friend.friendUserId, editDisplayName.value)
displayNamePopoverVisible.value = false
ElMessage.success('保存成功')
await friendStore.setFriendDisplayName(
props.friend.friendUserId,
editDisplayName.value,
);
displayNamePopoverVisible.value = false;
ElMessage.success('保存成功');
}
/**
@@ -88,42 +103,50 @@ async function handleSaveDisplayName() {
*/
function handleMutedChange(value: boolean | number | string) {
if (!props.conversation) {
return
return;
}
const next = !!value
const { type, targetId } = props.conversation
conversationStore.setConversationSilent(type, targetId, next)
const next = !!value;
const { type, targetId } = props.conversation;
conversationStore.setConversationSilent(type, targetId, next);
if (type !== ImConversationType.PRIVATE) {
return
return;
}
friendStore.setFriendSilent(targetId, next).catch((error) => {
console.error('[IM ConversationPrivateSide] 切换免打扰失败', { targetId }, error)
conversationStore.setConversationSilent(type, targetId, !next)
})
console.error(
'[IM ConversationPrivateSide] 切换免打扰失败',
{ targetId },
error,
);
conversationStore.setConversationSilent(type, targetId, !next);
});
}
/** 切置顶:纯本地 conversationStore 排序态(无后端字段) */
function handleTopChange(value: boolean | number | string) {
if (!props.conversation) {
return
return;
}
conversationStore.setConversationTop(props.conversation.type, props.conversation.targetId, !!value)
conversationStore.setConversationTop(
props.conversation.type,
props.conversation.targetId,
!!value,
);
}
/** 群创建成功:跳到新群会话 + 关掉本侧抽屉,让用户专注新群 */
function handleGroupCreated(groupId: number) {
const group = groupStore.getGroup(groupId)
const group = groupStore.getGroup(groupId);
if (!group) {
return
return;
}
conversationStore.openConversation(
groupId,
ImConversationType.GROUP,
getGroupDisplayName(group),
group.avatar || '',
{ silent: !!group.silent }
)
visible.value = false
{ silent: !!group.silent },
);
visible.value = false;
}
</script>
@@ -152,7 +175,9 @@ function handleGroupCreated(groupId: number) {
<div v-else class="flex flex-col h-full bg-[var(--ant-color-bg-container)]">
<div class="flex-1 overflow-y-auto bg-[var(--ant-color-fill-secondary)]">
<!-- 好友宫格 tile + "+" tile对齐 GroupSide 视觉让两种抽屉看起来是一家的 -->
<div class="flex flex-wrap gap-1 px-4 pt-4 pb-[14px] bg-[var(--ant-color-bg-container)]">
<div
class="flex flex-wrap gap-1 px-4 pt-4 pb-[14px] bg-[var(--ant-color-bg-container)]"
>
<div class="flex flex-col items-center w-[66px]">
<UserAvatar
:id="friend.friendUserId"
@@ -160,7 +185,9 @@ function handleGroupCreated(groupId: number) {
:name="friend.nickname"
:size="50"
/>
<div class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate">
<div
class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate"
>
{{ displayName }}
</div>
</div>
@@ -171,10 +198,16 @@ function handleGroupCreated(groupId: number) {
title="发起群聊"
@click="handleOpenCreateGroup"
>
<div class="im-conversation-private-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--ant-color-text)] bg-[var(--ant-color-fill-tertiary)] border border-dashed border-[var(--ant-color-border)] rounded-md transition-colors duration-200">
<div
class="im-conversation-private-side__icon-tile flex items-center justify-center w-[50px] h-[50px] text-20px text-[var(--ant-color-text)] bg-[var(--ant-color-fill-tertiary)] border border-dashed border-[var(--ant-color-border)] rounded-md transition-colors duration-200"
>
<Icon icon="ant-design:plus-outlined" />
</div>
<div class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate">添加</div>
<div
class="w-full mt-1.5 overflow-hidden text-12px leading-[1.5] text-[var(--ant-color-text)] text-center truncate"
>
添加
</div>
</div>
</div>
@@ -192,14 +225,21 @@ function handleGroupCreated(groupId: number) {
<div
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(--ant-color-fill-tertiary)]"
>
<span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">备注</span>
<span
class="flex-shrink-0 text-14px text-[var(--ant-color-text)]"
>
备注
</span>
<span
v-if="friend.displayName"
class="text-13px leading-[1.6] text-[var(--ant-color-text)] break-all line-clamp-2"
>
{{ friend.displayName }}
</span>
<span v-else class="text-13px leading-[1.6] text-[var(--ant-color-text-placeholder)]">
<span
v-else
class="text-13px leading-[1.6] text-[var(--ant-color-text-placeholder)]"
>
好友备注仅自己可见
</span>
</div>
@@ -212,8 +252,17 @@ function handleGroupCreated(groupId: number) {
placeholder="请输入备注名"
/>
<div class="flex justify-end gap-2">
<ElButton size="small" @click="displayNamePopoverVisible = false">取消</ElButton>
<ElButton size="small" type="primary" @click="handleSaveDisplayName">
<ElButton
size="small"
@click="displayNamePopoverVisible = false"
>
取消
</ElButton>
<ElButton
size="small"
type="primary"
@click="handleSaveDisplayName"
>
保存
</ElButton>
</div>
@@ -229,7 +278,9 @@ function handleGroupCreated(groupId: number) {
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(--ant-color-fill-tertiary)]"
@click="emit('openHistory')"
>
<span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">查找聊天内容</span>
<span class="flex-shrink-0 text-14px text-[var(--ant-color-text)]">
查找聊天内容
</span>
<Icon
icon="ant-design:right-outlined"
:size="11"
@@ -242,34 +293,52 @@ function handleGroupCreated(groupId: number) {
<!-- 开关项 -->
<div class="bg-[var(--ant-color-bg-container)]">
<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="flex-shrink-0 text-14px text-[var(--ant-color-text)]">消息免打扰</span>
<ElSwitch :model-value="!!conversation?.silent" @change="handleMutedChange" />
<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="flex-shrink-0 text-14px text-[var(--ant-color-text)]">
消息免打扰
</span>
<ElSwitch
:model-value="!!conversation?.silent"
@change="handleMutedChange"
/>
</div>
<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="flex-shrink-0 text-14px text-[var(--ant-color-text)]">置顶聊天</span>
<ElSwitch :model-value="!!conversation?.top" @change="handleTopChange" />
<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="flex-shrink-0 text-14px text-[var(--ant-color-text)]">
置顶聊天
</span>
<ElSwitch
:model-value="!!conversation?.top"
@change="handleTopChange"
/>
</div>
</div>
</div>
</div>
<!-- 子对话框发起群聊锁定对方为已选 -->
<GroupCreateDialog ref="createGroupDialogRef" @created="handleGroupCreated" />
<GroupCreateDialog
ref="createGroupDialogRef"
@created="handleGroupCreated"
/>
</ElDrawer>
</template>
<style scoped>
/* 「+」 tile hover 时联动内部 icon-tile 走主色; 跨子元素的 hover 联动无法用单元素工具类表达 */
.im-conversation-private-side__tile-wrap-clickable:hover .im-conversation-private-side__icon-tile {
.im-conversation-private-side__tile-wrap-clickable:hover
.im-conversation-private-side__icon-tile {
color: var(--ant-color-primary);
border-color: var(--ant-color-primary);
background-color: var(--ant-color-primary-bg);
border-color: var(--ant-color-primary);
}
/* :deep 穿透 Icon 内部 svg el-icon 全局 color 在暗色模式下被主题盖过,锁 fill 到当前色 */
.im-conversation-private-side__icon-tile :deep(svg) {
fill: currentColor !important;
fill: currentcolor !important;
}
/* 相邻信息行加分隔线; 相邻兄弟选择器无法用工具类表达 */