feat: 使用tailwindcss简化style,统一文件命名

This commit is contained in:
jawe
2025-11-03 16:42:13 +08:00
parent a79483a71d
commit b94f34c67d
25 changed files with 694 additions and 1158 deletions

View File

@@ -4,7 +4,7 @@ VITE_PORT=5666
VITE_BASE=/
# 请求路径
VITE_BASE_URL=http://127.0.0.1:48080
VITE_BASE_URL=http://47.103.66.220:48080
# 接口地址
VITE_GLOB_API_URL=/admin-api
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务

View File

@@ -1,36 +0,0 @@
<script lang="ts" setup>
defineOptions({ name: 'CardTitle' });
// TODO @jawe from xingyuhttps://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>

View File

@@ -1 +0,0 @@
export { default as CardTitle } from './CardTitle.vue';

View File

@@ -556,7 +556,7 @@ $phone-width: 375px;
width: 100%;
margin: 16px 0 0;
overflow: hidden;
background-color: var(--app-content-bg-color);
background-color: var(--background);
/* 手机顶部 */
.editor-design-top {

View File

@@ -59,7 +59,7 @@ const handleDelete = function (index: number) {
<!-- 操作按钮区 -->
<div
class="-m-2 mb-1 flex flex-row items-center justify-between p-2"
style="background-color: var(--app-content-bg-color)"
style="background-color: var(--background)"
>
<Tooltip title="拖动排序">
<IconifyIcon

View File

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

View File

@@ -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 @jawecomponents =》modules在 vben 里modules 是给自己用的,把一个大 vue 拆成 n 个小 vuecomponents 是给别的模块使用的;
// TODO @jawe1组件名小写类似 conversation-list.vue2KeFu 开头可以去掉,因为已经是当前模块下,不用重复拼写;

View File

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

View File

@@ -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 @jawestyle 使用 tailwindcssAI 友好 -->
<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>

View File

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

View File

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

View File

@@ -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 @jawedebounce 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 @javefrom xingyua- 换成大写的方式另外组件没有进行导入其他页面也有这个问题 -->
<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-[50%]" />
</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>

View File

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

View File

@@ -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); //

View File

@@ -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-[50%]" />
</Layout.Content>
</Layout>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ export default defineConfig(async () => {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/admin-api/, ''),
// mock代理目标地址
target: 'http://localhost:48080/admin-api',
target: 'http://47.103.66.220:48080/admin-api',
ws: true,
},
},

View File

@@ -587,7 +587,7 @@ $phone-width: 375px;
width: 100%;
margin: 16px 0 0;
overflow: hidden;
background-color: var(--app-content-bg-color);
background-color: var(--background);
/* 手机顶部 */
.editor-design-top {

View File

@@ -129,7 +129,8 @@
"maxSize": "单个文件不超过{0}MB",
"maxSizeMultiple": "只能上传不超过{0}MB的文件!",
"maxNumber": "最多只能上传{0}个文件",
"uploadSuccess": "上传成功"
"uploadSuccess": "上传成功",
"imgUploading": "图片发送中,请稍等。。。"
},
"cropper": {
"selectImage": "选择图片",