项目初始化

This commit is contained in:
jerry
2025-01-21 01:46:34 +08:00
parent 364021b042
commit 48153e7761
962 changed files with 172070 additions and 0 deletions

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

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

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

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

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

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

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