refactor(im): 重构聊天面板UI组件并修复多处交互问题

- 将头像组件从 dicebear 外链改为本地首字+底色生成,新增 avatarColor 工具函数
- 重构订单卡片(footPrint.vue)布局,优化商品与订单信息展示结构
- 重构店铺详情卡片(storeDetail.vue),统一信息行与评分行样式
- 移除消息气泡中的昵称时间行,调整气泡背景色与头像内边距
- 修复 face 变量名与全局组件 <face> 冲突导致头像URL被当作标签渲染
- 将 unload 事件监听替换为 pagehide,兼容 Chrome 弃用策略
- 修复搜索关键词未转义正则元字符导致输入特殊字符时抛异常
- 修复 WebSocket onMessage 对无 text 字段的推送消息误调用 JSON.parse
- 将 setup 中同步调用全局 mixin 方法移至 onMounted,避免代理未就绪
- 商品/订单图片统一添加 onerror 默认图兜底
- 聊天列表消息预览支持 emoji 图片渲染(textReplaceEmoji)
- 搜索框增加 clearable 属性及"没有找到相关好友"空态提示
- 锁定 element-plus 版本为 2.14.1 防止自动升级引入兼容问题
- 日期格式化分隔符由斜杠改为横杠
- 提现设置页新增提现规则说明字段,满足微信审核公示要求
This commit is contained in:
pikachu1995@126.com
2026-10-29 10:56:28 +08:00
parent 68dfae9a62
commit 97e611c072
14 changed files with 473 additions and 246 deletions

View File

@@ -16,7 +16,7 @@
"axios": "^1.7.9",
"babel-plugin-prismjs": "^2.0.1",
"core-js": "^3.49.0",
"element-plus": "^2.14.1",
"element-plus": "2.14.1",
"js-audio-recorder": "^1.0.6",
"js-base64": "^2.5.1",
"nprogress": "^0.2.0",

View File

@@ -34,11 +34,12 @@ function unloadHandler (e) {
onMounted(() => {
setRefreshView(refreshView);
window.addEventListener('beforeunload', e => beforeunloadHandler(e))
window.addEventListener('unload', e => unloadHandler(e))
window.addEventListener('beforeunload', beforeunloadHandler)
// unload 事件已被 Chrome 弃用(Permissions-Policy 禁止),改用 pagehide
window.addEventListener('pagehide', unloadHandler)
});
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', e => beforeunloadHandler(e))
window.removeEventListener('unload', e => unloadHandler(e))
window.removeEventListener('beforeunload', beforeunloadHandler)
window.removeEventListener('pagehide', unloadHandler)
});
</script>

View File

