聊天问题修复优化
This commit is contained in:
@@ -40,12 +40,14 @@
|
|||||||
>
|
>
|
||||||
<view class="user u-flex u-row-right">
|
<view class="user u-flex u-row-right">
|
||||||
<view class="u-p-r-18">
|
<view class="u-p-r-18">
|
||||||
<view class="color-000 u-line-1 text-right">{{ item.nick_name }}</view>
|
<view class="color-000 u-line-1 text-right">{{
|
||||||
|
item.nick_name
|
||||||
|
}}</view>
|
||||||
<view
|
<view
|
||||||
class="u-m-t-14 msg u-p-l-30"
|
class="u-m-t-14 msg u-p-l-30"
|
||||||
:class="['type' + item.msg_type]"
|
:class="['type' + item.msg_type]"
|
||||||
>
|
>
|
||||||
<chatItem :item="item"></chatItem>
|
<chatItem :item="item" @previewImage="previewImage"></chatItem>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<up-avatar
|
<up-avatar
|
||||||
@@ -60,21 +62,26 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<view class="shop u-flex">
|
<view class="shop u-flex">
|
||||||
<up-avatar
|
<up-avatar
|
||||||
:src="shopInfo.logo"
|
:src="item.avatar"
|
||||||
size="122rpx"
|
size="122rpx"
|
||||||
shape="square"
|
shape="square"
|
||||||
bg-color="#fff"
|
bg-color="#fff"
|
||||||
></up-avatar>
|
></up-avatar>
|
||||||
<view class="u-p-l-18">
|
<view class="u-p-l-18">
|
||||||
<view class="u-flex">
|
<view class="u-flex">
|
||||||
<view class="tag">商家</view>
|
<template v-if="item.is_shop == 1">
|
||||||
<view class="color-000">{{ shopInfo.shopName }}</view>
|
<view class="tag">商家</view>
|
||||||
|
<view class="color-000">{{ item.nick_name }}</view>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<view class="color-000">{{ item.nick }}</view>
|
||||||
|
</template>
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
class="u-m-t-14 msg"
|
class="u-m-t-14 msg"
|
||||||
:class="['type' + item.msg_type]"
|
:class="['type' + item.msg_type]"
|
||||||
>
|
>
|
||||||
<chatItem :item="item" @getCoupon="getCoupon"></chatItem>
|
<chatItem :item="item" @getCoupon="getCoupon" @previewImage="previewImage"></chatItem>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -92,7 +99,11 @@
|
|||||||
<!-- <view :style="bottomSlotHeight"></view> -->
|
<!-- <view :style="bottomSlotHeight"></view> -->
|
||||||
|
|
||||||
<view class="bottom" :class="[showMoreBtn ? '' : 'safe-bottom']">
|
<view class="bottom" :class="[showMoreBtn ? '' : 'safe-bottom']">
|
||||||
<view class="u-flex" style="padding: 14rpx 28rpx" v-if="groupInfo&&!groupInfo.is_mute">
|
<view
|
||||||
|
class="u-flex"
|
||||||
|
style="padding: 14rpx 28rpx"
|
||||||
|
v-if="groupInfo && !groupInfo.is_mute"
|
||||||
|
>
|
||||||
<!-- <input
|
<!-- <input
|
||||||
type="text"
|
type="text"
|
||||||
class="u-flex-1 u-m-r-52 iput"
|
class="u-flex-1 u-m-r-52 iput"
|
||||||
@@ -112,12 +123,14 @@
|
|||||||
<up-icon
|
<up-icon
|
||||||
name="plus-circle"
|
name="plus-circle"
|
||||||
color="#666"
|
color="#666"
|
||||||
size="40rpx"
|
size="60rpx"
|
||||||
@click="showMoreBtnToggle"
|
@click="showMoreBtnToggle"
|
||||||
v-else
|
v-else
|
||||||
></up-icon>
|
></up-icon>
|
||||||
</view>
|
</view>
|
||||||
<view class="color-666 u-font-28 text-center u-m-t-28" v-else>商家已禁言</view>
|
<view class="color-666 u-font-28 text-center u-m-t-28" v-else
|
||||||
|
>商家已禁言</view
|
||||||
|
>
|
||||||
<view class="more-btn" v-if="showMoreBtn">
|
<view class="more-btn" v-if="showMoreBtn">
|
||||||
<view
|
<view
|
||||||
v-for="(item, index) in moreBtns"
|
v-for="(item, index) in moreBtns"
|
||||||
@@ -144,7 +157,7 @@
|
|||||||
<view class="header-wrap">
|
<view class="header-wrap">
|
||||||
<text class="t">退出群{{ "{" + groupInfo.name + "}" }}</text>
|
<text class="t">退出群{{ "{" + groupInfo.name + "}" }}</text>
|
||||||
<view class="close" @click="popupShow = false">
|
<view class="close" @click="popupShow = false">
|
||||||
<u-icon name="close" size="16" color="#666"></u-icon>
|
<u-icon name="close" size="20" color="#666"></u-icon>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="u-p-40 text-center u-font-28 color-333 border-bottom"
|
<view class="u-p-40 text-center u-font-28 color-333 border-bottom"
|
||||||
@@ -168,10 +181,16 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</u-popup>
|
</u-popup>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 预览图 -->
|
||||||
|
<xbSwiperPreview :visable="showPrveImg" :imgs="prveImgsList" @update:visable="showPrveImg = $event"></xbSwiperPreview>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
onReady,
|
onReady,
|
||||||
|
onShow,
|
||||||
onReachBottom,
|
onReachBottom,
|
||||||
onLoad,
|
onLoad,
|
||||||
onPageScroll,
|
onPageScroll,
|
||||||
@@ -183,12 +202,18 @@ import {
|
|||||||
nextTick,
|
nextTick,
|
||||||
reactive,
|
reactive,
|
||||||
watch,
|
watch,
|
||||||
|
onUnmounted,
|
||||||
computed,
|
computed,
|
||||||
} from "vue";
|
} from "vue";
|
||||||
import { useChatStore } from "@/stores/chat";
|
import { useChatStore } from "@/stores/chat";
|
||||||
import * as chatApi from "@/http/php/chat";
|
import * as chatApi from "@/http/php/chat";
|
||||||
import { uploadFile } from "@/common/api/upload.js";
|
import { uploadFile } from "@/common/api/upload.js";
|
||||||
import * as javaChatApi from "@/common/api/market/chat";
|
import * as javaChatApi from "@/common/api/market/chat";
|
||||||
|
import xbSwiperPreview from '@/components/xb-swiper-preview/index.vue';
|
||||||
|
|
||||||
|
const showPrveImg = ref(false);
|
||||||
|
|
||||||
|
const prveImgsList = ref([]);
|
||||||
|
|
||||||
function exitGroup() {
|
function exitGroup() {
|
||||||
chatApi
|
chatApi
|
||||||
@@ -214,7 +239,8 @@ function getCoupon(item) {
|
|||||||
javaChatApi
|
javaChatApi
|
||||||
.couponGrant({
|
.couponGrant({
|
||||||
id: item.coupon.activity_id,
|
id: item.coupon.activity_id,
|
||||||
shopUserId: shopInfo.id,
|
// shopUserId: shopUserInfo.id,
|
||||||
|
shopId: groupInfo.value.shop_id,
|
||||||
userId: uni.cache.get("userInfo").id,
|
userId: uni.cache.get("userInfo").id,
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@@ -224,13 +250,12 @@ function getCoupon(item) {
|
|||||||
icon: "none",
|
icon: "none",
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
});
|
});
|
||||||
refresh()
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
query.page=1;
|
query.page = 1;
|
||||||
getMsgList();
|
getMsgList();
|
||||||
}
|
}
|
||||||
const go = {
|
const go = {
|
||||||
@@ -282,12 +307,7 @@ const modalData = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
chatStore.onReceiveMsg = (msg) => {
|
|
||||||
nextTick(() => {
|
|
||||||
scrollView.intoView = "msg-0";
|
|
||||||
});
|
|
||||||
};
|
|
||||||
chatStore.connectSocket();
|
|
||||||
const msg = ref("");
|
const msg = ref("");
|
||||||
|
|
||||||
const shopInfo = uni.cache.get("shopInfo");
|
const shopInfo = uni.cache.get("shopInfo");
|
||||||
@@ -323,67 +343,222 @@ function moreBtnsClick(item, index) {
|
|||||||
sendVideo();
|
sendVideo();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function videoErrorCallback(e) {
|
// 通用相机权限校验函数(参考openLocationAuth逻辑)
|
||||||
console.error("视频播放失败", e);
|
async function checkCameraAuth() {
|
||||||
}
|
return new Promise((resolve, reject) => {
|
||||||
function sendImg() {
|
try {
|
||||||
uni.chooseImage({
|
// 1. 检查当前相机权限状态
|
||||||
count: 3, //默认9
|
uni.getSetting({
|
||||||
sizeType: ["original", "compressed"], //可以指定是原图还是压缩图,默认二者都有
|
success: (settingRes) => {
|
||||||
sourceType: ["album", "camera "],
|
const authSetting = settingRes.authSetting || {};
|
||||||
success: async function (res) {
|
|
||||||
uni.showLoading({
|
if (authSetting["scope.camera"]) {
|
||||||
title: "发送中",
|
// 2. 已授权:直接返回成功
|
||||||
});
|
resolve(true);
|
||||||
console.log(res);
|
} else if (authSetting["scope.camera"] === undefined) {
|
||||||
for (let i = 0; i < res.tempFiles.length; i++) {
|
// 3. 未请求过授权:发起授权请求
|
||||||
const fileRes = await uploadFile(res.tempFiles[i]);
|
uni.authorize({
|
||||||
if (fileRes) {
|
scope: "scope.camera",
|
||||||
sendMsg({
|
success: () => {
|
||||||
image_url: fileRes,
|
// 授权成功
|
||||||
msg_type: 2,
|
resolve(true);
|
||||||
});
|
},
|
||||||
} else {
|
fail: (authErr) => {
|
||||||
|
// 授权失败(用户拒绝)
|
||||||
|
console.error("相机授权失败:", authErr);
|
||||||
|
reject(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 4. 已拒绝授权:提示用户去设置页开启
|
||||||
|
uni.showModal({
|
||||||
|
title: "开启相机权限",
|
||||||
|
content: "请允许“零点八零”使用您的相机,方便您拍摄图片/视频",
|
||||||
|
confirmText: "去设置",
|
||||||
|
cancelText: "取消",
|
||||||
|
success: (modalRes) => {
|
||||||
|
if (modalRes.confirm) {
|
||||||
|
// 跳转微信小程序授权设置页
|
||||||
|
uni.openSetting({
|
||||||
|
success: (openRes) => {
|
||||||
|
if (openRes.authSetting["scope.camera"]) {
|
||||||
|
// 用户在设置页开启授权
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
// 用户未开启授权
|
||||||
|
reject(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (openErr) => {
|
||||||
|
console.error("打开设置页失败:", openErr);
|
||||||
|
reject(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 用户取消设置
|
||||||
|
reject(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (modalErr) => {
|
||||||
|
console.error("弹窗失败:", modalErr);
|
||||||
|
reject(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (settingErr) => {
|
||||||
|
console.error("获取权限设置失败:", settingErr);
|
||||||
|
reject(false);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
},
|
} catch (err) {
|
||||||
|
console.error("相机权限校验异常:", err);
|
||||||
|
reject(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendVideo() {
|
// 图片选择与发送(优化后)
|
||||||
uni.chooseVideo({
|
async function sendImg() {
|
||||||
count: 1, //默认9
|
try {
|
||||||
sizeType: ["original", "compressed"], //可以指定是原图还是压缩图,默认二者都有
|
// 1. 调用相机权限校验
|
||||||
sourceType: ["album", "camera "],
|
const isAuth = await checkCameraAuth();
|
||||||
success: async function (res) {
|
if (!isAuth) {
|
||||||
uni.showLoading({
|
// 权限校验失败,终止流程
|
||||||
title: "发送中",
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 权限校验通过,调用图片选择API
|
||||||
|
const res = await new Promise((resolve, reject) => {
|
||||||
|
uni.chooseImage({
|
||||||
|
count: 3,
|
||||||
|
sizeType: ["original", "compressed"],
|
||||||
|
sourceType: ["album", "camera"],
|
||||||
|
success: resolve,
|
||||||
|
fail: (err) => {
|
||||||
|
// 捕获选择失败(如用户取消选择)
|
||||||
|
if (err.errMsg && err.errMsg.includes("cancel")) {
|
||||||
|
// 用户主动取消,静默处理
|
||||||
|
reject(new Error("user cancel"));
|
||||||
|
} else {
|
||||||
|
// 其他错误(如系统异常)
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
console.log(res);
|
});
|
||||||
const fileRes = await uploadFile({ path: res.tempFilePath });
|
|
||||||
uni.hideLoading();
|
uni.showLoading({ title: "发送中" });
|
||||||
|
console.log("选择图片成功", res);
|
||||||
|
|
||||||
|
// 3. 批量上传图片
|
||||||
|
for (let i = 0; i < res.tempFiles.length; i++) {
|
||||||
|
const fileRes = await uploadFile(res.tempFiles[i]);
|
||||||
if (fileRes) {
|
if (fileRes) {
|
||||||
sendMsg({
|
sendMsg({
|
||||||
image_url: fileRes,
|
image_url: fileRes,
|
||||||
msg_type: 5,
|
msg_type: 2,
|
||||||
});
|
|
||||||
} else {
|
|
||||||
uni.showToast({
|
|
||||||
title: "发送失败",
|
|
||||||
icon: "none",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
});
|
} catch (err) {
|
||||||
|
console.error("图片选择/发送失败", err);
|
||||||
|
// 仅处理非用户取消的错误
|
||||||
|
if (err.message !== "user cancel") {
|
||||||
|
uni.showToast({
|
||||||
|
title: "图片发送失败",
|
||||||
|
icon: "none",
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频选择与发送(优化后)
|
||||||
|
async function sendVideo() {
|
||||||
|
try {
|
||||||
|
// 1. 调用相机权限校验(与图片共用)
|
||||||
|
const isAuth = await checkCameraAuth();
|
||||||
|
if (!isAuth) {
|
||||||
|
// 权限校验失败,终止流程
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 权限校验通过,调用视频选择API
|
||||||
|
const res = await new Promise((resolve, reject) => {
|
||||||
|
uni.chooseVideo({
|
||||||
|
count: 1,
|
||||||
|
sizeType: ["original", "compressed"],
|
||||||
|
sourceType: ["album", "camera"],
|
||||||
|
success: resolve,
|
||||||
|
fail: (err) => {
|
||||||
|
// 捕获选择失败(如用户取消选择)
|
||||||
|
if (err.errMsg && err.errMsg.includes("cancel")) {
|
||||||
|
// 用户主动取消,静默处理
|
||||||
|
reject(new Error("user cancel"));
|
||||||
|
} else {
|
||||||
|
// 其他错误(如系统异常)
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
uni.showLoading({ title: "发送中" });
|
||||||
|
console.log("选择视频成功", res);
|
||||||
|
|
||||||
|
// 3. 上传视频
|
||||||
|
const fileRes = await uploadFile({ path: res.tempFilePath });
|
||||||
|
if (fileRes) {
|
||||||
|
sendMsg({
|
||||||
|
image_url: fileRes,
|
||||||
|
msg_type: 5,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
uni.showToast({
|
||||||
|
title: "视频发送失败",
|
||||||
|
icon: "none",
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("视频选择/发送失败", err);
|
||||||
|
// 仅处理非用户取消的错误
|
||||||
|
if (err.message !== "user cancel") {
|
||||||
|
uni.showToast({
|
||||||
|
title: "视频发送失败",
|
||||||
|
icon: "none",
|
||||||
|
duration: 2000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupInfo = ref({});
|
const groupInfo = ref({});
|
||||||
|
|
||||||
|
const handleReceiveMsg = (msg) => {
|
||||||
|
nextTick(() => {
|
||||||
|
scrollView.intoView = "msg-0";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 注册消息回调
|
||||||
|
chatStore.registerReceiveMsgCallback(handleReceiveMsg);
|
||||||
async function init() {
|
async function init() {
|
||||||
const res = await chatApi.groupInfo({
|
const res = await chatApi.groupInfo({
|
||||||
group_id: options.group_id,
|
group_id: options.group_id,
|
||||||
});
|
});
|
||||||
console.log(res);
|
console.log(res);
|
||||||
groupInfo.value = res || {};
|
groupInfo.value = res || {};
|
||||||
|
if (res) {
|
||||||
|
chatStore.shop_id = groupInfo.value.shop_id;
|
||||||
|
chatStore.connectSocket();
|
||||||
|
chatStore.init();
|
||||||
|
}
|
||||||
getMsgList();
|
getMsgList();
|
||||||
}
|
}
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
@@ -395,7 +570,6 @@ async function getMsgList() {
|
|||||||
if (isEnd.value) {
|
if (isEnd.value) {
|
||||||
scrollView.refresherTriggered = false;
|
scrollView.refresherTriggered = false;
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
uni.showToast({ title: "没有更多了", icon: "none" });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,8 +617,9 @@ onLoad((opt) => {
|
|||||||
Object.assign(options, opt);
|
Object.assign(options, opt);
|
||||||
init();
|
init();
|
||||||
chatApi.messageMarkReadAll({
|
chatApi.messageMarkReadAll({
|
||||||
session_ids: options.session_id,
|
session_ids: options.group_id,
|
||||||
});
|
});
|
||||||
|
chatStore.group_id = options.group_id;
|
||||||
chatApi.groupMembers({ group_id: options.group_id }).then((res) => {
|
chatApi.groupMembers({ group_id: options.group_id }).then((res) => {
|
||||||
console.log(res);
|
console.log(res);
|
||||||
membersRes.value = res;
|
membersRes.value = res;
|
||||||
@@ -474,7 +649,7 @@ function sendMsg(msg) {
|
|||||||
content: msg.value,
|
content: msg.value,
|
||||||
image_url: "",
|
image_url: "",
|
||||||
order_id: "",
|
order_id: "",
|
||||||
session_id: "",
|
session_id: groupInfo.value.session_id || options.session_id,
|
||||||
...msg,
|
...msg,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -519,9 +694,9 @@ function confirmCoupon() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function previewImage(url) {
|
function previewImage(url) {
|
||||||
uni.previewImage({
|
|
||||||
urls: [url],
|
prveImgsList.value = [url];
|
||||||
});
|
showPrveImg.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
//是否到底了
|
//是否到底了
|
||||||
@@ -556,6 +731,18 @@ const pageHeight = computed(() => {
|
|||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
});
|
});
|
||||||
|
onShow(() => {
|
||||||
|
// 页面显示时,检查连接状态
|
||||||
|
if (!chatStore.isConnect || !chatStore.socketTask) {
|
||||||
|
console.log("聊天页显示,检查Socket连接");
|
||||||
|
chatStore.forceReconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 组件卸载时,移除消息回调
|
||||||
|
chatStore.removeReceiveMsgCallback(handleReceiveMsg);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.top {
|
.top {
|
||||||
|
|||||||
@@ -13,11 +13,10 @@
|
|||||||
:src="item.image_url"
|
:src="item.image_url"
|
||||||
class="img"
|
class="img"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
@click="previewVideo(item.video_url)"
|
|
||||||
></video>
|
></video>
|
||||||
<view class="" v-if="item.msg_type == 4">
|
<view class="" v-if="item.msg_type == 4">
|
||||||
<view>{{ item.coupon.title }}</view>
|
<view>{{ item.coupon.title }}</view>
|
||||||
<view class="u-m-t-16 bg-f7 coupon u-flex" style="min-width: 500rpx;">
|
<view class="u-m-t-16 bg-f7 coupon u-flex u-col-stretch" style="min-width: 500rpx;">
|
||||||
<template v-if="item.coupon.type == 1">
|
<template v-if="item.coupon.type == 1">
|
||||||
<view class="left">
|
<view class="left">
|
||||||
<view class="price">
|
<view class="price">
|
||||||
@@ -77,11 +76,9 @@
|
|||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<view class="right u-p-l-28">
|
<view class="right u-p-l-28 u-flex u-col-center">
|
||||||
<view class="u-font-32">{{ item.coupon.couponName }}</view>
|
<view class="u-font-32">{{ item.coupon.couponName }}</view>
|
||||||
<view class="u-font-24 color-999 u-m-t-8"
|
|
||||||
>有效期:{{ returnTime(item.coupon) }}</view
|
|
||||||
>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="u-flex u-row-center u-m-t-32">
|
<view class="u-flex u-row-center u-m-t-32">
|
||||||
@@ -99,14 +96,12 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emits = defineEmits(["getCoupon"]);
|
const emits = defineEmits(["getCoupon",'previewImage']);
|
||||||
function getCoupon() {
|
function getCoupon() {
|
||||||
emits("getCoupon", props.item);
|
emits("getCoupon", props.item);
|
||||||
}
|
}
|
||||||
function previewImage(url) {
|
function previewImage(url) {
|
||||||
uni.previewImage({
|
emits('previewImage',url)
|
||||||
urls: [url],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function returnTime(coupon){
|
function returnTime(coupon){
|
||||||
let startTime = coupon.useStartTime;
|
let startTime = coupon.useStartTime;
|
||||||
@@ -149,4 +144,7 @@ function returnTime(coupon){
|
|||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.u-col-stretch{
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<view class="min-page bg-f7 color-333 u-font-28">
|
<view class="min-page bg-f7 color-333 u-font-28">
|
||||||
<up-sticky>
|
<up-sticky>
|
||||||
<view class="top u-flex u-row-between u-col-center">
|
<view class="top u-flex u-row-between u-col-center">
|
||||||
<view style="width: 420rpx;">
|
<view style="width: 420rpx">
|
||||||
<up-search
|
<up-search
|
||||||
v-model="query.key"
|
v-model="query.key"
|
||||||
placeholder="搜索群名称"
|
placeholder="搜索群名称"
|
||||||
@@ -19,36 +19,37 @@
|
|||||||
</up-sticky>
|
</up-sticky>
|
||||||
<view class="list">
|
<view class="list">
|
||||||
<up-swipe-action>
|
<up-swipe-action>
|
||||||
<up-swipe-action-item
|
<template v-for="(item, index) in list" :key="item.id">
|
||||||
:options="options1"
|
<up-swipe-action-item
|
||||||
v-for="(item, index) in list"
|
:options="options1"
|
||||||
v-model:show="item.showOptions"
|
v-model:show="item.showOptions"
|
||||||
@click="optionsClick($event, item, index)"
|
@click="optionsClick($event, item, index)"
|
||||||
:key="item.id"
|
>
|
||||||
>
|
<view class="item u-flex" @click="toDetail(item)">
|
||||||
<view class="item u-flex" @click="toDetail(item)">
|
<view class="u-flex avatar">
|
||||||
<view class="u-flex avatar">
|
<up-avatar
|
||||||
<up-avatar
|
size="118rpx"
|
||||||
size="118rpx"
|
:src="item.avatar"
|
||||||
:src="item.avatar"
|
shape="square"
|
||||||
shape="square"
|
round="8rpx"
|
||||||
round="8rpx"
|
></up-avatar>
|
||||||
></up-avatar>
|
<view class="bandage" v-if="item.unread_count > 0">{{
|
||||||
<view class="bandage" v-if="item.unread_count > 0">{{
|
item.unread_count >= 99 ? "99" : item.unread_count
|
||||||
item.unread_count >= 99 ? "99" : item.unread_count
|
}}</view>
|
||||||
}}</view>
|
</view>
|
||||||
</view>
|
<view class="u-flex-1 u-flex u-row-between u-p-l-14">
|
||||||
<view class="u-flex-1 u-flex u-row-between u-p-l-14">
|
<view style="max-width: 364rpx">
|
||||||
<view style="max-width: 364rpx">
|
<view class="color-000 u-line-1">{{ item.name }}</view>
|
||||||
<view class="color-000 u-line-1">{{ item.name }}</view>
|
<view class="u-m-t-28 u-line-1 u-font-24 color-999">{{
|
||||||
<view class="u-m-t-28 u-line-1 u-font-24 color-999"
|
item.msg
|
||||||
>{{ item.msg }}</view
|
}}</view>
|
||||||
>
|
</view>
|
||||||
|
<view class="color-333 u-font-24">{{ item.send_time }}</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="color-333 u-font-24">{{ item.send_time }}</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</up-swipe-action-item>
|
||||||
</up-swipe-action-item>
|
<view style="height: 16rpx" class="bg-f7"></view>
|
||||||
|
</template>
|
||||||
</up-swipe-action>
|
</up-swipe-action>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -56,8 +57,64 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import * as chatApi from "@/http/php/chat";
|
import * as chatApi from "@/http/php/chat";
|
||||||
import { ref, reactive } from "vue";
|
import { ref, reactive, onMounted, onUnmounted } from "vue";
|
||||||
import { onShow } from "@dcloudio/uni-app";
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
|
import { useChatStore } from "@/stores/chat";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
|
const originalOnReceiveMsg = chatStore.onReceiveMsg;
|
||||||
|
|
||||||
|
// 定义消息回调函数
|
||||||
|
const handleReceiveMsg = (msg) => {
|
||||||
|
// 先执行原始回调(如果有)
|
||||||
|
if (typeof originalOnReceiveMsg === "function") {
|
||||||
|
originalOnReceiveMsg(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.operate_type == "receive_msg" && msg.chat_type == 2) {
|
||||||
|
const index = allList.value.findIndex((v) => v.group_id == msg.group_id);
|
||||||
|
if (index != -1) {
|
||||||
|
allList.value[index].unread_count += 1;
|
||||||
|
allList.value[index].msg = returnMsg(msg);
|
||||||
|
allList.value[index].send_time = msg.send_time;
|
||||||
|
allList.value[index].send_time_origin = msg.send_time_origin;
|
||||||
|
allList.value = listSort(allList.value);
|
||||||
|
}
|
||||||
|
const index1 = list.value.findIndex((v) => v.group_id == msg.group_id);
|
||||||
|
if (index1 != -1) {
|
||||||
|
list.value[index1].unread_count += 1;
|
||||||
|
list.value[index1].msg = returnMsg(msg);
|
||||||
|
list.value[index1].send_time = msg.send_time;
|
||||||
|
list.value[index1].send_time_origin = msg.send_time_origin;
|
||||||
|
list.value = listSort(list.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
onMounted(() => {
|
||||||
|
chatStore.registerReceiveMsgCallback(handleReceiveMsg);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
// 移除消息回调,避免内存泄漏
|
||||||
|
chatStore.removeReceiveMsgCallback(handleReceiveMsg);
|
||||||
|
});
|
||||||
|
|
||||||
|
function returnMsg(msg) {
|
||||||
|
if (msg.msg_type == 1) {
|
||||||
|
return msg.nick + ":" + msg.content;
|
||||||
|
}
|
||||||
|
if (msg.msg_type == 2) {
|
||||||
|
return msg.nick + ":" + "[图片]";
|
||||||
|
}
|
||||||
|
if (msg.msg_type == 5) {
|
||||||
|
return msg.nick + ":" + "[视频]";
|
||||||
|
}
|
||||||
|
if (msg.msg_type == 4) {
|
||||||
|
return msg.nick + ":" + "[优惠券]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chatStore.connectSocket();
|
||||||
|
|
||||||
// 使用 reactive 创建响应式对象
|
// 使用 reactive 创建响应式对象
|
||||||
const options1 = reactive([
|
const options1 = reactive([
|
||||||
@@ -95,7 +152,7 @@ function optionsClick(e, item, index) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const list = ref([]);
|
const list = ref([]);
|
||||||
let allList = [];
|
let allList = ref([]);
|
||||||
|
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
key: "",
|
key: "",
|
||||||
@@ -116,7 +173,7 @@ function throttle(fn, delay) {
|
|||||||
//使用节流函数
|
//使用节流函数
|
||||||
const throttleSearch = throttle(search, 500);
|
const throttleSearch = throttle(search, 500);
|
||||||
function search() {
|
function search() {
|
||||||
list.value = allList
|
const arr = allList.value
|
||||||
.filter((v) => v.name.includes(query.key.trim()))
|
.filter((v) => v.name.includes(query.key.trim()))
|
||||||
.map((v) => {
|
.map((v) => {
|
||||||
return {
|
return {
|
||||||
@@ -124,10 +181,20 @@ function search() {
|
|||||||
showOptions: false,
|
showOptions: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
list.value = listSort(arr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listSort(arr) {
|
||||||
|
return arr.sort((a, b) => {
|
||||||
|
return dayjs(b.send_time_origin).unix() - dayjs(a.send_time_origin).unix();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function getList() {
|
async function getList() {
|
||||||
const res = await chatApi.messageSessionList({});
|
const res = await chatApi.messageSessionList({});
|
||||||
allList = (res.list || []).filter((v) => !v.is_del);
|
const arr = (res.list || []).filter((v) => !v.is_del);
|
||||||
|
allList.value = listSort(arr);
|
||||||
|
|
||||||
search();
|
search();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +205,7 @@ async function clearAllmsg() {
|
|||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (res.confirm) {
|
if (res.confirm) {
|
||||||
const res = await chatApi.messageMarkReadAll({
|
const res = await chatApi.messageMarkReadAll({
|
||||||
session_ids: list.value.map((v) => v.session_id).join(","),
|
session_ids: list.value.map((v) => v.group_id).join(","),
|
||||||
});
|
});
|
||||||
if (res) {
|
if (res) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
@@ -173,6 +240,15 @@ function toDetail(item) {
|
|||||||
const messageUnreadCount = ref(0);
|
const messageUnreadCount = ref(0);
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
getList();
|
getList();
|
||||||
|
// 检查连接状态,确保连接活跃
|
||||||
|
if (!chatStore.isConnect || !chatStore.socketTask) {
|
||||||
|
console.log("列表页显示,检查Socket连接");
|
||||||
|
chatStore.forceReconnect();
|
||||||
|
} else {
|
||||||
|
chatStore.shop_id = "";
|
||||||
|
chatStore.init();
|
||||||
|
}
|
||||||
|
|
||||||
// 获取未读消息总数
|
// 获取未读消息总数
|
||||||
chatApi.messageUnreadCount({}).then((res) => {
|
chatApi.messageUnreadCount({}).then((res) => {
|
||||||
console.log(res);
|
console.log(res);
|
||||||
@@ -192,13 +268,11 @@ onShow(() => {
|
|||||||
}
|
}
|
||||||
.list {
|
.list {
|
||||||
padding: 36rpx 28rpx;
|
padding: 36rpx 28rpx;
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
padding: 32rpx 28rpx;
|
padding: 32rpx 28rpx;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16rpx;
|
|
||||||
.avatar {
|
.avatar {
|
||||||
position: relative;
|
position: relative;
|
||||||
.bandage {
|
.bandage {
|
||||||
@@ -220,4 +294,10 @@ onShow(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
:deep(.u-swipe-action-item__content) {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
:deep(.u-swipe-action-item) {
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ async function getCouponList() {
|
|||||||
userId: uni.cache.get('userInfo').id,
|
userId: uni.cache.get('userInfo').id,
|
||||||
name: querForm.value.searchValue,
|
name: querForm.value.searchValue,
|
||||||
status: statusList.value[querForm.value.statusActiveIndex].value,
|
status: statusList.value[querForm.value.statusActiveIndex].value,
|
||||||
shopId: querForm.value.shopId ? querForm.value.shopId : uni.cache.get('shopId'),
|
shopId: querForm.value.shopId ? querForm.value.shopId :'',
|
||||||
page: list.page,
|
page: list.page,
|
||||||
size: list.size
|
size: list.size
|
||||||
});
|
});
|
||||||
|
|||||||
705
stores/chat.js
705
stores/chat.js
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { defineStore } from "pinia";
|
||||||
defineStore
|
|
||||||
} from "pinia";
|
|
||||||
// import * as shopApi from "@/http/api/shop.js";
|
|
||||||
|
|
||||||
// #ifdef H5
|
// #ifdef H5
|
||||||
const socketUrl = "http://192.168.1.42:2348";
|
const socketUrl = "http://192.168.1.42:2348";
|
||||||
@@ -12,115 +9,603 @@ const socketUrl = "ws://192.168.1.42:2348";
|
|||||||
|
|
||||||
// 聊天
|
// 聊天
|
||||||
export const useChatStore = defineStore("chat", {
|
export const useChatStore = defineStore("chat", {
|
||||||
state: () => {
|
state: () => {
|
||||||
return {
|
return {
|
||||||
socketUrl,
|
socketUrl,
|
||||||
isConnect: false,
|
isConnect: false,
|
||||||
socketTask: null,
|
socketTask: null,
|
||||||
onReceiveMsg: () => {},
|
onReceiveMsg: () => {},
|
||||||
chatList: [],
|
chatList: [],
|
||||||
};
|
shop_id: "",
|
||||||
},
|
group_id:'',
|
||||||
actions: {
|
|
||||||
init() {
|
|
||||||
if (!this.isConnect) {
|
|
||||||
return uni.showToast({
|
|
||||||
title: "请先连接socket",
|
|
||||||
icon: "none",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.sendMessage({
|
|
||||||
type: "OnbocChat",
|
|
||||||
operate_type: "init",
|
|
||||||
shop_id: uni.cache.get('shopInfo').id || '',
|
|
||||||
token: uni.cache.get('token'),
|
|
||||||
},
|
|
||||||
false
|
|
||||||
);
|
|
||||||
},
|
|
||||||
sendMessage(msg, isAutoAppend = true) {
|
|
||||||
if (!this.isConnect) {
|
|
||||||
return uni.showToast({
|
|
||||||
title: "请先连接socket",
|
|
||||||
icon: "none",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log(this.socketTask);
|
|
||||||
const message = isAutoAppend ? {
|
|
||||||
type: "OnbocChat",
|
|
||||||
operate_type: "sendMsg",
|
|
||||||
shop_id: uni.cache.get('shopInfo').id || '',
|
|
||||||
token: uni.cache.get('token'),
|
|
||||||
...msg,
|
|
||||||
} :
|
|
||||||
msg;
|
|
||||||
this.socketTask.send({
|
|
||||||
data: JSON.stringify(message),
|
|
||||||
success: (res) => {
|
|
||||||
console.log("发送成功", message);
|
|
||||||
},
|
|
||||||
fail: (error) => {
|
|
||||||
console.log("发送失败", error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
connectSocket() {
|
|
||||||
this.socketTask = uni.connectSocket({
|
|
||||||
url: socketUrl,
|
|
||||||
success: (res) => {},
|
|
||||||
fail: (res) => {
|
|
||||||
console.log(res);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socketTask.onOpen((res) => {
|
// ========== Socket 保活与重连状态 ==========
|
||||||
this.isConnect = true;
|
isManualClose: false, // 是否主动关闭(true:主动关闭,不重连;false:意外断开,自动重连)
|
||||||
this.init();
|
heartbeatTimer: null, // 心跳定时器
|
||||||
});
|
reconnectTimer: null, // 重连定时器
|
||||||
|
connectionMonitorTimer: null, // 连接状态监控定时器
|
||||||
|
reconnectCount: 0, // 当前重连次数
|
||||||
|
maxReconnectCount: 8, // 最大重连次数
|
||||||
|
reconnectDelay: 1000, // 初始重连延迟(ms)
|
||||||
|
maxReconnectDelay: 8000, // 最大重连延迟(ms)
|
||||||
|
heartbeatInterval: 15000, // 心跳间隔15s
|
||||||
|
|
||||||
this.socketTask.onMessage((res) => {
|
// ========== 应用与网络状态监听 ==========
|
||||||
const data = JSON.parse(res.data);
|
isAppActive: true, // 应用是否在前台
|
||||||
console.log("收到服务器消息", data);
|
lastHeartbeatTime: 0, // 上次心跳时间戳
|
||||||
if (data.msg) {
|
_listenersInitialized: false, // 状态监听器是否已初始化
|
||||||
uni.showToast({
|
|
||||||
title: data.msg,
|
|
||||||
icon: "none",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data && data.operate_type == "sendMsg") {
|
// ========== 消息去重机制 ==========
|
||||||
this.chatList.unshift(data.data);
|
recentMessages: new Map(), // 最近处理的消息映射表:key=消息特征哈希,value=处理时间戳
|
||||||
this.onReceiveMsg(data.data);
|
deduplicationWindow: 5000, // 去重时间窗口(5秒内相同消息只处理一次)
|
||||||
console.log(this.chatList);
|
maxRecentMessages: 100, // 最近消息缓存最大数量
|
||||||
}
|
|
||||||
if (data && data.operate_type == "receive_msg") {
|
|
||||||
const msg={
|
|
||||||
...data.data,
|
|
||||||
coupon:data.data.coupon?JSON.parse(data.data.coupon):{},
|
|
||||||
operate_type:"receive_msg",
|
|
||||||
}
|
|
||||||
this.chatList.unshift(msg);
|
|
||||||
this.onReceiveMsg(msg);
|
|
||||||
console.log(this.chatList);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.socketTask.onError((res) => {
|
// ========== 连接状态锁 ==========
|
||||||
this.isConnect = false;
|
isConnecting: false, // 是否正在建立连接(防止重复创建连接)
|
||||||
console.log("连接错误", res);
|
lastConnectTime: 0, // 上次连接时间(防止短时间内频繁连接)
|
||||||
});
|
|
||||||
|
|
||||||
this.socketTask.onClose(() => {
|
// ========== 消息回调队列 ==========
|
||||||
this.isConnect = false;
|
receiveMsgCallbacks: [], // 消息接收回调队列,支持多页面同时监听
|
||||||
console.log("连接已关闭");
|
};
|
||||||
this.connectSocket();
|
},
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
closeSocket() {
|
actions: {
|
||||||
this.socketTask.close();
|
// ========== 初始化 ==========
|
||||||
this.isConnect = false;
|
init() {
|
||||||
},
|
if (!this.isConnect) {
|
||||||
},
|
return uni.showToast({
|
||||||
unistorage: false, // 开启后对 state 的数据读写都将持久化
|
title: "请先连接socket",
|
||||||
});
|
icon: "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.sendMessage(
|
||||||
|
{
|
||||||
|
type: "OnbocChat",
|
||||||
|
operate_type: "init",
|
||||||
|
shop_id: this.shop_id,
|
||||||
|
token: uni.cache.get("token"),
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 发送消息(核心优化) ==========
|
||||||
|
sendMessage(msg, isAutoAppend = true) {
|
||||||
|
// 1. 主动关闭状态:提示用户
|
||||||
|
if (this.isManualClose) {
|
||||||
|
return uni.showToast({
|
||||||
|
title: "Socket已主动关闭,请重新连接",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 连接未建立或 SocketTask 已失效:立即重连并延迟发送
|
||||||
|
if (!this.isConnect || !this.socketTask) {
|
||||||
|
console.warn("Socket连接未建立,尝试重连...");
|
||||||
|
// 立即重连
|
||||||
|
this.forceReconnect();
|
||||||
|
// 延迟发送,确保重连成功
|
||||||
|
setTimeout(() => {
|
||||||
|
this.sendMessage(msg, isAutoAppend);
|
||||||
|
}, 1500); // 延长延迟时间,确保重连完成
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 正常发送消息
|
||||||
|
console.log("发送的消息", msg);
|
||||||
|
const message = isAutoAppend
|
||||||
|
? {
|
||||||
|
type: "OnbocChat",
|
||||||
|
operate_type: "sendMsg",
|
||||||
|
shop_id: this.shop_id,
|
||||||
|
token:
|
||||||
|
uni.getStorageSync("iToken")?.tokenValue ||
|
||||||
|
uni.cache.get("token") ||
|
||||||
|
"",
|
||||||
|
...msg,
|
||||||
|
}
|
||||||
|
: msg;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.socketTask.send({
|
||||||
|
data: JSON.stringify(message),
|
||||||
|
success: (res) => {
|
||||||
|
console.log("发送成功", res);
|
||||||
|
},
|
||||||
|
fail: (error) => {
|
||||||
|
console.error("发送失败,触发重连", error);
|
||||||
|
// 发送失败,立即重连
|
||||||
|
this.forceReconnect();
|
||||||
|
// 重连后重试发送
|
||||||
|
setTimeout(() => {
|
||||||
|
this.sendMessage(msg, isAutoAppend);
|
||||||
|
}, 1500);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("发送消息异常,触发重连", error);
|
||||||
|
// 捕获异常,立即重连
|
||||||
|
this.forceReconnect();
|
||||||
|
// 重连后重试发送
|
||||||
|
setTimeout(() => {
|
||||||
|
this.sendMessage(msg, isAutoAppend);
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 发送心跳包 ==========
|
||||||
|
sendHeartbeat() {
|
||||||
|
if (!this.isConnect || this.isManualClose || !this.socketTask) return;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
this.lastHeartbeatTime = now; // 记录心跳时间
|
||||||
|
|
||||||
|
this.socketTask.send({
|
||||||
|
data: JSON.stringify({
|
||||||
|
type: "ping_interval",
|
||||||
|
}),
|
||||||
|
fail: (error) => {
|
||||||
|
console.log("心跳发送失败,触发重连", error);
|
||||||
|
this.forceReconnect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 启动心跳定时器 ==========
|
||||||
|
startHeartbeat() {
|
||||||
|
// 清除旧定时器,避免重复
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即发送一次心跳,确保连接活跃
|
||||||
|
this.sendHeartbeat();
|
||||||
|
|
||||||
|
// 每15s发送一次心跳
|
||||||
|
this.heartbeatTimer = setInterval(() => {
|
||||||
|
this.sendHeartbeat();
|
||||||
|
}, this.heartbeatInterval);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 停止心跳定时器 ==========
|
||||||
|
stopHeartbeat() {
|
||||||
|
if (this.heartbeatTimer) {
|
||||||
|
clearInterval(this.heartbeatTimer);
|
||||||
|
this.heartbeatTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 启动连接状态监控 ==========
|
||||||
|
startConnectionMonitor() {
|
||||||
|
// 清除旧定时器
|
||||||
|
if (this.connectionMonitorTimer) {
|
||||||
|
clearInterval(this.connectionMonitorTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每30秒检查一次连接状态
|
||||||
|
this.connectionMonitorTimer = setInterval(() => {
|
||||||
|
// 如果应该连接但实际未连接,触发重连
|
||||||
|
if (!this.isManualClose && this.isConnect && !this.socketTask) {
|
||||||
|
console.warn("连接监控:发现连接状态异常,强制重置");
|
||||||
|
this.forceReconnect();
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 停止连接状态监控 ==========
|
||||||
|
stopConnectionMonitor() {
|
||||||
|
if (this.connectionMonitorTimer) {
|
||||||
|
clearInterval(this.connectionMonitorTimer);
|
||||||
|
this.connectionMonitorTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 智能重连逻辑 ==========
|
||||||
|
reconnect() {
|
||||||
|
// 1. 主动关闭或已连接:不重连
|
||||||
|
if (this.isManualClose || this.isConnect) return;
|
||||||
|
|
||||||
|
// 2. 正在连接中:不重连
|
||||||
|
if (this.isConnecting) {
|
||||||
|
console.log("正在建立连接中,跳过重连");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 短时间内已尝试连接:不重连(防止频繁连接)
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - this.lastConnectTime < 1000) {
|
||||||
|
console.log("短时间内已尝试连接,跳过重连");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 超过最大重连次数:停止重连
|
||||||
|
if (this.reconnectCount >= this.maxReconnectCount) {
|
||||||
|
console.log("已达最大重连次数,停止重连");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 清除旧重连定时器
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 计算重连延迟(退避算法)
|
||||||
|
const baseDelay = this.isAppActive ? 500 : this.reconnectDelay;
|
||||||
|
const delay = Math.min(
|
||||||
|
baseDelay * Math.pow(2, this.reconnectCount),
|
||||||
|
this.maxReconnectDelay
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`第${this.reconnectCount + 1}次重连,延迟${delay}ms`);
|
||||||
|
|
||||||
|
// 7. 延迟执行重连
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.connectSocket();
|
||||||
|
this.reconnectCount++;
|
||||||
|
}, delay);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 强制重连方法 ==========
|
||||||
|
forceReconnect() {
|
||||||
|
// 主动关闭状态下不重连
|
||||||
|
if (this.isManualClose) return;
|
||||||
|
|
||||||
|
console.log("执行强制重连");
|
||||||
|
|
||||||
|
// 1. 清理现有连接资源,强制重置所有状态
|
||||||
|
if (this.socketTask) {
|
||||||
|
try {
|
||||||
|
this.socketTask.close({
|
||||||
|
code: 1000, // 正常关闭
|
||||||
|
reason: "force reconnect",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("关闭Socket失败", error);
|
||||||
|
} finally {
|
||||||
|
// 强制重置所有状态
|
||||||
|
this.socketTask = null;
|
||||||
|
this.isConnect = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 重置重连状态
|
||||||
|
this.reconnectCount = 0;
|
||||||
|
this.stopHeartbeat();
|
||||||
|
|
||||||
|
// 3. 立即重新连接
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connectSocket();
|
||||||
|
}, 100);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 内部关闭Socket方法(不设置手动关闭标记) ==========
|
||||||
|
closeSocketInternal() {
|
||||||
|
// 1. 停止所有定时器
|
||||||
|
this.stopHeartbeat();
|
||||||
|
this.stopConnectionMonitor();
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 关闭Socket连接(Uniapp会自动清理监听器)
|
||||||
|
if (this.socketTask) {
|
||||||
|
try {
|
||||||
|
this.socketTask.close({
|
||||||
|
code: 1000, // 正常关闭
|
||||||
|
reason: "internal close",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("关闭Socket失败", error);
|
||||||
|
} finally {
|
||||||
|
// 3. 重置状态
|
||||||
|
this.socketTask = null;
|
||||||
|
this.isConnect = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 初始化状态监听器 ==========
|
||||||
|
initStateListeners() {
|
||||||
|
// 避免重复初始化
|
||||||
|
if (this._listenersInitialized) return;
|
||||||
|
|
||||||
|
console.log("初始化状态监听器");
|
||||||
|
|
||||||
|
// 监听应用前后台切换
|
||||||
|
uni.onAppShow(() => {
|
||||||
|
console.log("应用切前台,检查Socket连接");
|
||||||
|
this.isAppActive = true;
|
||||||
|
// 应用切前台,强制重连
|
||||||
|
this.forceReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
uni.onAppHide(() => {
|
||||||
|
console.log("应用切后台");
|
||||||
|
this.isAppActive = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听网络状态变化
|
||||||
|
uni.onNetworkStatusChange((res) => {
|
||||||
|
console.log("网络状态变化", res);
|
||||||
|
if (res.isConnected) {
|
||||||
|
// 网络恢复,自动重连
|
||||||
|
console.log("网络恢复,触发重连");
|
||||||
|
this.forceReconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this._listenersInitialized = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 连接Socket ==========
|
||||||
|
connectSocket() {
|
||||||
|
// 1. 严格检查:避免重复连接
|
||||||
|
if (this.isConnect || this.isConnecting || this.isManualClose) {
|
||||||
|
console.warn("跳过连接:当前状态不允许建立新连接", {
|
||||||
|
isConnect: this.isConnect,
|
||||||
|
isConnecting: this.isConnecting,
|
||||||
|
isManualClose: this.isManualClose,
|
||||||
|
});
|
||||||
|
// 关键:如果 socketTask 已失效但 isConnect 为 true,强制重置状态
|
||||||
|
if (this.isConnect && !this.socketTask) {
|
||||||
|
console.warn("Socket连接状态异常,强制重置");
|
||||||
|
this.isConnect = false;
|
||||||
|
this.forceReconnect();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 设置连接中状态
|
||||||
|
this.isConnecting = true;
|
||||||
|
this.lastConnectTime = Date.now();
|
||||||
|
|
||||||
|
// 3. 重置主动关闭标记
|
||||||
|
this.isManualClose = false;
|
||||||
|
|
||||||
|
console.log("开始创建新的Socket连接");
|
||||||
|
|
||||||
|
// 4. 创建Socket连接
|
||||||
|
this.socketTask = uni.connectSocket({
|
||||||
|
url: this.socketUrl,
|
||||||
|
success: (res) => {
|
||||||
|
console.log("Socket连接请求发送成功");
|
||||||
|
},
|
||||||
|
fail: (res) => {
|
||||||
|
console.log("Socket连接请求失败", res);
|
||||||
|
// 请求失败,重置状态
|
||||||
|
this.isConnecting = false;
|
||||||
|
// 触发重连
|
||||||
|
this.reconnect();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 连接成功回调
|
||||||
|
this.socketTask.onOpen((res) => {
|
||||||
|
console.log("Socket连接成功");
|
||||||
|
// 重置连接状态
|
||||||
|
this.isConnect = true;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.reconnectCount = 0; // 重置重连次数
|
||||||
|
|
||||||
|
this.init(); // 初始化聊天
|
||||||
|
this.startHeartbeat(); // 启动心跳
|
||||||
|
this.startConnectionMonitor(); // 启动连接状态监控
|
||||||
|
|
||||||
|
// 初始化状态监听器
|
||||||
|
this.initStateListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. 接收消息回调 - 支持多回调
|
||||||
|
this.socketTask.onMessage((res) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(res.data);
|
||||||
|
console.log("收到服务器消息", data);
|
||||||
|
|
||||||
|
// 处理发送消息(服务器转发的自己发送的消息)
|
||||||
|
if (data?.operate_type === "sendMsg") {
|
||||||
|
// 添加方向标识:send
|
||||||
|
const sendMsg = {
|
||||||
|
...data.data,
|
||||||
|
direction: "send",
|
||||||
|
};
|
||||||
|
this.processMessage(sendMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理接收消息(他人发送的消息)
|
||||||
|
if (data?.operate_type === "receive_msg") {
|
||||||
|
// 添加方向标识:receive
|
||||||
|
const receiveMsg = {
|
||||||
|
...data.data,
|
||||||
|
coupon: data.data.coupon ? JSON.parse(data.data.coupon) : {},
|
||||||
|
operate_type: "receive_msg",
|
||||||
|
direction: "receive",
|
||||||
|
};
|
||||||
|
this.processMessage(receiveMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理心跳响应
|
||||||
|
if (data?.operate_type === "heartbeat_ack") {
|
||||||
|
console.log("心跳响应正常");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("消息解析失败", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. 连接错误回调
|
||||||
|
this.socketTask.onError((res) => {
|
||||||
|
console.log("Socket连接错误", res);
|
||||||
|
// 重置连接状态
|
||||||
|
this.isConnect = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
// 错误触发重连
|
||||||
|
this.forceReconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. 连接关闭回调
|
||||||
|
this.socketTask.onClose(() => {
|
||||||
|
console.log("Socket连接已关闭");
|
||||||
|
// 重置连接状态
|
||||||
|
this.isConnect = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.stopHeartbeat(); // 停止心跳
|
||||||
|
this.stopConnectionMonitor(); // 停止连接监控
|
||||||
|
|
||||||
|
// 只有非主动关闭时才重连
|
||||||
|
if (!this.isManualClose) {
|
||||||
|
console.log("意外断开,触发重连");
|
||||||
|
this.reconnect();
|
||||||
|
} else {
|
||||||
|
console.log("主动关闭,不重连");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 消息处理与去重 ==========
|
||||||
|
processMessage(msg) {
|
||||||
|
// 1. 生成消息特征哈希
|
||||||
|
const msgHash = this.generateMessageHash(msg);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 2. 清理过期消息
|
||||||
|
this.cleanupExpiredMessages(now);
|
||||||
|
|
||||||
|
// 3. 去重逻辑:只对接收的消息应用严格去重
|
||||||
|
if (msg.direction === "receive") {
|
||||||
|
// 接收消息:5秒内相同特征的消息只处理一次
|
||||||
|
const lastProcessTime = this.recentMessages.get(msgHash);
|
||||||
|
if (
|
||||||
|
lastProcessTime &&
|
||||||
|
now - lastProcessTime < this.deduplicationWindow
|
||||||
|
) {
|
||||||
|
console.log("重复接收消息,已忽略(特征哈希:", msgHash, ")");
|
||||||
|
return; // 重复消息,直接返回
|
||||||
|
}
|
||||||
|
// 记录接收消息的处理时间
|
||||||
|
this.recentMessages.set(msgHash, now);
|
||||||
|
} else {
|
||||||
|
// 发送消息:直接通过,不做严格去重
|
||||||
|
console.log("发送消息,直接处理(特征哈希:", msgHash, ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 限制最近消息数量,避免内存泄漏
|
||||||
|
if (this.recentMessages.size > this.maxRecentMessages) {
|
||||||
|
// 移除最早的消息
|
||||||
|
const firstKey = this.recentMessages.keys().next().value;
|
||||||
|
this.recentMessages.delete(firstKey);
|
||||||
|
}
|
||||||
|
// 5. 正常处理消息
|
||||||
|
|
||||||
|
if (this.group_id == msg.to_id) {
|
||||||
|
this.chatList.unshift(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 触发所有消息回调(支持多页面同时监听)
|
||||||
|
this.triggerReceiveMsgCallbacks(msg);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 触发消息回调 ==========
|
||||||
|
triggerReceiveMsgCallbacks(msg) {
|
||||||
|
// 调用旧的单个回调(兼容原有代码)
|
||||||
|
if (typeof this.onReceiveMsg === "function") {
|
||||||
|
this.onReceiveMsg(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用所有注册的回调
|
||||||
|
this.receiveMsgCallbacks.forEach((callback) => {
|
||||||
|
if (typeof callback === "function") {
|
||||||
|
callback(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 注册消息回调 ==========
|
||||||
|
registerReceiveMsgCallback(callback) {
|
||||||
|
if (
|
||||||
|
typeof callback === "function" &&
|
||||||
|
!this.receiveMsgCallbacks.includes(callback)
|
||||||
|
) {
|
||||||
|
this.receiveMsgCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 移除消息回调 ==========
|
||||||
|
removeReceiveMsgCallback(callback) {
|
||||||
|
const index = this.receiveMsgCallbacks.indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
this.receiveMsgCallbacks.splice(index, 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 生成消息特征哈希 ==========
|
||||||
|
generateMessageHash(msg) {
|
||||||
|
// 基于消息的核心字段生成哈希
|
||||||
|
const {
|
||||||
|
content, // 消息内容
|
||||||
|
msg_type, // 消息类型
|
||||||
|
timestamp, // 时间戳
|
||||||
|
operate_type, // 操作类型
|
||||||
|
from_user_id, // 发送者ID
|
||||||
|
to_user_id, // 接收者ID
|
||||||
|
image_url, // 图片URL
|
||||||
|
coupon, // 优惠券信息
|
||||||
|
direction, // 消息方向
|
||||||
|
} = msg;
|
||||||
|
|
||||||
|
// 组合核心字段,生成唯一字符串
|
||||||
|
// 发送消息添加随机数,确保每次发送的消息哈希唯一
|
||||||
|
const msgFeatures = JSON.stringify({
|
||||||
|
content: content || "",
|
||||||
|
msg_type: msg_type || "",
|
||||||
|
operate_type: operate_type || "",
|
||||||
|
from_user_id: from_user_id || "",
|
||||||
|
to_user_id: to_user_id || "",
|
||||||
|
image_url: image_url || "",
|
||||||
|
coupon_id: coupon?.id || "",
|
||||||
|
direction: direction || "",
|
||||||
|
// 发送消息添加随机数,确保每次发送的消息哈希唯一
|
||||||
|
random:
|
||||||
|
direction === "send" ? Math.random().toString(36).substr(2, 9) : "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用简单的哈希算法生成32位哈希值
|
||||||
|
return this.simpleHash(msgFeatures);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 简单哈希算法 ==========
|
||||||
|
simpleHash(str) {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i);
|
||||||
|
hash = (hash << 5) - hash + char;
|
||||||
|
hash = hash & hash; // 转换为32位整数
|
||||||
|
}
|
||||||
|
return Math.abs(hash).toString(16); // 转换为16进制字符串
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 清理过期消息 ==========
|
||||||
|
cleanupExpiredMessages(currentTime) {
|
||||||
|
for (const [hash, time] of this.recentMessages.entries()) {
|
||||||
|
if (currentTime - time > this.deduplicationWindow) {
|
||||||
|
this.recentMessages.delete(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========== 主动关闭Socket ==========
|
||||||
|
closeSocket() {
|
||||||
|
// 1. 设置主动关闭标记
|
||||||
|
this.isManualClose = true;
|
||||||
|
|
||||||
|
// 2. 关闭Socket连接(内部方法会处理状态重置)
|
||||||
|
this.closeSocketInternal();
|
||||||
|
|
||||||
|
console.log("Socket已主动关闭");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
unistorage: false, // 开启后对 state 的数据读写都将持久化
|
||||||
|
});
|
||||||
|
|||||||
502
utils/util.js
502
utils/util.js
@@ -4,17 +4,17 @@
|
|||||||
* @returns {string} 脱敏后手机号
|
* @returns {string} 脱敏后手机号
|
||||||
*/
|
*/
|
||||||
export function desensitizePhone(phone) {
|
export function desensitizePhone(phone) {
|
||||||
// 1. 提取纯数字(过滤非数字字符)
|
// 1. 提取纯数字(过滤非数字字符)
|
||||||
const purePhone = (phone || "").replace(/[^\d]/g, "");
|
const purePhone = (phone || "").replace(/[^\d]/g, "");
|
||||||
|
|
||||||
// 2. 边界判断:非11位手机号返回原字符串(或自定义提示)
|
// 2. 边界判断:非11位手机号返回原字符串(或自定义提示)
|
||||||
if (purePhone.length !== 11) {
|
if (purePhone.length !== 11) {
|
||||||
console.warn("手机号格式不正确,需11位纯数字");
|
console.warn("手机号格式不正确,需11位纯数字");
|
||||||
return phone; // 或返回 ''、'手机号格式错误' 等
|
return phone; // 或返回 ''、'手机号格式错误' 等
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 脱敏:前3位 + **** + 后4位
|
// 3. 脱敏:前3位 + **** + 后4位
|
||||||
return purePhone.replace(/(\d{3})(\d{4})(\d{4})/, "$1****$3");
|
return purePhone.replace(/(\d{3})(\d{4})(\d{4})/, "$1****$3");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,46 +23,46 @@ export function desensitizePhone(phone) {
|
|||||||
* @returns {Object} 校验结果:{ valid: boolean, msg: string }
|
* @returns {Object} 校验结果:{ valid: boolean, msg: string }
|
||||||
*/
|
*/
|
||||||
export function validateName(name) {
|
export function validateName(name) {
|
||||||
// 1. 空值校验
|
// 1. 空值校验
|
||||||
if (!name || name.trim() === '') {
|
if (!name || name.trim() === "") {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
msg: '姓名不能为空'
|
msg: "姓名不能为空",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const pureName = name.trim();
|
const pureName = name.trim();
|
||||||
|
|
||||||
// 2. 长度校验(2-6位,含少数民族中间点)
|
// 2. 长度校验(2-6位,含少数民族中间点)
|
||||||
if (pureName.length < 2 || pureName.length > 6) {
|
if (pureName.length < 2 || pureName.length > 6) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
msg: '姓名长度应为2-6位'
|
msg: "姓名长度应为2-6位",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 正则校验:仅允许中文、少数民族中间点(·),且中间点不能在开头/结尾
|
// 3. 正则校验:仅允许中文、少数民族中间点(·),且中间点不能在开头/结尾
|
||||||
// 中文范围:[\u4e00-\u9fa5],中间点:[\u00b7](Unicode 标准中间点,非小数点)
|
// 中文范围:[\u4e00-\u9fa5],中间点:[\u00b7](Unicode 标准中间点,非小数点)
|
||||||
const nameReg = /^[\u4e00-\u9fa5]+([\u00b7][\u4e00-\u9fa5]+)*$/;
|
const nameReg = /^[\u4e00-\u9fa5]+([\u00b7][\u4e00-\u9fa5]+)*$/;
|
||||||
if (!nameReg.test(pureName)) {
|
if (!nameReg.test(pureName)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
msg: '姓名仅支持中文和少数民族中间点(·),且不能包含数字、字母或特殊符号'
|
msg: "姓名仅支持中文和少数民族中间点(·),且不能包含数字、字母或特殊符号",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 额外限制:中间点不能连续(如“李··四”)
|
// 4. 额外限制:中间点不能连续(如“李··四”)
|
||||||
if (/[\u00b7]{2,}/.test(pureName)) {
|
if (/[\u00b7]{2,}/.test(pureName)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
msg: '姓名中的中间点(·)不能连续'
|
msg: "姓名中的中间点(·)不能连续",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验通过
|
// 校验通过
|
||||||
return {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
msg: '姓名格式合法'
|
msg: "姓名格式合法",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,95 +72,117 @@ export function validateName(name) {
|
|||||||
* info 可选返回:{ birthDate: string, gender: string }(出生日期、性别)
|
* info 可选返回:{ birthDate: string, gender: string }(出生日期、性别)
|
||||||
*/
|
*/
|
||||||
export function validateIdCard(idCard) {
|
export function validateIdCard(idCard) {
|
||||||
// 1. 空值校验
|
// 1. 空值校验
|
||||||
if (!idCard || idCard.trim() === '') {
|
if (!idCard || idCard.trim() === "") {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
msg: '身份证号码不能为空'
|
msg: "身份证号码不能为空",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const pureIdCard = idCard.trim().toUpperCase(); // 统一转为大写(处理X)
|
const pureIdCard = idCard.trim().toUpperCase(); // 统一转为大写(处理X)
|
||||||
|
|
||||||
// 2. 格式校验(18位或15位)
|
// 2. 格式校验(18位或15位)
|
||||||
const id18Reg = /^[1-9]\d{5}(19|20)\d{2}((0[1-9])|(1[0-2]))((0[1-9])|([12]\d)|(3[01]))\d{3}([0-9]|X)$/;
|
const id18Reg =
|
||||||
const id15Reg = /^[1-9]\d{5}\d{2}((0[1-9])|(1[0-2]))((0[1-9])|([12]\d)|(3[01]))\d{3}$/;
|
/^[1-9]\d{5}(19|20)\d{2}((0[1-9])|(1[0-2]))((0[1-9])|([12]\d)|(3[01]))\d{3}([0-9]|X)$/;
|
||||||
|
const id15Reg =
|
||||||
|
/^[1-9]\d{5}\d{2}((0[1-9])|(1[0-2]))((0[1-9])|([12]\d)|(3[01]))\d{3}$/;
|
||||||
|
|
||||||
if (!id18Reg.test(pureIdCard) && !id15Reg.test(pureIdCard)) {
|
if (!id18Reg.test(pureIdCard) && !id15Reg.test(pureIdCard)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
msg: '身份证号码格式错误(需18位,最后一位可含X;或15位纯数字)'
|
msg: "身份证号码格式错误(需18位,最后一位可含X;或15位纯数字)",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 提取出生日期并校验合法性
|
// 3. 提取出生日期并校验合法性
|
||||||
let birthDateStr, birthDate;
|
let birthDateStr, birthDate;
|
||||||
if (pureIdCard.length === 18) {
|
if (pureIdCard.length === 18) {
|
||||||
// 18位:第7-14位为出生日期(YYYYMMDD)
|
// 18位:第7-14位为出生日期(YYYYMMDD)
|
||||||
birthDateStr = pureIdCard.slice(6, 14);
|
birthDateStr = pureIdCard.slice(6, 14);
|
||||||
birthDate = new Date(`${birthDateStr.slice(0,4)}-${birthDateStr.slice(4,6)}-${birthDateStr.slice(6,8)}`);
|
birthDate = new Date(
|
||||||
} else {
|
`${birthDateStr.slice(0, 4)}-${birthDateStr.slice(
|
||||||
// 15位:第7-12位为出生日期(YYMMDD),补全为YYYYMMDD(19xx或20xx,默认19xx)
|
4,
|
||||||
const year = `19${pureIdCard.slice(6, 8)}`;
|
6
|
||||||
const month = pureIdCard.slice(8, 10);
|
)}-${birthDateStr.slice(6, 8)}`
|
||||||
const day = pureIdCard.slice(10, 12);
|
);
|
||||||
birthDateStr = `${year}${month}${day}`;
|
} else {
|
||||||
birthDate = new Date(`${year}-${month}-${day}`);
|
// 15位:第7-12位为出生日期(YYMMDD),补全为YYYYMMDD(19xx或20xx,默认19xx)
|
||||||
}
|
const year = `19${pureIdCard.slice(6, 8)}`;
|
||||||
|
const month = pureIdCard.slice(8, 10);
|
||||||
|
const day = pureIdCard.slice(10, 12);
|
||||||
|
birthDateStr = `${year}${month}${day}`;
|
||||||
|
birthDate = new Date(`${year}-${month}-${day}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 校验出生日期有效性(如20230230 → 日期对象会是Invalid Date)
|
// 校验出生日期有效性(如20230230 → 日期对象会是Invalid Date)
|
||||||
if (
|
if (
|
||||||
isNaN(birthDate.getTime()) ||
|
isNaN(birthDate.getTime()) ||
|
||||||
birthDateStr.slice(0, 4) !== birthDate.getFullYear().toString() ||
|
birthDateStr.slice(0, 4) !== birthDate.getFullYear().toString() ||
|
||||||
birthDateStr.slice(4, 6) !== (birthDate.getMonth() + 1).toString().padStart(2, '0') ||
|
birthDateStr.slice(4, 6) !==
|
||||||
birthDateStr.slice(6, 8) !== birthDate.getDate().toString().padStart(2, '0')
|
(birthDate.getMonth() + 1).toString().padStart(2, "0") ||
|
||||||
) {
|
birthDateStr.slice(6, 8) !== birthDate.getDate().toString().padStart(2, "0")
|
||||||
return {
|
) {
|
||||||
valid: false,
|
return {
|
||||||
msg: '身份证中的出生日期无效'
|
valid: false,
|
||||||
};
|
msg: "身份证中的出生日期无效",
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 18位身份证额外校验:校验码合法性(加权算法)
|
// 4. 18位身份证额外校验:校验码合法性(加权算法)
|
||||||
if (pureIdCard.length === 18) {
|
if (pureIdCard.length === 18) {
|
||||||
// 加权因子
|
// 加权因子
|
||||||
const weightFactors = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
|
const weightFactors = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
|
||||||
// 校验码对应值(0-10 → 10对应X)
|
// 校验码对应值(0-10 → 10对应X)
|
||||||
const checkCodeMap = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
|
const checkCodeMap = [
|
||||||
// 计算前17位与加权因子的乘积和
|
"1",
|
||||||
let sum = 0;
|
"0",
|
||||||
for (let i = 0; i < 17; i++) {
|
"X",
|
||||||
sum += parseInt(pureIdCard[i]) * weightFactors[i];
|
"9",
|
||||||
}
|
"8",
|
||||||
// 计算预期校验码
|
"7",
|
||||||
const expectedCheckCode = checkCodeMap[sum % 11];
|
"6",
|
||||||
// 对比实际校验码(最后一位)
|
"5",
|
||||||
if (pureIdCard[17] !== expectedCheckCode) {
|
"4",
|
||||||
return {
|
"3",
|
||||||
valid: false,
|
"2",
|
||||||
msg: '身份证校验码错误,可能是无效身份证'
|
];
|
||||||
};
|
// 计算前17位与加权因子的乘积和
|
||||||
}
|
let sum = 0;
|
||||||
}
|
for (let i = 0; i < 17; i++) {
|
||||||
|
sum += parseInt(pureIdCard[i]) * weightFactors[i];
|
||||||
|
}
|
||||||
|
// 计算预期校验码
|
||||||
|
const expectedCheckCode = checkCodeMap[sum % 11];
|
||||||
|
// 对比实际校验码(最后一位)
|
||||||
|
if (pureIdCard[17] !== expectedCheckCode) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
msg: "身份证校验码错误,可能是无效身份证",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 5. 可选:提取性别(18位第17位,15位第15位;奇数=男,偶数=女)
|
// 5. 可选:提取性别(18位第17位,15位第15位;奇数=男,偶数=女)
|
||||||
let gender = '';
|
let gender = "";
|
||||||
if (pureIdCard.length === 18) {
|
if (pureIdCard.length === 18) {
|
||||||
const genderCode = parseInt(pureIdCard[16]);
|
const genderCode = parseInt(pureIdCard[16]);
|
||||||
gender = genderCode % 2 === 1 ? '男' : '女';
|
gender = genderCode % 2 === 1 ? "男" : "女";
|
||||||
} else {
|
} else {
|
||||||
const genderCode = parseInt(pureIdCard[14]);
|
const genderCode = parseInt(pureIdCard[14]);
|
||||||
gender = genderCode % 2 === 1 ? '男' : '女';
|
gender = genderCode % 2 === 1 ? "男" : "女";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验通过,返回额外信息(出生日期、性别)
|
// 校验通过,返回额外信息(出生日期、性别)
|
||||||
return {
|
return {
|
||||||
valid: true,
|
valid: true,
|
||||||
msg: '身份证号码合法',
|
msg: "身份证号码合法",
|
||||||
info: {
|
info: {
|
||||||
birthDate: `${birthDate.getFullYear()}-${(birthDate.getMonth() + 1).toString().padStart(2, '0')}-${birthDate.getDate().toString().padStart(2, '0')}`,
|
birthDate: `${birthDate.getFullYear()}-${(birthDate.getMonth() + 1)
|
||||||
gender: gender
|
.toString()
|
||||||
}
|
.padStart(2, "0")}-${birthDate.getDate().toString().padStart(2, "0")}`,
|
||||||
};
|
gender: gender,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -170,40 +192,208 @@ export function validateIdCard(idCard) {
|
|||||||
* @returns {string} 过滤后的合法值
|
* @returns {string} 过滤后的合法值
|
||||||
*/
|
*/
|
||||||
export function filterNumberInput(value, isIntegerOnly = false) {
|
export function filterNumberInput(value, isIntegerOnly = false) {
|
||||||
// 第一步就过滤所有非数字和非小数点的字符(包括字母)
|
// 第一步就过滤所有非数字和非小数点的字符(包括字母)
|
||||||
let filtered = value.replace(/[^\d.]/g, "");
|
let filtered = value.replace(/[^\d.]/g, "");
|
||||||
|
|
||||||
// 整数模式处理
|
// 整数模式处理
|
||||||
if (isIntegerOnly !== false) {
|
if (isIntegerOnly !== false) {
|
||||||
// 移除所有小数点
|
// 移除所有小数点
|
||||||
filtered = filtered.replace(/\./g, "");
|
filtered = filtered.replace(/\./g, "");
|
||||||
|
|
||||||
// 处理前导零
|
// 处理前导零
|
||||||
filtered = filtered.replace(/^0+(\d)/, "$1") || filtered;
|
filtered = filtered.replace(/^0+(\d)/, "$1") || filtered;
|
||||||
|
|
||||||
// 空值处理(允许临时删除)
|
// 空值处理(允许临时删除)
|
||||||
if (filtered === "") {
|
if (filtered === "") {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最小值限制
|
// 最小值限制
|
||||||
if (filtered === isIntegerOnly || parseInt(filtered, 10) < isIntegerOnly) {
|
if (filtered === isIntegerOnly || parseInt(filtered, 10) < isIntegerOnly) {
|
||||||
return isIntegerOnly;
|
return isIntegerOnly;
|
||||||
}
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 小数模式处理
|
// 小数模式处理
|
||||||
const parts = filtered.split(".");
|
const parts = filtered.split(".");
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
filtered = parts[0] + "." + (parts[1].substring(0, 2) || "");
|
filtered = parts[0] + "." + (parts[1].substring(0, 2) || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理前导零
|
// 处理前导零
|
||||||
if (filtered.startsWith("0") && filtered.length > 1 && !filtered.startsWith("0.")) {
|
if (
|
||||||
filtered = filtered.replace(/^0+(\d)/, "$1");
|
filtered.startsWith("0") &&
|
||||||
}
|
filtered.length > 1 &&
|
||||||
|
!filtered.startsWith("0.")
|
||||||
|
) {
|
||||||
|
filtered = filtered.replace(/^0+(\d)/, "$1");
|
||||||
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* uni-app 跨平台权限判断通用方法
|
||||||
|
* @param {String} permissionType - 权限类型(通用名称,如 'camera'、'location' 等)
|
||||||
|
* @returns {Promise<{granted: Boolean, status: String, platform: String}>} - 权限状态结果
|
||||||
|
* - granted: 是否已授权(Boolean)
|
||||||
|
* - status: 详细状态('granted'|'denied'|'undetermined'|'limited')
|
||||||
|
* - platform: 当前平台
|
||||||
|
*/
|
||||||
|
export function checkPermission(permissionType) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// 获取当前平台信息
|
||||||
|
const systemInfo = uni.getSystemInfoSync();
|
||||||
|
const platform = systemInfo.platform; // 'android'|'ios'|'devtools'(小程序模拟器)
|
||||||
|
const env = process.env.NODE_ENV; // 环境变量(用于区分小程序/H5/App)
|
||||||
|
|
||||||
|
// 权限类型映射表:将通用权限名称映射到各平台具体权限名
|
||||||
|
const permissionMap = {
|
||||||
|
// 通用权限名称: { 平台: 平台权限名 }
|
||||||
|
camera: {
|
||||||
|
weixin: "scope.camera", // 微信小程序
|
||||||
|
alipay: "scope.camera", // 支付宝小程序
|
||||||
|
android: "android.permission.CAMERA", // App-Android
|
||||||
|
ios: "camera", // App-iOS(简化处理,实际需调用原生 API)
|
||||||
|
h5: "camera", // H5 浏览器权限
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
weixin: "scope.userLocation",
|
||||||
|
alipay: "scope.userLocation",
|
||||||
|
android: "android.permission.ACCESS_FINE_LOCATION",
|
||||||
|
ios: "location",
|
||||||
|
h5: "geolocation",
|
||||||
|
},
|
||||||
|
microphone: {
|
||||||
|
weixin: "scope.record",
|
||||||
|
alipay: "scope.record",
|
||||||
|
android: "android.permission.RECORD_AUDIO",
|
||||||
|
ios: "microphone",
|
||||||
|
h5: "microphone",
|
||||||
|
},
|
||||||
|
album: {
|
||||||
|
weixin: "scope.writePhotosAlbum",
|
||||||
|
alipay: "scope.album",
|
||||||
|
android: "android.permission.WRITE_EXTERNAL_STORAGE",
|
||||||
|
ios: "photoLibrary",
|
||||||
|
h5: "clipboard-write", // H5 相册权限支持有限
|
||||||
|
},
|
||||||
|
// 可根据需求扩展更多权限类型
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取当前平台对应的权限名
|
||||||
|
const getPlatformPermission = () => {
|
||||||
|
if (typeof wx !== "undefined" && wx.getSetting)
|
||||||
|
return permissionMap[permissionType]?.weixin; // 微信小程序
|
||||||
|
if (typeof my !== "undefined" && my.getSetting)
|
||||||
|
return permissionMap[permissionType]?.alipay; // 支付宝小程序
|
||||||
|
if (platform === "android") return permissionMap[permissionType]?.android; // App-Android
|
||||||
|
if (platform === "ios") return permissionMap[permissionType]?.ios; // App-iOS
|
||||||
|
return permissionMap[permissionType]?.h5; // H5
|
||||||
|
};
|
||||||
|
|
||||||
|
const platformPermission = getPlatformPermission();
|
||||||
|
if (!platformPermission) {
|
||||||
|
return reject(new Error(`不支持的权限类型: ${permissionType}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------- 分平台处理 -----------------
|
||||||
|
// 1. 微信/支付宝小程序
|
||||||
|
if (typeof wx !== "undefined" && wx.getSetting) {
|
||||||
|
uni.getSetting({
|
||||||
|
success: (res) => {
|
||||||
|
const authSetting = res.authSetting || {};
|
||||||
|
const isGranted = authSetting[platformPermission] === true;
|
||||||
|
const status = isGranted
|
||||||
|
? "granted"
|
||||||
|
: authSetting[platformPermission] === false
|
||||||
|
? "denied"
|
||||||
|
: "undetermined";
|
||||||
|
resolve({ granted: isGranted, status, platform: "weixin" });
|
||||||
|
},
|
||||||
|
fail: (err) => reject(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 2. App-Android 平台
|
||||||
|
else if (platform === "android" && typeof plus !== "undefined") {
|
||||||
|
try {
|
||||||
|
const Context = plus.android.importClass("android.content.Context");
|
||||||
|
const Activity = plus.android.runtimeMainActivity();
|
||||||
|
const PackageManager = plus.android.importClass(
|
||||||
|
"android.content.pm.PackageManager"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 检查权限状态
|
||||||
|
const granted =
|
||||||
|
Activity.checkSelfPermission(platformPermission) ===
|
||||||
|
PackageManager.PERMISSION_GRANTED;
|
||||||
|
resolve({
|
||||||
|
granted,
|
||||||
|
status: granted ? "granted" : "denied",
|
||||||
|
platform: "android",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. App-iOS 平台
|
||||||
|
else if (platform === "ios" && typeof plus !== "undefined") {
|
||||||
|
try {
|
||||||
|
// iOS 权限需通过原生 API 检查(以相机为例,其他权限类似)
|
||||||
|
const result = { granted: false, status: "denied", platform: "ios" };
|
||||||
|
|
||||||
|
if (permissionType === "camera") {
|
||||||
|
// 相机权限检查(iOS 需调用 AVCaptureDevice)
|
||||||
|
const AVCaptureDevice = plus.ios.importClass("AVCaptureDevice");
|
||||||
|
const authStatus =
|
||||||
|
AVCaptureDevice.authorizationStatusForMediaType("vide");
|
||||||
|
// AVCaptureDeviceAuthorizationStatus 枚举值:
|
||||||
|
// 0: notDetermined(未请求), 1: restricted(受限制), 2: denied(拒绝), 3: authorized(授权)
|
||||||
|
result.granted = authStatus === 3;
|
||||||
|
result.status =
|
||||||
|
authStatus === 3
|
||||||
|
? "granted"
|
||||||
|
: authStatus === 0
|
||||||
|
? "undetermined"
|
||||||
|
: "denied";
|
||||||
|
}
|
||||||
|
// 可扩展其他 iOS 权限(如定位、麦克风等)
|
||||||
|
else if (permissionType === "location") {
|
||||||
|
const CLLocationManager = plus.ios.importClass("CLLocationManager");
|
||||||
|
const authStatus = CLLocationManager.authorizationStatus();
|
||||||
|
// CLAuthorizationStatus 枚举值:
|
||||||
|
// 0: notDetermined, 1: restricted, 2: denied, 3: authorizedAlways, 4: authorizedWhenInUse
|
||||||
|
result.granted = authStatus === 3 || authStatus === 4;
|
||||||
|
result.status = result.granted
|
||||||
|
? "granted"
|
||||||
|
: authStatus === 0
|
||||||
|
? "undetermined"
|
||||||
|
: "denied";
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(result);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 4. H5 平台(浏览器权限)
|
||||||
|
else if (typeof navigator !== "undefined" && navigator.permissions) {
|
||||||
|
navigator.permissions
|
||||||
|
.query({ name: platformPermission })
|
||||||
|
.then((permissionStatus) => {
|
||||||
|
resolve({
|
||||||
|
granted: permissionStatus.state === "granted",
|
||||||
|
status: permissionStatus.state, // 'granted'|'denied'|'prompt'
|
||||||
|
platform: "h5",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => reject(err));
|
||||||
|
}
|
||||||
|
// 5. 其他平台(如百度小程序、字节跳动小程序等,可扩展)
|
||||||
|
else {
|
||||||
|
reject(new Error(`当前平台不支持权限判断: ${platform}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user