Merge pull request 'yhy' (#15) from yhy into master

Reviewed-on: #15
This commit is contained in:
root 2025-06-09 06:51:10 +00:00
commit 62879ce179
9 changed files with 661 additions and 63 deletions

View File

@ -801,6 +801,20 @@
"title": "客服",
"group": "客服"
}
},
{
"path" : "chatList",
"style" :
{
"navigationBarTitleText" : ""
}
},
{
"path" : "chatKefu",
"style" :
{
"navigationBarTitleText" : ""
}
}]
},
{

224
pages/chat/chatKefu.vue Normal file
View File

@ -0,0 +1,224 @@
<template>
<s-layout
class="chat-wrap"
:title="toUserName"
navbar="inner"
>
<!-- 覆盖头部导航栏背景颜色 -->
<view class="page-bg" :style="{ height: sys_navBar + 'px' }"></view>
<!-- 聊天区域 -->
<MessageList ref="messageListRef" :conversationId="conversationId" :useravatar="useravatar" :touserid="currId">
<template #bottom>
<message-input
v-model="chat.msg"
@on-tools="onTools"
@send-message="onSendMessage"
></message-input>
</template>
</MessageList>
<!-- 聊天工具 -->
<tools-popup
:show-tools="chat.showTools"
:tools-mode="chat.toolsMode"
@close="handleToolsClose"
@on-emoji="onEmoji"
@image-select="onSelect"
@on-show-select="onShowSelect"
>
<message-input
v-model="chat.msg"
@on-tools="onTools"
@send-message="onSendMessage"
></message-input>
</tools-popup>
<!-- 商品订单选择 -->
<SelectPopup
:mode="chat.selectMode"
:show="chat.showSelect"
@select="onSelect"
@close="chat.showSelect = false"
/>
</s-layout>
</template>
<script setup>
import MessageList from '@/pages/chat/components/messageList.vue';
import { reactive, ref, toRefs,getCurrentInstance,onMounted } from 'vue';
import sheep from '@/sheep';
import ToolsPopup from '@/pages/chat/components/toolsPopup.vue';
import MessageInput from '@/pages/chat/components/messageInput.vue';
import SelectPopup from '@/pages/chat/components/select-popup.vue';
import {
KeFuMessageContentTypeEnum,
WebSocketMessageTypeConstants,
} from '@/pages/chat/util/constants';
import FileApi from '@/sheep/api/infra/file';
import KeFuApi from '@/sheep/api/promotion/kefu';
import { useWebSocket } from '@/sheep/hooks/useWebSocket';
import { jsonParse } from '@/sheep/util';
import {
onLoad
} from '@dcloudio/uni-app';
const sys_navBar = sheep.$platform.navbar;
let conversationId = ref('')
let touserId = ref('')
let currId = ref('')
let useravatar = ref('')
let toUserName = ref('')
onLoad((options)=>{
console.log("路由参数",options)
conversationId.value = options.userinfo
touserId.value = options.userid
useravatar.value = options.useravatar
currId.value = options.id
toUserName.value = options.touserName
})
const chat = reactive({
msg: '',
scrollInto: '',
showTools: false,
toolsMode: '',
showSelect: false,
selectMode: '',
});
//
async function onSendMessage() {
if (!chat.msg) return;
try {
const data = {
contentType: KeFuMessageContentTypeEnum.TEXT,
content: JSON.stringify({ text: chat.msg }),
receiverId:touserId.value,
};
console.log("send message",data)
await KeFuApi.sendKefuMessage(data);
await messageListRef.value.refreshMessageList();
chat.msg = '';
} finally {
chat.showTools = false;
}
}
const messageListRef = ref();
//======================= start =======================
function handleToolsClose() {
chat.showTools = false;
chat.toolsMode = '';
}
function onEmoji(item) {
chat.msg += item.name;
}
//
function onTools(mode) {
if (isReconnecting.value) {
sheep.$helper.toast('您已掉线!请返回重试');
return;
}
if (!chat.toolsMode || chat.toolsMode === mode) {
chat.showTools = !chat.showTools;
}
chat.toolsMode = mode;
if (!chat.showTools) {
chat.toolsMode = '';
}
}
function onShowSelect(mode) {
chat.showTools = false;
chat.showSelect = true;
chat.selectMode = mode;
}
async function onSelect({ type, data }) {
let msg;
switch (type) {
case 'image':
const res = await FileApi.uploadFile(data.tempFiles[0].path);
msg = {
contentType: KeFuMessageContentTypeEnum.IMAGE,
content: JSON.stringify({ picUrl: res.data }),
};
break;
case 'goods':
msg = {
contentType: KeFuMessageContentTypeEnum.PRODUCT,
content: JSON.stringify(data),
};
break;
case 'order':
msg = {
contentType: KeFuMessageContentTypeEnum.ORDER,
content: JSON.stringify(data),
};
break;
}
if (msg) {
//
// scrollBottom();
await KeFuApi.sendKefuMessage(msg);
await messageListRef.value.refreshMessageList();
chat.showTools = false;
chat.showSelect = false;
chat.selectMode = '';
}
}
//======================= end =======================
const { options } = useWebSocket({
//
onConnected: async () => {},
//
onMessage: async (data) => {
const type = data.type;
if (!type) {
console.error('未知的消息类型:' + data);
return;
}
// 2.2 KEFU_MESSAGE_TYPE
if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_TYPE) {
//
await messageListRef.value.refreshMessageList(jsonParse(data.content));
return;
}
// 2.3 KEFU_MESSAGE_ADMIN_READ
if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_ADMIN_READ) {
console.log('管理员已读消息');
}
},
});
const isReconnecting = toRefs(options).isReconnecting; //
</script>
<style scoped lang="scss">
.chat-wrap {
.page-bg {
width: 100%;
position: absolute;
top: 0;
left: 0;
background-color: var(--ui-BG-Main);
z-index: 1;
}
.status {
position: relative;
box-sizing: border-box;
z-index: 3;
height: 70rpx;
padding: 0 30rpx;
background: var(--ui-BG-Main-opacity-1);
display: flex;
align-items: center;
font-size: 30rpx;
font-weight: 400;
color: var(--ui-BG-Main);
}
}
</style>

