项目初始化
This commit is contained in:
21
pages/chat/components/goods.vue
Normal file
21
pages/chat/components/goods.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<s-goods-item
|
||||
:title="goodsData.spuName"
|
||||
:img="goodsData.picUrl"
|
||||
:price="goodsData.price"
|
||||
:skuText="goodsData.introduction"
|
||||
priceColor="#FF3000"
|
||||
:titleWidth="400"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const props = defineProps({
|
||||
goodsData: {
|
||||
type: Object,
|
||||
default: {},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
102
pages/chat/components/messageInput.vue
Normal file
102
pages/chat/components/messageInput.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<view class="send-wrap ss-flex">
|
||||
<view class="left ss-flex ss-flex-1">
|
||||
<uni-easyinput
|
||||
class="ss-flex-1 ss-p-l-22"
|
||||
:inputBorder="false"
|
||||
:clearable="false"
|
||||
v-model="message"
|
||||
placeholder="请输入你要咨询的问题"
|
||||
></uni-easyinput>
|
||||
</view>
|
||||
<text class="sicon-basic bq" @tap.stop="onTools('emoji')"></text>
|
||||
<text
|
||||
v-if="!message"
|
||||
class="sicon-edit"
|
||||
:class="{ 'is-active': toolsMode === 'tools' }"
|
||||
@tap.stop="onTools('tools')"
|
||||
></text>
|
||||
<button v-if="message" class="ss-reset-button send-btn" @tap="sendMessage">
|
||||
发送
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
/**
|
||||
* 消息发送组件
|
||||
*/
|
||||
const props = defineProps({
|
||||
// 消息
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 工具模式
|
||||
toolsMode: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(['update:modelValue', 'onTools', 'sendMessage']);
|
||||
const message = computed({
|
||||
get() {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(newValue) {
|
||||
emits(`update:modelValue`, newValue);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 打开工具菜单
|
||||
function onTools(mode) {
|
||||
emits('onTools', mode);
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
function sendMessage() {
|
||||
emits('sendMessage');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.send-wrap {
|
||||
padding: 18rpx 20rpx;
|
||||
background: #fff;
|
||||
|
||||
.left {
|
||||
height: 64rpx;
|
||||
border-radius: 32rpx;
|
||||
background: var(--ui-BG-1);
|
||||
}
|
||||
|
||||
.bq {
|
||||
font-size: 50rpx;
|
||||
margin-left: 10rpx;
|
||||
}
|
||||
|
||||
.sicon-edit {
|
||||
font-size: 50rpx;
|
||||
margin-left: 10rpx;
|
||||
transform: rotate(0deg);
|
||||
transition: all linear 0.2s;
|
||||
|
||||
&.is-active {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 100rpx;
|
||||
height: 60rpx;
|
||||
line-height: 60rpx;
|
||||
border-radius: 30rpx;
|
||||
background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
|
||||
font-size: 26rpx;
|
||||
color: #fff;
|
||||
margin-left: 11rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
94
pages/chat/components/messageList.vue
Normal file
94
pages/chat/components/messageList.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<!-- 聊天虚拟列表 -->
|
||||
<z-paging ref="pagingRef" v-model="messageList" use-chat-record-mode use-virtual-list
|
||||
cell-height-mode="dynamic" default-page-size="20" :auto-clean-list-when-reload="false"
|
||||
safe-area-inset-bottom bottom-bg-color="#f8f8f8" :back-to-top-style="backToTopStyle"
|
||||
:auto-show-back-to-top="showNewMessageTip" @backToTopClick="onBackToTopClick"
|
||||
@scrolltoupper="onScrollToUpper" @query="queryList">
|
||||
<template #top>
|
||||
<!-- 撑一下顶部导航 -->
|
||||
<view style="height: 45px"></view>
|
||||
</template>
|
||||
<!-- style="transform: scaleY(-1)"必须写,否则会导致列表倒置!!! -->
|
||||
<!-- 注意不要直接在chat-item组件标签上设置style,因为在微信小程序中是无效的,请包一层view -->
|
||||
<template #cell="{item,index}">
|
||||
<view style="transform: scaleY(-1)">
|
||||
<!-- 消息渲染 -->
|
||||
<MessageListItem :message="item" :message-index="index" :message-list="messageList"></MessageListItem>
|
||||
</view>
|
||||
</template>
|
||||
<!-- 底部聊天输入框 -->
|
||||
<template #bottom>
|
||||
<slot name="bottom"></slot>
|
||||
</template>
|
||||
<!-- 查看最新消息 -->
|
||||
<template #backToTop>
|
||||
<text>有新消息</text>
|
||||
</template>
|
||||
</z-paging>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MessageListItem from '@/pages/chat/components/messageListItem.vue';
|
||||
import { reactive, ref } from 'vue';
|
||||
import KeFuApi from '@/sheep/api/promotion/kefu';
|
||||
import { isEmpty } from '@/sheep/helper/utils';
|
||||
|
||||
const messageList = ref([]); // 消息列表
|
||||
const showNewMessageTip = ref(false); // 显示有新消息提示
|
||||
const backToTopStyle = reactive({
|
||||
'width': '100px',
|
||||
'background-color': '#fff',
|
||||
'border-radius': '30px',
|
||||
'box-shadow': '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
'display': 'flex',
|
||||
'justifyContent': 'center',
|
||||
'alignItems': 'center',
|
||||
}); // 返回顶部样式
|
||||
const queryParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
const pagingRef = ref(null); // 虚拟列表
|
||||
const queryList = async (pageNo, pageSize) => {
|
||||
// 组件加载时会自动触发此方法,因此默认页面加载时会自动触发,无需手动调用
|
||||
// 这里的pageNo和pageSize会自动计算好,直接传给服务器即可
|
||||
queryParams.pageNo = pageNo;
|
||||
queryParams.pageSize = pageSize;
|
||||
await getMessageList();
|
||||
};
|
||||
// 获得消息分页列表
|
||||
const getMessageList = async () => {
|
||||
const { data } = await KeFuApi.getKefuMessagePage(queryParams);
|
||||
if (isEmpty(data.list)) {
|
||||
return;
|
||||
}
|
||||
pagingRef.value.completeByTotal(data.list, data.total);
|
||||
};
|
||||
/** 刷新消息列表 */
|
||||
const refreshMessageList = (message = undefined) => {
|
||||
if (queryParams.pageNo != 1 && message !== undefined) {
|
||||
showNewMessageTip.value = true;
|
||||
// 追加数据
|
||||
pagingRef.value.addChatRecordData([message], false);
|
||||
return;
|
||||
}
|
||||
pagingRef.value.reload();
|
||||
};
|
||||
/** 滚动到最新消息 */
|
||||
const onBackToTopClick = (event) => {
|
||||
event(false); // 禁用默认操作
|
||||
pagingRef.value.scrollToBottom();
|
||||
};
|
||||
/** 监听滚动到底部事件(因为 scroll 翻转了顶就是底) */
|
||||
const onScrollToUpper = () => {
|
||||
// 若已是第一页则不做处理
|
||||
if (queryParams.pageNo === 1) {
|
||||
return;
|
||||
}
|
||||
showNewMessageTip.value = false;
|
||||
// 到底重置消息列表
|
||||
refreshMessageList();
|
||||
};
|
||||
defineExpose({ getMessageList, refreshMessageList });
|
||||
</script>
|
304
pages/chat/components/messageListItem.vue
Normal file
304
pages/chat/components/messageListItem.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<view class="chat-box">
|
||||
<!-- 消息渲染 -->
|
||||
<view class="message-item ss-flex-col scroll-item">
|
||||
<view class="ss-flex ss-row-center ss-col-center">
|
||||
<!-- 日期 -->
|
||||
<view
|
||||
v-if="
|
||||
message.contentType !== KeFuMessageContentTypeEnum.SYSTEM &&
|
||||
showTime(message, messageIndex)
|
||||
"
|
||||
class="date-message"
|
||||
>
|
||||
{{ formatDate(message.createTime) }}
|
||||
</view>
|
||||
<!-- 系统消息 -->
|
||||
<view
|
||||
v-if="message.contentType === KeFuMessageContentTypeEnum.SYSTEM"
|
||||
class="system-message"
|
||||
>
|
||||
{{ message.content }}
|
||||
</view>
|
||||
</view>
|
||||
<!-- 消息体渲染管理员消息和用户消息并左右展示 -->
|
||||
<view
|
||||
v-if="message.contentType !== KeFuMessageContentTypeEnum.SYSTEM"
|
||||
class="ss-flex ss-col-top"
|
||||
:class="[
|
||||
message.senderType === UserTypeEnum.ADMIN
|
||||
? `ss-row-left`
|
||||
: message.senderType === UserTypeEnum.MEMBER
|
||||
? `ss-row-right`
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<!-- 客服头像 -->
|
||||
<image
|
||||
v-show="message.senderType === UserTypeEnum.ADMIN"
|
||||
class="chat-avatar ss-m-r-24"
|
||||
:src="
|
||||
sheep.$url.cdn(message.senderAvatar) ||
|
||||
sheep.$url.static('/static/img/shop/chat/default.png')
|
||||
"
|
||||
mode="aspectFill"
|
||||
></image>
|
||||
<!-- 内容 -->
|
||||
<template v-if="message.contentType === KeFuMessageContentTypeEnum.TEXT">
|
||||
<view class="message-box" :class="{ admin: message.senderType === UserTypeEnum.ADMIN }">
|
||||
<mp-html :content="replaceEmoji(message.content)" />
|
||||
</view>
|
||||
</template>
|
||||
<template v-if="message.contentType === KeFuMessageContentTypeEnum.IMAGE">
|
||||
<view
|
||||
class="message-box"
|
||||
:class="{ admin: message.senderType === UserTypeEnum.ADMIN }"
|
||||
>
|
||||
<su-image
|
||||
class="message-img"
|
||||
isPreview
|
||||
:previewList="[sheep.$url.cdn(message.content)]"
|
||||
:current="0"
|
||||
:src="sheep.$url.cdn(message.content)"
|
||||
:height="200"
|
||||
:width="200"
|
||||
mode="aspectFill"
|
||||
></su-image>
|
||||
</view>
|
||||
</template>
|
||||
<template v-if="message.contentType === KeFuMessageContentTypeEnum.PRODUCT">
|
||||
<GoodsItem
|
||||
:goodsData="getMessageContent(message)"
|
||||
@tap="sheep.$router.go('/pages/goods/index', { id: getMessageContent(message).spuId })"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="message.contentType === KeFuMessageContentTypeEnum.ORDER">
|
||||
<OrderItem
|
||||
:orderData="getMessageContent(message)"
|
||||
@tap="sheep.$router.go('/pages/order/my/detail', { id: getMessageContent(message).id })"
|
||||
/>
|
||||
</template>
|
||||
<!-- user头像 -->
|
||||
<image
|
||||
v-if="message.senderType === UserTypeEnum.MEMBER"
|
||||
class="chat-avatar ss-m-l-24"
|
||||
:src="
|
||||
sheep.$url.cdn(message.senderAvatar) ||
|
||||
sheep.$url.static('/static/img/shop/chat/default.png')
|
||||
"
|
||||
mode="aspectFill"
|
||||
>
|
||||
</image>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, unref } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { KeFuMessageContentTypeEnum, UserTypeEnum } from '@/pages/chat/util/constants';
|
||||
import { emojiList } from '@/pages/chat/util/emoji';
|
||||
import sheep from '@/sheep';
|
||||
import { formatDate } from '@/sheep/util';
|
||||
import GoodsItem from '@/pages/chat/components/goods.vue';
|
||||
import OrderItem from '@/pages/chat/components/order.vue';
|
||||
|
||||
const props = defineProps({
|
||||
// 消息
|
||||
message: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
// 消息索引
|
||||
messageIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
// 消息列表
|
||||
messageList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
const getMessageContent = computed(() => (item) => JSON.parse(item.content)); // 解析消息内容
|
||||
|
||||
//======================= 工具 =======================
|
||||
|
||||
const showTime = computed(() => (item, index) => {
|
||||
if (unref(props.messageList)[index + 1]) {
|
||||
let dateString = dayjs(unref(props.messageList)[index + 1].createTime).fromNow();
|
||||
return dateString !== dayjs(unref(item).createTime).fromNow();
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// 处理表情
|
||||
function replaceEmoji(data) {
|
||||
let newData = data;
|
||||
if (typeof newData !== 'object') {
|
||||
let reg = /\[(.+?)]/g; // [] 中括号
|
||||
let zhEmojiName = newData.match(reg);
|
||||
if (zhEmojiName) {
|
||||
zhEmojiName.forEach((item) => {
|
||||
let emojiFile = selEmojiFile(item);
|
||||
newData = newData.replace(
|
||||
item,
|
||||
`<img class="chat-img" style="width: 24px;height: 24px;margin: 0 3px;" src="${sheep.$url.cdn(
|
||||
'/static/img/chat/emoji/' + emojiFile,
|
||||
)}"/>`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
return newData;
|
||||
}
|
||||
|
||||
function selEmojiFile(name) {
|
||||
for (let index in emojiList) {
|
||||
if (emojiList[index].name === name) {
|
||||
return emojiList[index].file;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.message-item {
|
||||
margin-bottom: 33rpx;
|
||||
}
|
||||
|
||||
.date-message,
|
||||
.system-message {
|
||||
width: fit-content;
|
||||
border-radius: 12rpx;
|
||||
padding: 8rpx 16rpx;
|
||||
margin-bottom: 16rpx;
|
||||
background-color: var(--ui-BG-3);
|
||||
color: #999;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
width: 70rpx;
|
||||
height: 70rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.send-status {
|
||||
color: #333;
|
||||
height: 80rpx;
|
||||
margin-right: 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.loading {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
-webkit-animation: rotating 2s linear infinite;
|
||||
animation: rotating 2s linear infinite;
|
||||
|
||||
@-webkit-keyframes rotating {
|
||||
0% {
|
||||
transform: rotateZ(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotateZ(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
0% {
|
||||
transform: rotateZ(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotateZ(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
color: #ff3000;
|
||||
}
|
||||
}
|
||||
|
||||
.message-box {
|
||||
max-width: 50%;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
word-wrap: break-word;
|
||||
padding: 20rpx;
|
||||
border-radius: 10rpx;
|
||||
color: #fff;
|
||||
background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
|
||||
|
||||
&.admin {
|
||||
background: #fff;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:deep() {
|
||||
.imgred {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.imgred,
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:deep() {
|
||||
.goods,
|
||||
.order {
|
||||
max-width: 500rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.message-img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.template-wrap {
|
||||
// width: 100%;
|
||||
padding: 20rpx 24rpx;
|
||||
background: #fff;
|
||||
border-radius: 10rpx;
|
||||
|
||||
.title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 29rpx;
|
||||
}
|
||||
|
||||
.item {
|
||||
font-size: 24rpx;
|
||||
color: var(--ui-BG-Main);
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-img {
|
||||
width: 400rpx;
|
||||
height: 400rpx;
|
||||
}
|
||||
|
||||
.chat-box {
|
||||
padding: 10px;
|
||||
}
|
||||
</style>
|
115
pages/chat/components/order.vue
Normal file
115
pages/chat/components/order.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<view class="bg-white order-list-card-box ss-r-10 ss-m-t-14 ss-m-20"
|
||||
:key="orderData.id">
|
||||
<view class="order-card-header ss-flex ss-col-center ss-row-between ss-p-x-20">
|
||||
<view class="order-no">订单号:{{ orderData.no }}</view>
|
||||
<view class="order-state ss-font-26" :class="formatOrderColor(orderData)">
|
||||
{{ formatOrderStatus(orderData) }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="border-bottom" v-for="item in orderData.items" :key="item.id">
|
||||
<s-goods-item
|
||||
:img="item.picUrl"
|
||||
:title="item.spuName"
|
||||
:skuText="item.properties.map((property) => property.valueName).join(' ')"
|
||||
:price="item.price"
|
||||
:num="item.count"
|
||||
/>
|
||||
</view>
|
||||
<view class="pay-box ss-m-t-30 ss-p-b-30 ss-flex ss-row-right ss-p-r-20">
|
||||
<view class="ss-flex ss-col-center">
|
||||
<view class="discounts-title pay-color">共 {{ orderData.productCount }} 件商品,总金额:</view>
|
||||
<view class="discounts-money pay-color">
|
||||
¥{{ fen2yuan(orderData.payPrice) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { fen2yuan, formatOrderColor, formatOrderStatus } from '@/sheep/hooks/useGoods';
|
||||
|
||||
const props = defineProps({
|
||||
orderData: {
|
||||
type: Object,
|
||||
default: {},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.order-list-card-box {
|
||||
.order-card-header {
|
||||
height: 80rpx;
|
||||
|
||||
.order-no {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.order-state {}
|
||||
}
|
||||
.pay-box {
|
||||
.discounts-title {
|
||||
font-size: 24rpx;
|
||||
line-height: normal;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.discounts-money {
|
||||
font-size: 24rpx;
|
||||
line-height: normal;
|
||||
color: #999;
|
||||
font-family: OPPOSANS;
|
||||
}
|
||||
|
||||
.pay-color {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
.order-card-footer {
|
||||
height: 100rpx;
|
||||
|
||||
.more-item-box {
|
||||
padding: 20rpx;
|
||||
|
||||
.more-item {
|
||||
height: 60rpx;
|
||||
|
||||
.title {
|
||||
font-size: 26rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
color: $dark-9;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 154rpx;
|
||||
color: #333333;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
.warning-color {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.danger-color {
|
||||
color: #ff3000;
|
||||
}
|
||||
|
||||
.success-color {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.info-color {
|
||||
color: #999999;
|
||||
}
|
||||
</style>
|
151
pages/chat/components/select-popup.vue
Normal file
151
pages/chat/components/select-popup.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<su-popup :show="show" showClose round="10" backgroundColor="#eee" @close="emits('close')">
|
||||
<view class="select-popup">
|
||||
<view class="title">
|
||||
<span>{{ mode == 'goods' ? '我的浏览' : '我的订单' }}</span>
|
||||
</view>
|
||||
<scroll-view
|
||||
class="scroll-box"
|
||||
scroll-y="true"
|
||||
:scroll-with-animation="true"
|
||||
:show-scrollbar="false"
|
||||
@scrolltolower="loadmore"
|
||||
>
|
||||
<view
|
||||
class="item"
|
||||
v-for="item in state.pagination.data"
|
||||
:key="item.id"
|
||||
@tap="emits('select', { type: mode, data: item })"
|
||||
>
|
||||
<template v-if="mode == 'goods'">
|
||||
<GoodsItem :goodsData="item" />
|
||||
</template>
|
||||
<template v-if="mode == 'order'">
|
||||
<OrderItem :orderData="item" />
|
||||
</template>
|
||||
</view>
|
||||
<uni-load-more :status="state.loadStatus" :content-text="{ contentdown: '上拉加载更多' }" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</su-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, watch } from 'vue';
|
||||
import _ from 'lodash-es';
|
||||
import GoodsItem from './goods.vue';
|
||||
import OrderItem from './order.vue';
|
||||
import OrderApi from '@/sheep/api/trade/order';
|
||||
import SpuHistoryApi from '@/sheep/api/product/history';
|
||||
|
||||
const emits = defineEmits(['select', 'close']);
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'goods',
|
||||
},
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.mode,
|
||||
() => {
|
||||
state.pagination.data = [];
|
||||
if (props.mode) {
|
||||
getList(state.pagination.page);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const state = reactive({
|
||||
loadStatus: '',
|
||||
pagination: {
|
||||
data: [],
|
||||
current_page: 1,
|
||||
total: 1,
|
||||
last_page: 1,
|
||||
},
|
||||
});
|
||||
|
||||
async function getList(page, list_rows = 5) {
|
||||
state.loadStatus = 'loading';
|
||||
const res =
|
||||
props.mode == 'goods'
|
||||
? await SpuHistoryApi.getBrowseHistoryPage({
|
||||
page,
|
||||
list_rows,
|
||||
})
|
||||
: await OrderApi.getOrderPage({
|
||||
page,
|
||||
list_rows,
|
||||
});
|
||||
let orderList = _.concat(state.pagination.data, res.data.list);
|
||||
state.pagination = {
|
||||
...res.data,
|
||||
data: orderList,
|
||||
};
|
||||
if (state.pagination.current_page < state.pagination.last_page) {
|
||||
state.loadStatus = 'more';
|
||||
} else {
|
||||
state.loadStatus = 'noMore';
|
||||
}
|
||||
}
|
||||
|
||||
function loadmore() {
|
||||
if (state.loadStatus !== 'noMore') {
|
||||
getList(state.pagination.current_page + 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.select-popup {
|
||||
max-height: 600rpx;
|
||||
|
||||
.title {
|
||||
height: 100rpx;
|
||||
line-height: 100rpx;
|
||||
padding: 0 26rpx;
|
||||
background: #fff;
|
||||
border-radius: 20rpx 20rpx 0 0;
|
||||
|
||||
span {
|
||||
font-size: 32rpx;
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -15px;
|
||||
background: var(--ui-BG-Main);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-box {
|
||||
height: 500rpx;
|
||||
}
|
||||
|
||||
.item {
|
||||
background: #fff;
|
||||
margin: 26rpx 26rpx 0;
|
||||
border-radius: 20rpx;
|
||||
|
||||
:deep() {
|
||||
.image {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
166
pages/chat/components/toolsPopup.vue
Normal file
166
pages/chat/components/toolsPopup.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<su-popup
|
||||
:show="showTools"
|
||||
@close="handleClose"
|
||||
>
|
||||
<view class="ss-modal-box ss-flex-col">
|
||||
<slot></slot>
|
||||
<view class="content ss-flex ss-flex-1">
|
||||
<template v-if="toolsMode === 'emoji'">
|
||||
<swiper
|
||||
class="emoji-swiper"
|
||||
:indicator-dots="true"
|
||||
circular
|
||||
indicator-active-color="#7063D2"
|
||||
indicator-color="rgba(235, 231, 255, 1)"
|
||||
:autoplay="false"
|
||||
:interval="3000"
|
||||
:duration="1000"
|
||||
>
|
||||
<swiper-item v-for="emoji in emojiPage" :key="emoji">
|
||||
<view class="ss-flex ss-flex-wrap">
|
||||
<image
|
||||
v-for="item in emoji" :key="item"
|
||||
class="emoji-img"
|
||||
:src="sheep.$url.cdn(`/static/img/chat/emoji/${item.file}`)"
|
||||
@tap="onEmoji(item)"
|
||||
>
|
||||
</image>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</template>
|
||||
<template v-else>
|
||||
<view class="image">
|
||||
<s-uploader
|
||||
file-mediatype="image"
|
||||
:imageStyles="{ width: 50, height: 50, border: false }"
|
||||
@select="imageSelect({ type: 'image', data: $event })"
|
||||
>
|
||||
<image
|
||||
class="icon"
|
||||
:src="sheep.$url.static('/static/img/shop/chat/image.png')"
|
||||
mode="aspectFill"
|
||||
></image>
|
||||
</s-uploader>
|
||||
<view>图片</view>
|
||||
</view>
|
||||
<!-- <view class="goods" @tap="onShowSelect('goods')">
|
||||
<image
|
||||
class="icon"
|
||||
:src="sheep.$url.static('/static/img/shop/chat/goods.png')"
|
||||
mode="aspectFill"
|
||||
></image>
|
||||
<view>商品</view>
|
||||
</view> -->
|
||||
<view class="order" @tap="onShowSelect('order')">
|
||||
<image
|
||||
class="icon"
|
||||
:src="sheep.$url.static('/static/img/shop/chat/order.png')"
|
||||
mode="aspectFill"
|
||||
></image>
|
||||
<view>订单</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
</su-popup>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 聊天工具
|
||||
*/
|
||||
import { emojiPage } from '@/pages/chat/util/emoji';
|
||||
import sheep from '@/sheep';
|
||||
|
||||
const props = defineProps({
|
||||
// 工具模式
|
||||
toolsMode: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// 控制工具菜单弹出
|
||||
showTools: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(['onEmoji', 'imageSelect', 'onShowSelect', 'close']);
|
||||
|
||||
// 关闭弹出工具菜单
|
||||
function handleClose() {
|
||||
emits('close');
|
||||
}
|
||||
|
||||
// 选择表情
|
||||
function onEmoji(emoji) {
|
||||
emits('onEmoji', emoji);
|
||||
}
|
||||
|
||||
// 选择图片
|
||||
function imageSelect(val) {
|
||||
emits('imageSelect', val);
|
||||
}
|
||||
|
||||
// 选择商品或订单
|
||||
function onShowSelect(mode) {
|
||||
emits('onShowSelect', mode);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.content {
|
||||
width: 100%;
|
||||
align-content: space-around;
|
||||
border-top: 1px solid #dfdfdf;
|
||||
padding: 20rpx 0 0;
|
||||
|
||||
.emoji-swiper {
|
||||
width: 100%;
|
||||
height: 280rpx;
|
||||
padding: 0 20rpx;
|
||||
|
||||
.emoji-img {
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
display: inline-block;
|
||||
margin: 10rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.image,
|
||||
.goods,
|
||||
.order {
|
||||
width: 33.3%;
|
||||
height: 280rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.icon {
|
||||
width: 50rpx;
|
||||
height: 50rpx;
|
||||
margin-bottom: 21rpx;
|
||||
}
|
||||
}
|
||||
|
||||
:deep() {
|
||||
.uni-file-picker__container {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-picker__box {
|
||||
display: none;
|
||||
|
||||
&:last-of-type {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
187
pages/chat/index.vue
Normal file
187
pages/chat/index.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<s-layout class="chat-wrap" :title="!isReconnecting ? '连接客服成功' : '会话重连中'" navbar="inner">
|
||||
<!-- 覆盖头部导航栏背景颜色 -->
|
||||
<div class="page-bg" :style="{ height: sys_navBar + 'px' }"></div>
|
||||
<!-- 聊天区域 -->
|
||||
<MessageList ref="messageListRef">
|
||||
<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 } 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';
|
||||
|
||||
const sys_navBar = sheep.$platform.navbar;
|
||||
|
||||
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: chat.msg,
|
||||
};
|
||||
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: 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.value);
|
||||
return;
|
||||
}
|
||||
// 2.2 消息类型:KEFU_MESSAGE_TYPE
|
||||
if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_TYPE) {
|
||||
// 刷新消息列表
|
||||
await messageListRef.value.refreshMessageList(JSON.parse(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>
|
21
pages/chat/util/constants.js
Normal file
21
pages/chat/util/constants.js
Normal file
@@ -0,0 +1,21 @@
|
||||
export const KeFuMessageContentTypeEnum = {
|
||||
TEXT: 1, // 文本消息
|
||||
IMAGE: 2, // 图片消息
|
||||
VOICE: 3, // 语音消息
|
||||
VIDEO: 4, // 视频消息
|
||||
SYSTEM: 5, // 系统消息
|
||||
// ========== 商城特殊消息 ==========
|
||||
PRODUCT: 10,// 商品消息
|
||||
ORDER: 11,// 订单消息"
|
||||
};
|
||||
export const UserTypeEnum = {
|
||||
MEMBER: 1, // 会员 面向 c 端,普通用户
|
||||
ADMIN: 2, // 管理员 面向 b 端,管理后台
|
||||
};
|
||||
// Promotion 的 WebSocket 消息类型枚举类
|
||||
export const WebSocketMessageTypeConstants = {
|
||||
KEFU_MESSAGE_TYPE: 'kefu_message_type', // 客服消息类型
|
||||
IM_MESSAGE_READ: 'im_message_read_status_change', // IM消息已读
|
||||
IM_MESSAGE_NEWS: 'im_message_news', // IM新消息
|
||||
KEFU_MESSAGE_ADMIN_READ: 'kefu_message_read_status_change' // 客服消息管理员已读
|
||||
}
|
58
pages/chat/util/emoji.js
Normal file
58
pages/chat/util/emoji.js
Normal file
@@ -0,0 +1,58 @@
|
||||
export 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 let emojiPage = {};
|
||||
emojiList.forEach((item, index) => {
|
||||
if (!emojiPage[Math.floor(index / 30) + 1]) {
|
||||
emojiPage[Math.floor(index / 30) + 1] = [];
|
||||
}
|
||||
emojiPage[Math.floor(index / 30) + 1].push(item);
|
||||
});
|
Reference in New Issue
Block a user