@@ -1,36 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
defineOptions({ name: 'CardTitle' });
|
||||
|
||||
// TODO @jawe from xingyu:https://gitee.com/yudaocode/yudao-ui-admin-vben/pulls/243/files#diff_note_47350213;这个组件没有必要,直接用antdv card 的slot去做就行了,只有这一个地方用,没有必要单独写一个组件
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="card-title">{{ title }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
&::before {
|
||||
position: relative;
|
||||
top: 8px;
|
||||
left: -5px;
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
content: '';
|
||||
//background-color: #105cfb;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 5px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1 +0,0 @@
|
||||
export { default as CardTitle } from './CardTitle.vue';
|
||||
@@ -1,574 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UseScrollReturn } from '@vueuse/core';
|
||||
|
||||
import type { Emoji } from './tools/emoji';
|
||||
|
||||
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
|
||||
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
|
||||
|
||||
import { computed, nextTick, reactive, ref, unref } from 'vue';
|
||||
|
||||
import { UserTypeEnum } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatDate, isEmpty, jsonParse } from '@vben/utils';
|
||||
|
||||
import { vScroll } from '@vueuse/components';
|
||||
import { useDebounceFn, useScroll } from '@vueuse/core';
|
||||
import {
|
||||
Avatar,
|
||||
Empty,
|
||||
Image,
|
||||
Layout,
|
||||
notification,
|
||||
Textarea,
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import {
|
||||
getKeFuMessageList,
|
||||
sendKeFuMessage,
|
||||
updateKeFuMessageReadStatus,
|
||||
} from '#/api/mall/promotion/kefu/message';
|
||||
import { useMallKefuStore } from '#/store/mall/kefu';
|
||||
|
||||
import MessageItem from './message/MessageItem.vue';
|
||||
import OrderItem from './message/OrderItem.vue';
|
||||
import ProductItem from './message/ProductItem.vue';
|
||||
import { KeFuMessageContentTypeEnum } from './tools/constants';
|
||||
import { useEmoji } from './tools/emoji';
|
||||
import EmojiSelectPopover from './tools/EmojiSelectPopover.vue';
|
||||
import PictureSelectUpload from './tools/PictureSelectUpload.vue';
|
||||
|
||||
defineOptions({ name: 'KeFuMessageList' });
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const message = ref(''); // 消息弹窗
|
||||
const { replaceEmoji } = useEmoji();
|
||||
const messageList = ref<MallKefuMessageApi.Message[]>([]); // 消息列表
|
||||
const conversation = ref<MallKefuConversationApi.Conversation>(
|
||||
{} as MallKefuConversationApi.Conversation,
|
||||
); // 用户会话
|
||||
const showNewMessageTip = ref(false); // 显示有新消息提示
|
||||
const queryParams = reactive({
|
||||
conversationId: 0,
|
||||
createTime: undefined,
|
||||
});
|
||||
const total = ref(0); // 消息总条数
|
||||
const refreshContent = ref(false); // 内容刷新,主要解决会话消息页面高度不一致导致的滚动功能精度失效
|
||||
const kefuStore = useMallKefuStore(); // 客服缓存
|
||||
|
||||
/** 获悉消息内容 */
|
||||
const getMessageContent = computed(
|
||||
() => (item: any) => jsonParse(item.content),
|
||||
);
|
||||
|
||||
/** 获得消息列表 */
|
||||
// TODO @jave:idea 的 linter 报错,处理下;
|
||||
async function getMessageList() {
|
||||
const res: any = await getKeFuMessageList(queryParams as any);
|
||||
if (isEmpty(res)) {
|
||||
// 当返回的是空列表说明没有消息或者已经查询完了历史消息
|
||||
skipGetMessageList.value = true;
|
||||
return;
|
||||
}
|
||||
queryParams.createTime = formatDate((res as any).at(-1).createTime) as any;
|
||||
|
||||
// 情况一:加载最新消息
|
||||
if (queryParams.createTime) {
|
||||
// 情况二:加载历史消息
|
||||
for (const item of res as any) {
|
||||
pushMessage(item);
|
||||
}
|
||||
} else {
|
||||
messageList.value = res;
|
||||
}
|
||||
refreshContent.value = true;
|
||||
}
|
||||
|
||||
/** 添加消息 */
|
||||
function pushMessage(message: any) {
|
||||
if (messageList.value.some((val) => val.id === message.id)) {
|
||||
return;
|
||||
}
|
||||
messageList.value.push(message);
|
||||
}
|
||||
|
||||
/** 按照时间倒序,获取消息列表 */
|
||||
const getMessageList0 = computed(() => {
|
||||
// 使用展开运算符创建新数组,避免直接修改原数组
|
||||
return [...messageList.value].sort(
|
||||
(a: any, b: any) => a.createTime - b.createTime,
|
||||
);
|
||||
});
|
||||
|
||||
/** 刷新消息列表 */
|
||||
async function refreshMessageList(message?: any) {
|
||||
if (!conversation.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message === undefined) {
|
||||
queryParams.createTime = undefined;
|
||||
await getMessageList();
|
||||
} else {
|
||||
// 当前查询会话与消息所属会话不一致则不做处理
|
||||
if (message.conversationId !== conversation.value.id) {
|
||||
return;
|
||||
}
|
||||
pushMessage(message);
|
||||
}
|
||||
|
||||
if (loadHistory.value) {
|
||||
// 右下角显示有新消息提示
|
||||
showNewMessageTip.value = true;
|
||||
} else {
|
||||
// 滚动到最新消息处
|
||||
await handleToNewMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/** 获得新会话的消息列表, 点击切换时,读取缓存;然后异步获取新消息,merge 下; */
|
||||
async function getNewMessageList(val: MallKefuMessageApi.Message) {
|
||||
// 1. 缓存当前会话消息列表
|
||||
kefuStore.saveMessageList(conversation.value.id, messageList.value);
|
||||
// 2.1 会话切换,重置相关参数
|
||||
messageList.value = kefuStore.getConversationMessageList(val.id) || [];
|
||||
total.value = messageList.value.length || 0;
|
||||
loadHistory.value = false;
|
||||
refreshContent.value = false;
|
||||
skipGetMessageList.value = false;
|
||||
// 2.2 设置会话相关属性
|
||||
conversation.value = val;
|
||||
queryParams.conversationId = val.id;
|
||||
queryParams.createTime = undefined;
|
||||
// 3. 获取消息
|
||||
await refreshMessageList();
|
||||
}
|
||||
defineExpose({ getNewMessageList, refreshMessageList });
|
||||
|
||||
/** 是否显示聊天区域 */
|
||||
function showKeFuMessageList() {
|
||||
return !isEmpty(conversation.value);
|
||||
}
|
||||
|
||||
const skipGetMessageList = ref(false); // 跳过消息获取
|
||||
|
||||
/** 处理表情选择 */
|
||||
function handleEmojiSelect(item: Emoji) {
|
||||
message.value += item.name;
|
||||
}
|
||||
|
||||
/** 处理图片发送 */
|
||||
async function handleSendPicture(picUrl: string) {
|
||||
// 组织发送消息
|
||||
const msg = {
|
||||
conversationId: conversation.value.id,
|
||||
contentType: KeFuMessageContentTypeEnum.IMAGE,
|
||||
content: JSON.stringify({ picUrl }),
|
||||
};
|
||||
await sendMessage(msg);
|
||||
}
|
||||
|
||||
/** 发送文本消息 */
|
||||
async function handleSendMessage(event: any) {
|
||||
// shift 不发送
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
// 1. 校验消息是否为空
|
||||
if (isEmpty(unref(message.value)?.trim())) {
|
||||
notification.warning({ message: '请输入消息后再发送哦!' });
|
||||
message.value = '';
|
||||
return;
|
||||
}
|
||||
// 2. 组织发送消息
|
||||
const msg = {
|
||||
conversationId: conversation.value.id,
|
||||
contentType: KeFuMessageContentTypeEnum.TEXT,
|
||||
content: JSON.stringify({ text: message.value }),
|
||||
};
|
||||
await sendMessage(msg);
|
||||
}
|
||||
|
||||
/** 真正发送消息 【共用】*/
|
||||
async function sendMessage(msg: MallKefuMessageApi.MessageSend) {
|
||||
// 发送消息
|
||||
await sendKeFuMessage(msg);
|
||||
message.value = '';
|
||||
// 加载消息列表
|
||||
await refreshMessageList();
|
||||
// 更新会话缓存
|
||||
await kefuStore.updateConversation(conversation.value.id);
|
||||
}
|
||||
|
||||
/** 滚动到底部 */
|
||||
const innerRef = ref<HTMLDivElement>();
|
||||
const scrollbarRef = ref<HTMLElement | null>(null);
|
||||
const { y } = useScroll(scrollbarRef);
|
||||
|
||||
async function scrollToBottom() {
|
||||
if (!scrollbarRef.value) return;
|
||||
// 1. 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动
|
||||
if (loadHistory.value) {
|
||||
return;
|
||||
}
|
||||
// 2.1 滚动到最新消息,关闭新消息提示
|
||||
await nextTick();
|
||||
// 使用 useScroll 监听滚动容器
|
||||
|
||||
y.value = scrollbarRef.value.scrollHeight - innerRef.value!.clientHeight;
|
||||
// scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight)
|
||||
showNewMessageTip.value = false;
|
||||
// 2.2 消息已读
|
||||
await updateKeFuMessageReadStatus(conversation.value.id);
|
||||
}
|
||||
|
||||
/** 查看新消息 */
|
||||
async function handleToNewMessage() {
|
||||
loadHistory.value = false;
|
||||
await scrollToBottom();
|
||||
}
|
||||
|
||||
const loadHistory = ref(false); // 加载历史消息
|
||||
/** 处理消息列表滚动事件(debounce 限流) */
|
||||
const handleScroll = useDebounceFn((state: UseScrollReturn) => {
|
||||
const { arrivedState } = state;
|
||||
if (skipGetMessageList.value) {
|
||||
return;
|
||||
}
|
||||
// 滚动到底部了
|
||||
if (arrivedState.bottom) {
|
||||
loadHistory.value = false;
|
||||
refreshMessageList();
|
||||
}
|
||||
|
||||
// 触顶自动加载下一页数据
|
||||
if (arrivedState.top) {
|
||||
handleOldMessage();
|
||||
}
|
||||
}, 200);
|
||||
/** 加载历史消息 */
|
||||
async function handleOldMessage() {
|
||||
// 记录已有页面高度
|
||||
const oldPageHeight = innerRef.value?.clientHeight;
|
||||
if (!oldPageHeight) {
|
||||
return;
|
||||
}
|
||||
loadHistory.value = true;
|
||||
await getMessageList();
|
||||
// 等页面加载完后,获得上一页最后一条消息的位置,控制滚动到它所在位置
|
||||
// scrollbarRef.value!.setScrollTop(innerRef.value!.clientHeight - oldPageHeight)
|
||||
y.value =
|
||||
scrollbarRef.value.scrollHeight -
|
||||
innerRef.value!.clientHeight -
|
||||
oldPageHeight;
|
||||
}
|
||||
|
||||
/** 是否显示时间 */
|
||||
function showTime(item: MallKefuMessageApi.Message, index: number) {
|
||||
if (unref(messageList.value)[index + 1]) {
|
||||
const dateString = dayjs(
|
||||
unref(messageList.value)[index + 1].createTime,
|
||||
).fromNow();
|
||||
return dateString !== dayjs(unref(item).createTime).fromNow();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout v-if="showKeFuMessageList()" class="kefu">
|
||||
<Layout.Header class="kefu-header">
|
||||
<div class="kefu-title">{{ conversation.userNickname }}</div>
|
||||
</Layout.Header>
|
||||
<Layout.Content class="kefu-content">
|
||||
<div
|
||||
ref="scrollbarRef"
|
||||
class="flex h-full overflow-y-auto"
|
||||
v-scroll="handleScroll"
|
||||
>
|
||||
<div v-if="refreshContent" ref="innerRef" class="w-full px-[10px]">
|
||||
<!-- 消息列表 -->
|
||||
<div
|
||||
v-for="(item, index) in getMessageList0"
|
||||
:key="item.id"
|
||||
class="w-full"
|
||||
>
|
||||
<div class="mb-[20px] flex items-center justify-center">
|
||||
<!-- 日期 -->
|
||||
<div
|
||||
v-if="
|
||||
item.contentType !== KeFuMessageContentTypeEnum.SYSTEM &&
|
||||
showTime(item, index)
|
||||
"
|
||||
class="date-message"
|
||||
>
|
||||
{{ formatDate(item.createTime) }}
|
||||
</div>
|
||||
<!-- 系统消息 -->
|
||||
<div
|
||||
v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM"
|
||||
class="system-message"
|
||||
>
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
item.senderType === UserTypeEnum.MEMBER
|
||||
? `ss-row-left`
|
||||
: item.senderType === UserTypeEnum.ADMIN
|
||||
? `ss-row-right`
|
||||
: '',
|
||||
]"
|
||||
class="mb-[20px] flex w-full"
|
||||
>
|
||||
<Avatar
|
||||
v-if="item.senderType === UserTypeEnum.MEMBER"
|
||||
:src="conversation.userAvatar"
|
||||
alt="avatar"
|
||||
class="h-[60px] w-[60px]"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
'kefu-message':
|
||||
KeFuMessageContentTypeEnum.TEXT === item.contentType,
|
||||
}"
|
||||
>
|
||||
<!-- 文本消息 -->
|
||||
<MessageItem :message="item">
|
||||
<template
|
||||
v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType"
|
||||
>
|
||||
<div
|
||||
v-dompurify-html="
|
||||
replaceEmoji(
|
||||
getMessageContent(item).text || item.content,
|
||||
)
|
||||
"
|
||||
class="line-height-normal h-1/1 w-full text-justify"
|
||||
></div>
|
||||
</template>
|
||||
</MessageItem>
|
||||
<!-- 图片消息 -->
|
||||
<MessageItem :message="item">
|
||||
<Image
|
||||
v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType"
|
||||
:initial-index="0"
|
||||
:preview-src-list="[
|
||||
getMessageContent(item).picUrl || item.content,
|
||||
]"
|
||||
:src="getMessageContent(item).picUrl || item.content"
|
||||
class="mx-[10px] w-[200px]"
|
||||
fit="contain"
|
||||
preview-teleported
|
||||
/>
|
||||
</MessageItem>
|
||||
<!-- 商品消息 -->
|
||||
<MessageItem :message="item">
|
||||
<ProductItem
|
||||
v-if="
|
||||
KeFuMessageContentTypeEnum.PRODUCT === item.contentType
|
||||
"
|
||||
:pic-url="getMessageContent(item).picUrl"
|
||||
:price="getMessageContent(item).price"
|
||||
:sales-count="getMessageContent(item).salesCount"
|
||||
:spu-id="getMessageContent(item).spuId"
|
||||
:stock="getMessageContent(item).stock"
|
||||
:title="getMessageContent(item).spuName"
|
||||
class="mx-[10px] max-w-[300px]"
|
||||
/>
|
||||
</MessageItem>
|
||||
<!-- 订单消息 -->
|
||||
<MessageItem :message="item">
|
||||
<OrderItem
|
||||
v-if="KeFuMessageContentTypeEnum.ORDER === item.contentType"
|
||||
:message="item"
|
||||
class="mx-[10px] max-w-full"
|
||||
/>
|
||||
</MessageItem>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="item.senderType === UserTypeEnum.ADMIN"
|
||||
:src="item.senderAvatar"
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-show="showNewMessageTip"
|
||||
class="newMessageTip flex cursor-pointer items-center"
|
||||
@click="handleToNewMessage"
|
||||
>
|
||||
<span>有新消息</span>
|
||||
<IconifyIcon class="ml-5px" icon="ep:bottom" />
|
||||
</div>
|
||||
</Layout.Content>
|
||||
<Layout.Footer class="kefu-footer">
|
||||
<div class="chat-tools flex items-center">
|
||||
<EmojiSelectPopover @select-emoji="handleEmojiSelect" />
|
||||
<PictureSelectUpload
|
||||
class="ml-[15px] mt-[3px] cursor-pointer"
|
||||
@send-picture="handleSendPicture"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
v-model:value="message"
|
||||
:rows="6"
|
||||
placeholder="输入消息,Enter发送,Shift+Enter换行"
|
||||
style="border-style: none"
|
||||
@keyup.enter.prevent="handleSendMessage"
|
||||
/>
|
||||
</Layout.Footer>
|
||||
</Layout>
|
||||
<Layout v-else class="kefu">
|
||||
<Layout.Content>
|
||||
<Empty description="请选择左侧的一个会话后开始" class="mt-[50px]" />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/** TODO @jave:看看哪些可以用 tailwind 简化掉 */
|
||||
.kefu {
|
||||
position: relative;
|
||||
width: calc(100% - 300px - 260px);
|
||||
background-color: var(--app-content-bg-color);
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px; /* 实际宽度 */
|
||||
height: 100%;
|
||||
content: '';
|
||||
background-color: var(--border-color);
|
||||
transform: scaleX(0.3); /* 缩小宽度 */
|
||||
}
|
||||
|
||||
.kefu-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background-color: var(--app-content-bg-color);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1px; /* 初始宽度 */
|
||||
content: '';
|
||||
background-color: var(-border-color);
|
||||
transform: scaleY(0.3); /* 缩小视觉高度 */
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
|
||||
.newMessageTip {
|
||||
position: absolute;
|
||||
right: 35px;
|
||||
bottom: 35px;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
background-color: var(--app-content-bg-color);
|
||||
border-radius: 30px;
|
||||
box-shadow: 0 2px 4px rgb(0 0 0 / 10%); /* 阴影效果 */
|
||||
}
|
||||
|
||||
.ss-row-left {
|
||||
justify-content: flex-start;
|
||||
|
||||
.kefu-message {
|
||||
margin-top: 3px;
|
||||
margin-left: 10px;
|
||||
background-color: #fff;
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.ss-row-right {
|
||||
justify-content: flex-end;
|
||||
|
||||
.kefu-message {
|
||||
margin-top: 3px;
|
||||
margin-right: 10px;
|
||||
background-color: rgb(206 223 255);
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
// 消息气泡
|
||||
.kefu-message {
|
||||
width: auto;
|
||||
max-width: 50%;
|
||||
padding: 5px 10px;
|
||||
font-weight: 500;
|
||||
color: #414141;
|
||||
//text-align: left;
|
||||
//display: inline-block !important;
|
||||
//word-break: break-all;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
}
|
||||
|
||||
.date-message,
|
||||
.system-message {
|
||||
width: fit-content;
|
||||
padding: 0 5px;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
background-color: rgb(0 0 0 / 10%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.kefu-footer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1px; /* 初始宽度 */
|
||||
content: '';
|
||||
background-color: var(-border-color);
|
||||
transform: scaleY(0.3); /* 缩小视觉高度 */
|
||||
}
|
||||
|
||||
.chat-tools {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +0,0 @@
|
||||
export { default as KeFuConversationList } from './KeFuConversationList.vue';
|
||||
export { default as KeFuMessageList } from './KeFuMessageList.vue';
|
||||
export { default as MemberInfo } from './member/MemberInfo.vue';
|
||||
|
||||
// TODO @jawe:components =》modules;在 vben 里,modules 是给自己用的,把一个大 vue 拆成 n 个小 vue;components 是给别的模块使用的;
|
||||
// TODO @jawe:1)组件名小写,类似 conversation-list.vue;2)KeFu 开头可以去掉,因为已经是当前模块下,不用重复拼写;
|
||||
@@ -1,206 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
|
||||
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { fenToYuan, isObject, jsonParse } from '@vben/utils';
|
||||
|
||||
import ProductItem from '#/views/mall/promotion/kefu/components/message/ProductItem.vue';
|
||||
|
||||
defineOptions({ name: 'OrderItem' });
|
||||
|
||||
const props = defineProps<{
|
||||
message?: MallKefuMessageApi.Message;
|
||||
order?: any;
|
||||
}>();
|
||||
|
||||
const { push } = useRouter();
|
||||
|
||||
const getMessageContent = computed(() =>
|
||||
props.message === undefined
|
||||
? props.order
|
||||
: jsonParse(props!.message!.content),
|
||||
);
|
||||
|
||||
/** 查看订单详情 */
|
||||
function openDetail(id: number) {
|
||||
push({ name: 'TradeOrderDetail', params: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化订单状态的颜色
|
||||
*
|
||||
* @param order 订单
|
||||
* @return {string} 颜色的 class 名称
|
||||
*/
|
||||
function formatOrderColor(order: any) {
|
||||
if (order.status === 0) {
|
||||
return 'info-color';
|
||||
}
|
||||
if (
|
||||
order.status === 10 ||
|
||||
order.status === 20 ||
|
||||
(order.status === 30 && !order.commentStatus)
|
||||
) {
|
||||
return 'warning-color';
|
||||
}
|
||||
if (order.status === 30 && order.commentStatus) {
|
||||
return 'success-color';
|
||||
}
|
||||
return 'danger-color';
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化订单状态
|
||||
*
|
||||
* @param order 订单
|
||||
*/
|
||||
function formatOrderStatus(order: any) {
|
||||
if (order.status === 0) {
|
||||
return '待付款';
|
||||
}
|
||||
if (order.status === 10 && order.deliveryType === 1) {
|
||||
return '待发货';
|
||||
}
|
||||
if (order.status === 10 && order.deliveryType === 2) {
|
||||
return '待核销';
|
||||
}
|
||||
if (order.status === 20) {
|
||||
return '待收货';
|
||||
}
|
||||
if (order.status === 30 && !order.commentStatus) {
|
||||
return '待评价';
|
||||
}
|
||||
if (order.status === 30 && order.commentStatus) {
|
||||
return '已完成';
|
||||
}
|
||||
return '已关闭';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isObject(getMessageContent)">
|
||||
<div :key="getMessageContent.id" class="order-list-card-box mt-[14px]">
|
||||
<div
|
||||
class="order-card-header p-x-[5px] flex items-center justify-between"
|
||||
>
|
||||
<div class="order-no">
|
||||
订单号:
|
||||
<span
|
||||
style="cursor: pointer"
|
||||
@click="openDetail(getMessageContent.id)"
|
||||
>
|
||||
{{ getMessageContent.no }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
:class="formatOrderColor(getMessageContent)"
|
||||
class="order-state font-16"
|
||||
>
|
||||
{{ formatOrderStatus(getMessageContent) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in getMessageContent.items"
|
||||
:key="item.id"
|
||||
class="border-bottom"
|
||||
>
|
||||
<ProductItem
|
||||
:num="item.count"
|
||||
:pic-url="item.picUrl"
|
||||
:price="item.price"
|
||||
:sku-text="
|
||||
item.properties.map((property: any) => property.valueName).join(' ')
|
||||
"
|
||||
:spu-id="item.spuId"
|
||||
:title="item.spuName"
|
||||
/>
|
||||
</div>
|
||||
<div class="pay-box flex justify-end pr-[5px]">
|
||||
<div class="flex items-center">
|
||||
<div class="discounts-title pay-color">
|
||||
共 {{ getMessageContent?.productCount }} 件商品,总金额:
|
||||
</div>
|
||||
<div class="discounts-money pay-color">
|
||||
¥{{ fenToYuan(getMessageContent?.payPrice) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/** TODO @jave:看看哪些可以用 tailwind 简化掉 */
|
||||
.order-list-card-box {
|
||||
padding: 10px;
|
||||
background-color: rgb(128 128 128 / 30%); // 透明色,暗黑模式下也能体现
|
||||
border: 1px var(--el-border-color) solid;
|
||||
border-radius: 10px;
|
||||
|
||||
.order-card-header {
|
||||
height: 28px;
|
||||
font-weight: bold;
|
||||
|
||||
.order-no {
|
||||
font-size: 13px;
|
||||
|
||||
span {
|
||||
&:hover {
|
||||
color: var(--left-menu-bg-active-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.order-state {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.pay-box {
|
||||
padding-top: 10px;
|
||||
font-weight: bold;
|
||||
|
||||
.discounts-title {
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.discounts-money {
|
||||
font-family: OPPOSANS;
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.pay-color {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.warning-color {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.danger-color {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #ff3000;
|
||||
}
|
||||
|
||||
.success-color {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.info-color {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
|
||||
|
||||
import { defineOptions, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
@@ -11,18 +11,19 @@ import { Layout, message } from 'ant-design-vue';
|
||||
|
||||
import { useMallKefuStore } from '#/store/mall/kefu';
|
||||
|
||||
import {
|
||||
KeFuConversationList,
|
||||
KeFuMessageList,
|
||||
MemberInfo,
|
||||
} from './components';
|
||||
import { WebSocketMessageTypeConstants } from './components/tools/constants';
|
||||
|
||||
defineOptions({ name: 'KeFu' });
|
||||
import ConversationList from './modules/conversation-list.vue';
|
||||
import MemberInfo from './modules/member/member-info.vue';
|
||||
import MessageList from './modules/message-list.vue';
|
||||
import { WebSocketMessageTypeConstants } from './modules/tools/constants';
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const kefuStore = useMallKefuStore(); // 客服缓存
|
||||
|
||||
/** 组件引用 */
|
||||
const messageListRef = ref<InstanceType<typeof MessageList>>();
|
||||
const memberInfoRef = ref<InstanceType<typeof MemberInfo>>();
|
||||
const conversationListRef = ref<InstanceType<typeof ConversationList>>();
|
||||
|
||||
// ======================= WebSocket start =======================
|
||||
const url = `${`${import.meta.env.VITE_BASE_URL}/infra/ws`.replace(
|
||||
'http',
|
||||
@@ -59,7 +60,7 @@ watch(
|
||||
// 刷新会话列表
|
||||
kefuStore.updateConversation(message.conversationId);
|
||||
// 刷新消息列表
|
||||
keFuChatBoxRef.value?.refreshMessageList(message);
|
||||
messageListRef.value?.refreshMessageList(message);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,21 +80,16 @@ watch(
|
||||
);
|
||||
// ======================= WebSocket end =======================
|
||||
|
||||
/** 加载指定会话的消息列表 */
|
||||
const keFuChatBoxRef = ref<InstanceType<typeof KeFuMessageList>>();
|
||||
const memberInfoRef = ref<InstanceType<typeof MemberInfo>>();
|
||||
// TODO @jawe:这里没导入
|
||||
function handleChange(conversation: MallKefuConversationApi.Conversation) {
|
||||
keFuChatBoxRef.value?.getNewMessageList(conversation as any);
|
||||
messageListRef.value?.getNewMessageList(conversation);
|
||||
memberInfoRef.value?.initHistory(conversation);
|
||||
}
|
||||
|
||||
const keFuConversationRef = ref<InstanceType<typeof KeFuConversationList>>();
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
// 加载会话列表
|
||||
kefuStore.setConversationList().then(() => {
|
||||
keFuConversationRef.value?.calculationLastMessageTime();
|
||||
conversationListRef.value?.calculationLastMessageTime();
|
||||
});
|
||||
// 打开 websocket 连接
|
||||
open();
|
||||
@@ -107,30 +103,14 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<!-- TODO @jawe:style 使用 tailwindcss,AI 友好; -->
|
||||
<Layout.Content class="kefu-layout hrow">
|
||||
<Page auto-content-height>
|
||||
<Layout.Content class="absolute left-0 top-0 flex h-full w-full flex-1">
|
||||
<!-- 会话列表 -->
|
||||
<KeFuConversationList ref="keFuConversationRef" @change="handleChange" />
|
||||
<ConversationList ref="conversationListRef" @change="handleChange" />
|
||||
<!-- 会话详情(选中会话的消息列表) -->
|
||||
<KeFuMessageList ref="keFuChatBoxRef" />
|
||||
<MessageList ref="messageListRef" />
|
||||
<!-- 会员信息(选中会话的会员信息) -->
|
||||
<MemberInfo ref="memberInfoRef" />
|
||||
</Layout.Content>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.kefu-layout {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.hrow {
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,8 +18,6 @@ import { useMallKefuStore } from '#/store/mall/kefu';
|
||||
import { KeFuMessageContentTypeEnum } from './tools/constants';
|
||||
import { useEmoji } from './tools/emoji';
|
||||
|
||||
defineOptions({ name: 'KeFuConversationList' });
|
||||
|
||||
/** 打开右侧的消息列表 */
|
||||
const emits = defineEmits<{
|
||||
(e: 'change', v: MallKefuConversationApi.Conversation): void;
|
||||
@@ -160,23 +158,22 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout.Sider class="kefu h-full pt-[5px]" width="260px">
|
||||
<div class="color-[#999] my-[10px] font-bold">
|
||||
<Layout.Sider class="h-full bg-[var(--background)] pt-[5px]" width="260px">
|
||||
<div class="my-[10px] font-bold text-[#999]">
|
||||
会话记录({{ kefuStore.getConversationList.length }})
|
||||
</div>
|
||||
<div
|
||||
v-for="item in kefuStore.getConversationList"
|
||||
:key="item.id"
|
||||
:class="{
|
||||
active: item.id === activeConversationId,
|
||||
pinned: item.adminPinned,
|
||||
'bg-gray-500/50': item.id === activeConversationId,
|
||||
}"
|
||||
class="kefu-conversation flex items-center px-[10px]"
|
||||
class="flex h-[60px] cursor-pointer items-center px-[10px]"
|
||||
@click="openRightMessage(item)"
|
||||
@contextmenu.prevent="rightClick($event as PointerEvent, item)"
|
||||
>
|
||||
<div class="!flex w-full items-center justify-center">
|
||||
<div class="w-50[px] h-50[px] flex items-center justify-center">
|
||||
<div class="flex w-full items-center justify-center">
|
||||
<div class="flex h-[50px] w-[50px] items-center justify-center">
|
||||
<!-- 头像 + 未读 -->
|
||||
<Badge
|
||||
:hidden="item.adminUnreadMessageCount === 0"
|
||||
@@ -187,9 +184,11 @@ onBeforeUnmount(() => {
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="ml-[10px] w-full">
|
||||
<div class="!flex w-full items-center justify-between">
|
||||
<span class="username">{{ item.userNickname || 'null' }}</span>
|
||||
<span class="color-[#999]" style="font-size: 13px">
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<span class="line-clamp-1 min-w-0 max-w-[60%]">{{
|
||||
item.userNickname || 'null'
|
||||
}}</span>
|
||||
<span class="text-[13px] text-[#999]">
|
||||
{{ lastMessageTimeMap.get(item.id) ?? '计算中' }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -201,17 +200,21 @@ onBeforeUnmount(() => {
|
||||
item.lastMessageContent,
|
||||
)
|
||||
"
|
||||
class="last-message color-[#999] !flex items-center"
|
||||
class="line-clamp-1 flex items-center text-[13px] text-[#999]"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右键,进行操作(类似微信) -->
|
||||
<ul v-show="showRightMenu" :style="rightMenuStyle" class="right-menu-ul">
|
||||
<ul
|
||||
v-show="showRightMenu"
|
||||
:style="rightMenuStyle"
|
||||
class="absolute z-[1999] m-0 w-[130px] list-none rounded-xl bg-[hsl(var(--background))] p-[5px] shadow-md"
|
||||
>
|
||||
<li
|
||||
v-show="!rightClickConversation.adminPinned"
|
||||
class="flex items-center"
|
||||
class="flex cursor-pointer items-center rounded-xl px-4 py-2 transition-colors hover:bg-gray-500/50"
|
||||
@click.stop="updateConversationPinnedFn(true)"
|
||||
>
|
||||
<IconifyIcon class="mr-[5px]" icon="ep:top" />
|
||||
@@ -219,78 +222,26 @@ onBeforeUnmount(() => {
|
||||
</li>
|
||||
<li
|
||||
v-show="rightClickConversation.adminPinned"
|
||||
class="flex items-center"
|
||||
class="flex cursor-pointer items-center rounded-xl px-4 py-2 transition-colors hover:bg-gray-500/50"
|
||||
@click.stop="updateConversationPinnedFn(false)"
|
||||
>
|
||||
<IconifyIcon class="mr-[5px]" icon="ep:bottom" />
|
||||
取消置顶
|
||||
</li>
|
||||
<li class="flex items-center" @click.stop="deleteConversationFn">
|
||||
<li
|
||||
class="flex cursor-pointer items-center rounded-xl px-4 py-2 transition-colors hover:bg-gray-500/50"
|
||||
@click.stop="deleteConversationFn"
|
||||
>
|
||||
<IconifyIcon class="mr-[5px]" color="red" icon="ep:delete" />
|
||||
删除会话
|
||||
</li>
|
||||
<li class="flex items-center" @click.stop="closeRightMenu">
|
||||
<li
|
||||
class="flex cursor-pointer items-center rounded-xl px-4 py-2 transition-colors hover:bg-gray-500/50"
|
||||
@click.stop="closeRightMenu"
|
||||
>
|
||||
<IconifyIcon class="mr-[5px]" color="red" icon="ep:close" />
|
||||
取消
|
||||
</li>
|
||||
</ul>
|
||||
</Layout.Sider>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/** TODO @jave:看看哪些可以用 tailwind 简化掉 */
|
||||
.kefu {
|
||||
background-color: var(--app-content-bg-color);
|
||||
|
||||
&-conversation {
|
||||
height: 60px;
|
||||
//background-color: #fff;
|
||||
//transition: border-left 0.05s ease-in-out; /* 设置过渡效果 */
|
||||
|
||||
.username {
|
||||
min-width: 0;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.last-message {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.last-message,
|
||||
.username {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: rgb(128 128 128 / 50%); // 透明色,暗黑模式下也能体现
|
||||
}
|
||||
|
||||
.right-menu-ul {
|
||||
position: absolute;
|
||||
width: 130px;
|
||||
padding: 5px;
|
||||
margin: 0;
|
||||
list-style-type: none; /* 移除默认的项目符号 */
|
||||
background-color: var(--app-content-bg-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgb(0 0 0 / 10%); /* 阴影效果 */
|
||||
|
||||
li {
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.3s; /* 平滑过渡 */
|
||||
&:hover {
|
||||
background-color: var(
|
||||
--left-menu-bg-active-color
|
||||
); /* 悬停时的背景颜色 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as ConversationList } from './conversation-list.vue';
|
||||
export { default as MemberInfo } from './member/member-info.vue';
|
||||
export { default as MessageList } from './message-list.vue';
|
||||
@@ -1,5 +1,7 @@
|
||||
<!-- 右侧信息:会员信息 + 最近浏览 + 交易订单 -->
|
||||
<script lang="ts" setup>
|
||||
import type { UseScrollReturn } from '@vueuse/core';
|
||||
|
||||
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
|
||||
import type { MemberUserApi } from '#/api/member/user';
|
||||
import type { PayWalletApi } from '#/api/pay/wallet/balance';
|
||||
@@ -8,8 +10,7 @@ import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
// TODO @jawe:debounce 是不是还是需要的哈;应该有 2 处需要;可以微信沟通哈;
|
||||
// import { debounce } from 'lodash-es'
|
||||
import { vScroll } from '@vueuse/components';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { Card, Empty, Layout, message } from 'ant-design-vue';
|
||||
|
||||
@@ -18,12 +19,11 @@ import { getWallet } from '#/api/pay/wallet/balance';
|
||||
import AccountInfo from '#/views/member/user/detail/modules/account-info.vue';
|
||||
import BasicInfo from '#/views/member/user/detail/modules/basic-info.vue';
|
||||
|
||||
import OrderBrowsingHistory from './OrderBrowsingHistory.vue';
|
||||
import ProductBrowsingHistory from './ProductBrowsingHistory.vue';
|
||||
import OrderBrowsingHistory from './order-browsing-history.vue';
|
||||
import ProductBrowsingHistory from './product-browsing-history.vue';
|
||||
|
||||
defineOptions({ name: 'MemberBrowsingHistory' });
|
||||
const activeTab = ref<'交易订单' | '会员信息' | '最近浏览'>('会员信息');
|
||||
|
||||
const activeTab = ref('会员信息');
|
||||
const tabActivation = computed(() => (tab: string) => activeTab.value === tab);
|
||||
|
||||
/** tab 切换 */
|
||||
@@ -93,10 +93,9 @@ defineExpose({ initHistory });
|
||||
|
||||
/** 处理消息列表滚动事件(debounce 限流) */
|
||||
const scrollbarRef = ref<InstanceType<any>>();
|
||||
const handleScroll = useDebounceFn(async () => {
|
||||
const wrap = scrollbarRef.value?.wrapRef;
|
||||
// 触底重置
|
||||
if (Math.abs(wrap!.scrollHeight - wrap!.clientHeight - wrap!.scrollTop) < 1) {
|
||||
const handleScroll = useDebounceFn(async (state: UseScrollReturn) => {
|
||||
const { arrivedState } = state;
|
||||
if (arrivedState.bottom) {
|
||||
await loadMore();
|
||||
}
|
||||
}, 200);
|
||||
@@ -140,53 +139,70 @@ async function getUserData() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- TODO @jave:from xingyu:a- 换成大写的方式,另外组件没有进行导入,其他页面也有这个问题 -->
|
||||
<Layout class="kefu">
|
||||
<Layout.Header class="kefu-header">
|
||||
<Layout
|
||||
class="relative w-[300px] bg-[var(--background)] after:absolute after:left-0 after:top-0 after:h-full after:w-[1px] after:scale-x-[0.3] after:bg-[var(--el-border-color)] after:content-['']"
|
||||
>
|
||||
<Layout.Header
|
||||
class="relative flex items-center justify-around bg-[var(--background)] before:absolute before:bottom-0 before:left-0 before:h-[1px] before:w-full before:scale-y-[0.3] before:bg-[var(--el-border-color)] before:content-['']"
|
||||
>
|
||||
<div
|
||||
:class="{ 'kefu-header-item-activation': tabActivation('会员信息') }"
|
||||
class="kefu-header-item flex cursor-pointer items-center justify-center"
|
||||
:class="{
|
||||
'before:border-b-2 before:border-gray-500/50':
|
||||
tabActivation('会员信息'),
|
||||
}"
|
||||
class="relative flex h-full w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
|
||||
@click="handleClick('会员信息')"
|
||||
>
|
||||
会员信息
|
||||
</div>
|
||||
<div
|
||||
:class="{ 'kefu-header-item-activation': tabActivation('最近浏览') }"
|
||||
class="kefu-header-item flex cursor-pointer items-center justify-center"
|
||||
:class="{
|
||||
'before:border-b-2 before:border-gray-500/50':
|
||||
tabActivation('最近浏览'),
|
||||
}"
|
||||
class="relative flex h-full w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
|
||||
@click="handleClick('最近浏览')"
|
||||
>
|
||||
最近浏览
|
||||
</div>
|
||||
<div
|
||||
:class="{ 'kefu-header-item-activation': tabActivation('交易订单') }"
|
||||
class="kefu-header-item flex cursor-pointer items-center justify-center"
|
||||
:class="{
|
||||
'before:border-b-2 before:border-gray-500/50':
|
||||
tabActivation('交易订单'),
|
||||
}"
|
||||
class="relative flex h-full w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
|
||||
@click="handleClick('交易订单')"
|
||||
>
|
||||
交易订单
|
||||
</div>
|
||||
</Layout.Header>
|
||||
<Layout.Content class="kefu-content p-10px!">
|
||||
<div v-if="!isEmpty(conversation)" v-loading="loading">
|
||||
<!-- 基本信息 -->
|
||||
<BasicInfo v-if="activeTab === '会员信息'" :user="user" mode="kefu">
|
||||
<template #title>
|
||||
<span class="text-sm font-bold">基本信息</span>
|
||||
</template>
|
||||
</BasicInfo>
|
||||
<!-- 账户信息 -->
|
||||
<Card
|
||||
<Layout.Content class="relative m-0 h-full w-full p-[10px]">
|
||||
<template v-if="!isEmpty(conversation)">
|
||||
<div
|
||||
v-loading="loading"
|
||||
v-if="activeTab === '会员信息'"
|
||||
class="mt-10px h-full"
|
||||
shadow="never"
|
||||
class="relative h-full overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<BasicInfo :user="user" mode="kefu">
|
||||
<template #title>
|
||||
<span class="text-sm font-bold">基本信息</span>
|
||||
</template>
|
||||
</BasicInfo>
|
||||
<!-- 账户信息 -->
|
||||
<Card class="mt-10px h-full" shadow="never">
|
||||
<template #title>
|
||||
<span class="text-sm font-bold">账户信息</span>
|
||||
</template>
|
||||
<AccountInfo :column="1" :user="user" :wallet="wallet" />
|
||||
</Card>
|
||||
</div>
|
||||
<div
|
||||
v-show="activeTab !== '会员信息'"
|
||||
ref="scrollbarRef"
|
||||
v-scroll="handleScroll"
|
||||
class="relative h-full overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<template #title>
|
||||
<span class="text-sm font-bold">账户信息</span>
|
||||
</template>
|
||||
<AccountInfo :column="1" :user="user" :wallet="wallet" />
|
||||
</Card>
|
||||
</div>
|
||||
<div v-show="!isEmpty(conversation)">
|
||||
<div ref="scrollbarRef" always @scroll="handleScroll">
|
||||
<!-- 最近浏览 -->
|
||||
<ProductBrowsingHistory
|
||||
v-if="activeTab === '最近浏览'"
|
||||
@@ -198,96 +214,8 @@ async function getUserData() {
|
||||
ref="orderBrowsingHistoryRef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Empty
|
||||
v-show="isEmpty(conversation)"
|
||||
description="请选择左侧的一个会话后开始"
|
||||
class="mt-[50px]"
|
||||
/>
|
||||
</template>
|
||||
<Empty v-else description="请选择左侧的一个会话后开始" class="mt-[20%]" />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/** TODO @jave:看看哪些可以用 tailwind 简化掉 */
|
||||
.kefu {
|
||||
position: relative;
|
||||
width: 300px !important;
|
||||
background-color: var(--app-content-bg-color);
|
||||
|
||||
&::after {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px; /* 实际宽度 */
|
||||
height: 100%;
|
||||
content: '';
|
||||
background-color: var(--el-border-color);
|
||||
transform: scaleX(0.3); /* 缩小宽度 */
|
||||
}
|
||||
|
||||
&-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
background-color: var(--app-content-bg-color);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 1px; /* 初始宽度 */
|
||||
content: '';
|
||||
background-color: var(--el-border-color);
|
||||
transform: scaleY(0.3); /* 缩小视觉高度 */
|
||||
}
|
||||
|
||||
&-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&-activation::before {
|
||||
position: absolute; /* 绝对定位 */
|
||||
inset: 0; /* 覆盖整个元素 */
|
||||
pointer-events: none; /* 确保点击事件不会被伪元素拦截 */
|
||||
content: '';
|
||||
border-bottom: 2px solid rgb(128 128 128 / 50%); /* 边框样式 */
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
position: absolute; /* 绝对定位 */
|
||||
inset: 0; /* 覆盖整个元素 */
|
||||
pointer-events: none; /* 确保点击事件不会被伪元素拦截 */
|
||||
content: '';
|
||||
border-bottom: 2px solid rgb(128 128 128 / 50%); /* 边框样式 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
&-tabs {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.header-title {
|
||||
border-bottom: #e4e0e0 solid 1px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,9 +4,7 @@ import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversa
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { getOrderPage } from '#/api/mall/trade/order';
|
||||
import OrderItem from '#/views/mall/promotion/kefu/components/message/OrderItem.vue';
|
||||
|
||||
defineOptions({ name: 'OrderBrowsingHistory' });
|
||||
import OrderItem from '#/views/mall/promotion/kefu/modules/message/order-item.vue';
|
||||
|
||||
const list = ref<any>([]); // 列表
|
||||
const total = ref(0); // 总数
|
||||
@@ -45,10 +43,5 @@ defineExpose({ getHistoryList, loadMore });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OrderItem
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
:order="item"
|
||||
class="mb-[10px]"
|
||||
/>
|
||||
<OrderItem v-for="item in list" :key="item.id" :order="item" />
|
||||
</template>
|
||||
@@ -4,9 +4,7 @@ import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversa
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { getBrowseHistoryPage } from '#/api/mall/product/history';
|
||||
import ProductItem from '#/views/mall/promotion/kefu/components/message/ProductItem.vue';
|
||||
|
||||
defineOptions({ name: 'ProductBrowsingHistory' });
|
||||
import ProductItem from '#/views/mall/promotion/kefu/modules/message/product-item.vue';
|
||||
|
||||
const list = ref<any>([]); // 列表
|
||||
const total = ref(0); // 总数
|
||||
@@ -0,0 +1,425 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UseScrollReturn } from '@vueuse/core';
|
||||
|
||||
import type { Emoji } from './tools/emoji';
|
||||
|
||||
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
|
||||
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
|
||||
|
||||
import { computed, reactive, ref, unref } from 'vue';
|
||||
|
||||
import { UserTypeEnum } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatDate, isEmpty, jsonParse } from '@vben/utils';
|
||||
|
||||
import { vScroll } from '@vueuse/components';
|
||||
import { useDebounceFn, useScroll } from '@vueuse/core';
|
||||
import {
|
||||
Avatar,
|
||||
Empty,
|
||||
Image,
|
||||
Layout,
|
||||
notification,
|
||||
Textarea,
|
||||
} from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
import {
|
||||
getKeFuMessageList,
|
||||
sendKeFuMessage,
|
||||
updateKeFuMessageReadStatus,
|
||||
} from '#/api/mall/promotion/kefu/message';
|
||||
import { useMallKefuStore } from '#/store/mall/kefu';
|
||||
|
||||
import MessageItem from './message/message-item.vue';
|
||||
import OrderItem from './message/order-item.vue';
|
||||
import ProductItem from './message/product-item.vue';
|
||||
import { KeFuMessageContentTypeEnum } from './tools/constants';
|
||||
import { useEmoji } from './tools/emoji';
|
||||
import EmojiSelectPopover from './tools/emoji-select-popover.vue';
|
||||
import PictureSelectUpload from './tools/picture-select-upload.vue';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const message = ref(''); // 消息弹窗
|
||||
const { replaceEmoji } = useEmoji();
|
||||
const messageList = ref<MallKefuMessageApi.Message[]>([]); // 消息列表
|
||||
const conversation = ref<MallKefuConversationApi.Conversation>(
|
||||
{} as MallKefuConversationApi.Conversation,
|
||||
); // 用户会话
|
||||
const showNewMessageTip = ref(false); // 显示有新消息提示
|
||||
const queryParams = reactive({
|
||||
conversationId: 0,
|
||||
createTime: undefined,
|
||||
});
|
||||
const total = ref(0); // 消息总条数
|
||||
const refreshContent = ref(false); // 内容刷新,主要解决会话消息页面高度不一致导致的滚动功能精度失效
|
||||
const kefuStore = useMallKefuStore(); // 客服缓存
|
||||
const skipGetMessageList = ref(false); // 跳过消息获取
|
||||
const loadHistory = ref(false); // 加载历史消息
|
||||
|
||||
/** 获悉消息内容 */
|
||||
const getMessageContent = computed(
|
||||
() => (item: any) => jsonParse(item.content),
|
||||
);
|
||||
|
||||
/** 获得消息列表 */
|
||||
async function getMessageList() {
|
||||
const res: any = await getKeFuMessageList(queryParams as any);
|
||||
if (isEmpty(res)) {
|
||||
// 当返回的是空列表说明没有消息或者已经查询完了历史消息
|
||||
skipGetMessageList.value = true;
|
||||
return;
|
||||
}
|
||||
queryParams.createTime = formatDate(
|
||||
(res as any).at(-1).createTime,
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
) as any;
|
||||
|
||||
// 如果 createTime 存在,说明是加载历史消息,需要追加到列表
|
||||
if (queryParams.createTime) {
|
||||
// 情况一:加载历史消息,追加到列表
|
||||
for (const item of res as any) {
|
||||
pushMessage(item);
|
||||
}
|
||||
} else {
|
||||
// 情况二:加载最新消息,直接替换列表
|
||||
messageList.value = res;
|
||||
}
|
||||
refreshContent.value = true;
|
||||
}
|
||||
|
||||
/** 添加消息 */
|
||||
function pushMessage(message: any) {
|
||||
if (messageList.value.some((val) => val.id === message.id)) {
|
||||
return;
|
||||
}
|
||||
messageList.value.push(message);
|
||||
}
|
||||
|
||||
/** 按照时间倒序,获取消息列表 */
|
||||
const getMessageList0 = computed(() => {
|
||||
// 使用展开运算符创建新数组,避免直接修改原数组
|
||||
return [...messageList.value].sort(
|
||||
(a: any, b: any) => a.createTime - b.createTime,
|
||||
);
|
||||
});
|
||||
|
||||
/** 刷新消息列表 */
|
||||
async function refreshMessageList(message?: any) {
|
||||
if (!conversation.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message === undefined) {
|
||||
queryParams.createTime = undefined;
|
||||
await getMessageList();
|
||||
} else {
|
||||
// 当前查询会话与消息所属会话不一致则不做处理
|
||||
if (message.conversationId !== conversation.value.id) {
|
||||
return;
|
||||
}
|
||||
pushMessage(message);
|
||||
}
|
||||
|
||||
if (loadHistory.value) {
|
||||
// 右下角显示有新消息提示
|
||||
showNewMessageTip.value = true;
|
||||
} else {
|
||||
// 滚动到最新消息处
|
||||
await handleToNewMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/** 获得新会话的消息列表, 点击切换时,读取缓存;然后异步获取新消息,merge 下; */
|
||||
async function getNewMessageList(val: MallKefuConversationApi.Conversation) {
|
||||
// 1. 缓存当前会话消息列表
|
||||
kefuStore.saveMessageList(conversation.value.id, messageList.value);
|
||||
// 2.1 会话切换,重置相关参数
|
||||
messageList.value = kefuStore.getConversationMessageList(val.id) || [];
|
||||
total.value = messageList.value.length || 0;
|
||||
loadHistory.value = false;
|
||||
refreshContent.value = false;
|
||||
skipGetMessageList.value = false;
|
||||
// 2.2 设置会话相关属性
|
||||
conversation.value = val;
|
||||
queryParams.conversationId = val.id;
|
||||
queryParams.createTime = undefined;
|
||||
// 3. 获取消息
|
||||
await refreshMessageList();
|
||||
}
|
||||
defineExpose({ getNewMessageList, refreshMessageList });
|
||||
|
||||
/** 是否显示聊天区域 */
|
||||
function showMessageList() {
|
||||
return !isEmpty(conversation.value);
|
||||
}
|
||||
|
||||
/** 处理表情选择 */
|
||||
function handleEmojiSelect(item: Emoji) {
|
||||
message.value += item.name;
|
||||
}
|
||||
|
||||
/** 处理图片发送 */
|
||||
async function handleSendPicture(picUrl: string) {
|
||||
// 组织发送消息
|
||||
const msg = {
|
||||
conversationId: conversation.value.id,
|
||||
contentType: KeFuMessageContentTypeEnum.IMAGE,
|
||||
content: JSON.stringify({ picUrl }),
|
||||
};
|
||||
await sendMessage(msg);
|
||||
}
|
||||
|
||||
/** 发送文本消息 */
|
||||
async function handleSendMessage(event: any) {
|
||||
// shift 不发送
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
// 1. 校验消息是否为空
|
||||
if (isEmpty(unref(message.value)?.trim())) {
|
||||
notification.warning({ message: '请输入消息后再发送哦!' });
|
||||
message.value = '';
|
||||
return;
|
||||
}
|
||||
// 2. 组织发送消息
|
||||
const msg = {
|
||||
conversationId: conversation.value.id,
|
||||
contentType: KeFuMessageContentTypeEnum.TEXT,
|
||||
content: JSON.stringify({ text: message.value }),
|
||||
};
|
||||
await sendMessage(msg);
|
||||
}
|
||||
|
||||
/** 真正发送消息 【共用】*/
|
||||
async function sendMessage(msg: MallKefuMessageApi.MessageSend) {
|
||||
// 发送消息
|
||||
await sendKeFuMessage(msg);
|
||||
message.value = '';
|
||||
// 加载消息列表
|
||||
await refreshMessageList();
|
||||
// 更新会话缓存
|
||||
await kefuStore.updateConversation(conversation.value.id);
|
||||
}
|
||||
|
||||
/** 滚动到底部 */
|
||||
const scrollbarRef = ref<HTMLElement | null>(null);
|
||||
const { y } = useScroll(scrollbarRef);
|
||||
|
||||
async function scrollToBottom() {
|
||||
if (!scrollbarRef.value) return;
|
||||
// 1. 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动
|
||||
if (loadHistory.value) {
|
||||
return;
|
||||
}
|
||||
// 2.1 滚动到最新消息,关闭新消息提示
|
||||
// 使用 useScroll 监听滚动容器
|
||||
y.value = scrollbarRef.value.scrollHeight - scrollbarRef.value!.clientHeight;
|
||||
showNewMessageTip.value = false;
|
||||
// 2.2 消息已读
|
||||
await updateKeFuMessageReadStatus(conversation.value.id);
|
||||
}
|
||||
|
||||
/** 查看新消息 */
|
||||
async function handleToNewMessage() {
|
||||
loadHistory.value = false;
|
||||
await scrollToBottom();
|
||||
}
|
||||
|
||||
/** 处理消息列表滚动事件(debounce 限流) */
|
||||
const handleScroll = useDebounceFn((state: UseScrollReturn) => {
|
||||
const { arrivedState } = state;
|
||||
if (skipGetMessageList.value) {
|
||||
return;
|
||||
}
|
||||
// 滚动到底部了
|
||||
if (arrivedState.bottom) {
|
||||
loadHistory.value = false;
|
||||
refreshMessageList();
|
||||
}
|
||||
|
||||
// 触顶自动加载下一页数据
|
||||
if (arrivedState.top) {
|
||||
handleOldMessage();
|
||||
}
|
||||
}, 200);
|
||||
/** 加载历史消息 */
|
||||
async function handleOldMessage() {
|
||||
loadHistory.value = true;
|
||||
await getMessageList();
|
||||
}
|
||||
|
||||
/** 是否显示时间 */
|
||||
function showTime(item: MallKefuMessageApi.Message, index: number) {
|
||||
if (unref(messageList.value)[index + 1]) {
|
||||
const dateString = dayjs(
|
||||
unref(messageList.value)[index + 1].createTime,
|
||||
).fromNow();
|
||||
return dateString !== dayjs(unref(item).createTime).fromNow();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout
|
||||
v-if="showMessageList()"
|
||||
class="bg-card relative w-[calc(100%-300px-260px)]"
|
||||
>
|
||||
<Layout.Header
|
||||
class="!bg-card border-border flex items-center justify-between border-b"
|
||||
>
|
||||
<div class="text-lg font-bold">{{ conversation.userNickname }}</div>
|
||||
</Layout.Header>
|
||||
<Layout.Content class="relative m-0 h-full w-full p-0">
|
||||
<div
|
||||
ref="scrollbarRef"
|
||||
class="absolute inset-0 m-0 overflow-y-auto overflow-x-hidden p-0"
|
||||
v-scroll="handleScroll"
|
||||
>
|
||||
<!-- <div v-if="refreshContent" ref="innerRef" class="w-full px-[10px] absolute inset-0 m-0 overflow-x-hidden p-0 overflow-y-auto"> -->
|
||||
<!-- 消息列表 -->
|
||||
<div
|
||||
v-for="(item, index) in getMessageList0"
|
||||
:key="item.id"
|
||||
class="w-full"
|
||||
>
|
||||
<div class="mb-[20px] flex items-center justify-center">
|
||||
<!-- 日期 -->
|
||||
<div
|
||||
v-if="
|
||||
item.contentType !== KeFuMessageContentTypeEnum.SYSTEM &&
|
||||
showTime(item, index)
|
||||
"
|
||||
class="w-fit rounded-lg bg-black/10 px-[5px] text-[10px] text-white"
|
||||
>
|
||||
{{ formatDate(item.createTime) }}
|
||||
</div>
|
||||
<!-- 系统消息 -->
|
||||
<div
|
||||
v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM"
|
||||
class="w-fit rounded-lg bg-black/10 px-[5px] text-[10px] text-white"
|
||||
>
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
item.senderType === UserTypeEnum.MEMBER
|
||||
? 'justify-start'
|
||||
: item.senderType === UserTypeEnum.ADMIN
|
||||
? 'justify-end'
|
||||
: '',
|
||||
]"
|
||||
class="mb-[20px] flex w-full"
|
||||
>
|
||||
<Avatar
|
||||
v-if="item.senderType === UserTypeEnum.MEMBER"
|
||||
:src="conversation.userAvatar"
|
||||
alt="avatar"
|
||||
class="h-[60px] w-[60px]"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
'w-auto max-w-[50%] px-[10px] py-[5px] font-medium text-[#414141] transition-all duration-200 hover:scale-105':
|
||||
KeFuMessageContentTypeEnum.TEXT === item.contentType,
|
||||
'ml-[10px] mt-[3px] rounded-bl-[10px] rounded-br-[10px] rounded-tr-[10px] bg-white':
|
||||
KeFuMessageContentTypeEnum.TEXT === item.contentType &&
|
||||
item.senderType === UserTypeEnum.MEMBER,
|
||||
'mr-[10px] mt-[3px] rounded-bl-[10px] rounded-br-[10px] rounded-tl-[10px] bg-[rgb(206_223_255)]':
|
||||
KeFuMessageContentTypeEnum.TEXT === item.contentType &&
|
||||
item.senderType === UserTypeEnum.ADMIN,
|
||||
}"
|
||||
>
|
||||
<!-- 文本消息 -->
|
||||
<MessageItem :message="item">
|
||||
<template
|
||||
v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType"
|
||||
>
|
||||
<div
|
||||
v-dompurify-html="
|
||||
replaceEmoji(getMessageContent(item).text || item.content)
|
||||
"
|
||||
class="line-height-normal h-1/1 w-full text-justify"
|
||||
></div>
|
||||
</template>
|
||||
</MessageItem>
|
||||
<!-- 图片消息 -->
|
||||
<MessageItem :message="item">
|
||||
<Image
|
||||
v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType"
|
||||
:initial-index="0"
|
||||
:preview-src-list="[
|
||||
getMessageContent(item).picUrl || item.content,
|
||||
]"
|
||||
:src="getMessageContent(item).picUrl || item.content"
|
||||
class="mx-[10px] !w-[200px]"
|
||||
fit="contain"
|
||||
preview-teleported
|
||||
/>
|
||||
</MessageItem>
|
||||
<!-- 商品消息 -->
|
||||
<MessageItem :message="item">
|
||||
<ProductItem
|
||||
v-if="KeFuMessageContentTypeEnum.PRODUCT === item.contentType"
|
||||
:pic-url="getMessageContent(item).picUrl"
|
||||
:price="getMessageContent(item).price"
|
||||
:sales-count="getMessageContent(item).salesCount"
|
||||
:spu-id="getMessageContent(item).spuId"
|
||||
:stock="getMessageContent(item).stock"
|
||||
:title="getMessageContent(item).spuName"
|
||||
class="mx-[10px] max-w-[300px]"
|
||||
/>
|
||||
</MessageItem>
|
||||
<!-- 订单消息 -->
|
||||
<MessageItem :message="item">
|
||||
<OrderItem
|
||||
v-if="KeFuMessageContentTypeEnum.ORDER === item.contentType"
|
||||
:message="item"
|
||||
class="mx-[10px] max-w-full"
|
||||
/>
|
||||
</MessageItem>
|
||||
</div>
|
||||
<Avatar
|
||||
v-if="item.senderType === UserTypeEnum.ADMIN"
|
||||
:src="item.senderAvatar"
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
<div
|
||||
v-show="showNewMessageTip"
|
||||
class="absolute bottom-[35px] right-[35px] z-10 flex cursor-pointer items-center rounded-[30px] bg-[var(--background)] p-[10px] text-xs shadow-md"
|
||||
@click="handleToNewMessage"
|
||||
>
|
||||
<span>有新消息</span>
|
||||
<IconifyIcon class="ml-5px" icon="ep:bottom" />
|
||||
</div>
|
||||
</Layout.Content>
|
||||
<Layout.Footer class="!bg-card m-0 flex flex-col p-0">
|
||||
<div class="flex h-[44px] w-full items-center">
|
||||
<EmojiSelectPopover @select-emoji="handleEmojiSelect" />
|
||||
<PictureSelectUpload
|
||||
class="ml-[15px] mt-[3px] cursor-pointer"
|
||||
@send-picture="handleSendPicture"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
v-model:value="message"
|
||||
:rows="6"
|
||||
placeholder="输入消息,Enter发送,Shift+Enter换行"
|
||||
style="border-style: none"
|
||||
@press-enter="handleSendMessage"
|
||||
/>
|
||||
</Layout.Footer>
|
||||
</Layout>
|
||||
<Layout v-else class="bg-card relative w-[calc(100%-300px-260px)]">
|
||||
<Layout.Content>
|
||||
<Empty description="请选择左侧的一个会话后开始" class="mt-[20%]" />
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</template>
|
||||
@@ -4,7 +4,6 @@ import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
|
||||
import { UserTypeEnum } from '@vben/constants';
|
||||
|
||||
/** 消息组件 */
|
||||
defineOptions({ name: 'MessageItem' });
|
||||
|
||||
defineProps<{
|
||||
message: MallKefuMessageApi.Message;
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
|
||||
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { fenToYuan, isObject, jsonParse } from '@vben/utils';
|
||||
|
||||
import ProductItem from './product-item.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
message?: MallKefuMessageApi.Message;
|
||||
order?: any;
|
||||
}>();
|
||||
|
||||
const { push } = useRouter();
|
||||
|
||||
const getMessageContent = computed(() =>
|
||||
props.message === undefined
|
||||
? props.order
|
||||
: jsonParse(props!.message!.content),
|
||||
);
|
||||
|
||||
/** 查看订单详情 */
|
||||
function openDetail(id: number) {
|
||||
push({ name: 'TradeOrderDetail', params: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化订单状态的颜色
|
||||
*
|
||||
* @param order 订单
|
||||
* @return {string} 颜色的 class 名称
|
||||
*/
|
||||
function formatOrderColor(order: any) {
|
||||
if (order.status === 0) {
|
||||
return 'text-[#999]';
|
||||
}
|
||||
if (
|
||||
order.status === 10 ||
|
||||
order.status === 20 ||
|
||||
(order.status === 30 && !order.commentStatus)
|
||||
) {
|
||||
return 'text-[#faad14]';
|
||||
}
|
||||
if (order.status === 30 && order.commentStatus) {
|
||||
return 'text-[#52c41a]';
|
||||
}
|
||||
return 'text-[#ff3000]';
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化订单状态
|
||||
*
|
||||
* @param order 订单
|
||||
*/
|
||||
function formatOrderStatus(order: any) {
|
||||
if (order.status === 0) {
|
||||
return '待付款';
|
||||
}
|
||||
if (order.status === 10 && order.deliveryType === 1) {
|
||||
return '待发货';
|
||||
}
|
||||
if (order.status === 10 && order.deliveryType === 2) {
|
||||
return '待核销';
|
||||
}
|
||||
if (order.status === 20) {
|
||||
return '待收货';
|
||||
}
|
||||
if (order.status === 30 && !order.commentStatus) {
|
||||
return '待评价';
|
||||
}
|
||||
if (order.status === 30 && order.commentStatus) {
|
||||
return '已完成';
|
||||
}
|
||||
return '已关闭';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isObject(getMessageContent)"
|
||||
:key="getMessageContent.id"
|
||||
class="mb-[10px] rounded-[10px] bg-gray-500/30 p-[10px]"
|
||||
>
|
||||
<div class="flex h-[28px] items-center justify-between px-[5px] font-bold">
|
||||
<div class="text-[13px]">
|
||||
订单号:
|
||||
<span
|
||||
class="cursor-pointer hover:text-[var(--left-menu-bg-active-color)] hover:underline"
|
||||
@click="openDetail(getMessageContent.id)"
|
||||
>
|
||||
{{ getMessageContent.no }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
:class="formatOrderColor(getMessageContent)"
|
||||
class="text-[13px] font-bold"
|
||||
>
|
||||
{{ formatOrderStatus(getMessageContent) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in getMessageContent.items"
|
||||
:key="item.id"
|
||||
class="border-b"
|
||||
>
|
||||
<ProductItem
|
||||
:num="item.count"
|
||||
:pic-url="item.picUrl"
|
||||
:price="item.price"
|
||||
:sku-text="
|
||||
item.properties.map((property: any) => property.valueName).join(' ')
|
||||
"
|
||||
:spu-id="item.spuId"
|
||||
:title="item.spuName"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end pr-[5px] pt-[10px] font-bold">
|
||||
<div class="flex items-center">
|
||||
<div class="text-[13px] leading-normal">
|
||||
共 {{ getMessageContent?.productCount }} 件商品,总金额:
|
||||
</div>
|
||||
<div class="font-[OPPOSANS] text-[13px] leading-normal">
|
||||
¥{{ fenToYuan(getMessageContent?.payPrice) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -5,8 +5,6 @@ import { fenToYuan } from '@vben/utils';
|
||||
|
||||
import { Button, Image } from 'ant-design-vue';
|
||||
|
||||
defineOptions({ name: 'ProductItem' });
|
||||
|
||||
defineProps({
|
||||
spuId: {
|
||||
type: Number,
|
||||
@@ -43,81 +41,33 @@ function openDetail(spuId: number) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="product-warp cursor-pointer" @click.stop="openDetail(spuId)">
|
||||
<div
|
||||
class="mb-[10px] flex w-full cursor-pointer items-center rounded-lg bg-gray-500/30 p-[10px]"
|
||||
@click.stop="openDetail(spuId)"
|
||||
>
|
||||
<!-- 左侧商品图片-->
|
||||
<div class="product-warp-left mr-6">
|
||||
<div class="mr-6 w-[70px]">
|
||||
<Image
|
||||
:initial-index="0"
|
||||
:preview-src-list="[picUrl]"
|
||||
:src="picUrl"
|
||||
class="product-warp-left-img"
|
||||
class="h-full w-full rounded-lg"
|
||||
fit="contain"
|
||||
preview-teleported
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<!-- 右侧商品信息 -->
|
||||
<div class="product-warp-right">
|
||||
<div class="description">{{ title }}</div>
|
||||
<div class="flex-1">
|
||||
<div class="line-clamp-1 w-full text-base font-bold">{{ title }}</div>
|
||||
<div class="my-1">
|
||||
<span class="mr-5">库存: {{ stock || 0 }}</span>
|
||||
<span>销量: {{ salesCount || 0 }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="price">¥{{ fenToYuan(price) }}</span>
|
||||
<span class="text-[#ff3000]">¥{{ fenToYuan(price) }}</span>
|
||||
<Button size="small" text type="primary">详情</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/** TODO @jawe:下面的,看看要不要用 tailwind 简化,ai 友好; */
|
||||
.button {
|
||||
padding: 5px 10px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
background-color: #007bff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.product-warp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
background-color: rgb(128 128 128 / 30%);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
|
||||
&-left {
|
||||
width: 70px;
|
||||
|
||||
&-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&-right {
|
||||
flex: 1;
|
||||
|
||||
.description {
|
||||
display: -webkit-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: 1; /* 显示一行 */
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: #ff3000;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,8 +10,6 @@ import { List, Popover } from 'ant-design-vue';
|
||||
|
||||
import { useEmoji } from './emoji';
|
||||
|
||||
defineOptions({ name: 'EmojiSelectPopover' });
|
||||
|
||||
/** 选择 emoji 表情 */
|
||||
const emits = defineEmits<{
|
||||
(e: 'selectEmoji', v: Emoji): void;
|
||||
@@ -26,9 +24,9 @@ function handleSelect(item: Emoji) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover :width="500" placement="top" trigger="click">
|
||||
<Popover placement="top" trigger="click">
|
||||
<template #content>
|
||||
<List height="300px">
|
||||
<List height="300px" class="w-[500px]">
|
||||
<ul class="ml-2 flex flex-wrap px-2">
|
||||
<li
|
||||
v-for="(item, index) in emojiList"
|
||||
@@ -1,12 +1,11 @@
|
||||
<!-- 图片选择 -->
|
||||
<script lang="ts" setup>
|
||||
import { message } from 'ant-design-vue';
|
||||
import { Image, message } from 'ant-design-vue';
|
||||
|
||||
import { uploadFile } from '#/api/infra/file';
|
||||
import { useUpload } from '#/components/upload/use-upload';
|
||||
import { $t } from '#/locales';
|
||||
import Picture from '#/views/mall/promotion/kefu/asserts/picture.svg';
|
||||
|
||||
defineOptions({ name: 'PictureSelectUpload' });
|
||||
|
||||
/** 选择并上传文件 */
|
||||
const emits = defineEmits<{
|
||||
(e: 'sendPicture', v: string): void;
|
||||
@@ -14,11 +13,10 @@ const emits = defineEmits<{
|
||||
|
||||
async function selectAndUpload() {
|
||||
const files: any = await getFiles();
|
||||
message.success('图片发送中请稍等。。。');
|
||||
// TODO @jawe:直接使用 updateFile,不通过 FileApi。vben 这里的规范;
|
||||
// TODO @jawe:这里的上传,看看能不能替换成 export function useUpload(directory?: string) {;它支持前端直传,更统一;
|
||||
const res = await uploadFile({ file: files[0].file });
|
||||
emits('sendPicture', res.data);
|
||||
message.success($t('ui.upload.imgUploading'));
|
||||
const res = await useUpload().httpRequest(files[0].file);
|
||||
message.success($t('ui.upload.uploadSuccess'));
|
||||
emits('sendPicture', res);
|
||||
}
|
||||
|
||||
/** 唤起文件选择窗口,并获取选择的文件 */
|
||||
@@ -82,7 +80,12 @@ async function getFiles(options = {}) {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- TODO @jawe:看看能不能换成 antd 的 Image 组件 -->
|
||||
<img :src="Picture" class="h-[35px] w-[35px]" @click="selectAndUpload" />
|
||||
<Image
|
||||
:preview="false"
|
||||
:src="Picture"
|
||||
width="35px"
|
||||
height="35px"
|
||||
@click="selectAndUpload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -43,6 +43,7 @@
|
||||
"@vben/styles": "workspace:*",
|
||||
"@vben/types": "workspace:*",
|
||||
"@vben/utils": "workspace:*",
|
||||
"@vueuse/components": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"cropperjs": "catalog:",
|
||||
|
||||
102
apps/web-ele/src/store/mall/kefu.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
|
||||
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
|
||||
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { acceptHMRUpdate, defineStore } from 'pinia';
|
||||
|
||||
import * as KeFuConversationApi from '#/api/mall/promotion/kefu/conversation';
|
||||
|
||||
interface MallKefuInfoVO {
|
||||
conversationList: MallKefuConversationApi.Conversation[]; // 会话列表
|
||||
conversationMessageList: Map<number, MallKefuMessageApi.Message[]>; // 会话消息
|
||||
}
|
||||
|
||||
export const useMallKefuStore = defineStore('mall-kefu', {
|
||||
state: (): MallKefuInfoVO => ({
|
||||
conversationList: [],
|
||||
conversationMessageList: new Map<number, MallKefuMessageApi.Message[]>(), // key 会话,value 会话消息列表
|
||||
}),
|
||||
getters: {
|
||||
getConversationList(): MallKefuConversationApi.Conversation[] {
|
||||
return this.conversationList;
|
||||
},
|
||||
getConversationMessageList(): (
|
||||
conversationId: number,
|
||||
) => MallKefuMessageApi.Message[] | undefined {
|
||||
return (conversationId: number) =>
|
||||
this.conversationMessageList.get(conversationId);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
// ======================= 会话消息相关 =======================
|
||||
/** 缓存历史消息 */
|
||||
saveMessageList(
|
||||
conversationId: number,
|
||||
messageList: MallKefuMessageApi.Message[],
|
||||
) {
|
||||
this.conversationMessageList.set(conversationId, messageList);
|
||||
},
|
||||
|
||||
// ======================= 会话相关 =======================
|
||||
/** 加载会话缓存列表 */
|
||||
async setConversationList() {
|
||||
// TODO @jave:idea linter 告警,修复下;
|
||||
// TODO @jave:不使用 KeFuConversationApi.,直接用 getConversationList
|
||||
this.conversationList = await KeFuConversationApi.getConversationList();
|
||||
this.conversationSort();
|
||||
},
|
||||
/** 更新会话缓存已读 */
|
||||
async updateConversationStatus(conversationId: number) {
|
||||
if (isEmpty(this.conversationList)) {
|
||||
return;
|
||||
}
|
||||
const conversation = this.conversationList.find(
|
||||
(item) => item.id === conversationId,
|
||||
);
|
||||
conversation && (conversation.adminUnreadMessageCount = 0);
|
||||
},
|
||||
/** 更新会话缓存 */
|
||||
async updateConversation(conversationId: number) {
|
||||
if (isEmpty(this.conversationList)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation =
|
||||
await KeFuConversationApi.getConversation(conversationId);
|
||||
this.deleteConversation(conversationId);
|
||||
conversation && this.conversationList.push(conversation);
|
||||
this.conversationSort();
|
||||
},
|
||||
/** 删除会话缓存 */
|
||||
deleteConversation(conversationId: number) {
|
||||
const index = this.conversationList.findIndex(
|
||||
(item) => item.id === conversationId,
|
||||
);
|
||||
// 存在则删除
|
||||
if (index !== -1) {
|
||||
this.conversationList.splice(index, 1);
|
||||
}
|
||||
},
|
||||
conversationSort() {
|
||||
// 按置顶属性和最后消息时间排序
|
||||
this.conversationList.sort((a, b) => {
|
||||
// 按照置顶排序,置顶的会在前面
|
||||
if (a.adminPinned !== b.adminPinned) {
|
||||
return a.adminPinned ? -1 : 1;
|
||||
}
|
||||
// 按照最后消息时间排序,最近的会在前面
|
||||
return (
|
||||
(b.lastMessageTime as unknown as number) -
|
||||
(a.lastMessageTime as unknown as number)
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 解决热更新问题
|
||||
const hot = import.meta.hot;
|
||||
if (hot) {
|
||||
hot.accept(acceptHMRUpdate(useMallKefuStore, hot));
|
||||
}
|
||||
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/a.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/aini.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/aixin.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/baiyan.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/bizui.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/buhaoyisi.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/bukesiyi.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/dajing.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/danao.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/daxiao.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/dianzan.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/emo.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/esi.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/fadai.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/fankun.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/feiwen.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/fennu.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/ganga.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/ganmao.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/hanyan.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/haochi.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/hongxin.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/huaixiao.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/jingkong.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/jingshu.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/jingya.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/kaixin.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/keai.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/keshui.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/kun.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/lengku.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/liuhan.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/liulei.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/mengbi.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/nanguo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/outu.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg t="1720063872285" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6895"
|
||||
width="200" height="200">
|
||||
<path d="M782.16 880.98c-179.31 23.91-361 23.91-540.32 0C138.89 867.25 62 779.43 62 675.57V348.43c0-103.86 76.89-191.69 179.84-205.41 179.31-23.91 361-23.91 540.31 0C885.11 156.75 962 244.57 962 348.43v327.13c0 103.87-76.89 191.69-179.84 205.42z"
|
||||
fill="#FF554D" p-id="6896"></path>
|
||||
<path d="M226.11 596.86c-9.74 47.83 17.26 95.6 63.48 111.3C333.49 723.08 394.55 737 469.53 737c59.25 0 105.46-8.69 140.23-19.7 51.59-16.34 79.94-71.16 63.37-122.68-24.47-76.11-65.57-180.7-106.68-180.7-64.62 0-64.62 96.92-64.62 96.92S437.22 317 372.61 317c-82.11 0-117.85 139.12-146.5 279.86z"
|
||||
fill="#FFFFFF" p-id="6897"></path>
|
||||
<path d="M782 347m-60 0a60 60 0 1 0 120 0 60 60 0 1 0-120 0Z" fill="#FFBC55" p-id="6898"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/shengqi.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/shuizhuo.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/tianshi.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/xiaoku.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/xinsui.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/xiong.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/yiwen.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/yun.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
apps/web-ele/src/views/mall/promotion/kefu/asserts/ziya.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
@@ -1,26 +1,116 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
|
||||
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { useWebSocket } from '@vueuse/core';
|
||||
import { ElContainer, ElMessage } from 'element-plus';
|
||||
|
||||
import { useMallKefuStore } from '#/store/mall/kefu';
|
||||
|
||||
import ConversationList from './modules/conversation-list.vue';
|
||||
import MemberInfo from './modules/member/member-info.vue';
|
||||
import MessageList from './modules/message-list.vue';
|
||||
import { WebSocketMessageTypeConstants } from './modules/tools/constants';
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const kefuStore = useMallKefuStore(); // 客服缓存
|
||||
|
||||
/** 组件引用 */
|
||||
const messageListRef = ref<InstanceType<typeof MessageList>>();
|
||||
const memberInfoRef = ref<InstanceType<typeof MemberInfo>>();
|
||||
const conversationListRef = ref<InstanceType<typeof ConversationList>>();
|
||||
|
||||
// ======================= WebSocket start =======================
|
||||
const url = `${`${import.meta.env.VITE_BASE_URL}/infra/ws`.replace(
|
||||
'http',
|
||||
'ws',
|
||||
)}?token=${accessStore.refreshToken}`; // 使用 refreshToken() :WebSocket 无法方便的刷新访问令牌
|
||||
const server = ref(url); // WebSocket 服务地址
|
||||
|
||||
/** 发起 WebSocket 连接 */
|
||||
const { data, close, open } = useWebSocket(server.value, {
|
||||
autoReconnect: true,
|
||||
heartbeat: true,
|
||||
});
|
||||
|
||||
/** 监听 WebSocket 数据 */
|
||||
watch(
|
||||
() => data.value,
|
||||
(newData) => {
|
||||
if (!newData) return;
|
||||
try {
|
||||
// 1. 收到心跳
|
||||
if (newData === 'pong') return;
|
||||
|
||||
// 2.1 解析 type 消息类型
|
||||
const jsonMessage = JSON.parse(newData);
|
||||
const type = jsonMessage.type;
|
||||
if (!type) {
|
||||
ElMessage.error(`未知的消息类型:${newData}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.2 消息类型:KEFU_MESSAGE_TYPE
|
||||
if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_TYPE) {
|
||||
const message = JSON.parse(jsonMessage.content);
|
||||
// 刷新会话列表
|
||||
kefuStore.updateConversation(message.conversationId);
|
||||
// 刷新消息列表
|
||||
messageListRef.value?.refreshMessageList(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2.3 消息类型:KEFU_MESSAGE_ADMIN_READ
|
||||
if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_ADMIN_READ) {
|
||||
// 更新会话已读
|
||||
const message = JSON.parse(jsonMessage.content);
|
||||
kefuStore.updateConversationStatus(message.conversationId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: false, // 不立即执行
|
||||
},
|
||||
);
|
||||
// ======================= WebSocket end =======================
|
||||
|
||||
function handleChange(conversation: MallKefuConversationApi.Conversation) {
|
||||
messageListRef.value?.getNewMessageList(conversation);
|
||||
memberInfoRef.value?.initHistory(conversation);
|
||||
}
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
// 加载会话列表
|
||||
kefuStore.setConversationList().then(() => {
|
||||
conversationListRef.value?.calculationLastMessageTime();
|
||||
});
|
||||
// 打开 websocket 连接
|
||||
open();
|
||||
});
|
||||
|
||||
/** 销毁 */
|
||||
onBeforeUnmount(() => {
|
||||
// 关闭 websocket 连接
|
||||
close();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<Button
|
||||
danger
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3"
|
||||
>
|
||||
该功能支持 Vue3 + element-plus 版本!
|
||||
</Button>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
target="_blank"
|
||||
href="https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/promotion/kefu/index"
|
||||
>
|
||||
可参考
|
||||
https://github.com/yudaocode/yudao-ui-admin-vue3/blob/master/src/views/mall/promotion/kefu/index
|
||||
代码,pull request 贡献给我们!
|
||||
</Button>
|
||||
<Page auto-content-height>
|
||||
<ElContainer class="absolute left-0 top-0 flex h-full w-full flex-1">
|
||||
<!-- 会话列表 -->
|
||||
<ConversationList ref="conversationListRef" @change="handleChange" />
|
||||
<!-- 会话详情(选中会话的消息列表) -->
|
||||
<MessageList ref="messageListRef" />
|
||||
<!-- 会员信息(选中会话的会员信息) -->
|
||||
<MemberInfo ref="memberInfoRef" />
|
||||
</ElContainer>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { confirm } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatPast, jsonParse } from '@vben/utils';
|
||||
|
||||
import { ElAside, ElAvatar, ElBadge, ElMessage } from 'element-plus';
|
||||
|
||||
import {
|
||||
deleteConversation,
|
||||
updateConversationPinned,
|
||||
} from '#/api/mall/promotion/kefu/conversation';
|
||||
import { useMallKefuStore } from '#/store/mall/kefu';
|
||||
|
||||
import { KeFuMessageContentTypeEnum } from './tools/constants';
|
||||
import { useEmoji } from './tools/emoji';
|
||||
|
||||
/** 打开右侧的消息列表 */
|
||||
const emits = defineEmits<{
|
||||
(e: 'change', v: MallKefuConversationApi.Conversation): void;
|
||||
}>();
|
||||
const kefuStore = useMallKefuStore(); // 客服缓存
|
||||
const { replaceEmoji } = useEmoji();
|
||||
const activeConversationId = ref(-1); // 选中的会话
|
||||
const collapse = ref(false); // 折叠菜单
|
||||
|
||||
/** 计算消息最后发送时间距离现在过去了多久 */
|
||||
const lastMessageTimeMap = ref<Map<number, string>>(new Map<number, string>());
|
||||
function calculationLastMessageTime() {
|
||||
kefuStore.getConversationList?.forEach((item) => {
|
||||
lastMessageTimeMap.value.set(
|
||||
item.id,
|
||||
formatPast(item.lastMessageTime, 'YYYY-MM-DD'),
|
||||
);
|
||||
});
|
||||
}
|
||||
defineExpose({ calculationLastMessageTime });
|
||||
|
||||
function openRightMessage(item: MallKefuConversationApi.Conversation) {
|
||||
// 同一个会话则不处理
|
||||
if (activeConversationId.value === item.id) {
|
||||
return;
|
||||
}
|
||||
activeConversationId.value = item.id;
|
||||
emits('change', item);
|
||||
}
|
||||
|
||||
/** 获得消息类型 */
|
||||
const getConversationDisplayText = computed(
|
||||
() => (lastMessageContentType: number, lastMessageContent: string) => {
|
||||
switch (lastMessageContentType) {
|
||||
case KeFuMessageContentTypeEnum.IMAGE: {
|
||||
return '[图片消息]';
|
||||
}
|
||||
case KeFuMessageContentTypeEnum.ORDER: {
|
||||
return '[订单消息]';
|
||||
}
|
||||
case KeFuMessageContentTypeEnum.PRODUCT: {
|
||||
return '[商品消息]';
|
||||
}
|
||||
case KeFuMessageContentTypeEnum.SYSTEM: {
|
||||
return '[系统消息]';
|
||||
}
|
||||
case KeFuMessageContentTypeEnum.TEXT: {
|
||||
return replaceEmoji(
|
||||
jsonParse(lastMessageContent).text || lastMessageContent,
|
||||
);
|
||||
}
|
||||
case KeFuMessageContentTypeEnum.VIDEO: {
|
||||
return '[视频消息]';
|
||||
}
|
||||
case KeFuMessageContentTypeEnum.VOICE: {
|
||||
return '[语音消息]';
|
||||
}
|
||||
default: {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ======================= 右键菜单 =======================
|
||||
const showRightMenu = ref(false); // 显示右键菜单
|
||||
const rightMenuStyle = ref<any>({}); // 右键菜单 Style
|
||||
const rightClickConversation = ref<MallKefuConversationApi.Conversation>(
|
||||
{} as MallKefuConversationApi.Conversation,
|
||||
); // 右键选中的会话对象
|
||||
|
||||
/** 打开右键菜单 */
|
||||
function rightClick(
|
||||
mouseEvent: PointerEvent,
|
||||
item: MallKefuConversationApi.Conversation,
|
||||
) {
|
||||
rightClickConversation.value = item;
|
||||
// 显示右键菜单
|
||||
showRightMenu.value = true;
|
||||
rightMenuStyle.value = {
|
||||
top: `${mouseEvent.clientY - 110}px`,
|
||||
left: collapse.value
|
||||
? `${mouseEvent.clientX - 80}px`
|
||||
: `${mouseEvent.clientX - 210}px`,
|
||||
};
|
||||
}
|
||||
/** 关闭右键菜单 */
|
||||
function closeRightMenu() {
|
||||
showRightMenu.value = false;
|
||||
}
|
||||
|
||||
/** 置顶会话 */
|
||||
async function updateConversationPinnedFn(pinned: boolean) {
|
||||
// 1. 会话置顶/取消置顶
|
||||
await updateConversationPinned({
|
||||
id: rightClickConversation.value.id,
|
||||
pinned,
|
||||
});
|
||||
ElMessage.success(pinned ? '置顶成功' : '取消置顶成功');
|
||||
// 2. 关闭右键菜单,更新会话列表
|
||||
closeRightMenu();
|
||||
await kefuStore.updateConversation(rightClickConversation.value.id);
|
||||
}
|
||||
|
||||
/** 删除会话 */
|
||||
async function deleteConversationFn() {
|
||||
// 1. 删除会话
|
||||
confirm({
|
||||
content: '您确定要删除该会话吗?',
|
||||
}).then(async () => {
|
||||
await deleteConversation(rightClickConversation.value.id);
|
||||
// 2. 关闭右键菜单,更新会话列表
|
||||
closeRightMenu();
|
||||
kefuStore.deleteConversation(rightClickConversation.value.id);
|
||||
});
|
||||
}
|
||||
|
||||
/** 监听右键菜单的显示状态,添加点击事件监听器 */
|
||||
watch(showRightMenu, (val) => {
|
||||
if (val) {
|
||||
document.body.addEventListener('click', closeRightMenu);
|
||||
} else {
|
||||
document.body.removeEventListener('click', closeRightMenu);
|
||||
}
|
||||
});
|
||||
|
||||
const timer = ref<any>();
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(() => {
|
||||
timer.value = setInterval(calculationLastMessageTime, 1000 * 10); // 十秒计算一次
|
||||
});
|
||||
|
||||
/** 组件卸载前 */
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElAside class="!bg-card pt-[5px]" width="260px">
|
||||
<div class="my-[10px] font-bold text-[#999]">
|
||||
会话记录({{ kefuStore.getConversationList.length }})
|
||||
</div>
|
||||
<div
|
||||
v-for="item in kefuStore.getConversationList"
|
||||
:key="item.id"
|
||||
:class="{
|
||||
'bg-gray-500/50': item.id === activeConversationId,
|
||||
}"
|
||||
class="flex h-[60px] cursor-pointer items-center px-[10px]"
|
||||
@click="openRightMessage(item)"
|
||||
@contextmenu.prevent="rightClick($event as PointerEvent, item)"
|
||||
>
|
||||
<div class="flex w-full items-center justify-center">
|
||||
<div class="flex h-[50px] w-[50px] items-center justify-center">
|
||||
<!-- 头像 + 未读 -->
|
||||
<ElBadge
|
||||
:hidden="item.adminUnreadMessageCount === 0"
|
||||
:max="99"
|
||||
:value="item.adminUnreadMessageCount"
|
||||
>
|
||||
<ElAvatar :src="item.userAvatar" alt="avatar" />
|
||||
</ElBadge>
|
||||
</div>
|
||||
<div class="ml-[10px] w-full">
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<span class="line-clamp-1 min-w-0 max-w-[60%]">{{
|
||||
item.userNickname || 'null'
|
||||
}}</span>
|
||||
<span class="text-[13px] text-[#999]">
|
||||
{{ lastMessageTimeMap.get(item.id) ?? '计算中' }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 最后聊天内容 -->
|
||||
<div
|
||||
v-dompurify-html="
|
||||
getConversationDisplayText(
|
||||
item.lastMessageContentType,
|
||||
item.lastMessageContent,
|
||||
)
|
||||
"
|
||||
class="line-clamp-1 flex items-center text-[13px] text-[#999]"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右键,进行操作(类似微信) -->
|
||||
<ul
|
||||
v-show="showRightMenu"
|
||||
:style="rightMenuStyle"
|
||||
class="absolute z-[1999] m-0 w-[130px] list-none rounded-xl bg-[hsl(var(--background))] p-[5px] shadow-md"
|
||||
>
|
||||
<li
|
||||
v-show="!rightClickConversation.adminPinned"
|
||||
class="flex cursor-pointer items-center rounded-xl px-4 py-2 transition-colors hover:bg-gray-500/50"
|
||||
@click.stop="updateConversationPinnedFn(true)"
|
||||
>
|
||||
<IconifyIcon class="mr-[5px]" icon="ep:top" />
|
||||
置顶会话
|
||||
</li>
|
||||
<li
|
||||
v-show="rightClickConversation.adminPinned"
|
||||
class="flex cursor-pointer items-center rounded-xl px-4 py-2 transition-colors hover:bg-gray-500/50"
|
||||
@click.stop="updateConversationPinnedFn(false)"
|
||||
>
|
||||
<IconifyIcon class="mr-[5px]" icon="ep:bottom" />
|
||||
取消置顶
|
||||
</li>
|
||||
<li
|
||||
class="flex cursor-pointer items-center rounded-xl px-4 py-2 transition-colors hover:bg-gray-500/50"
|
||||
@click.stop="deleteConversationFn"
|
||||
>
|
||||
<IconifyIcon class="mr-[5px]" color="red" icon="ep:delete" />
|
||||
删除会话
|
||||
</li>
|
||||
<li
|
||||
class="flex cursor-pointer items-center rounded-xl px-4 py-2 transition-colors hover:bg-gray-500/50"
|
||||
@click.stop="closeRightMenu"
|
||||
>
|
||||
<IconifyIcon class="mr-[5px]" color="red" icon="ep:close" />
|
||||
取消
|
||||
</li>
|
||||
</ul>
|
||||
</ElAside>
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as ConversationList } from './conversation-list.vue';
|
||||
export { default as MemberInfo } from './member/member-info.vue';
|
||||
export { default as MessageList } from './message-list.vue';
|
||||
@@ -0,0 +1,232 @@
|
||||
<!-- 右侧信息:会员信息 + 最近浏览 + 交易订单 -->
|
||||
<script lang="ts" setup>
|
||||
import type { UseScrollReturn } from '@vueuse/core';
|
||||
|
||||
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
|
||||
import type { MemberUserApi } from '#/api/member/user';
|
||||
import type { PayWalletApi } from '#/api/pay/wallet/balance';
|
||||
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
import { vScroll } from '@vueuse/components';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import {
|
||||
ElCard,
|
||||
ElContainer,
|
||||
ElEmpty,
|
||||
ElHeader,
|
||||
ElMain,
|
||||
ElMessage,
|
||||
} from 'element-plus';
|
||||
|
||||
import { getUser } from '#/api/member/user';
|
||||
import { getWallet } from '#/api/pay/wallet/balance';
|
||||
import AccountInfo from '#/views/member/user/detail/modules/account-info.vue';
|
||||
import BasicInfo from '#/views/member/user/detail/modules/basic-info.vue';
|
||||
|
||||
import OrderBrowsingHistory from './order-browsing-history.vue';
|
||||
import ProductBrowsingHistory from './product-browsing-history.vue';
|
||||
|
||||
const activeTab = ref<'交易订单' | '会员信息' | '最近浏览'>('会员信息');
|
||||
|
||||
const tabActivation = computed(() => (tab: string) => activeTab.value === tab);
|
||||
|
||||
/** tab 切换 */
|
||||
const productBrowsingHistoryRef =
|
||||
ref<InstanceType<typeof ProductBrowsingHistory>>();
|
||||
const orderBrowsingHistoryRef =
|
||||
ref<InstanceType<typeof OrderBrowsingHistory>>();
|
||||
async function handleClick(tab: string) {
|
||||
activeTab.value = tab;
|
||||
await nextTick();
|
||||
await getHistoryList();
|
||||
}
|
||||
|
||||
/** 获得历史数据 */
|
||||
async function getHistoryList() {
|
||||
switch (activeTab.value) {
|
||||
case '交易订单': {
|
||||
await orderBrowsingHistoryRef.value?.getHistoryList(conversation.value);
|
||||
break;
|
||||
}
|
||||
case '会员信息': {
|
||||
await getUserData();
|
||||
await getUserWallet();
|
||||
break;
|
||||
}
|
||||
case '最近浏览': {
|
||||
await productBrowsingHistoryRef.value?.getHistoryList(conversation.value);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载下一页数据 */
|
||||
async function loadMore() {
|
||||
switch (activeTab.value) {
|
||||
case '交易订单': {
|
||||
await orderBrowsingHistoryRef.value?.loadMore();
|
||||
break;
|
||||
}
|
||||
case '会员信息': {
|
||||
break;
|
||||
}
|
||||
case '最近浏览': {
|
||||
await productBrowsingHistoryRef.value?.loadMore();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 浏览历史初始化 */
|
||||
const conversation = ref<MallKefuConversationApi.Conversation>(
|
||||
{} as MallKefuConversationApi.Conversation,
|
||||
); // 用户会话
|
||||
async function initHistory(val: MallKefuConversationApi.Conversation) {
|
||||
activeTab.value = '会员信息';
|
||||
conversation.value = val;
|
||||
await nextTick();
|
||||
await getHistoryList();
|
||||
}
|
||||
defineExpose({ initHistory });
|
||||
|
||||
/** 处理消息列表滚动事件(debounce 限流) */
|
||||
const scrollbarRef = ref<InstanceType<any>>();
|
||||
const handleScroll = useDebounceFn(async (state: UseScrollReturn) => {
|
||||
const { arrivedState } = state;
|
||||
if (arrivedState.bottom) {
|
||||
await loadMore();
|
||||
}
|
||||
}, 200);
|
||||
|
||||
/** 查询用户钱包信息 */
|
||||
// TODO @jawe:idea 的导入报错;需要看下;
|
||||
const WALLET_INIT_DATA = {
|
||||
balance: 0,
|
||||
totalExpense: 0,
|
||||
totalRecharge: 0,
|
||||
} as PayWalletApi.Wallet; // 钱包初始化数据
|
||||
const wallet = ref<PayWalletApi.Wallet>(WALLET_INIT_DATA); // 钱包信息
|
||||
|
||||
async function getUserWallet() {
|
||||
if (!conversation.value.userId) {
|
||||
wallet.value = WALLET_INIT_DATA;
|
||||
return;
|
||||
}
|
||||
wallet.value =
|
||||
(await getWallet({ userId: conversation.value.userId })) ||
|
||||
WALLET_INIT_DATA;
|
||||
}
|
||||
|
||||
/** 获得用户 */
|
||||
const loading = ref(true); // 加载中
|
||||
const user = ref<MemberUserApi.User>({} as MemberUserApi.User);
|
||||
async function getUserData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getUser(conversation.value.userId);
|
||||
if (res) {
|
||||
user.value = res;
|
||||
} else {
|
||||
user.value = {} as MemberUserApi.User;
|
||||
ElMessage.error('会员不存在!');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElContainer
|
||||
class="relative w-[300px] bg-[var(--background)] after:absolute after:left-0 after:top-0 after:h-full after:w-[1px] after:scale-x-[0.3] after:bg-[var(--el-border-color)] after:content-['']"
|
||||
>
|
||||
<ElHeader
|
||||
class="relative flex items-center justify-around bg-[var(--background)] before:absolute before:bottom-0 before:left-0 before:h-[1px] before:w-full before:scale-y-[0.3] before:bg-[var(--el-border-color)] before:content-['']"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'before:border-b-2 before:border-gray-500/50':
|
||||
tabActivation('会员信息'),
|
||||
}"
|
||||
class="relative flex h-full w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
|
||||
@click="handleClick('会员信息')"
|
||||
>
|
||||
会员信息
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'before:border-b-2 before:border-gray-500/50':
|
||||
tabActivation('最近浏览'),
|
||||
}"
|
||||
class="relative flex h-full w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
|
||||
@click="handleClick('最近浏览')"
|
||||
>
|
||||
最近浏览
|
||||
</div>
|
||||
<div
|
||||
:class="{
|
||||
'before:border-b-2 before:border-gray-500/50':
|
||||
tabActivation('交易订单'),
|
||||
}"
|
||||
class="relative flex h-full w-full cursor-pointer items-center justify-center before:pointer-events-none before:absolute before:inset-0 before:content-[''] hover:before:border-b-2 hover:before:border-gray-500/50"
|
||||
@click="handleClick('交易订单')"
|
||||
>
|
||||
交易订单
|
||||
</div>
|
||||
</ElHeader>
|
||||
<ElMain class="relative m-0 h-full w-full p-[10px]">
|
||||
<template v-if="!isEmpty(conversation)">
|
||||
<div
|
||||
v-loading="loading"
|
||||
v-if="activeTab === '会员信息'"
|
||||
class="relative h-full overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<!-- 基本信息 -->
|
||||
<BasicInfo :user="user" mode="kefu">
|
||||
<template #title>
|
||||
<span class="text-sm font-bold">基本信息</span>
|
||||
</template>
|
||||
</BasicInfo>
|
||||
<!-- 账户信息 -->
|
||||
<ElCard class="mt-10px h-full" shadow="never">
|
||||
<template #title>
|
||||
<span class="text-sm font-bold">账户信息</span>
|
||||
</template>
|
||||
<AccountInfo :column="1" :user="user" :wallet="wallet" />
|
||||
</ElCard>
|
||||
</div>
|
||||
<div
|
||||
v-show="activeTab !== '会员信息'"
|
||||
ref="scrollbarRef"
|
||||
v-scroll="handleScroll"
|
||||
class="relative h-full overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<!-- 最近浏览 -->
|
||||
<ProductBrowsingHistory
|
||||
v-if="activeTab === '最近浏览'"
|
||||
ref="productBrowsingHistoryRef"
|
||||
/>
|
||||
<!-- 交易订单 -->
|
||||
<OrderBrowsingHistory
|
||||
v-if="activeTab === '交易订单'"
|
||||
ref="orderBrowsingHistoryRef"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<ElEmpty
|
||||
v-else
|
||||
description="请选择左侧的一个会话后开始"
|
||||
class="mt-[20%]"
|
||||
/>
|
||||
</ElMain>
|
||||
</ElContainer>
|
||||
</template>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
|
||||
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { getOrderPage } from '#/api/mall/trade/order';
|
||||
import OrderItem from '#/views/mall/promotion/kefu/modules/message/order-item.vue';
|
||||
|
||||
const list = ref<any>([]); // 列表
|
||||
const total = ref(0); // 总数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userId: 0,
|
||||
});
|
||||
const skipGetMessageList = computed(() => {
|
||||
// 已加载到最后一页的话则不触发新的消息获取
|
||||
return (
|
||||
total.value > 0 &&
|
||||
Math.ceil(total.value / queryParams.pageSize) === queryParams.pageNo
|
||||
);
|
||||
}); // 跳过消息获取
|
||||
|
||||
/** 获得浏览记录 */
|
||||
async function getHistoryList(val: MallKefuConversationApi.Conversation) {
|
||||
queryParams.userId = val.userId;
|
||||
const res = await getOrderPage(queryParams);
|
||||
total.value = res.total;
|
||||
list.value = res.list;
|
||||
}
|
||||
|
||||
/** 加载下一页数据 */
|
||||
async function loadMore() {
|
||||
if (skipGetMessageList.value) {
|
||||
return;
|
||||
}
|
||||
queryParams.pageNo += 1;
|
||||
const res = await getOrderPage(queryParams);
|
||||
total.value = res.total;
|
||||
list.value = [...list.value, ...res.list];
|
||||
}
|
||||
defineExpose({ getHistoryList, loadMore });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OrderItem v-for="item in list" :key="item.id" :order="item" />
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
|
||||
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { getBrowseHistoryPage } from '#/api/mall/product/history';
|
||||
import ProductItem from '#/views/mall/promotion/kefu/modules/message/product-item.vue';
|
||||
|
||||
const list = ref<any>([]); // 列表
|
||||
const total = ref(0); // 总数
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
userId: 0,
|
||||
userDeleted: false,
|
||||
});
|
||||
const skipGetMessageList = computed(() => {
|
||||
// 已加载到最后一页的话则不触发新的消息获取
|
||||
return (
|
||||
total.value > 0 &&
|
||||
Math.ceil(total.value / queryParams.pageSize) === queryParams.pageNo
|
||||
);
|
||||
}); // 跳过消息获取
|
||||
|
||||
/** 获得浏览记录 */
|
||||
async function getHistoryList(val: MallKefuConversationApi.Conversation) {
|
||||
queryParams.userId = val.userId;
|
||||
const res = await getBrowseHistoryPage(queryParams);
|
||||
total.value = res.total;
|
||||
list.value = res.list;
|
||||
}
|
||||
|
||||
/** 加载下一页数据 */
|
||||
async function loadMore() {
|
||||
if (skipGetMessageList.value) {
|
||||
return;
|
||||
}
|
||||
queryParams.pageNo += 1;
|
||||
const res = await getBrowseHistoryPage(queryParams);
|
||||
total.value = res.total;
|
||||
list.value = [...list.value, ...res.list];
|
||||
}
|
||||
defineExpose({ getHistoryList, loadMore });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ProductItem
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
:pic-url="item.picUrl"
|
||||
:price="item.price"
|
||||
:sales-count="item.salesCount"
|
||||
:spu-id="item.spuId"
|
||||
:stock="item.stock"
|
||||
:title="item.spuName"
|
||||
class="mb-10px"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,424 @@
|
||||
<script lang="ts" setup>
|
||||
import type { UseScrollReturn } from '@vueuse/core';
|
||||
|
||||
import type { Emoji } from './tools/emoji';
|
||||
|
||||
import type { MallKefuConversationApi } from '#/api/mall/promotion/kefu/conversation';
|
||||
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
|
||||
|
||||
import { computed, reactive, ref, unref } from 'vue';
|
||||
|
||||
import { UserTypeEnum } from '@vben/constants';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
import { formatDate, isEmpty, jsonParse } from '@vben/utils';
|
||||
|
||||
import { vScroll } from '@vueuse/components';
|
||||
import { useDebounceFn, useScroll } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import {
|
||||
ElAvatar,
|
||||
ElContainer,
|
||||
ElEmpty,
|
||||
ElFooter,
|
||||
ElHeader,
|
||||
ElImage,
|
||||
ElInput,
|
||||
ElMain,
|
||||
ElNotification,
|
||||
} from 'element-plus';
|
||||
|
||||
import {
|
||||
getKeFuMessageList,
|
||||
sendKeFuMessage,
|
||||
updateKeFuMessageReadStatus,
|
||||
} from '#/api/mall/promotion/kefu/message';
|
||||
import { useMallKefuStore } from '#/store/mall/kefu';
|
||||
|
||||
import MessageItem from './message/message-item.vue';
|
||||
import OrderItem from './message/order-item.vue';
|
||||
import ProductItem from './message/product-item.vue';
|
||||
import { KeFuMessageContentTypeEnum } from './tools/constants';
|
||||
import { useEmoji } from './tools/emoji';
|
||||
import EmojiSelectPopover from './tools/emoji-select-popover.vue';
|
||||
import PictureSelectUpload from './tools/picture-select-upload.vue';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
const message = ref(''); // 消息弹窗
|
||||
const { replaceEmoji } = useEmoji();
|
||||
const messageList = ref<MallKefuMessageApi.Message[]>([]); // 消息列表
|
||||
const conversation = ref<MallKefuConversationApi.Conversation>(
|
||||
{} as MallKefuConversationApi.Conversation,
|
||||
); // 用户会话
|
||||
const showNewMessageTip = ref(false); // 显示有新消息提示
|
||||
const queryParams = reactive({
|
||||
conversationId: 0,
|
||||
createTime: undefined,
|
||||
});
|
||||
const total = ref(0); // 消息总条数
|
||||
const refreshContent = ref(false); // 内容刷新,主要解决会话消息页面高度不一致导致的滚动功能精度失效
|
||||
const kefuStore = useMallKefuStore(); // 客服缓存
|
||||
const skipGetMessageList = ref(false); // 跳过消息获取
|
||||
const loadHistory = ref(false); // 加载历史消息
|
||||
|
||||
/** 获悉消息内容 */
|
||||
const getMessageContent = computed(
|
||||
() => (item: any) => jsonParse(item.content),
|
||||
);
|
||||
|
||||
/** 获得消息列表 */
|
||||
async function getMessageList() {
|
||||
const res: any = await getKeFuMessageList(queryParams as any);
|
||||
if (isEmpty(res)) {
|
||||
// 当返回的是空列表说明没有消息或者已经查询完了历史消息
|
||||
skipGetMessageList.value = true;
|
||||
return;
|
||||
}
|
||||
queryParams.createTime = formatDate(
|
||||
(res as any).at(-1).createTime,
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
) as any;
|
||||
|
||||
// 如果 createTime 存在,说明是加载历史消息,需要追加到列表
|
||||
if (queryParams.createTime) {
|
||||
// 情况一:加载历史消息,追加到列表
|
||||
for (const item of res as any) {
|
||||
pushMessage(item);
|
||||
}
|
||||
} else {
|
||||
// 情况二:加载最新消息,直接替换列表
|
||||
messageList.value = res;
|
||||
}
|
||||
refreshContent.value = true;
|
||||
}
|
||||
|
||||
/** 添加消息 */
|
||||
function pushMessage(message: any) {
|
||||
if (messageList.value.some((val) => val.id === message.id)) {
|
||||
return;
|
||||
}
|
||||
messageList.value.push(message);
|
||||
}
|
||||
|
||||
/** 按照时间倒序,获取消息列表 */
|
||||
const getMessageList0 = computed(() => {
|
||||
// 使用展开运算符创建新数组,避免直接修改原数组
|
||||
return [...messageList.value].sort(
|
||||
(a: any, b: any) => a.createTime - b.createTime,
|
||||
);
|
||||
});
|
||||
|
||||
/** 刷新消息列表 */
|
||||
async function refreshMessageList(message?: any) {
|
||||
if (!conversation.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message === undefined) {
|
||||
queryParams.createTime = undefined;
|
||||
await getMessageList();
|
||||
} else {
|
||||
// 当前查询会话与消息所属会话不一致则不做处理
|
||||
if (message.conversationId !== conversation.value.id) {
|
||||
return;
|
||||
}
|
||||
pushMessage(message);
|
||||
}
|
||||
|
||||
if (loadHistory.value) {
|
||||
// 右下角显示有新消息提示
|
||||
showNewMessageTip.value = true;
|
||||
} else {
|
||||
// 滚动到最新消息处
|
||||
await handleToNewMessage();
|
||||
}
|
||||
}
|
||||
|
||||
/** 获得新会话的消息列表, 点击切换时,读取缓存;然后异步获取新消息,merge 下; */
|
||||
async function getNewMessageList(val: MallKefuConversationApi.Conversation) {
|
||||
// 1. 缓存当前会话消息列表
|
||||
kefuStore.saveMessageList(conversation.value.id, messageList.value);
|
||||
// 2.1 会话切换,重置相关参数
|
||||
messageList.value = kefuStore.getConversationMessageList(val.id) || [];
|
||||
total.value = messageList.value.length || 0;
|
||||
loadHistory.value = false;
|
||||
refreshContent.value = false;
|
||||
skipGetMessageList.value = false;
|
||||
// 2.2 设置会话相关属性
|
||||
conversation.value = val;
|
||||
queryParams.conversationId = val.id;
|
||||
queryParams.createTime = undefined;
|
||||
// 3. 获取消息
|
||||
await refreshMessageList();
|
||||
}
|
||||
defineExpose({ getNewMessageList, refreshMessageList });
|
||||
|
||||
/** 是否显示聊天区域 */
|
||||
function showMessageList() {
|
||||
return !isEmpty(conversation.value);
|
||||
}
|
||||
|
||||
/** 处理表情选择 */
|
||||
function handleEmojiSelect(item: Emoji) {
|
||||
message.value += item.name;
|
||||
}
|
||||
|
||||
/** 处理图片发送 */
|
||||
async function handleSendPicture(picUrl: string) {
|
||||
// 组织发送消息
|
||||
const msg = {
|
||||
conversationId: conversation.value.id,
|
||||
contentType: KeFuMessageContentTypeEnum.IMAGE,
|
||||
content: JSON.stringify({ picUrl }),
|
||||
};
|
||||
await sendMessage(msg);
|
||||
}
|
||||
|
||||
/** 发送文本消息 */
|
||||
async function handleSendMessage(event: any) {
|
||||
// shift 不发送
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
// 1. 校验消息是否为空
|
||||
if (isEmpty(unref(message.value)?.trim())) {
|
||||
ElNotification.warning({ message: '请输入消息后再发送哦!' });
|
||||
message.value = '';
|
||||
return;
|
||||
}
|
||||
// 2. 组织发送消息
|
||||
const msg = {
|
||||
conversationId: conversation.value.id,
|
||||
contentType: KeFuMessageContentTypeEnum.TEXT,
|
||||
content: JSON.stringify({ text: message.value }),
|
||||
};
|
||||
await sendMessage(msg);
|
||||
}
|
||||
|
||||
/** 真正发送消息 【共用】*/
|
||||
async function sendMessage(msg: MallKefuMessageApi.MessageSend) {
|
||||
// 发送消息
|
||||
await sendKeFuMessage(msg);
|
||||
message.value = '';
|
||||
// 加载消息列表
|
||||
await refreshMessageList();
|
||||
// 更新会话缓存
|
||||
await kefuStore.updateConversation(conversation.value.id);
|
||||
}
|
||||
|
||||
/** 滚动到底部 */
|
||||
const scrollbarRef = ref<HTMLElement | null>(null);
|
||||
const { y } = useScroll(scrollbarRef);
|
||||
|
||||
async function scrollToBottom() {
|
||||
if (!scrollbarRef.value) return;
|
||||
// 1. 首次加载时滚动到最新消息,如果加载的是历史消息则不滚动
|
||||
if (loadHistory.value) {
|
||||
return;
|
||||
}
|
||||
// 2.1 滚动到最新消息,关闭新消息提示
|
||||
// 使用 useScroll 监听滚动容器
|
||||
y.value = scrollbarRef.value.scrollHeight - scrollbarRef.value!.clientHeight;
|
||||
showNewMessageTip.value = false;
|
||||
// 2.2 消息已读
|
||||
await updateKeFuMessageReadStatus(conversation.value.id);
|
||||
}
|
||||
|
||||
/** 查看新消息 */
|
||||
async function handleToNewMessage() {
|
||||
loadHistory.value = false;
|
||||
await scrollToBottom();
|
||||
}
|
||||
|
||||
/** 处理消息列表滚动事件(debounce 限流) */
|
||||
const handleScroll = useDebounceFn((state: UseScrollReturn) => {
|
||||
const { arrivedState } = state;
|
||||
if (skipGetMessageList.value) {
|
||||
return;
|
||||
}
|
||||
// 滚动到底部了
|
||||
if (arrivedState.bottom) {
|
||||
loadHistory.value = false;
|
||||
refreshMessageList();
|
||||
}
|
||||
|
||||
// 触顶自动加载下一页数据
|
||||
if (arrivedState.top) {
|
||||
handleOldMessage();
|
||||
}
|
||||
}, 200);
|
||||
/** 加载历史消息 */
|
||||
async function handleOldMessage() {
|
||||
loadHistory.value = true;
|
||||
await getMessageList();
|
||||
}
|
||||
|
||||
/** 是否显示时间 */
|
||||
function showTime(item: MallKefuMessageApi.Message, index: number) {
|
||||
if (unref(messageList.value)[index + 1]) {
|
||||
const dateString = dayjs(
|
||||
unref(messageList.value)[index + 1].createTime,
|
||||
).fromNow();
|
||||
return dateString !== dayjs(unref(item).createTime).fromNow();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElContainer
|
||||
v-if="showMessageList()"
|
||||
class="bg-card relative w-[calc(100%-300px-260px)]"
|
||||
>
|
||||
<ElHeader
|
||||
class="!bg-card border-border flex items-center justify-between border-b"
|
||||
>
|
||||
<div class="text-lg font-bold">{{ conversation.userNickname }}</div>
|
||||
</ElHeader>
|
||||
<ElMain class="relative m-0 p-0">
|
||||
<div
|
||||
ref="scrollbarRef"
|
||||
class="absolute inset-0 m-0 overflow-y-auto overflow-x-hidden p-0"
|
||||
v-scroll="handleScroll"
|
||||
>
|
||||
<!-- <div v-if="refreshContent" ref="innerRef" class="w-full px-[10px] absolute inset-0 m-0 overflow-x-hidden p-0 overflow-y-auto"> -->
|
||||
<!-- 消息列表 -->
|
||||
<div
|
||||
v-for="(item, index) in getMessageList0"
|
||||
:key="item.id"
|
||||
class="w-full"
|
||||
>
|
||||
<div class="mb-[20px] flex items-center justify-center">
|
||||
<!-- 日期 -->
|
||||
<div
|
||||
v-if="
|
||||
item.contentType !== KeFuMessageContentTypeEnum.SYSTEM &&
|
||||
showTime(item, index)
|
||||
"
|
||||
class="w-fit rounded-lg bg-black/10 px-[5px] text-[10px] text-white"
|
||||
>
|
||||
{{ formatDate(item.createTime) }}
|
||||
</div>
|
||||
<!-- 系统消息 -->
|
||||
<div
|
||||
v-if="item.contentType === KeFuMessageContentTypeEnum.SYSTEM"
|
||||
class="w-fit rounded-lg bg-black/10 px-[5px] text-[10px] text-white"
|
||||
>
|
||||
{{ item.content }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
item.senderType === UserTypeEnum.MEMBER
|
||||
? 'justify-start'
|
||||
: item.senderType === UserTypeEnum.ADMIN
|
||||
? 'justify-end'
|
||||
: '',
|
||||
]"
|
||||
class="mb-[20px] flex w-full"
|
||||
>
|
||||
<ElAvatar
|
||||
v-if="item.senderType === UserTypeEnum.MEMBER"
|
||||
:src="conversation.userAvatar"
|
||||
alt="avatar"
|
||||
class="h-[60px] w-[60px]"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
'w-auto max-w-[50%] px-[10px] py-[5px] font-medium text-[#414141] transition-all duration-200 hover:scale-105':
|
||||
KeFuMessageContentTypeEnum.TEXT === item.contentType,
|
||||
'ml-[10px] mt-[3px] rounded-bl-[10px] rounded-br-[10px] rounded-tr-[10px] bg-white':
|
||||
KeFuMessageContentTypeEnum.TEXT === item.contentType &&
|
||||
item.senderType === UserTypeEnum.MEMBER,
|
||||
'mr-[10px] mt-[3px] rounded-bl-[10px] rounded-br-[10px] rounded-tl-[10px] bg-[rgb(206_223_255)]':
|
||||
KeFuMessageContentTypeEnum.TEXT === item.contentType &&
|
||||
item.senderType === UserTypeEnum.ADMIN,
|
||||
}"
|
||||
>
|
||||
<!-- 文本消息 -->
|
||||
<MessageItem :message="item">
|
||||
<template
|
||||
v-if="KeFuMessageContentTypeEnum.TEXT === item.contentType"
|
||||
>
|
||||
<div
|
||||
v-dompurify-html="
|
||||
replaceEmoji(getMessageContent(item).text || item.content)
|
||||
"
|
||||
class="line-height-normal h-1/1 w-full text-justify"
|
||||
></div>
|
||||
</template>
|
||||
</MessageItem>
|
||||
<!-- 图片消息 -->
|
||||
<MessageItem :message="item">
|
||||
<ElImage
|
||||
v-if="KeFuMessageContentTypeEnum.IMAGE === item.contentType"
|
||||
:src="getMessageContent(item).picUrl || item.content"
|
||||
class="mx-[10px] !w-[200px]"
|
||||
fit="cover"
|
||||
/>
|
||||
</MessageItem>
|
||||
<!-- 商品消息 -->
|
||||
<MessageItem :message="item">
|
||||
<ProductItem
|
||||
v-if="KeFuMessageContentTypeEnum.PRODUCT === item.contentType"
|
||||
:pic-url="getMessageContent(item).picUrl"
|
||||
:price="getMessageContent(item).price"
|
||||
:sales-count="getMessageContent(item).salesCount"
|
||||
:spu-id="getMessageContent(item).spuId"
|
||||
:stock="getMessageContent(item).stock"
|
||||
:title="getMessageContent(item).spuName"
|
||||
class="mx-[10px] max-w-[300px]"
|
||||
/>
|
||||
</MessageItem>
|
||||
<!-- 订单消息 -->
|
||||
<MessageItem :message="item">
|
||||
<OrderItem
|
||||
v-if="KeFuMessageContentTypeEnum.ORDER === item.contentType"
|
||||
:message="item"
|
||||
class="mx-[10px] max-w-full"
|
||||
/>
|
||||
</MessageItem>
|
||||
</div>
|
||||
<ElAvatar
|
||||
v-if="item.senderType === UserTypeEnum.ADMIN"
|
||||
:src="item.senderAvatar"
|
||||
alt="avatar"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
<div
|
||||
v-show="showNewMessageTip"
|
||||
class="absolute bottom-[35px] right-[35px] z-10 flex cursor-pointer items-center rounded-[30px] bg-[var(--background)] p-[10px] text-xs shadow-md"
|
||||
@click="handleToNewMessage"
|
||||
>
|
||||
<span>有新消息</span>
|
||||
<IconifyIcon class="ml-5px" icon="ep:bottom" />
|
||||
</div>
|
||||
</ElMain>
|
||||
<ElFooter class="!bg-card m-0 flex !h-auto flex-col p-0">
|
||||
<div class="flex h-[44px] w-full items-center">
|
||||
<EmojiSelectPopover @select-emoji="handleEmojiSelect" />
|
||||
<PictureSelectUpload
|
||||
class="ml-[15px] mt-[3px] cursor-pointer"
|
||||
@send-picture="handleSendPicture"
|
||||
/>
|
||||
</div>
|
||||
<ElInput
|
||||
type="textarea"
|
||||
v-model="message"
|
||||
:rows="6"
|
||||
placeholder="输入消息,Enter发送,Shift+Enter换行"
|
||||
style="border-style: none"
|
||||
@keyup.enter="handleSendMessage"
|
||||
/>
|
||||
</ElFooter>
|
||||
</ElContainer>
|
||||
<ElContainer v-else class="bg-card relative w-[calc(100%-300px-260px)]">
|
||||
<ElMain>
|
||||
<ElEmpty description="请选择左侧的一个会话后开始" class="mt-[20%]" />
|
||||
</ElMain>
|
||||
</ElContainer>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
|
||||
|
||||
import { UserTypeEnum } from '@vben/constants';
|
||||
|
||||
/** 消息组件 */
|
||||
|
||||
defineProps<{
|
||||
message: MallKefuMessageApi.Message;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
message.senderType === UserTypeEnum.MEMBER
|
||||
? `ml-[10px]`
|
||||
: message.senderType === UserTypeEnum.ADMIN
|
||||
? `mr-[10px]`
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MallKefuMessageApi } from '#/api/mall/promotion/kefu/message';
|
||||
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { fenToYuan, isObject, jsonParse } from '@vben/utils';
|
||||
|
||||
import ProductItem from './product-item.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
message?: MallKefuMessageApi.Message;
|
||||
order?: any;
|
||||
}>();
|
||||
|
||||
const { push } = useRouter();
|
||||
|
||||
const getMessageContent = computed(() =>
|
||||
props.message === undefined
|
||||
? props.order
|
||||
: jsonParse(props!.message!.content),
|
||||
);
|
||||
|
||||
/** 查看订单详情 */
|
||||
function openDetail(id: number) {
|
||||
push({ name: 'TradeOrderDetail', params: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化订单状态的颜色
|
||||
*
|
||||
* @param order 订单
|
||||
* @return {string} 颜色的 class 名称
|
||||
*/
|
||||
function formatOrderColor(order: any) {
|
||||
if (order.status === 0) {
|
||||
return 'text-[#999]';
|
||||
}
|
||||
if (
|
||||
order.status === 10 ||
|
||||
order.status === 20 ||
|
||||
(order.status === 30 && !order.commentStatus)
|
||||
) {
|
||||
return 'text-[#faad14]';
|
||||
}
|
||||
if (order.status === 30 && order.commentStatus) {
|
||||
return 'text-[#52c41a]';
|
||||
}
|
||||
return 'text-[#ff3000]';
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化订单状态
|
||||
*
|
||||
* @param order 订单
|
||||
*/
|
||||
function formatOrderStatus(order: any) {
|
||||
if (order.status === 0) {
|
||||
return '待付款';
|
||||
}
|
||||
if (order.status === 10 && order.deliveryType === 1) {
|
||||
return '待发货';
|
||||
}
|
||||
if (order.status === 10 && order.deliveryType === 2) {
|
||||
return '待核销';
|
||||
}
|
||||
if (order.status === 20) {
|
||||
return '待收货';
|
||||
}
|
||||
if (order.status === 30 && !order.commentStatus) {
|
||||
return '待评价';
|
||||
}
|
||||
if (order.status === 30 && order.commentStatus) {
|
||||
return '已完成';
|
||||
}
|
||||
return '已关闭';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isObject(getMessageContent)"
|
||||
:key="getMessageContent.id"
|
||||
class="mb-[10px] rounded-[10px] bg-gray-500/30 p-[10px]"
|
||||
>
|
||||
<div class="flex h-[28px] items-center justify-between px-[5px] font-bold">
|
||||
<div class="text-[13px]">
|
||||
订单号:
|
||||
<span
|
||||
class="cursor-pointer hover:text-[var(--left-menu-bg-active-color)] hover:underline"
|
||||
@click="openDetail(getMessageContent.id)"
|
||||
>
|
||||
{{ getMessageContent.no }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
:class="formatOrderColor(getMessageContent)"
|
||||
class="text-[13px] font-bold"
|
||||
>
|
||||
{{ formatOrderStatus(getMessageContent) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in getMessageContent.items"
|
||||
:key="item.id"
|
||||
class="border-b"
|
||||
>
|
||||
<ProductItem
|
||||
:num="item.count"
|
||||
:pic-url="item.picUrl"
|
||||
:price="item.price"
|
||||
:sku-text="
|
||||
item.properties.map((property: any) => property.valueName).join(' ')
|
||||
"
|
||||
:spu-id="item.spuId"
|
||||
:title="item.spuName"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end pr-[5px] pt-[10px] font-bold">
|
||||
<div class="flex items-center">
|
||||
<div class="text-[13px] leading-normal">
|
||||
共 {{ getMessageContent?.productCount }} 件商品,总金额:
|
||||
</div>
|
||||
<div class="font-[OPPOSANS] text-[13px] leading-normal">
|
||||
¥{{ fenToYuan(getMessageContent?.payPrice) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { fenToYuan } from '@vben/utils';
|
||||
|
||||
import { ElButton, ElImage } from 'element-plus';
|
||||
|
||||
defineProps({
|
||||
spuId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
picUrl: {
|
||||
type: String,
|
||||
default: 'https://img1.baidu.com/it/u=1601695551,235775011&fm=26&fmt=auto',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
price: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
salesCount: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
stock: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { push } = useRouter();
|
||||
|
||||
/** 查看商品详情 */
|
||||
function openDetail(spuId: number) {
|
||||
push({ name: 'ProductSpuDetail', params: { id: spuId } });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="mb-[10px] flex w-full cursor-pointer items-center rounded-lg bg-gray-500/30 p-[10px]"
|
||||
@click.stop="openDetail(spuId)"
|
||||
>
|
||||
<!-- 左侧商品图片-->
|
||||
<div class="mr-6 w-[70px]">
|
||||
<ElImage
|
||||
:initial-index="0"
|
||||
:preview-src-list="[picUrl]"
|
||||
:src="picUrl"
|
||||
class="h-full w-full rounded-lg"
|
||||
fit="contain"
|
||||
preview-teleported
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
<!-- 右侧商品信息 -->
|
||||
<div class="flex-1">
|
||||
<div class="line-clamp-1 w-full text-base font-bold">{{ title }}</div>
|
||||
<div class="my-1">
|
||||
<span class="mr-5">库存: {{ stock || 0 }}</span>
|
||||
<span>销量: {{ salesCount || 0 }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[#ff3000]">¥{{ fenToYuan(price) }}</span>
|
||||
<ElButton size="small" text type="primary">详情</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
/** 客服消息类型枚举类 */
|
||||
export const KeFuMessageContentTypeEnum = {
|
||||
TEXT: 1, // 文本消息
|
||||
IMAGE: 2, // 图片消息
|
||||
VOICE: 3, // 语音消息
|
||||
VIDEO: 4, // 视频消息
|
||||
SYSTEM: 5, // 系统消息
|
||||
// ========== 商城特殊消息 ==========
|
||||
PRODUCT: 10, // 商品消息
|
||||
ORDER: 11, // 订单消息"
|
||||
};
|
||||
|
||||
/** Promotion 的 WebSocket 消息类型枚举类 */
|
||||
export const WebSocketMessageTypeConstants = {
|
||||
KEFU_MESSAGE_TYPE: 'kefu_message_type', // 客服消息类型
|
||||
KEFU_MESSAGE_ADMIN_READ: 'kefu_message_read_status_change', // 客服消息管理员已读
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
<!-- emoji 表情选择组件 -->
|
||||
<script lang="ts" setup>
|
||||
import type { Emoji } from './emoji';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { ElPopover, ElScrollbar } from 'element-plus';
|
||||
|
||||
import { useEmoji } from './emoji';
|
||||
|
||||
/** 选择 emoji 表情 */
|
||||
const emits = defineEmits<{
|
||||
(e: 'selectEmoji', v: Emoji): void;
|
||||
}>();
|
||||
const { getEmojiList } = useEmoji();
|
||||
const emojiList = computed(() => getEmojiList());
|
||||
|
||||
function handleSelect(item: Emoji) {
|
||||
// 整个 emoji 数据传递出去,方便以后输入框直接显示表情
|
||||
emits('selectEmoji', item);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElPopover width="500px" placement="top" trigger="click">
|
||||
<template #reference>
|
||||
<IconifyIcon
|
||||
:size="30"
|
||||
class="ml-2.5 cursor-pointer"
|
||||
icon="twemoji:grinning-face"
|
||||
/>
|
||||
</template>
|
||||
<ElScrollbar height="300px">
|
||||
<ul class="ml-2 flex flex-wrap px-2">
|
||||
<li
|
||||
v-for="(item, index) in emojiList"
|
||||
:key="index"
|
||||
:style="{
|
||||
borderColor: 'var(--primary)',
|
||||
color: 'var(--primary)',
|
||||
}"
|
||||
:title="item.name"
|
||||
class="icon-item w-1/10 mr-2 mt-1 flex cursor-pointer items-center justify-center border border-solid p-2"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<img :src="item.url" class="h-6 w-6" />
|
||||
</li>
|
||||
</ul>
|
||||
</ElScrollbar>
|
||||
</ElPopover>
|
||||
</template>
|
||||
@@ -0,0 +1,126 @@
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { isEmpty } from '@vben/utils';
|
||||
|
||||
const emojiList = [
|
||||
{ name: '[笑掉牙]', file: 'xiaodiaoya.png' },
|
||||
{ name: '[可爱]', file: 'keai.png' },
|
||||
{ name: '[冷酷]', file: 'lengku.png' },
|
||||
{ name: '[闭嘴]', file: 'bizui.png' },
|
||||
{ name: '[生气]', file: 'shengqi.png' },
|
||||
{ name: '[惊恐]', file: 'jingkong.png' },
|
||||
{ name: '[瞌睡]', file: 'keshui.png' },
|
||||
{ name: '[大笑]', file: 'daxiao.png' },
|
||||
{ name: '[爱心]', file: 'aixin.png' },
|
||||
{ name: '[坏笑]', file: 'huaixiao.png' },
|
||||
{ name: '[飞吻]', file: 'feiwen.png' },
|
||||
{ name: '[疑问]', file: 'yiwen.png' },
|
||||
{ name: '[开心]', file: 'kaixin.png' },
|
||||
{ name: '[发呆]', file: 'fadai.png' },
|
||||
{ name: '[流泪]', file: 'liulei.png' },
|
||||
{ name: '[汗颜]', file: 'hanyan.png' },
|
||||
{ name: '[惊悚]', file: 'jingshu.png' },
|
||||
{ name: '[困~]', file: 'kun.png' },
|
||||
{ name: '[心碎]', file: 'xinsui.png' },
|
||||
{ name: '[天使]', file: 'tianshi.png' },
|
||||
{ name: '[晕]', file: 'yun.png' },
|
||||
{ name: '[啊]', file: 'a.png' },
|
||||
{ name: '[愤怒]', file: 'fennu.png' },
|
||||
{ name: '[睡着]', file: 'shuizhuo.png' },
|
||||
{ name: '[面无表情]', file: 'mianwubiaoqing.png' },
|
||||
{ name: '[难过]', file: 'nanguo.png' },
|
||||
{ name: '[犯困]', file: 'fankun.png' },
|
||||
{ name: '[好吃]', file: 'haochi.png' },
|
||||
{ name: '[呕吐]', file: 'outu.png' },
|
||||
{ name: '[龇牙]', file: 'ziya.png' },
|
||||
{ name: '[懵比]', file: 'mengbi.png' },
|
||||
{ name: '[白眼]', file: 'baiyan.png' },
|
||||
{ name: '[饿死]', file: 'esi.png' },
|
||||
{ name: '[凶]', file: 'xiong.png' },
|
||||
{ name: '[感冒]', file: 'ganmao.png' },
|
||||
{ name: '[流汗]', file: 'liuhan.png' },
|
||||
{ name: '[笑哭]', file: 'xiaoku.png' },
|
||||
{ name: '[流口水]', file: 'liukoushui.png' },
|
||||
{ name: '[尴尬]', file: 'ganga.png' },
|
||||
{ name: '[惊讶]', file: 'jingya.png' },
|
||||
{ name: '[大惊]', file: 'dajing.png' },
|
||||
{ name: '[不好意思]', file: 'buhaoyisi.png' },
|
||||
{ name: '[大闹]', file: 'danao.png' },
|
||||
{ name: '[不可思议]', file: 'bukesiyi.png' },
|
||||
{ name: '[爱你]', file: 'aini.png' },
|
||||
{ name: '[红心]', file: 'hongxin.png' },
|
||||
{ name: '[点赞]', file: 'dianzan.png' },
|
||||
{ name: '[恶魔]', file: 'emo.png' },
|
||||
];
|
||||
|
||||
export interface Emoji {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const useEmoji = () => {
|
||||
const emojiPathList = ref<any[]>([]);
|
||||
|
||||
/** 加载本地图片 */
|
||||
const initStaticEmoji = async () => {
|
||||
const pathList = import.meta.glob('../../asserts/*.{png,jpg,jpeg,svg}');
|
||||
for (const path in pathList) {
|
||||
const imageModule: any = await pathList[path]?.();
|
||||
emojiPathList.value.push({ path, src: imageModule.default });
|
||||
}
|
||||
};
|
||||
|
||||
/** 初始化 */
|
||||
onMounted(async () => {
|
||||
if (isEmpty(emojiPathList.value)) {
|
||||
await initStaticEmoji();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 将文本中的表情替换成图片
|
||||
*
|
||||
* @return 替换后的文本
|
||||
* @param content 消息内容
|
||||
*/
|
||||
const replaceEmoji = (content: string) => {
|
||||
let newData = content;
|
||||
if (typeof newData !== 'object') {
|
||||
const reg = /\[(.+?)\]/g; // [] 中括号
|
||||
const zhEmojiName = newData.match(reg);
|
||||
if (zhEmojiName) {
|
||||
zhEmojiName.forEach((item) => {
|
||||
const emojiFile = getEmojiFileByName(item);
|
||||
newData = newData.replace(
|
||||
item,
|
||||
`<img style="width: 20px;height: 20px;margin:0 1px 3px 1px;vertical-align: middle;" src="${emojiFile}" alt=""/>`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
return newData;
|
||||
};
|
||||
|
||||
/** 获得所有表情 */
|
||||
function getEmojiList(): Emoji[] {
|
||||
return emojiList.map((item) => ({
|
||||
url: getEmojiFileByName(item.name),
|
||||
name: item.name,
|
||||
})) as Emoji[];
|
||||
}
|
||||
|
||||
function getEmojiFileByName(name: string) {
|
||||
for (const emoji of emojiList) {
|
||||
if (emoji.name === name) {
|
||||
const emojiPath = emojiPathList.value.find(
|
||||
(item: { path: string; src: string }) =>
|
||||
item.path.includes(emoji.file),
|
||||
);
|
||||
return emojiPath ? emojiPath.src : undefined;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return { replaceEmoji, getEmojiList };
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
<!-- 图片选择 -->
|
||||
<script lang="ts" setup>
|
||||
import { ElImage, ElMessage } from 'element-plus';
|
||||
|
||||
import { useUpload } from '#/components/upload/use-upload';
|
||||
import { $t } from '#/locales';
|
||||
import Picture from '#/views/mall/promotion/kefu/asserts/picture.svg';
|
||||
|
||||
/** 选择并上传文件 */
|
||||
const emits = defineEmits<{
|
||||
(e: 'sendPicture', v: string): void;
|
||||
}>();
|
||||
|
||||
async function selectAndUpload() {
|
||||
const files: any = await getFiles();
|
||||
ElMessage.success($t('ui.upload.imgUploading'));
|
||||
const res = await useUpload().httpRequest(files[0].file);
|
||||
ElMessage.success($t('ui.upload.uploadSuccess'));
|
||||
emits('sendPicture', res);
|
||||
}
|
||||
|
||||
/** 唤起文件选择窗口,并获取选择的文件 */
|
||||
async function getFiles(options = {}) {
|
||||
const { multiple, accept, limit, fileSize } = {
|
||||
multiple: true,
|
||||
accept: 'image/jpeg, image/png, image/gif', // 默认选择图片
|
||||
limit: 1,
|
||||
fileSize: 500,
|
||||
...options,
|
||||
};
|
||||
|
||||
// 创建文件选择元素
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.style.display = 'none';
|
||||
if (multiple) input.multiple = true;
|
||||
if (accept) input.accept = accept;
|
||||
|
||||
// 将文件选择元素添加到文档中
|
||||
document.body.append(input);
|
||||
|
||||
// 触发文件选择元素的点击事件
|
||||
input.click();
|
||||
|
||||
// 等待文件选择元素的 change 事件
|
||||
// 移除不必要的 try/catch 包装,直接返回 Promise
|
||||
return await new Promise((resolve, reject) => {
|
||||
input.addEventListener('change', (event: any) => {
|
||||
const filesArray = [...(event?.target?.files || [])];
|
||||
|
||||
// 从文档中移除文件选择元素
|
||||
input.remove();
|
||||
|
||||
// 判断是否超出上传数量限制
|
||||
if (filesArray.length > limit) {
|
||||
// 使用 Error 对象作为 reject 的原因
|
||||
reject(new Error(`超出上传数量限制,最多允许 ${limit} 个文件`));
|
||||
return;
|
||||
}
|
||||
// 判断是否超出上传文件大小限制
|
||||
const overSizedFiles = filesArray.filter(
|
||||
(file: File) => file.size / 1024 ** 2 > fileSize,
|
||||
);
|
||||
if (overSizedFiles.length > 0) {
|
||||
// 使用 Error 对象作为 reject 的原因
|
||||
reject(new Error(`文件大小超出限制,单个文件最大允许 ${fileSize}MB`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成文件列表,并添加 uid
|
||||
const fileList = filesArray.map((file, index) => ({
|
||||
file,
|
||||
uid: Date.now() + index,
|
||||
}));
|
||||
resolve(fileList);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ElImage
|
||||
:src="Picture"
|
||||
:style="{ width: '35px', height: '35px', cursor: 'pointer' }"
|
||||
fit="contain"
|
||||
@click="selectAndUpload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -129,7 +129,8 @@
|
||||
"maxSize": "A single file does not exceed {0}MB",
|
||||
"maxSizeMultiple": "Only upload files up to {0}MB!",
|
||||
"maxNumber": "Only upload up to {0} files",
|
||||
"uploadSuccess": "Upload successfully"
|
||||
"uploadSuccess": "Upload successfully",
|
||||
"imgUploading": "Image sending, please wait..."
|
||||
},
|
||||
"cropper": {
|
||||
"selectImage": "Select Image",
|
||||
|
||||
@@ -129,7 +129,8 @@
|
||||
"maxSize": "单个文件不超过{0}MB",
|
||||
"maxSizeMultiple": "只能上传不超过{0}MB的文件!",
|
||||
"maxNumber": "最多只能上传{0}个文件",
|
||||
"uploadSuccess": "上传成功"
|
||||
"uploadSuccess": "上传成功",
|
||||
"imgUploading": "图片发送中,请稍等。。。"
|
||||
},
|
||||
"cropper": {
|
||||
"selectImage": "选择图片",
|
||||
|
||||
43
pnpm-lock.yaml
generated
@@ -175,7 +175,7 @@ catalogs:
|
||||
specifier: ^2.4.6
|
||||
version: 2.4.6
|
||||
'@vueuse/components':
|
||||
specifier: ^13.4.0
|
||||
specifier: 13.9.0
|
||||
version: 13.9.0
|
||||
'@vueuse/core':
|
||||
specifier: ^13.4.0
|
||||
@@ -672,10 +672,10 @@ importers:
|
||||
version: link:scripts/vsh
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 'catalog:'
|
||||
version: 6.0.1(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
|
||||
version: 6.0.1(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
|
||||
'@vitejs/plugin-vue-jsx':
|
||||
specifier: 'catalog:'
|
||||
version: 5.1.1(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
|
||||
version: 5.1.1(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))
|
||||
'@vue/test-utils':
|
||||
specifier: 'catalog:'
|
||||
version: 2.4.6
|
||||
@@ -717,10 +717,10 @@ importers:
|
||||
version: 3.6.1(sass@1.93.2)(typescript@5.9.3)(vue-tsc@2.2.10(typescript@5.9.3))(vue@3.5.22(typescript@5.9.3))
|
||||
vite:
|
||||
specifier: 'catalog:'
|
||||
version: 7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
version: 7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 3.2.4(@types/node@22.18.12)(happy-dom@17.6.3)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
version: 3.2.4(@types/node@22.18.12)(happy-dom@17.6.3)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
vue:
|
||||
specifier: ^3.5.17
|
||||
version: 3.5.22(typescript@5.9.3)
|
||||
@@ -917,6 +917,9 @@ importers:
|
||||
'@vben/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/utils
|
||||
'@vueuse/components':
|
||||
specifier: 'catalog:'
|
||||
version: 13.9.0(vue@3.5.22(typescript@5.9.3))
|
||||
'@vueuse/core':
|
||||
specifier: 'catalog:'
|
||||
version: 13.9.0(vue@3.5.22(typescript@5.9.3))
|
||||
@@ -15123,14 +15126,14 @@ snapshots:
|
||||
dependencies:
|
||||
vite-plugin-pwa: 1.1.0(vite@5.4.21(@types/node@24.9.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0))(workbox-build@7.3.0)(workbox-window@7.3.0)
|
||||
|
||||
'@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue-jsx@5.1.1(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.4
|
||||
'@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.4)
|
||||
'@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.4)
|
||||
'@rolldown/pluginutils': 1.0.0-beta.44
|
||||
'@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.4)
|
||||
vite: 7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
vite: 7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -15152,10 +15155,10 @@ snapshots:
|
||||
vite: 5.4.21(@types/node@24.9.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
|
||||
'@vitejs/plugin-vue@6.0.1(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
|
||||
'@vitejs/plugin-vue@6.0.1(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.29
|
||||
vite: 7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
vite: 7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
vue: 3.5.22(typescript@5.9.3)
|
||||
|
||||
'@vitejs/plugin-vue@6.0.1(vite@7.1.11(@types/node@24.9.1)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))(vue@3.5.22(typescript@5.9.3))':
|
||||
@@ -15172,13 +15175,13 @@ snapshots:
|
||||
chai: 5.3.3
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vitest/mocker@3.2.4(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))':
|
||||
'@vitest/mocker@3.2.4(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@vitest/spy': 3.2.4
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.19
|
||||
optionalDependencies:
|
||||
vite: 7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
vite: 7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
|
||||
'@vitest/pretty-format@3.2.4':
|
||||
dependencies:
|
||||
@@ -21604,13 +21607,13 @@ snapshots:
|
||||
dependencies:
|
||||
vite: 7.1.11(@types/node@24.9.1)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
|
||||
vite-node@3.2.4(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1):
|
||||
vite-node@3.2.4(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
debug: 4.4.3
|
||||
es-module-lexer: 1.7.0
|
||||
pathe: 2.0.3
|
||||
vite: 7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
vite: 7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- jiti
|
||||
@@ -21779,7 +21782,7 @@ snapshots:
|
||||
sass: 1.93.2
|
||||
terser: 5.44.0
|
||||
|
||||
vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1):
|
||||
vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1):
|
||||
dependencies:
|
||||
esbuild: 0.25.3
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
@@ -21790,7 +21793,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 22.18.12
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
jiti: 1.21.7
|
||||
less: 4.4.2
|
||||
sass: 1.93.2
|
||||
terser: 5.44.0
|
||||
@@ -21872,11 +21875,11 @@ snapshots:
|
||||
- typescript
|
||||
- universal-cookie
|
||||
|
||||
vitest@3.2.4(@types/node@22.18.12)(happy-dom@17.6.3)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1):
|
||||
vitest@3.2.4(@types/node@22.18.12)(happy-dom@17.6.3)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))
|
||||
'@vitest/mocker': 3.2.4(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
@@ -21894,8 +21897,8 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
vite-node: 3.2.4(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
vite: 7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
vite-node: 3.2.4(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 22.18.12
|
||||
@@ -21918,7 +21921,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/expect': 3.2.4
|
||||
'@vitest/mocker': 3.2.4(vite@7.1.11(@types/node@22.18.12)(jiti@2.6.1)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))
|
||||
'@vitest/mocker': 3.2.4(vite@7.1.11(@types/node@22.18.12)(jiti@1.21.7)(less@4.4.2)(sass@1.93.2)(terser@5.44.0)(yaml@2.8.1))
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
'@vitest/runner': 3.2.4
|
||||
'@vitest/snapshot': 3.2.4
|
||||
|
||||
@@ -12,6 +12,7 @@ packages:
|
||||
- scripts/*
|
||||
- docs
|
||||
- playground
|
||||
|
||||
catalog:
|
||||
'@ast-grep/napi': ^0.37.0
|
||||
'@changesets/changelog-github': ^0.5.1
|
||||
@@ -73,7 +74,7 @@ catalog:
|
||||
'@vue/reactivity': ^3.5.17
|
||||
'@vue/shared': ^3.5.17
|
||||
'@vue/test-utils': ^2.4.6
|
||||
'@vueuse/components': ^13.4.0
|
||||
'@vueuse/components': 13.9.0
|
||||
'@vueuse/core': ^13.4.0
|
||||
'@vueuse/integrations': ^13.4.0
|
||||
'@vueuse/motion': ^3.0.3
|
||||
|
||||