241
pages/chat/chatList.vue Normal file
View File

@ -0,0 +1,241 @@
<template>
<s-layout title="消息列表" class="chatMerchant">
<!-- <view class="invoiceTitle">
<uni-search-bar placeholder="搜索好友" radius="30" bgColor="#E3E3E3" cancelButton="none" clearButton="none"
@confirm="search" v-model="state.searchVal" />
</view> -->
<view class="invoList" v-if="state.userList.length>0">
<view class="invoice_item" v-for="(item,index) in state.userList" @tap.stop="gotoChat(item.id,item.userId,item.userAvatar,item.userNickname)"
:key="index">
<uni-badge class="uni-badge-left-margin" :text="item.adminUnreadMessageCount|| 0" absolute="rightTop" size="small">
<image :src="item.userAvatar"></image>
</uni-badge>
<view class="userName_item">
<view class="userName">{{item.userNickname}}</view>
<view class="userMessage">
{{item.messageText || item.lastMessageContent}}
</view>
</view>
</view>
</view>
<view class="noInvoices" v-else>
<image src="/static/data-empty.png"></image>
没有信息呦~
</view>
<!-- <view class="addInvoiceBtn" @tap.stop="addInvoice">添加抬头发票</view> -->
</s-layout>
</template>
<script setup>
import DeliveryApi from '@/sheep/api/trade/delivery';
import KeFuApi from '@/sheep/api/promotion/kefu';
import {
onMounted,
onUnmounted,
reactive,
ref
} from 'vue';
import {
onLoad,
onShow,
onPageScroll,
onPullDownRefresh
} from '@dcloudio/uni-app';
import sheep from '@/sheep';
// const numData = computed(() => sheep.$store('user').numData);
let pollingInterval = null;
const state = reactive({
searchVal: '',
userList: [
],
})
//
function startPolling() {
getChatUserList();
pollingInterval = setInterval(() => {
getChatUserList();
}, 5000);
}
//
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
}
onMounted(() => {
startPolling();
// getChatUserList()
})
onUnmounted(()=>{
stopPolling();
})
const getChatUserList = async () => {
const {
data
} = await KeFuApi.getKefuChatuserList();
console.log("chatList",data)
// console.log("",data[0].adminUnreadMessageCount);
const cleanContent = (content) => {
if (typeof content !== "string") return null; //
return content.trim().replace(/^\uFEFF/, ""); // BOM
};
//
try {
const newData = data.map((item) => {
let parsedContent = null;
// lastMessageContent
const cleanedContent = cleanContent(item.lastMessageContent);
// JSON
try {
parsedContent = JSON.parse(cleanedContent);
} catch (parseError) {
//
return item;
}
// text
if (parsedContent && parsedContent.text) {
//
return {
...item, //
messageText: parsedContent.text, //
};
}
// text
return item;
});
console.log("处理后的数据:", newData);
state.userList = newData
} catch (error) {
console.error("处理数据时出错:", error);
}
// state.userList = data
}
const gotoChat = (conId,userId,userAvatar,toUserName) => {
console.log("用户id", userId)
uni.navigateTo({
url:"/pages/chat/chatKefu?userinfo="+conId+"&userid="+userId+"&useravatar="+userAvatar+"&touserName="+toUserName
})
}
const searchValue = ref("")
const searchHandle = () => {
console.log("点击了搜索")
}
//
onPullDownRefresh(() => {
// sheep.$store('user').updateUserData();
getChatUserList()
setTimeout(function() {
uni.stopPullDownRefresh();
}, 500);
});
</script>
<style lang="scss" scoped>
// .addInvoiceBtn{
// width: 90%;
// height: 95rpx;
// line-height: 95rpx;
// text-align: center;
// border-radius: 100rpx;
// position: fixed;
// color: white;
// font-size: 36rpx;
// background-color: #7D9337;
// bottom: 50rpx;
// left: 50%;
// transform: translateX(-50%);
// }
.noInvoices{
width: 100%;
display: flex;
padding-top: 135rpx;
text-align: center;
flex-direction: column;
align-items: center;
font-size: 31rpx;
color: #B0B0B0;
>image{
margin-bottom: 20rpx;
}
}
::v-deep .uni-searchbar {
padding: 5px 0 !important;
}
.invoiceTitle {
width: 95%;
margin: 0 auto;
padding: 2rpx 0 16rpx 0;
height: 80rpx;
}
.invoList {
width: 100%;
background-color: #F5F5F5;
.invoice_item {
width: 100%;
margin: 0 auto;
padding: 21rpx 20rpx;
box-sizing: border-box;
border-radius: 10rpx;
background-color: white;
display: flex;
position: relative;
::v-deep .uni-badge-left-margin {
image {
width: 100rpx;
height: 100rpx;
border-radius: 100%;
}
}
.invoice_no {
margin-bottom: 10rpx;
}
.userName_item {
display: flex;
flex-direction: column;
justify-content: space-evenly;
margin-left: 20rpx;
.userName {
font-size: 32rpx;
}
.userMessage {
color: gray;
line-height: 1.2;
max-height: 3.6em;
overflow-y: hidden;
}
}
}
.invoice_item::after{
content:'';
position: absolute;
left: 5%;
bottom: 0;
width: 90%;
height: 1rpx; /* 调整为你需要的边框厚度 */
background-color: #dadada;
}
}
</style>

