聊天问题修复优化

This commit is contained in:
2025-12-05 19:19:54 +08:00
parent b534344b25
commit 28041ddb09
6 changed files with 1322 additions and 382 deletions

View File

@@ -40,12 +40,14 @@
>
<view class="user u-flex u-row-right">
<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
class="u-m-t-14 msg u-p-l-30"
:class="['type' + item.msg_type]"
>
<chatItem :item="item"></chatItem>
<chatItem :item="item" @previewImage="previewImage"></chatItem>
</view>
</view>
<up-avatar
@@ -60,21 +62,26 @@
<template v-else>
<view class="shop u-flex">
<up-avatar
:src="shopInfo.logo"
:src="item.avatar"
size="122rpx"
shape="square"
bg-color="#fff"
></up-avatar>
<view class="u-p-l-18">
<view class="u-flex">
<view class="tag">商家</view>
<view class="color-000">{{ shopInfo.shopName }}</view>
<template v-if="item.is_shop == 1">
<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
class="u-m-t-14 msg"
:class="['type' + item.msg_type]"
>
<chatItem :item="item" @getCoupon="getCoupon"></chatItem>
<chatItem :item="item" @getCoupon="getCoupon" @previewImage="previewImage"></chatItem>
</view>
</view>
</view>
@@ -92,7 +99,11 @@
<!-- <view :style="bottomSlotHeight"></view> -->
<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
type="text"
class="u-flex-1 u-m-r-52 iput"
@@ -112,12 +123,14 @@
<up-icon
name="plus-circle"
color="#666"
size="40rpx"
size="60rpx"
@click="showMoreBtnToggle"
v-else
></up-icon>
</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
v-for="(item, index) in moreBtns"
@@ -144,7 +157,7 @@
<view class="header-wrap">
<text class="t">退出群{{ "{" + groupInfo.name + "}" }}</text>
<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 class="u-p-40 text-center u-font-28 color-333 border-bottom"
@@ -168,10 +181,16 @@
</view>
</view>
</u-popup>
<!-- 预览图 -->
<xbSwiperPreview :visable="showPrveImg" :imgs="prveImgsList" @update:visable="showPrveImg = $event"></xbSwiperPreview>
</template>
<script setup>
import {
onReady,
onShow,
onReachBottom,
onLoad,
onPageScroll,
@@ -183,12 +202,18 @@ import {
nextTick,
reactive,
watch,
onUnmounted,
computed,
} from "vue";
import { useChatStore } from "@/stores/chat";
import * as chatApi from "@/http/php/chat";
import { uploadFile } from "@/common/api/upload.js";
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() {
chatApi
@@ -214,7 +239,8 @@ function getCoupon(item) {
javaChatApi
.couponGrant({
id: item.coupon.activity_id,
shopUserId: shopInfo.id,
// shopUserId: shopUserInfo.id,
shopId: groupInfo.value.shop_id,
userId: uni.cache.get("userInfo").id,
})
.then((res) => {
@@ -224,13 +250,12 @@ function getCoupon(item) {
icon: "none",
duration: 2000,
});
refresh()
}
});
}
function refresh() {
query.page=1;
function refresh() {
query.page = 1;
getMsgList();
}
const go = {
@@ -282,12 +307,7 @@ const modalData = reactive({
});
const chatStore = useChatStore();
chatStore.onReceiveMsg = (msg) => {
nextTick(() => {
scrollView.intoView = "msg-0";
});
};
chatStore.connectSocket();
const msg = ref("");
const shopInfo = uni.cache.get("shopInfo");
@@ -323,67 +343,222 @@ function moreBtnsClick(item, index) {
sendVideo();
}
}
function videoErrorCallback(e) {
console.error("视频播放失败", e);
}
function sendImg() {
uni.chooseImage({
count: 3, //默认9
sizeType: ["original", "compressed"], //可以指定是原图还是压缩图,默认二者都有
sourceType: ["album", "camera "],
success: async function (res) {
uni.showLoading({
title: "发送中",
});
console.log(res);
for (let i = 0; i < res.tempFiles.length; i++) {
const fileRes = await uploadFile(res.tempFiles[i]);
if (fileRes) {
sendMsg({
image_url: fileRes,
msg_type: 2,
});
} else {
// 通用相机权限校验函数参考openLocationAuth逻辑
async function checkCameraAuth() {
return new Promise((resolve, reject) => {
try {
// 1. 检查当前相机权限状态
uni.getSetting({
success: (settingRes) => {
const authSetting = settingRes.authSetting || {};
if (authSetting["scope.camera"]) {
// 2. 已授权:直接返回成功
resolve(true);
} else if (authSetting["scope.camera"] === undefined) {
// 3. 未请求过授权:发起授权请求
uni.authorize({
scope: "scope.camera",
success: () => {
// 授权成功
resolve(true);
},
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({
count: 1, //默认9
sizeType: ["original", "compressed"], //可以指定是原图还是压缩图,默认二者都有
sourceType: ["album", "camera "],
success: async function (res) {
uni.showLoading({
title: "发送中",
// 图片选择与发送(优化后)
async function sendImg() {
try {
// 1. 调用相机权限校验
const isAuth = await checkCameraAuth();
if (!isAuth) {
// 权限校验失败,终止流程
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) {
sendMsg({
image_url: fileRes,
msg_type: 5,
});
} else {
uni.showToast({
title: "发送失败",
icon: "none",
msg_type: 2,
});
}
},
});
}
} 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 handleReceiveMsg = (msg) => {
nextTick(() => {
scrollView.intoView = "msg-0";
});
};
// 注册消息回调
chatStore.registerReceiveMsgCallback(handleReceiveMsg);
async function init() {
const res = await chatApi.groupInfo({
group_id: options.group_id,
});
console.log(res);
groupInfo.value = res || {};
if (res) {
chatStore.shop_id = groupInfo.value.shop_id;
chatStore.connectSocket();
chatStore.init();
}
getMsgList();
}
const query = reactive({
@@ -395,7 +570,6 @@ async function getMsgList() {
if (isEnd.value) {
scrollView.refresherTriggered = false;
isLoading.value = false;
uni.showToast({ title: "没有更多了", icon: "none" });
return;
}
@@ -443,8 +617,9 @@ onLoad((opt) => {
Object.assign(options, opt);
init();
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) => {
console.log(res);
membersRes.value = res;
@@ -474,7 +649,7 @@ function sendMsg(msg) {
content: msg.value,
image_url: "",
order_id: "",
session_id: "",
session_id: groupInfo.value.session_id || options.session_id,
...msg,
});
}
@@ -519,9 +694,9 @@ function confirmCoupon() {
}
function previewImage(url) {
uni.previewImage({
urls: [url],
});
prveImgsList.value = [url];
showPrveImg.value = true;
}
//是否到底了
@@ -556,6 +731,18 @@ const pageHeight = computed(() => {
}
return "";
});
onShow(() => {
// 页面显示时,检查连接状态
if (!chatStore.isConnect || !chatStore.socketTask) {
console.log("聊天页显示检查Socket连接");
chatStore.forceReconnect();
}
});
onUnmounted(() => {
// 组件卸载时,移除消息回调
chatStore.removeReceiveMsgCallback(handleReceiveMsg);
});
</script>
<style lang="scss" scoped>
.top {

View File

@@ -13,11 +13,10 @@
:src="item.image_url"
class="img"
mode="widthFix"
@click="previewVideo(item.video_url)"
></video>
<view class="" v-if="item.msg_type == 4">
<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">
<view class="left">
<view class="price">
@@ -77,11 +76,9 @@
</view>
</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-24 color-999 u-m-t-8"
>有效期{{ returnTime(item.coupon) }}</view
>
</view>
</view>
<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() {
emits("getCoupon", props.item);
}
function previewImage(url) {
uni.previewImage({
urls: [url],
});
emits('previewImage',url)
}
function returnTime(coupon){
let startTime = coupon.useStartTime;
@@ -149,4 +144,7 @@ function returnTime(coupon){
color: #999;
}
}
.u-col-stretch{
align-items: stretch;
}
</style>

View File

@@ -2,7 +2,7 @@
<view class="min-page bg-f7 color-333 u-font-28">
<up-sticky>
<view class="top u-flex u-row-between u-col-center">
<view style="width: 420rpx;">
<view style="width: 420rpx">
<up-search
v-model="query.key"
placeholder="搜索群名称"
@@ -19,36 +19,37 @@
</up-sticky>
<view class="list">
<up-swipe-action>
<up-swipe-action-item
:options="options1"
v-for="(item, index) in list"
v-model:show="item.showOptions"
@click="optionsClick($event, item, index)"
:key="item.id"
>
<view class="item u-flex" @click="toDetail(item)">
<view class="u-flex avatar">
<up-avatar
size="118rpx"
:src="item.avatar"
shape="square"
round="8rpx"
></up-avatar>
<view class="bandage" v-if="item.unread_count > 0">{{
item.unread_count >= 99 ? "99" : item.unread_count
}}</view>
</view>
<view class="u-flex-1 u-flex u-row-between u-p-l-14">
<view style="max-width: 364rpx">
<view class="color-000 u-line-1">{{ item.name }}</view>
<view class="u-m-t-28 u-line-1 u-font-24 color-999"
>{{ item.msg }}</view
>
<template v-for="(item, index) in list" :key="item.id">
<up-swipe-action-item
:options="options1"
v-model:show="item.showOptions"
@click="optionsClick($event, item, index)"
>
<view class="item u-flex" @click="toDetail(item)">
<view class="u-flex avatar">
<up-avatar
size="118rpx"
:src="item.avatar"
shape="square"
round="8rpx"
></up-avatar>
<view class="bandage" v-if="item.unread_count > 0">{{
item.unread_count >= 99 ? "99" : item.unread_count
}}</view>
</view>
<view class="u-flex-1 u-flex u-row-between u-p-l-14">
<view style="max-width: 364rpx">
<view class="color-000 u-line-1">{{ item.name }}</view>
<view class="u-m-t-28 u-line-1 u-font-24 color-999">{{
item.msg
}}</view>
</view>
<view class="color-333 u-font-24">{{ item.send_time }}</view>
</view>
<view class="color-333 u-font-24">{{ item.send_time }}</view>
</view>
</view>
</up-swipe-action-item>
</up-swipe-action-item>
<view style="height: 16rpx" class="bg-f7"></view>
</template>
</up-swipe-action>
</view>
</view>
@@ -56,8 +57,64 @@
<script setup>
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 { 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 创建响应式对象
const options1 = reactive([
@@ -95,7 +152,7 @@ function optionsClick(e, item, index) {
}
const list = ref([]);
let allList = [];
let allList = ref([]);
const query = reactive({
key: "",
@@ -116,7 +173,7 @@ function throttle(fn, delay) {
//使用节流函数
const throttleSearch = throttle(search, 500);
function search() {
list.value = allList
const arr = allList.value
.filter((v) => v.name.includes(query.key.trim()))
.map((v) => {
return {
@@ -124,10 +181,20 @@ function search() {
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() {
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();
}
@@ -138,7 +205,7 @@ async function clearAllmsg() {
success: async (res) => {
if (res.confirm) {
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) {
uni.showToast({
@@ -173,6 +240,15 @@ function toDetail(item) {
const messageUnreadCount = ref(0);
onShow(() => {
getList();
// 检查连接状态,确保连接活跃
if (!chatStore.isConnect || !chatStore.socketTask) {
console.log("列表页显示检查Socket连接");
chatStore.forceReconnect();
} else {
chatStore.shop_id = "";
chatStore.init();
}
// 获取未读消息总数
chatApi.messageUnreadCount({}).then((res) => {
console.log(res);
@@ -192,13 +268,11 @@ onShow(() => {
}
.list {
padding: 36rpx 28rpx;
.item {
padding: 32rpx 28rpx;
background-color: #fff;
border-radius: 16rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
.avatar {
position: relative;
.bandage {
@@ -220,4 +294,10 @@ onShow(() => {
}
}
}
:deep(.u-swipe-action-item__content) {
background-color: transparent;
}
:deep(.u-swipe-action-item) {
border-radius: 16rpx;
}
</style>

View File

@@ -223,7 +223,7 @@ async function getCouponList() {
userId: uni.cache.get('userInfo').id,
name: querForm.value.searchValue,
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,
size: list.size
});

View File

@@ -1,7 +1,4 @@
import {
defineStore
} from "pinia";
// import * as shopApi from "@/http/api/shop.js";
import { defineStore } from "pinia";
// #ifdef H5
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", {
state: () => {
return {
socketUrl,
isConnect: false,
socketTask: null,
onReceiveMsg: () => {},
chatList: [],
};
},
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);
},
});
state: () => {
return {
socketUrl,
isConnect: false,
socketTask: null,
onReceiveMsg: () => {},
chatList: [],
shop_id: "",
group_id:'',
this.socketTask.onOpen((res) => {
this.isConnect = true;
this.init();
});
// ========== Socket 保活与重连状态 ==========
isManualClose: false, // 是否主动关闭true主动关闭不重连false意外断开自动重连
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);
console.log("收到服务器消息", data);
if (data.msg) {
uni.showToast({
title: data.msg,
icon: "none",
});
}
// ========== 应用与网络状态监听 ==========
isAppActive: true, // 应用是否在前台
lastHeartbeatTime: 0, // 上次心跳时间戳
_listenersInitialized: false, // 状态监听器是否已初始化
if (data && data.operate_type == "sendMsg") {
this.chatList.unshift(data.data);
this.onReceiveMsg(data.data);
console.log(this.chatList);
}
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);
}
});
// ========== 消息去重机制 ==========
recentMessages: new Map(), // 最近处理的消息映射表key=消息特征哈希value=处理时间戳
deduplicationWindow: 5000, // 去重时间窗口5秒内相同消息只处理一次
maxRecentMessages: 100, // 最近消息缓存最大数量
this.socketTask.onError((res) => {
this.isConnect = false;
console.log("连接错误", res);
});
// ========== 连接状态锁 ==========
isConnecting: false, // 是否正在建立连接(防止重复创建连接)
lastConnectTime: 0, // 上次连接时间(防止短时间内频繁连接)
this.socketTask.onClose(() => {
this.isConnect = false;
console.log("连接已关闭");
this.connectSocket();
});
},
// ========== 消息回调队列 ==========
receiveMsgCallbacks: [], // 消息接收回调队列,支持多页面同时监听
};
},
closeSocket() {
this.socketTask.close();
this.isConnect = false;
},
},
unistorage: false, // 开启后对 state 的数据读写都将持久化
});
actions: {
// ========== 初始化 ==========
init() {
if (!this.isConnect) {
return uni.showToast({
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 的数据读写都将持久化
});

View File

@@ -4,17 +4,17 @@
* @returns {string} 脱敏后手机号
*/
export function desensitizePhone(phone) {
// 1. 提取纯数字(过滤非数字字符)
const purePhone = (phone || "").replace(/[^\d]/g, "");
// 1. 提取纯数字(过滤非数字字符)
const purePhone = (phone || "").replace(/[^\d]/g, "");
// 2. 边界判断非11位手机号返回原字符串或自定义提示
if (purePhone.length !== 11) {
console.warn("手机号格式不正确需11位纯数字");
return phone; // 或返回 ''、'手机号格式错误' 等
}
// 2. 边界判断非11位手机号返回原字符串或自定义提示
if (purePhone.length !== 11) {
console.warn("手机号格式不正确需11位纯数字");
return phone; // 或返回 ''、'手机号格式错误' 等
}
// 3. 脱敏前3位 + **** + 后4位
return purePhone.replace(/(\d{3})(\d{4})(\d{4})/, "$1****$3");
// 3. 脱敏前3位 + **** + 后4位
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 }
*/
export function validateName(name) {
// 1. 空值校验
if (!name || name.trim() === '') {
return {
valid: false,
msg: '姓名不能为空'
};
}
const pureName = name.trim();
// 1. 空值校验
if (!name || name.trim() === "") {
return {
valid: false,
msg: "姓名不能为空",
};
}
const pureName = name.trim();
// 2. 长度校验2-6位含少数民族中间点
if (pureName.length < 2 || pureName.length > 6) {
return {
valid: false,
msg: '姓名长度应为2-6位'
};
}
// 2. 长度校验2-6位含少数民族中间点
if (pureName.length < 2 || pureName.length > 6) {
return {
valid: false,
msg: "姓名长度应为2-6位",
};
}
// 3. 正则校验:仅允许中文、少数民族中间点(·),且中间点不能在开头/结尾
// 中文范围:[\u4e00-\u9fa5],中间点:[\u00b7]Unicode 标准中间点,非小数点)
const nameReg = /^[\u4e00-\u9fa5]+([\u00b7][\u4e00-\u9fa5]+)*$/;
if (!nameReg.test(pureName)) {
return {
valid: false,
msg: '姓名仅支持中文和少数民族中间点(·),且不能包含数字、字母或特殊符号'
};
}
// 3. 正则校验:仅允许中文、少数民族中间点(·),且中间点不能在开头/结尾
// 中文范围:[\u4e00-\u9fa5],中间点:[\u00b7]Unicode 标准中间点,非小数点)
const nameReg = /^[\u4e00-\u9fa5]+([\u00b7][\u4e00-\u9fa5]+)*$/;
if (!nameReg.test(pureName)) {
return {
valid: false,
msg: "姓名仅支持中文和少数民族中间点(·),且不能包含数字、字母或特殊符号",
};
}
// 4. 额外限制:中间点不能连续(如“李··四”)
if (/[\u00b7]{2,}/.test(pureName)) {
return {
valid: false,
msg: '姓名中的中间点(·)不能连续'
};
}
// 4. 额外限制:中间点不能连续(如“李··四”)
if (/[\u00b7]{2,}/.test(pureName)) {
return {
valid: false,
msg: "姓名中的中间点(·)不能连续",
};
}
// 校验通过
return {
valid: true,
msg: '姓名格式合法'
};
// 校验通过
return {
valid: true,
msg: "姓名格式合法",
};
}
/**
@@ -72,95 +72,117 @@ export function validateName(name) {
* info 可选返回:{ birthDate: string, gender: string }(出生日期、性别)
*/
export function validateIdCard(idCard) {
// 1. 空值校验
if (!idCard || idCard.trim() === '') {
return {
valid: false,
msg: '身份证号码不能为空'
};
}
const pureIdCard = idCard.trim().toUpperCase(); // 统一转为大写处理X
// 1. 空值校验
if (!idCard || idCard.trim() === "") {
return {
valid: false,
msg: "身份证号码不能为空",
};
}
const pureIdCard = idCard.trim().toUpperCase(); // 统一转为大写处理X
// 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 id15Reg = /^[1-9]\d{5}\d{2}((0[1-9])|(1[0-2]))((0[1-9])|([12]\d)|(3[01]))\d{3}$/;
// 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 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)) {
return {
valid: false,
msg: '身份证号码格式错误需18位最后一位可含X或15位纯数字'
};
}
if (!id18Reg.test(pureIdCard) && !id15Reg.test(pureIdCard)) {
return {
valid: false,
msg: "身份证号码格式错误需18位最后一位可含X或15位纯数字",
};
}
// 3. 提取出生日期并校验合法性
let birthDateStr, birthDate;
if (pureIdCard.length === 18) {
// 18位第7-14位为出生日期YYYYMMDD
birthDateStr = pureIdCard.slice(6, 14);
birthDate = new Date(`${birthDateStr.slice(0,4)}-${birthDateStr.slice(4,6)}-${birthDateStr.slice(6,8)}`);
} else {
// 15位第7-12位为出生日期YYMMDD补全为YYYYMMDD19xx或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}`);
}
// 3. 提取出生日期并校验合法性
let birthDateStr, birthDate;
if (pureIdCard.length === 18) {
// 18位第7-14位为出生日期YYYYMMDD
birthDateStr = pureIdCard.slice(6, 14);
birthDate = new Date(
`${birthDateStr.slice(0, 4)}-${birthDateStr.slice(
4,
6
)}-${birthDateStr.slice(6, 8)}`
);
} else {
// 15位第7-12位为出生日期YYMMDD补全为YYYYMMDD19xx或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
if (
isNaN(birthDate.getTime()) ||
birthDateStr.slice(0, 4) !== birthDate.getFullYear().toString() ||
birthDateStr.slice(4, 6) !== (birthDate.getMonth() + 1).toString().padStart(2, '0') ||
birthDateStr.slice(6, 8) !== birthDate.getDate().toString().padStart(2, '0')
) {
return {
valid: false,
msg: '身份证中的出生日期无效'
};
}
// 校验出生日期有效性如20230230 → 日期对象会是Invalid Date
if (
isNaN(birthDate.getTime()) ||
birthDateStr.slice(0, 4) !== birthDate.getFullYear().toString() ||
birthDateStr.slice(4, 6) !==
(birthDate.getMonth() + 1).toString().padStart(2, "0") ||
birthDateStr.slice(6, 8) !== birthDate.getDate().toString().padStart(2, "0")
) {
return {
valid: false,
msg: "身份证中的出生日期无效",
};
}
// 4. 18位身份证额外校验校验码合法性加权算法
if (pureIdCard.length === 18) {
// 加权因子
const weightFactors = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
// 校验码对应值0-10 → 10对应X
const checkCodeMap = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
// 计算前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: '身份证校验码错误,可能是无效身份证'
};
}
}
// 4. 18位身份证额外校验校验码合法性加权算法
if (pureIdCard.length === 18) {
// 加权因子
const weightFactors = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
// 校验码对应值0-10 → 10对应X
const checkCodeMap = [
"1",
"0",
"X",
"9",
"8",
"7",
"6",
"5",
"4",
"3",
"2",
];
// 计算前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位奇数=男,偶数=女)
let gender = '';
if (pureIdCard.length === 18) {
const genderCode = parseInt(pureIdCard[16]);
gender = genderCode % 2 === 1 ? '男' : '女';
} else {
const genderCode = parseInt(pureIdCard[14]);
gender = genderCode % 2 === 1 ? '男' : '女';
}
// 5. 可选提取性别18位第17位15位第15位奇数=男,偶数=女)
let gender = "";
if (pureIdCard.length === 18) {
const genderCode = parseInt(pureIdCard[16]);
gender = genderCode % 2 === 1 ? "男" : "女";
} else {
const genderCode = parseInt(pureIdCard[14]);
gender = genderCode % 2 === 1 ? "男" : "女";
}
// 校验通过,返回额外信息(出生日期、性别)
return {
valid: true,
msg: '身份证号码合法',
info: {
birthDate: `${birthDate.getFullYear()}-${(birthDate.getMonth() + 1).toString().padStart(2, '0')}-${birthDate.getDate().toString().padStart(2, '0')}`,
gender: gender
}
};
// 校验通过,返回额外信息(出生日期、性别)
return {
valid: true,
msg: "身份证号码合法",
info: {
birthDate: `${birthDate.getFullYear()}-${(birthDate.getMonth() + 1)
.toString()
.padStart(2, "0")}-${birthDate.getDate().toString().padStart(2, "0")}`,
gender: gender,
},
};
}
/**
@@ -170,40 +192,208 @@ export function validateIdCard(idCard) {
* @returns {string} 过滤后的合法值
*/
export function filterNumberInput(value, isIntegerOnly = false) {
// 第一步就过滤所有非数字和非小数点的字符(包括字母)
let filtered = value.replace(/[^\d.]/g, "");
// 第一步就过滤所有非数字和非小数点的字符(包括字母)
let filtered = value.replace(/[^\d.]/g, "");
// 整数模式处理
if (isIntegerOnly !== false) {
// 移除所有小数点
filtered = filtered.replace(/\./g, "");
// 整数模式处理
if (isIntegerOnly !== false) {
// 移除所有小数点
filtered = filtered.replace(/\./g, "");
// 处理前导零
filtered = filtered.replace(/^0+(\d)/, "$1") || filtered;
// 处理前导零
filtered = filtered.replace(/^0+(\d)/, "$1") || filtered;
// 空值处理(允许临时删除)
if (filtered === "") {
return "";
}
// 空值处理(允许临时删除)
if (filtered === "") {
return "";
}
// 最小值限制
if (filtered === isIntegerOnly || parseInt(filtered, 10) < isIntegerOnly) {
return isIntegerOnly;
}
// 最小值限制
if (filtered === isIntegerOnly || parseInt(filtered, 10) < isIntegerOnly) {
return isIntegerOnly;
}
return filtered;
}
return filtered;
}
// 小数模式处理
const parts = filtered.split(".");
if (parts.length > 1) {
filtered = parts[0] + "." + (parts[1].substring(0, 2) || "");
}
// 小数模式处理
const parts = filtered.split(".");
if (parts.length > 1) {
filtered = parts[0] + "." + (parts[1].substring(0, 2) || "");
}
// 处理前导零
if (filtered.startsWith("0") && filtered.length > 1 && !filtered.startsWith("0.")) {
filtered = filtered.replace(/^0+(\d)/, "$1");
}
// 处理前导零
if (
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}`));
}
});
}