@@ -1,5 +1,5 @@
<template>
<el-tabs v-model="activeName" @tab-click="handleClick" type="card" :stretch=true>
<el-tabs v-model="activeName" @tab-click="handleClick" :stretch=true>
<el-tab-pane :label="toUser.storeFlag ? '正在咨询' : '他的足迹'" name="history">
<div>
<GoodsLink :goodsDetail="goodsDetail" v-if="toUser.userId === goodsDetail.storeId"
@@ -306,4 +306,20 @@ function formatDateToString(date) {
:deep(.el-tabs__header) {
margin-bottom: 0;
}
/* 高度链:tabs 撑满面板,pane 高度传递给内部滚动容器 */
:deep(.el-tabs) {
height: 100%;
display: flex;
flex-direction: column;
}
:deep(.el-tabs__content) {
flex: 1;
min-height: 0;
}
:deep(.el-tab-pane) {
height: 100%;
}
</style>

View File

@@ -43,7 +43,7 @@
</aside>
<aside class="avatar-column">
<div class="avatar-box" v-if="item.float == 'right'">
<face :text="face" v-if="face"></face>
<face :text="userFace" v-if="userFace"></face>
<face-null :text="name" v-else></face-null>
</div>
<div class="avatar-box" v-if="item.float == 'left'">
@@ -53,14 +53,10 @@
</aside>
<main class="main-column">
<div class="talk-content">
<span class="nickname">
{{ item.float == "right" ? name : toUser.name }} |
{{ unixToDate(item.createTime, "MM月dd日 hh:mm") }}
</span>
<div class="flex flex-a-c">
<legacy-el-icon name="el-icon-refresh-left" class="again main-color" @click="againSendMessage(item)" v-if="item.webSocketStatus" />
<!-- 文本消息 -->
<div v-if="item.messageType == 'MESSAGE'" style="background-color: #d0e9ff;color: black;"
<div v-if="item.messageType == 'MESSAGE'"
class="text-message" :class="{
left: item.float == 'left',
right: item.float == 'right',
@@ -79,7 +75,7 @@
}">
<div class="base" @click="linkToGoods(item.text.goodsId, item.text.id)">
<div>
<img :src="item.text.thumbnail" class="image" />
<img :src="item.text.thumbnail" :onerror="defaultAvatar" class="image" />
</div>
<div>
<div class="goods_name">
@@ -106,7 +102,7 @@
<div class="goods-shared-box">
<div class="goods-item" v-for="(order,orderIndex) in item.text.orderItems" :key="orderIndex">
<img :src="order.image" style="height: 100px;width: 100px;" />
<img :src="order.image" :onerror="defaultAvatar" style="height: 100px;width: 100px;" />
<div>
<span class="orderGoodsName">{{ order.name }}</span>
<div class="goods-item-price">
@@ -212,6 +208,7 @@ import SocketInstance from "@/im-server/socket-instance";
import { SvgMentionDown } from "@/core/icons";
import { formatTime, parseTime, copyTextToClipboard } from "@/utils/functions";
import { useUserStore } from "@/stores/user";
import { useAppStore } from "@/stores/app";
import { useTalkStore } from "@/stores/talk";
import { useNotifyStore } from "@/stores/notify";
import { useSettingsStore } from "@/stores/settings";
@@ -259,6 +256,7 @@ const emit = defineEmits(["close-talk"]);
const { proxy } = getCurrentInstance();
const userStore = useUserStore();
const appStore = useAppStore();
const talkStore = useTalkStore();
const notifyStore = useNotifyStore();
const settingsStore = useSettingsStore();
@@ -306,13 +304,16 @@ const panelHeader = ref(null);
const talkItems = computed(() => talkStore.talkItems);
const name = computed(() => userStore.name);
const face = computed(() => userStore.face);
// 不能命名为 face:会遮蔽 <face> 组件解析,头像 URL 会被当作标签名渲染
const userFace = computed(() => userStore.face);
const unreadMessage = computed(() => talkStore.unreadMessage);
const inputEvent = computed(() => notifyStore.inputEvent);
const id = computed(() => userStore.id);
const records = computed(() => dialogueStore.records);
const index_name = computed(() => dialogueStore.index_name);
const toUser = computed(() => userStore.toUser);
// 商品/订单图片加载失败时的默认图(onerror 属性值)
const defaultAvatar = computed(() => appStore.defaultAvatar);
const sendTime = formatTime;
@@ -1147,7 +1148,7 @@ function talkPanelScrollBottom() {
flex-shrink: 0;
order: 1;
user-select: none;
padding-top: 25px;
padding-top: 8px;
i {
color: #ccc;
@@ -1168,7 +1169,7 @@ function talkPanelScrollBottom() {
align-items: center;
order: 2;
user-select: none;
padding-top: 22px;
padding-top: 5px;
flex-direction: column;
}
@@ -1206,13 +1207,6 @@ function talkPanelScrollBottom() {
align-items: flex-start;
box-sizing: border-box;
width: 100%;
.nickname {
font-size: 12px;
color: #a7a0a0;
margin: -4px 0 4px -8px;
transform: scale(0.9);
}
}
&:hover {
@@ -1250,8 +1244,8 @@ function talkPanelScrollBottom() {
}
}
@bg-left-color: #f5f5f5;
@bg-right-color: #ffffff;
@bg-left-color: #f2f2f2;
@bg-right-color: #409eff;
.text-message {
position: relative;

View File

@@ -1,14 +1,14 @@
<template>
<div class="box" :style="{height:toUser.storeFlag ? '858px' : '815px'}" @scroll="scrollBottom">
<div class="box" @scroll="scrollBottom">
<div class="tab">
<el-tabs v-model="activeName" @tab-click="handleClick" :stretch=true>
<el-tabs v-model="activeName" @tab-click="handleClick" type="card" :stretch=true>
<el-tab-pane label="最近浏览" name="goods">
<dl v-if='list.length' class='base-list'>
<dd :key="index" @click="linkToGoods(item.goodsId, item.id)" v-for="(item,index) in list">
<div class="base">
<div>
<img style="width: 60px; height: 60px;margin-left:20px;box-sizing: border-box;" :src="item.thumbnail"
class="image" />
:onerror="defaultAvatar" class="image" />
</div>
<div class="recent_views">
<a class="goods_name">{{ item.goodsName }}</a>
@@ -35,24 +35,8 @@
<dl class='base-order-list' v-if='orderList.length'>
<dd v-for="(item, index) in orderList" :key="index">
<div class="order-list">
<div class="order-top order-padding">
<span class="order-sn" @click="linkToOrders(item.sn)">订单号:{{ item.sn }}</span>
</div>
<div class="order-section order-padding">
<div class="order-items" v-for="(order,orderIndex) in item.orderItems" :key="orderIndex">
<img :src="order.image" alt="">
<span class="order-goods-name" @click="linkToOrders(item.sn)"> {{ order.name }}</span>
<span class="price">{{ $filters.unitPrice(order.goodsPrice, "¥") }}</span>
</div>
<!-- <img :src="item.groupImages" alt=""> -->
<div class="order-btn ">
<el-button class="store-button" v-if="item.btnHide == 1 && toUser.storeFlag"
size="small" @click="submitSendOrderMessage(item, index)" plain>发送</el-button>
</div>
</div>
<div class="order-footer order-padding">
<span></span>
<div class="order-head">
<span class="order-sn" :title="item.sn" @click="linkToOrders(item.sn)">订单号:{{ item.sn }}</span>
<el-tag size="small" :type="col[item.orderStatus]">{{
item.orderStatus == 'STAY_PICKED_UP' ? '待自提'
: item.orderStatus == 'CANCELLED' ? '已取消' : item.orderStatus == 'UNPAID' ? '未付款' : item.orderStatus
@@ -62,9 +46,18 @@
''
}}</el-tag>
</div>
<div class="order-items" v-for="(order,orderIndex) in item.orderItems" :key="orderIndex">
<img :src="order.image" :onerror="defaultAvatar" alt="">
<div class="order-item-info">
<span class="order-goods-name" :title="order.name" @click="linkToOrders(item.sn)">{{ order.name }}</span>
<span class="price">{{ $filters.unitPrice(order.goodsPrice, "¥") }}</span>
</div>
</div>
<div class="order-foot" v-if="item.btnHide == 1 && toUser.storeFlag">
<el-button class="store-button" size="small" @click="submitSendOrderMessage(item, index)" plain>发送</el-button>
</div>
</div>
</dd>
</dl>
<div v-else class='no-more'>
@@ -80,9 +73,11 @@
import { computed, onMounted, ref } from "vue";
import { useTalkStore } from "@/stores/talk";
import { useUserStore } from "@/stores/user";
import { useAppStore } from "@/stores/app";
const talkStore = useTalkStore();
const userStore = useUserStore();
const appStore = useAppStore();
const emit = defineEmits(["loadMore", "sendMessage"]);
@@ -118,6 +113,8 @@ const col = ref({
const talkItems = computed(() => talkStore.talkItems);
const id = computed(() => userStore.id);
const toUser = computed(() => userStore.toUser);
// 图片加载失败时的默认图(onerror 属性值)
const defaultAvatar = computed(() => appStore.defaultAvatar);
//跳转订单列表
@@ -204,7 +201,9 @@ onMounted(() => {
}
.box {
max-width: 362px;
width: 100%;
height: 100%;
box-sizing: border-box;
.top {
text-align: center;
@@ -237,57 +236,51 @@ onMounted(() => {
}
.order-list {
padding: 9px;
transition: 0.35s;
cursor: pointer;
border: 1px solid #eee;
border-radius: 6px;
padding: 10px;
margin-bottom: 10px;
background: #fff;
color: #5a606b;
box-sizing: border-box;
transition: border-color .2s;
&:hover {
border-color: #b3d3f9;
}
}
.order-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 1px solid #f5f5f5;
}
.order-sn {
flex: 1;
min-width: 0;
font-size: 12px;
color: #666;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
color: #409eff;
}
}
.box::-webkit-scrollbar {
width: 0px !important;
}
.order-section {
margin: 10px 0;
font-size: 12px;
img {
width: 60px;
height: 60px;
}
}
.order-footer {
font-size: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.order-goods-name {
width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
position: absolute;
margin-left: 10px;
font-weight: 500;
}
.orderSn {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap
}
.order-btn {
.order-foot {
display: flex;
justify-content: flex-end;
position: relative;
bottom: 55px;
margin-top: 8px;
}
.goods_name {
@@ -300,7 +293,7 @@ onMounted(() => {
:deep(.el-tabs__header ) {
position: absolute;
width: 362px;
width: 100%;
height: 50px;
left: 0;
z-index: 2;
@@ -329,10 +322,6 @@ onMounted(() => {
// width: 350px;
}
.order-padding {
padding: 0 10px;
}
.base {
padding: 8px;
cursor: pointer;
@@ -354,12 +343,12 @@ onMounted(() => {
}
}
.base:hover,.order-list:hover{
.base:hover{
background: #ededed;
padding-left: 14px;
>.review{
opacity: 1;
}
}
.goods-price{
@@ -371,9 +360,6 @@ onMounted(() => {
.separate {
margin-top: 8px;
}
.order-sn{
font-size: 12px;
}
// .el-tabs--card {
// height: calc(100vh - 110px);
// overflow-y: auto;
@@ -383,17 +369,54 @@ onMounted(() => {
// overflow-y: auto;
// }
.base-list{
padding-bottom: 120px;
padding: 10px 10px 12px;
}
.base-order-list{
padding-bottom: 100px;
}
.price{
color: red;
margin-left: 10px;
margin-bottom: 10px;
padding: 10px 10px 12px;
}
.order-items{
margin-bottom: 10px;
display: flex;
align-items: center;
margin-bottom: 8px;
&:last-of-type {
margin-bottom: 0;
}
img {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: 4px;
object-fit: cover;
background: #fafafa;
}
.order-item-info {
flex: 1;
min-width: 0;
margin-left: 10px;
display: flex;
flex-direction: column;
}
.order-goods-name {
font-size: 12px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
&:hover {
color: #409eff;
}
}
.price {
margin-top: 4px;
font-size: 13px;
color: #f23030;
}
}
</style>

View File

@@ -3,7 +3,7 @@
当前浏览
<div class="base">
<div>
<img :src="goodsDetail.thumbnail" class="image" />
<img :src="goodsDetail.thumbnail" :onerror="defaultAvatar" class="image" />
</div>
<div style="margin-left: 13px">
<a @click="linkToGoods(goodsDetail.goodsId, goodsDetail.id)"> {{ goodsDetail.goodsName }} </a>
@@ -23,10 +23,12 @@
import { computed, onMounted, nextTick, ref } from "vue";
import SocketInstance from "@/im-server/socket-instance";
import { useUserStore } from "@/stores/user";
import { useAppStore } from "@/stores/app";
import { useDialogueStore } from "@/stores/dialogue";
import { useTalkStore } from "@/stores/talk";
const userStore = useUserStore();
const appStore = useAppStore();
const dialogueStore = useDialogueStore();
const talkStore = useTalkStore();
@@ -44,6 +46,8 @@ const talkItems = computed(() => talkStore.talkItems);
const id = computed(() => userStore.id);
const index_name = computed(() => dialogueStore.index_name);
const toUser = computed(() => userStore.toUser);
// 图片加载失败时的默认图(onerror 属性值)
const defaultAvatar = computed(() => appStore.defaultAvatar);
onMounted(() => {
console.log(props.goodsDetail)

View File

@@ -1,100 +1,157 @@
<template>
<div style="width: 362px;">
<div class="store-card">
<div class="base">
<div>
<img :src="storeInfo.storeLogo" class="image" />
</div>
<img :src="storeInfo.storeLogo" :onerror="onError" class="image" alt="" />
<div class="store-detail">
<div>
<div class="div-zoom">
<span class="title_left_name"> 店铺名称:</span> <span class="title_right_name">{{ storeInfo.storeName }}</span>
<el-tag style="margin-left: 10px;" type="danger" v-if="storeInfo.selfOperated" size="small">自营</el-tag>
<div class="store-name">
<span class="name-text" :title="storeInfo.storeName">{{ storeInfo.storeName }}</span>
<el-tag type="danger" v-if="storeInfo.selfOperated" size="small">自营</el-tag>
</div>
<div>
<span class="title_left_name"> 联系方式:</span> <span class="title_right_name">{{ storeInfo.memberName }}</span>
</div>
</div>
<div>
<el-button class="store-button" @click="linkToStore(storeInfo.id)" size="small"
plain>进入店铺</el-button>
<div class="store-actions">
<el-button @click="linkToStore(storeInfo.id)" size="small" plain>进入店铺</el-button>
</div>
</div>
</div>
<div class="info-list">
<div class="info-row" v-for="row in infoRows" :key="row.label">
<span class="label">{{ row.label }}</span>
<span class="value" :title="row.value">{{ row.value || '—' }}</span>
</div>
</div>
<div class="wrapper">
<div class="separate">店铺评分: <el-rate v-model="storeInfo.serviceScore" disabled show-score text-color="#ff9900"
score-template="{value}">
</el-rate></div>
<div class="separate">服务评分: <el-rate v-model="storeInfo.descriptionScore" disabled show-score text-color="#ff9900"
score-template="{value}">
</el-rate></div>
<div class="separate">物流评分: <el-rate v-model="storeInfo.deliveryScore" disabled show-score text-color="#ff9900"
score-template="{value}">
</el-rate></div>
<div class="rate-row" v-for="row in rates" :key="row.label">
<span class="rate-label">{{ row.label }}</span>
<el-rate :model-value="row.score" disabled show-score text-color="#ff9900" score-template="{value}" />
</div>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
import { computed } from "vue";
import { defaultAvatar } from "@/assets/images";
defineProps({
const props = defineProps({
storeInfo: {
type: Object,
default: null,
default: () => ({}),
},
});
const value = ref(null);
const colors = ref(['#99A9BF', '#F7BA2A', '#FF9900']);
const onError = "this.src='" + defaultAvatar + "'";
const infoRows = computed(() => [
{ label: "店铺地址", value: props.storeInfo.storeAddressPath },
{ label: "详细地址", value: props.storeInfo.storeAddressDetail },
{ label: "店铺简介", value: props.storeInfo.storeDesc },
]);
// 与买家端字段口径一致:店铺评分=描述相符 descriptionScore,服务评分=serviceScore
const rates = computed(() => [
{ label: "店铺评分", score: Number(props.storeInfo.descriptionScore) || 0 },
{ label: "服务评分", score: Number(props.storeInfo.serviceScore) || 0 },
{ label: "物流评分", score: Number(props.storeInfo.deliveryScore) || 0 },
]);
</script>
<style scoped lang="less">
.wrapper{
.store-card {
width: 100%;
box-sizing: border-box;
}
.base {
padding: 16px;
border-top: 1px solid #f5eeee;
}
.title_left_name {
font-size: 12px;
color: #999;
display: flex;
.image {
height: 72px;
width: 72px;
flex-shrink: 0;
border-radius: 6px;
object-fit: cover;
background: #fafafa;
}
}
.title_right_name {
font-size: 12px;
color: #343036;
}
.store-button {
background-color: white;
}
.store-detail{
.store-detail {
flex: 1;
min-width: 0;
margin-left: 13px;
display:flex;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.base {
padding: 16px;
margin-top: 5px;
.store-name {
display: flex;
.image {
height: 100px;
align-items: center;
gap: 8px;
width: 100px
.name-text {
font-size: 14px;
font-weight: 600;
color: #343036;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.store-actions {
display: flex;
}
.info-list {
padding: 0 16px 12px;
}
.info-row {
display: flex;
align-items: flex-start;
margin-top: 8px;
.label {
width: 60px;
flex-shrink: 0;
font-size: 12px;
color: #999;
line-height: 20px;
}
.value {
flex: 1;
min-width: 0;
font-size: 12px;
color: #343036;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
.separate {
margin-top: 8px;
// display: inline;
.wrapper {
padding: 12px 16px 16px;
border-top: 1px solid #f5f5f5;
}
.rate-row {
display: flex;
font-size: 12px;
align-items: center;
margin-top: 8px;
color: #a1a6af;
&:first-child {
margin-top: 0;
}
.rate-label {
width: 60px;
flex-shrink: 0;
font-size: 12px;
color: #a1a6af;
}
}
</style>
</style>

View File

@@ -1,16 +1,32 @@
<template>
<img :src="'https://avatars.dicebear.com/api/initials/'+text+'.svg?fontSize=38'" alt=""/>
<i class="face-initial" :style="{ backgroundColor: color }">{{ initial }}</i>
</template>
<script setup>
defineProps({
text:{
type:null,
default:''
}
})
import { computed } from "vue";
import { avatarColor } from "@/utils/functions";
const props = defineProps({
text: {
type: null,
default: ''
}
});
// 无头像时用「底色 + 首字」本地生成,替代原来的 dicebear 外链
const initial = computed(() => String(props.text || "").slice(0, 1) || "?");
const color = computed(() => avatarColor(String(props.text || "")));
</script>
<style lang="scss" scoped>
<style lang="less" scoped>
.face-initial {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #fff;
font-style: normal;
user-select: none;
}
</style>

View File

@@ -1,20 +1,53 @@
<template>
<img :src="text || 'https://avatars.dicebear.com/api/initials/'+name+'.svg?fontSize=38'" alt=""/>
<span class="face-wrap">
<img v-if="text" :src="text" :onerror="onError" alt="" />
<i v-else class="face-initial" :style="{ backgroundColor: color }">{{ initial }}</i>
</span>
</template>
<script setup>
const props = defineProps({
text: {
type: null,
default: ''
},
name: {
type: null,
default: ''
},
});
import { computed } from "vue";
import { avatarColor } from "@/utils/functions";
import { defaultAvatar } from "@/assets/images";
const props = defineProps({
text: {
type: null,
default: ''
},
name: {
type: null,
default: ''
},
});
// 无头像时用「底色 + 首字」本地生成,替代原来的 dicebear 外链
const initial = computed(() => String(props.name || "").slice(0, 1) || "?");
const color = computed(() => avatarColor(String(props.name || "")));
const onError = "this.src='" + defaultAvatar + "'";
</script>
<style lang="scss" scoped>
<style lang="less" scoped>
.face-wrap {
display: block;
width: 100%;
height: 100%;
</style>
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.face-initial {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: #fff;
font-style: normal;
user-select: none;
}
}
</style>

View File

@@ -273,22 +273,17 @@ class WsSocket {
*/
onMessage(evt) {
let result = this.onParse(evt);
if (
this.onParse(evt).text.includes("goodsName") ||
this.onParse(evt).text.includes("groupName")
) {
let params = {
...this.onParse(evt),
text: JSON.parse(this.onParse(evt).text),
};
this.onCallBacks["event_talk"](params);
} else {
let params = {
...this.onParse(evt),
text: this.onParse(evt).text,
};
this.onCallBacks["event_talk"](params);
}
// 部分推送(在线状态、系统通知等)没有 text 字段,不做商品/群名解析直接透传
const text = result.text;
let params = {
...result,
text:
typeof text === "string" &&
(text.includes("goodsName") || text.includes("groupName"))
? JSON.parse(text)
: text,
};
this.onCallBacks["event_talk"](params);
// 指定推送消息
}
/**

View File

@@ -390,6 +390,19 @@ export function hidePhone (phone) {
return phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2");
}
/**
* 根据名字生成稳定的头像底色(本地首字头像用,替代 dicebear 外链)
*
* @param {String} name
*/
export function avatarColor (name = "") {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
}
return `hsl(${hash % 360}, 55%, 55%)`;
}
/**
* 人性化显示时间
*
@@ -409,15 +422,15 @@ export function beautifyTime (datetime = "") {
}
if (time.getTime() < outTime.getTime()) {
return parseTime(outTime, "{y}/{m}/{d}");
return parseTime(outTime, "{y}-{m}-{d}");
}
if (time.getFullYear() != outTime.getFullYear()) {
return parseTime(outTime, "{y}/{m}/{d}");
return parseTime(outTime, "{y}-{m}-{d}");
}
if (time.getMonth() != outTime.getMonth()) {
return parseTime(outTime, "{m}/{d}");
return parseTime(outTime, "{m}-{d}");
}
if (time.getDate() != outTime.getDate()) {

View File

@@ -6,7 +6,7 @@
<el-header height="118px" class="logo-header">
<div class="user-login" v-popover:usercard>
<div class="user-box">
<face :text="face"></face>
<face :text="userFace"></face>
</div>
</div>
<p class="user-status">
@@ -66,7 +66,8 @@ const userStore = useUserStore();
const notifyStore = useNotifyStore();
const settingsStore = useSettingsStore();
const face = computed(() => userStore.face);
// 不能命名为 face:会遮蔽 <face> 组件解析,头像 URL 会被当作标签名渲染
const userFace = computed(() => userStore.face);
const name = computed(() => userStore.name);
const id = computed(() => userStore.id);
const defaultAvatar = computed(() => appStore.defaultAvatar);
@@ -107,8 +108,8 @@ function setApplyNum () {
<style lang="less" >
.main-layout {
position: fixed;
width: 80%;
height: 80%;
width: 88%;
height: 92%;
top: 0;
right: 0;
bottom: 0;
@@ -119,8 +120,8 @@ function setApplyNum () {
border-radius: 5px;
&.full-mode {
width: 1200px;
height: 85%;
width: 92%;
height: 96%;
border-radius: 0;
}

View File

@@ -10,7 +10,7 @@
<el-header height="60px" class="header">
<div class="user-login">
<div class="user-box">
<face :text="face" :name="name" class="user-face"></face>
<face :text="userFace" :name="name" class="user-face"></face>
</div>
</div>
<p class="user-status">
@@ -20,7 +20,7 @@
</el-header>
<el-header height="60px" class="header">
<div class="from-search">
<el-input v-model="input" :prefix-icon="$legacyIcon('el-icon-search')" placeholder="搜索好友" size="small" />
<el-input v-model="input" :prefix-icon="$legacyIcon('el-icon-search')" placeholder="搜索好友" size="small" clearable />
</div>
</el-header>
<!-- <el-header height="118px" class="logo-header">
@@ -58,6 +58,10 @@
暂无聊天消息
</p>
<p v-show="loadStatus === 1 && talkNum > 0 && userTalkItem.length === 0" class="empty-data">
没有找到相关好友
</p>
<p v-show="loadStatus === 1 && talkNum > 0" class="main-menu">
<span class="title">对话记录({{ talkNum }})</span>
@@ -108,7 +112,7 @@
</div>
</div>
<div class="talk-message">
<span v-if="item.lastMessageType === 'MESSAGE'">{{ item.lastTalkMessage }}</span>
<span v-if="item.lastMessageType === 'MESSAGE'" v-html="textReplaceEmoji(item.lastTalkMessage)"></span>
<span v-if="item.lastMessageType === 'GOODS'">[商品链接]</span>
<span v-if="item.lastMessageType === 'ORDER'">[订单链接]</span>
</div>
@@ -156,6 +160,7 @@ import {
getCurrentInstance,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from "vue";
@@ -174,6 +179,7 @@ import {
} from "@/api/chat";
import { ServeDeleteContact, ServeEditContactRemark } from "@/api/contacts";
import { beautifyTime } from "@/utils/functions";
import { textReplaceEmoji } from "@/utils/emojis";
import { formatTalkItem, findTalkIndex, getCacheIndexName } from "@/utils/talk";
import { setToken } from "@/utils/auth";
import { syncImTokenFromRoute } from "@/utils/im-mode";
@@ -243,7 +249,9 @@ const unreadNum = computed(() => talkStore.unreadNum);
const talkNum = computed(() => talkStore.talkNum);
// 原 vuex mapState
const face = computed(() => userStore.face);
// 注意:不能命名为 face——会与全局注册的 <face> 组件绑定冲突,
// 模板里的 <face> 标签会被解析成这个 computed 的值(头像 base64 字符串)当作标签名渲染
const userFace = computed(() => userStore.face);
const name = computed(() => userStore.name);
const talks = computed(() => talkStore.items);
const id = computed(() => userStore.id);
@@ -655,10 +663,11 @@ watch(talkItems, (val) => {
});
// 搜索用户的时候 根据当前用户表进行模糊搜索
watch(input, (val, oldVal) => {
console.log(val, oldVal);
watch(input, (val) => {
if (val) {
let str = ["", ...val, ""].join(".*");
// 转义正则元字符,避免输入 [ 等字符时构造 RegExp 抛异常
const keyword = val.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
let str = ["", ...keyword, ""].join(".*");
let reg = new RegExp(str);
userTalkItem.value = talkItems.value.filter((item) =>
reg.test(item.name)
@@ -703,8 +712,10 @@ onBeforeRouteUpdate((to, from, next) => {
// 原 beforeCreate
syncImTokenFromRoute(route, setToken);
// 原 async created(保持 await 顺序,但用 IIFE 避免 setup 变为异步)
(async () => {
// 原 async created。不能在 setup 同步执行:全局 mixin 的方法(initialize/
// loadUserSetting)在 finishComponentSetup 的 applyOptions 阶段才挂到公共代理上,
// 即 setup() 返回之后。故放到 onMounted 里执行。
onMounted(async () => {
await proxy.initialize();
await proxy.loadUserSetting();
// 初始化商品参数
@@ -714,7 +725,7 @@ syncImTokenFromRoute(route, setToken);
* 所以创建会话 并请求用户列表
* 如果没有id说明当前商家登录 直接请求用户列表
*/
})();
});
onBeforeUnmount(() => {
document.title = title;
@@ -747,8 +758,20 @@ onBeforeUnmount(() => {
flex-shrink: 0;
height: 40px;
:deep(.el-input .el-input__inner ) {
border-radius: 20px;
:deep(.el-input__wrapper) {
border-radius: 16px;
background-color: #f5f6f7;
box-shadow: none;
transition: box-shadow 0.2s;
&.is-focus {
background-color: #fff;
box-shadow: 0 0 0 1px #409eff inset;
}
}
:deep(.el-input__inner) {
font-size: 12px;
}
}
@@ -1058,6 +1081,12 @@ onBeforeUnmount(() => {
.talk-message {
font-size: 12px;
color: #8f959e;
:deep(img) {
width: 16px;
height: 16px;
vertical-align: middle;
}
}
.user-face {

View File

@@ -20,6 +20,18 @@
<el-input class="label-appkey" v-model="formValidate.wechatAppId" />
</el-form-item>
<el-form-item label="提现规则说明" prop="withdrawRule">
<div style="display: flex; flex-direction: column; width: 500px;">
<el-input
v-model="formValidate.withdrawRule"
type="textarea"
:rows="6"
placeholder="请输入提现规则说明(需包含可提现额度、每日提现次数、提现时间、到账时间等,以满足微信审核要求)"
/>
<div class="rule-tip">展示在移动端/小程序提现页面底部,用于满足微信审核对提现规则公示的要求。</div>
</div>
</el-form-item>
<div class="label-btns">
<el-button type="primary" @click="submit('formValidateRef')">保存</el-button>
</div>
@@ -32,6 +44,13 @@ import { ElMessage } from "element-plus";
import { setSetting } from "@/api/index";
import { handleSubmit } from "./validate";
const DEFAULT_WITHDRAW_RULE = `1. 提现额度:单笔最低提现金额以页面展示为准,单日累计最高提现 5000 元;
2. 提现次数:每个用户每日最多可申请提现 1 次;
3. 提现时间:支持全天 24 小时随时发起提现申请;
4. 到账时间:提交申请后预计 1~3 个工作日内审核并到账至您的微信零钱;
5. 手续费说明:提现免收手续费;
6. 如有其他疑问或遇到提现未到账,请联系在线客服协助处理。`;
const props = defineProps(["res", "type"]);
// handleSubmit(that, name) 通过 that.$refs[name] 取表单实例
@@ -64,29 +83,49 @@ function setupSetting() {
});
}
function init() {
result.value = JSON.parse(props.res);
result.value = JSON.parse(props.res || "{}");
Object.keys(result.value).map((item) => {
result.value[item] += "";
});
formValidate.value = { ...result.value };
if (!formValidate.value.withdrawRule) {
formValidate.value.withdrawRule = DEFAULT_WITHDRAW_RULE;
}
Object.keys(formValidate.value).forEach((item) => {
ruleValidate[item] = [
{
required: true,
message: "请填写必填项",
trigger: "blur",
},
{
validator: (rule, value, callback) => {
if (value < 0) {
callback(new Error("不能输入负数!"));
} else {
callback();
}
if (item === "minPrice") {
ruleValidate[item] = [
{
required: true,
message: "请填写必填项",
trigger: "blur",
},
trigger: "change",
},
];
{
validator: (rule, value, callback) => {
if (Number(value) < 0) {
callback(new Error("不能输入负数!"));
} else {
callback();
}
},
trigger: "change",
},
];
} else if (item === "withdrawRule") {
ruleValidate[item] = [
{
required: false,
trigger: "blur",
},
];
} else {
ruleValidate[item] = [
{
required: true,
message: "请填写必填项",
trigger: "blur",
},
];
}
});
}
@@ -96,7 +135,13 @@ init();
<style lang="scss" scoped>
@import "./style.scss";
:deep(.el-form-item__content){
align-items: center;
align-items: flex-start;
padding-bottom: 5px;
}
.rule-tip {
font-size: 12px;
color: #999;
line-height: 1.5;
margin-top: 6px;
}
</style>