View File

@ -2,6 +2,7 @@
<!-- 聊天虚拟列表 -->
<z-paging
ref="pagingRef"
v-model="messageList"
use-chat-record-mode
use-virtual-list
cell-height-mode="dynamic"
@ -17,7 +18,7 @@
>
<template #top>
<!-- 撑一下顶部导航 -->
<view :style="{height:hei+'px'}"></view>
<view :style="{ height: sys_navBar + 'px' }"></view>
</template>
<!-- style="transform: scaleY(-1)"必须写否则会导致列表倒置 -->
<!-- 注意不要直接在chat-item组件标签上设置style因为在微信小程序中是无效的请包一层view -->
@ -27,6 +28,8 @@
<MessageListItem
:message="item"
:message-index="index"
:message-list="messageList"
:userAva="props.useravatar"
></MessageListItem>
</view>
</template>
@ -43,16 +46,16 @@
<script setup>
import MessageListItem from '@/pages/chat/components/messageListItem.vue';
import { reactive, ref } from 'vue';
import { reactive, ref,onMounted,computed } from 'vue';
import KeFuApi from '@/sheep/api/promotion/kefu';
import { isEmpty } from '@/sheep/helper/utils';
const props = defineProps({
hei: {
type: [Object, String, Number],
default() {},
},
});
import sheep from '@/sheep';
import { formatDate } from '@/sheep/util';
const sys_navBar = sheep.$platform.navbar;
const messageList = ref([]); //
const showNewMessageTip = ref(false); //
const refreshMessage = ref(false); //
const backToTopStyle = reactive({
width: '100px',
'background-color': '#fff',
@ -62,41 +65,90 @@ const props = defineProps({
justifyContent: 'center',
alignItems: 'center',
}); //
const userInfo = computed(() => sheep.$store('user').userInfo);
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
no: 1, //
limit: 20,
conversationId:'',
createTime: undefined,
});
const props = defineProps({
conversationId:Number,
useravatar:String,
touserid:String
});
onMounted(()=>{
console.log("参数值",props)
})
const pagingRef = ref(null); //
const queryList = async (pageNo, pageSize) => {
const queryList = async (no, limit,conversationId) => {
//
// pageNopageSize
queryParams.pageNo = pageNo;
queryParams.pageSize = pageSize;
await getMessageList();
queryParams.no = no;
queryParams.limit = limit;
queryParams.conversationId = conversationId;
// if(userInfo.value.idKefu){
await getMessageList();
// }
};
const msss = ref([]);
//
const getMessageList = async () => {
queryParams.conversationId = props.conversationId
if(props.conversationId){
let ursData = {conversationId:props.conversationId,userId:userInfo.value.id,userType:2}
//const { data2 } = await KeFuApi.updateReadStatus(ursData);
}
console.log("queryParams",queryParams)
const { data } = await KeFuApi.getKefuMessagePage(queryParams);
if (isEmpty(data.list)) {
if (isEmpty(data)) {
pagingRef.value.completeByNoMore([], true);
return;
}
msss.value = data.list
pagingRef.value.completeByTotal(data.list, data.total);
console.log("消息列表",data);
// if( data.list[0].receiverId === userInfo.value.id){
// return
// }
if (queryParams.no > 1 && refreshMessage.value) {
const newMessageList = [];
for (const message of data.list) {
if (messageList.value.some((val) => val.id === message.id)) {
continue;
}
newMessageList.push(message);
}
//
messageList.value = [...newMessageList, ...messageList.value];
pagingRef.value.updateCache(); //
refreshMessage.value = false; //
return;
}
if (data.list.slice(-1).length > 0) {
// createTime
queryParams.createTime = formatDate(data.list.slice(-1)[0].createTime);
}
pagingRef.value.completeByNoMore(data.list, false);
};
const emits = defineEmits(['cc']);
/** 刷新消息列表 */
const refreshMessageList = (message = undefined) => {
if (queryParams.pageNo != 1 && message !== undefined) {
showNewMessageTip.value = true;
const refreshMessageList = async (message = undefined) => {
if (typeof message !== 'undefined') {
//
pagingRef.value.addChatRecordData([message], false);
return;
} else {
queryParams.createTime = undefined;
refreshMessage.value = true;
await getMessageList();
}
//
if (queryParams.no > 1) {
showNewMessageTip.value = true;
} else {
onScrollToUpper();
}
pagingRef.value.reload();
console.log("--data1---")
emits("cc", msss.value[msss.value.length-1].kefuName)
};
/** 滚动到最新消息 */
const onBackToTopClick = (event) => {
event(false); //
@ -105,12 +157,10 @@ const props = defineProps({
/** 监听滚动到底部事件(因为 scroll 翻转了顶就是底) */
const onScrollToUpper = () => {
//
if (queryParams.pageNo === 1) {
if (queryParams.no === 1) {
return;
}
showNewMessageTip.value = false;
//
refreshMessageList();
};
defineExpose({ getMessageList, refreshMessageList });
</script>

View File

@ -46,7 +46,7 @@
<!-- 内容 -->
<template v-if="message.contentType === KeFuMessageContentTypeEnum.TEXT">
<view class="message-box" :class="{ admin: message.senderType === UserTypeEnum.ADMIN }">
<mp-html :content="replaceEmoji(message.content)" />
<mp-html :content="replaceEmoji(getMessageContent(message).text || message.content)" />
</view>
</template>
<template v-if="message.contentType === KeFuMessageContentTypeEnum.IMAGE">
@ -108,6 +108,7 @@
import dayjs from 'dayjs';
import { KeFuMessageContentTypeEnum, UserTypeEnum } from '@/pages/chat/util/constants';
import { emojiList } from '@/pages/chat/util/emoji';
import { jsonParse } from '@/sheep/util';
import sheep from '@/sheep';
import { formatDate } from '@/sheep/util';
import GoodsItem from '@/pages/chat/components/goods.vue';
@ -130,7 +131,7 @@
default: () => [],
},
});
const getMessageContent = computed(() => (item) => JSON.parse(item.content)); //
const getMessageContent = computed(() => (item) => jsonParse(item.content)); //
//======================= =======================

View File

@ -1,15 +1,18 @@
<template>
<!-- :title="!isReconnecting ? '连接客服成功' : '会话重连中'" -->
<!-- :title="kefuName" -->
<!-- <s-layout
class="chat-wrap"
:title="!isReconnecting ? '连接客服成功' : '会话重连中'"
navbar="inner"
> -->
<s-layout
class="chat-wrap"
title="众悦商城客服"
title="对话中"
navbar="inner"
>
<!-- 覆盖头部导航栏背景颜色 -->
<div class="page-bg" :style="{ height: sys_navBar + 'px' }"></div>
<view class="page-bg" :style="{ height: sys_navBar + 'px' }"></view>
<!-- 聊天区域 -->
<MessageList ref="messageListRef" :hei="sys_navBar" @cc="ss">
<MessageList ref="messageListRef">
<template #bottom>
<message-input
v-model="chat.msg"
@ -57,12 +60,10 @@
import FileApi from '@/sheep/api/infra/file';
import KeFuApi from '@/sheep/api/promotion/kefu';
import { useWebSocket } from '@/sheep/hooks/useWebSocket';
const kefuName = ref('众悦商城客服');
import { jsonParse } from '@/sheep/util';
const sys_navBar = sheep.$platform.navbar;
function ss(val){
console.log('ss进来了',val)
kefuName.value = val;
}
const chat = reactive({
msg: '',
scrollInto: '',
@ -79,8 +80,13 @@ const kefuName = ref('众悦商城客服');
const data = {
contentType: KeFuMessageContentTypeEnum.TEXT,
content: chat.msg,
//content: JSON.stringify({ text: chat.msg }),
};
await KeFuApi.sendKefuMessage(data);
const res = await KeFuApi.sendKefuMessage(data);
if(res.data === 5){
sheep.$helper.toast('您是客服,不能跟自己对话');
return
}
await messageListRef.value.refreshMessageList();
chat.msg = '';
} finally {
@ -130,7 +136,7 @@ const kefuName = ref('众悦商城客服');
const res = await FileApi.uploadFile(data.tempFiles[0].path);
msg = {
contentType: KeFuMessageContentTypeEnum.IMAGE,
content: res.data,
content: JSON.stringify({ picUrl: res.data }),
};
break;
case 'goods':
@ -149,7 +155,11 @@ const kefuName = ref('众悦商城客服');
if (msg) {
//
// scrollBottom();
await KeFuApi.sendKefuMessage(msg);
const res = await KeFuApi.sendKefuMessage(msg);
if(res.data === 5){
sheep.$helper.toast('您是客服,不能跟自己对话');
return
}
await messageListRef.value.refreshMessageList();
chat.showTools = false;
chat.showSelect = false;
@ -165,13 +175,13 @@ const kefuName = ref('众悦商城客服');
onMessage: async (data) => {
const type = data.type;
if (!type) {
console.error('未知的消息类型:' + data.value);
console.error('未知的消息类型:' + data);
return;
}
// 2.2 KEFU_MESSAGE_TYPE
if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_TYPE) {
//
await messageListRef.value.refreshMessageList(JSON.parse(data.content));
await messageListRef.value.refreshMessageList(jsonParse(data.content));
return;
}
// 2.3 KEFU_MESSAGE_ADMIN_READ
@ -184,16 +194,10 @@ const kefuName = ref('众悦商城客服');
</script>
<style scoped lang="scss">
::v-deep(.chat-wrap){
.ui-bar{
position: fixed !important;
width: 100%;
padding-right: 0 !important;
}
.chat-wrap {
.page-bg {
width: 100%;
position: fixed;
position: absolute;
top: 0;
left: 0;
background-color: var(--ui-BG-Main);

View File

@ -26,6 +26,50 @@ const KeFuApi = {
},
});
},
getKefuMessageList: (params) => {
return request({
url: '/promotion/kefu-message/list',
method: 'GET',
params,
custom: {
auth: true,
showLoading: false,
},
});
},
//获取聊天列表用户
getKefuChatuserList: (params) => {
return request({
url: '/promotion/kefu-message/listCoversation',
method: 'GET',
params,
custom: {
auth: true,
showLoading: false,
},
});
},
getConversationList: (params) => {
return request({
url: '/promotion/kefu-conversation/list',
method: 'GET',
params,
custom: {
auth: true,
showLoading: false,
},
});
},
// 更新用户聊天状态
updateReadStatus: (data) => {
return request({
url: '/promotion/kefu-message/update-read-status?conversationId='+data.conversationId+"&userId="+data.userId+"&userType="+data.userType,
method: 'PUT',
data,
custom: {
},
});
},
};
export default KeFuApi;

View File

@ -53,12 +53,17 @@
</view> -->
<view class="newList">
<view class="new_menu" v-for="(arr, index) in menuList" :key="index">
<view v-for="(item, index) in arr" :key="index" class="new_items"
:style="[{ width: `${100 * (1 / data.column)}%`}]" @tap="sheep.$router.go(item.url)">
<view class="menu-box ss-flex ss-flex-col ss-col-center ss-row-center">
<view v-for="(item, index) in arr" :key="index"
class="new_items"
:style="[{ width: `${100 * (1 / data.column)}%`}]">
<view class="menu-box ss-flex ss-flex-col ss-col-center ss-row-center"
@tap="sheep.$router.go(item.url)" v-if="item.title !== '平台客服' || (item.title === '平台客服' && userInfo.idKefu)">
<view v-if="item.badge.show" class="tag-box">
{{ item.badge.text }}
</view>
<view v-if="!item.badge.show && item.title === '平台客服' && userInfo.unreadCount>0" class="tag-box" :style="[{ background: '#ff5500', color: 'white', }]">
{{userInfo.unreadCount}}
</view>
<image v-if="item.iconUrl" class="menu-icon" :src="sheep.$url.cdn(item.iconUrl)"
mode="aspectFill"></image>
<view v-if="data.layout === 'iconText'" class="menu-title"
@ -115,7 +120,8 @@
const state = reactive({
cur: 0,
});
const userInfo = computed(() => sheep.$store('user').userInfo);
const kefuUnread = userInfo.value.unreadCount
//
const props = defineProps({
@ -285,11 +291,11 @@
.tag-box {
position: absolute;
z-index: 2;
top: 0;
right: -6rpx;
top: 10rpx;
right: 50rpx;
font-size: 2em;
line-height: 1;
padding: 0.4em 0.6em 0.3em;
padding: 8rpx 16rpx; //0.4em 0.6em 0.3em;
transform: scale(0.4) translateX(0.5em) translatey(-0.6em);
transform-origin: 100% 0;
border-radius: 200rpx;
@ -392,4 +398,4 @@
}
}
}
</style>
</style>

View File

@ -137,3 +137,17 @@ export const copyValueToTarget = (target, source) => {
// 更新目标对象值
Object.assign(target, newObj);
};
/**
* 解析 JSON 字符串
*
* @param str
*/
export function jsonParse(str) {
try {
return JSON.parse(str);
} catch (e) {
console.warn(`str[${str}] 不是一个 JSON 字符串`);
return str;
}
}