import amapFile from '../libs/amap-wx.130.js';
|
import { _AMAP_KEY, _ROBOTAXI_HOST, _UID } from '../constants/api';
|
import Taro from '@tarojs/taro';
|
import request from './request';
|
import { getFenceCheck, getNearestSite } from '../robotaxi/api/home';
|
import { getOpenCity } from '../robotaxi/api/select-city';
|
import { getStorage, getOrCreateLocalId } from './tools';
|
import {
|
GEOFENCE_STATION_FILL_COLOR,
|
GEOFENCE_STATION_STROKE_COLOR,
|
GEOFENCE_TYPE_OPERATING,
|
GEOFENCE_TYPE_STATION
|
} from '../robotaxi/constants/geofence';
|
import { APPID } from '../constants/app'
|
|
/**
|
* 安全的 JSON 解析函数
|
* @param str 需要解析的字符串
|
* @param defaultVal 解析失败时的默认返回值
|
*/
|
export const safeParse = (str: any, defaultVal: any = null) => {
|
if (typeof str !== 'string') {
|
return str || defaultVal;
|
}
|
|
const trimmed = str.trim();
|
// 简单的格式预判:JSON 对象或数组字符串通常以 { 或 [ 开头
|
// 如果不是这两种开头,大概率不是我们需要解析的 JSON,直接返回原值或默认值,避免引发 JSON.parse 的异常和错误日志
|
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
return str || defaultVal;
|
}
|
|
try {
|
return JSON.parse(trimmed);
|
} catch (e) {
|
console.error('safeParse error:', e, 'original string:', str);
|
return defaultVal;
|
}
|
};
|
|
/**
|
* 安全拨打电话:号码为空时给出提示,避免传空字符串触发系统拨号异常。
|
* @param phoneNumber 待拨打的号码
|
* @param emptyTip 号码缺失时的提示文案
|
*/
|
export const safeMakePhoneCall = (phoneNumber?: string, emptyTip = '暂无客服电话') => {
|
const cachedPhoneNumber = Taro.getStorageSync('customerServicePhone');
|
const normalizedPhoneNumber = String(phoneNumber || cachedPhoneNumber || '').trim();
|
if (!normalizedPhoneNumber) {
|
Taro.showToast({
|
title: emptyTip,
|
icon: 'none'
|
});
|
return Promise.resolve(false);
|
}
|
return Taro.makePhoneCall({
|
phoneNumber: normalizedPhoneNumber
|
})
|
.then(() => true)
|
.catch(() => false);
|
};
|
|
/**
|
* 安全解析 `"longitude,latitude"` 格式的坐标字符串,格式非法时返回 `null`。
|
* @param location 坐标字符串
|
*/
|
export const parseLocationCoordinateString = (location?: string) => {
|
const locationParts = String(location || '').split(',');
|
if (locationParts.length < 2) {
|
return null;
|
}
|
const longitude = Number(locationParts[0]);
|
const latitude = Number(locationParts[1]);
|
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) {
|
return null;
|
}
|
return {
|
longitude,
|
latitude
|
};
|
};
|
|
// 辅助函数:计算两点之间的距离(单位:米)
|
export const getDistance = (lat1: number, lng1: number, lat2: number, lng2: number) => {
|
const radLat1 = lat1 * Math.PI / 180.0;
|
const radLat2 = lat2 * Math.PI / 180.0;
|
const a = radLat1 - radLat2;
|
const b = lng1 * Math.PI / 180.0 - lng2 * Math.PI / 180.0;
|
let s = 2 * Math.asin(Math.sqrt(Math.pow(Math.sin(a / 2), 2) + Math.cos(radLat1) * Math.cos(radLat2) * Math.pow(Math.sin(b / 2), 2)));
|
s = s * 6378.137; // 地球半径
|
s = Math.round(s * 10000) / 10000;
|
return s * 1000;
|
};
|
|
/**
|
* 统一标准化围栏点位,兼容 `{ lat, lng }` 和 `{ latitude, longitude }` 两种结构。
|
*/
|
const normalizePolygonPoints = (points: any[] = []) => {
|
return points
|
.map((point) => ({
|
latitude: Number(point?.latitude ?? point?.lat),
|
longitude: Number(point?.longitude ?? point?.lng)
|
}))
|
.filter((point) => Number.isFinite(point.latitude) && Number.isFinite(point.longitude));
|
};
|
|
/**
|
* 将经纬度投影到近似平面,便于计算点到边的距离。
|
*/
|
const projectPoint = (point: { latitude: number; longitude: number }, referenceLatitude: number) => {
|
const metersPerDegreeLat = 111320;
|
const metersPerDegreeLng = 111320 * Math.cos(referenceLatitude * Math.PI / 180);
|
return {
|
x: point.longitude * metersPerDegreeLng,
|
y: point.latitude * metersPerDegreeLat
|
};
|
};
|
|
/**
|
* 计算点到线段的最短距离,单位为米。
|
*/
|
export const getDistanceToSegment = (
|
point: { latitude: number; longitude: number },
|
start: { latitude: number; longitude: number },
|
end: { latitude: number; longitude: number }
|
) => {
|
const referenceLatitude = (point.latitude + start.latitude + end.latitude) / 3;
|
const pointXY = projectPoint(point, referenceLatitude);
|
const startXY = projectPoint(start, referenceLatitude);
|
const endXY = projectPoint(end, referenceLatitude);
|
const dx = endXY.x - startXY.x;
|
const dy = endXY.y - startXY.y;
|
const lengthSquared = dx * dx + dy * dy;
|
|
if (lengthSquared === 0) {
|
return Math.sqrt((pointXY.x - startXY.x) * (pointXY.x - startXY.x) + (pointXY.y - startXY.y) * (pointXY.y - startXY.y));
|
}
|
|
let ratio = ((pointXY.x - startXY.x) * dx + (pointXY.y - startXY.y) * dy) / lengthSquared;
|
ratio = Math.max(0, Math.min(1, ratio));
|
const closestX = startXY.x + ratio * dx;
|
const closestY = startXY.y + ratio * dy;
|
|
return Math.sqrt((pointXY.x - closestX) * (pointXY.x - closestX) + (pointXY.y - closestY) * (pointXY.y - closestY));
|
};
|
|
/**
|
* 判断点是否命中多边形内部。
|
*/
|
export const isPointInPolygon = (
|
point: { latitude: number; longitude: number },
|
polygonPoints: Array<{ latitude: number; longitude: number }>
|
) => {
|
if (!Array.isArray(polygonPoints) || polygonPoints.length < 3) {
|
return false;
|
}
|
|
let isInside = false;
|
const pointX = point.longitude;
|
const pointY = point.latitude;
|
|
for (let index = 0, lastIndex = polygonPoints.length - 1; index < polygonPoints.length; lastIndex = index, index += 1) {
|
const currentPoint = polygonPoints[index];
|
const previousPoint = polygonPoints[lastIndex];
|
const currentX = currentPoint.longitude;
|
const currentY = currentPoint.latitude;
|
const previousX = previousPoint.longitude;
|
const previousY = previousPoint.latitude;
|
|
const isIntersected = ((currentY > pointY) !== (previousY > pointY))
|
&& (pointX < ((previousX - currentX) * (pointY - currentY)) / ((previousY - currentY) || Number.EPSILON) + currentX);
|
|
if (isIntersected) {
|
isInside = !isInside;
|
}
|
}
|
|
return isInside;
|
};
|
|
/**
|
* 查找当前点命中的围栏;未命中时可退化为最近围栏边界。
|
*/
|
export const findMatchedPolygon = (
|
point: { latitude: number; longitude: number },
|
polygonList: any[] = [],
|
maxDistance = 180
|
) => {
|
if (!Array.isArray(polygonList) || polygonList.length === 0) {
|
return null;
|
}
|
|
const insidePolygon = polygonList.find((item) => {
|
const polygonPoints = normalizePolygonPoints(item?.polygonPoints || item?.points || []);
|
return isPointInPolygon(point, polygonPoints);
|
});
|
|
if (insidePolygon) {
|
return insidePolygon;
|
}
|
|
let nearestPolygon: any = null;
|
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
polygonList.forEach((item) => {
|
const polygonPoints = normalizePolygonPoints(item?.polygonPoints || item?.points || []);
|
if (polygonPoints.length < 2) {
|
return;
|
}
|
|
let minDistanceToBorder = Number.POSITIVE_INFINITY;
|
for (let index = 0; index < polygonPoints.length; index += 1) {
|
const start = polygonPoints[index];
|
const end = polygonPoints[(index + 1) % polygonPoints.length];
|
const distanceToSegment = getDistanceToSegment(point, start, end);
|
if (distanceToSegment < minDistanceToBorder) {
|
minDistanceToBorder = distanceToSegment;
|
}
|
}
|
|
const distanceToFirstPoint = getDistance(
|
point.latitude,
|
point.longitude,
|
polygonPoints[0].latitude,
|
polygonPoints[0].longitude
|
);
|
const matchDistance = Math.min(minDistanceToBorder, distanceToFirstPoint);
|
|
if (matchDistance < nearestDistance) {
|
nearestDistance = matchDistance;
|
nearestPolygon = item;
|
}
|
});
|
|
return nearestDistance <= maxDistance ? nearestPolygon : null;
|
};
|
|
/**
|
* 根据站点空间分布估算推荐缩放等级;如提供中心点,则优先保证从中心点往外至少可见 3 个点位。
|
*/
|
export const getAutoScaleBySites = (sites: any[], centerLat?: number, centerLng?: number) => {
|
if (!Array.isArray(sites) || sites.length === 0) return null;
|
const validSites = sites
|
.map((site) => ({
|
latitude: Number(site?.latitude),
|
longitude: Number(site?.longitude)
|
}))
|
.filter((site) => Number.isFinite(site.latitude) && Number.isFinite(site.longitude)); // 过滤无效坐标点位
|
if (validSites.length === 0) return null;
|
if (validSites.length === 1) return 16;
|
|
const resolveScaleByDiagonal = (diagonalMeters: number) => {
|
if (diagonalMeters > 12000) return 11;
|
if (diagonalMeters > 8000) return 12;
|
if (diagonalMeters > 5000) return 13;
|
if (diagonalMeters > 3000) return 14;
|
if (diagonalMeters > 1500) return 15;
|
if (diagonalMeters > 800) return 16;
|
return 17;
|
}; // 统一距离到缩放等级的映射规则
|
|
const hasValidCenter = Number.isFinite(Number(centerLat)) && Number.isFinite(Number(centerLng)); // 是否提供了有效中心点
|
|
/**
|
* 基于中心点计算至少可见 3 个点位所需缩放等级。
|
*/
|
const resolveScaleByCenterVisibleSites = () => {
|
if (!hasValidCenter) return null;
|
const visibleSiteCount = Math.min(validSites.length, 3); // 最多按最近 3 个点位估算
|
if (visibleSiteCount <= 1) return 16;
|
const sortedDistances = validSites
|
.map((site) => getDistance(Number(centerLat), Number(centerLng), site.latitude, site.longitude))
|
.filter((distance) => Number.isFinite(distance))
|
.sort((a, b) => a - b);
|
const targetRadius = sortedDistances[visibleSiteCount - 1]; // 取第 N 个最近点的半径,保证中心点往外至少覆盖 N 个点
|
if (!Number.isFinite(targetRadius)) return null;
|
const safeDiagonal = Math.max(targetRadius * 2 * 1.35, 300); // 预留边缘与卡片遮挡余量,避免第 3 个点卡边
|
return resolveScaleByDiagonal(safeDiagonal);
|
};
|
|
const centerVisibleScale = resolveScaleByCenterVisibleSites(); // 中心点口径下的推荐缩放等级
|
if (typeof centerVisibleScale === 'number') {
|
return centerVisibleScale;
|
}
|
|
/**
|
* 计算稀疏场景下“至少可见 3 个点位”所需的最大安全缩放等级。
|
*/
|
const resolveSparseScaleForThreeVisibleSites = () => {
|
if (validSites.length < 3) return null;
|
const radiusNeedForThreeSites = validSites.reduce((maxRadius, baseSite) => {
|
const neighborDistances = validSites
|
.filter((site) => site !== baseSite)
|
.map((site) => getDistance(baseSite.latitude, baseSite.longitude, site.latitude, site.longitude))
|
.sort((a, b) => a - b);
|
const radius = neighborDistances[1]; // 取第 2 个邻居距离,保证当前点周围至少覆盖 3 个点
|
if (!Number.isFinite(radius)) return maxRadius;
|
return Math.max(maxRadius, radius);
|
}, 0); // 取所有基点中的最大值,保证任意基点都能满足 3 点可见
|
if (radiusNeedForThreeSites <= 0) return null;
|
return resolveScaleByDiagonal(radiusNeedForThreeSites * 2);
|
};
|
|
/**
|
* 计算高密度场景下“精细视角至少可见 3 个点”的推荐缩放等级。
|
*/
|
const resolveDenseScaleForThreeVisibleSites = () => {
|
if (validSites.length < 3) return null;
|
const denseAnchor = validSites.reduce((bestSite, currentSite) => {
|
if (!bestSite) return currentSite;
|
const currentNeighborDistances = validSites
|
.filter((site) => site !== currentSite)
|
.map((site) => getDistance(currentSite.latitude, currentSite.longitude, site.latitude, site.longitude))
|
.sort((a, b) => a - b);
|
const bestNeighborDistances = validSites
|
.filter((site) => site !== bestSite)
|
.map((site) => getDistance(bestSite.latitude, bestSite.longitude, site.latitude, site.longitude))
|
.sort((a, b) => a - b);
|
const currentRadius = currentNeighborDistances[1];
|
const bestRadius = bestNeighborDistances[1];
|
if (!Number.isFinite(bestRadius)) return currentSite;
|
if (!Number.isFinite(currentRadius)) return bestSite;
|
return currentRadius < bestRadius ? currentSite : bestSite;
|
}, null as any); // 选择“第 3 个点最近”的簇中心,避免被远端离散点拉远
|
if (!denseAnchor) return null;
|
const denseNeighborDistances = validSites
|
.filter((site) => site !== denseAnchor)
|
.map((site) => getDistance(denseAnchor.latitude, denseAnchor.longitude, site.latitude, site.longitude))
|
.sort((a, b) => a - b);
|
const denseRadius = denseNeighborDistances[1];
|
if (!Number.isFinite(denseRadius) || denseRadius <= 0) return null;
|
return resolveScaleByDiagonal(denseRadius * 2);
|
};
|
|
const sparseScaleForThreeVisibleSites = resolveSparseScaleForThreeVisibleSites(); // 稀疏场景至少可见 3 点的最大缩放等级
|
const denseScaleForThreeVisibleSites = resolveDenseScaleForThreeVisibleSites(); // 高密度场景局部精细视角的缩放等级
|
|
const nearbyCounts = validSites.map((targetSite, targetIndex) => {
|
return validSites.reduce((count, compareSite, compareIndex) => {
|
if (targetIndex === compareIndex) return count; // 密度统计不包含自身
|
const meters = getDistance(targetSite.latitude, targetSite.longitude, compareSite.latitude, compareSite.longitude);
|
return meters <= 1000 ? count + 1 : count;
|
}, 0);
|
}); // 每个点位在 1km 半径内的“邻居点”数量
|
const maxNearbyCount = Math.max(...nearbyCounts);
|
|
// 高密度区:1km 内点位 >= 2(按邻居数统计),固定精细视角。
|
if (maxNearbyCount >= 2) {
|
if (typeof denseScaleForThreeVisibleSites === 'number') {
|
return Math.min(17, denseScaleForThreeVisibleSites);
|
}
|
return 17;
|
}
|
|
// 低密度区:1km 内点位 < 2,自动缩小到至少可见 3 个点位。
|
if (maxNearbyCount < 2) {
|
if (typeof sparseScaleForThreeVisibleSites === 'number') {
|
return sparseScaleForThreeVisibleSites;
|
}
|
}
|
|
let minLat = Number.POSITIVE_INFINITY;
|
let maxLat = Number.NEGATIVE_INFINITY;
|
let minLng = Number.POSITIVE_INFINITY;
|
let maxLng = Number.NEGATIVE_INFINITY;
|
validSites.forEach((site) => {
|
if (site.latitude < minLat) minLat = site.latitude;
|
if (site.latitude > maxLat) maxLat = site.latitude;
|
if (site.longitude < minLng) minLng = site.longitude;
|
if (site.longitude > maxLng) maxLng = site.longitude;
|
});
|
const diagonalMeters = getDistance(minLat, minLng, maxLat, maxLng);
|
return resolveScaleByDiagonal(diagonalMeters);
|
};
|
|
/**
|
* 计算点 P 在线段 AB 上的投影点(吸附算法)
|
* @param p 真实点 {latitude, longitude}
|
* @param a 线段起点 {latitude, longitude}
|
* @param b 线段终点 {latitude, longitude}
|
* @returns 投影点 {latitude, longitude}
|
*/
|
export const getProjectedPoint = (p: any, a: any, b: any) => {
|
if (!p || !a || !b) return p;
|
if (a.latitude === b.latitude && a.longitude === b.longitude) return a;
|
|
const x0 = p.longitude, y0 = p.latitude;
|
const x1 = a.longitude, y1 = a.latitude;
|
const x2 = b.longitude, y2 = b.latitude;
|
|
// 向量 AB
|
const dx = x2 - x1;
|
const dy = y2 - y1;
|
// 向量 AP
|
const ex = x0 - x1;
|
const ey = y0 - y1;
|
|
// 计算投影比例 t
|
const dotProduct = ex * dx + ey * dy;
|
const lengthSquared = dx * dx + dy * dy;
|
const t = dotProduct / lengthSquared;
|
|
// 如果投影点落在线段外,则取最近的端点
|
if (t <= 0) return { latitude: a.latitude, longitude: a.longitude };
|
if (t >= 1) return { latitude: b.latitude, longitude: b.longitude };
|
|
// 返回投影点
|
return {
|
latitude: y1 + t * dy,
|
longitude: x1 + t * dx
|
};
|
};
|
|
// 判断是否需要更新路线(距离大于100米或时间超过10秒)
|
export const shouldUpdateRoute = (lastLat: number, lastLng: number, newLat: number, newLng: number, lastUpdateTime: number): boolean => {
|
const distance = getDistance(lastLat, lastLng, newLat, newLng);
|
const now = Date.now();
|
const timeElapsed = now - lastUpdateTime;
|
return distance > 100 || timeElapsed >= 10000;
|
};
|
|
// 格式化距离
|
export const formatDistance = (val: any) => {
|
if (!val) return '0m';
|
const d = Number(val);
|
if (Number.isNaN(d)) return val;
|
if (d >= 1000) {
|
return (d / 1000).toFixed(1) + 'km';
|
}
|
return d.toFixed(0) + 'm';
|
}
|
|
export const computeAdaptiveBottomPadding = (
|
points: any[],
|
basePadding: number,
|
minPadding = 200,
|
minAspect = 0.8,
|
minRatio = 0.7
|
) => {
|
if (!Array.isArray(points) || points.length < 2) {
|
return basePadding;
|
}
|
let minLat = Number.POSITIVE_INFINITY;
|
let maxLat = Number.NEGATIVE_INFINITY;
|
let minLng = Number.POSITIVE_INFINITY;
|
let maxLng = Number.NEGATIVE_INFINITY;
|
for (const p of points) {
|
const lat = Number(p?.latitude);
|
const lng = Number(p?.longitude);
|
if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
|
continue;
|
}
|
if (lat < minLat) minLat = lat;
|
if (lat > maxLat) maxLat = lat;
|
if (lng < minLng) minLng = lng;
|
if (lng > maxLng) maxLng = lng;
|
}
|
if (!Number.isFinite(minLat) || !Number.isFinite(maxLat) || !Number.isFinite(minLng) || !Number.isFinite(maxLng)) {
|
return basePadding;
|
}
|
const latRange = maxLat - minLat;
|
const lngRange = maxLng - minLng;
|
if (latRange <= 0 || lngRange <= 0) {
|
return basePadding;
|
}
|
const avgLat = (minLat + maxLat) / 2;
|
const adjustedLngRange = lngRange * Math.cos((avgLat * Math.PI) / 180);
|
if (!Number.isFinite(adjustedLngRange) || adjustedLngRange <= 0) {
|
return basePadding;
|
}
|
const aspect = latRange / adjustedLngRange;
|
if (aspect >= minAspect) {
|
return basePadding;
|
}
|
const ratio = Math.max(minRatio, Math.min(1, minRatio + (aspect / minAspect) * (1 - minRatio)));
|
return Math.max(minPadding, Math.round(basePadding * ratio));
|
};
|
|
// 查找最近的标记点
|
export const findNearestMarker = (lat: number, lng: number, markers: any[]) => {
|
let minDistance = Infinity;
|
let targetMarker: any = null;
|
|
markers.forEach(marker => {
|
const distance = getDistance(lat, lng, marker.latitude, marker.longitude);
|
if (distance < minDistance) {
|
minDistance = distance;
|
targetMarker = marker;
|
}
|
});
|
|
return { targetMarker, minDistance };
|
};
|
|
// 解析路线数据为坐标点数组
|
export const parseRouteToPoints = (routeData: any) => {
|
const points: any[] = [];
|
if (routeData.paths && routeData.paths[0] && routeData.paths[0].steps) {
|
const steps = routeData.paths[0].steps;
|
for (let i = 0; i < steps.length; i++) {
|
const poLen = steps[i].polyline.split(';');
|
for (let j = 0; j < poLen.length; j++) {
|
points.push({
|
longitude: parseFloat(poLen[j].split(',')[0]),
|
latitude: parseFloat(poLen[j].split(',')[1])
|
})
|
}
|
}
|
}
|
return points;
|
};
|
|
// 格式化站点为标记点
|
export const formatSiteToMarker = (sites: any[]) => {
|
return sites.map((site, index) => ({
|
...site,
|
name: site.name,
|
// id: index + 1,
|
iconPath: 'https://s.xiaopeng.com/xp-fe/robot-taxi/imgs/site-poi.png',
|
width: 15,
|
height: 15,
|
anchor: { x: 0.5, y: 0.5 }
|
}));
|
};
|
|
// 获取位置的逆地理编码
|
export const getAddressByLocation = (longitude: number, latitude: number): Promise<any> => {
|
return new Promise((resolve, reject) => {
|
const myAmapFun = new amapFile.AMapWX({ key: _AMAP_KEY });
|
myAmapFun.getRegeo({
|
location: `${longitude},${latitude}`,
|
success: (data) => {
|
if (data && data[0]) {
|
resolve(data[0]);
|
} else {
|
reject('No data found');
|
}
|
},
|
fail: (info) => {
|
reject(info);
|
}
|
});
|
});
|
};
|
|
// 获取天气信息
|
export const getWeatherInfo = (city?: string): Promise<any> => {
|
return new Promise((resolve, reject) => {
|
const myAmapFun = new amapFile.AMapWX({ key: _AMAP_KEY });
|
const options: any = {
|
success: (data) => resolve(data),
|
fail: (info) => reject(info)
|
};
|
if (city) {
|
options.city = city;
|
}
|
myAmapFun.getWeather(options);
|
});
|
};
|
|
/**
|
* 过滤可渲染的围栏数据,首页需要同时渲染运营区与场站围栏。
|
*/
|
export const filterRenderableGeofence = (geofenceList: any[]) => {
|
if (!Array.isArray(geofenceList)) return [];
|
return geofenceList.filter((item) => [GEOFENCE_TYPE_OPERATING, GEOFENCE_TYPE_STATION].includes(Number(item?.type)));
|
};
|
|
/**
|
* 格式化电子围栏面覆盖物,场站围栏使用独立配色以便和运营区区分。
|
*/
|
export const formatGeofencePolygons = (geofenceList: any[]) => {
|
if (!geofenceList || !Array.isArray(geofenceList)) {
|
return [];
|
}
|
return geofenceList.map(item => {
|
const type = Number(item?.type); // 围栏类型
|
return {
|
points: item.points.map(p => ({
|
latitude: p.lat,
|
longitude: p.lng
|
})),
|
strokeWidth: type === GEOFENCE_TYPE_STATION ? 1 : 2,
|
strokeColor: type === GEOFENCE_TYPE_STATION ? GEOFENCE_STATION_STROKE_COLOR : '#A1B1FF',
|
fillColor: type === GEOFENCE_TYPE_STATION ? GEOFENCE_STATION_FILL_COLOR : '#6d7fd933',
|
zIndex: 1
|
}
|
});
|
};
|
|
/**
|
* 格式化场站围栏虚线边框,和选点页保持同一套表现。
|
*/
|
export const formatGeofenceFencePolylines = (geofenceList: any[]) => {
|
if (!Array.isArray(geofenceList)) {
|
return [];
|
}
|
return geofenceList.reduce((acc: any[], item: any, index: number) => {
|
const type = Number(item?.type); // 围栏类型
|
if (type !== GEOFENCE_TYPE_STATION || !Array.isArray(item?.points) || item.points.length < 2) {
|
return acc;
|
}
|
const linePoints = item.points.map((p: any) => ({
|
latitude: p.lat,
|
longitude: p.lng
|
}));
|
acc.push({
|
id: 900000 + index,
|
points: [...linePoints, linePoints[0]],
|
color: GEOFENCE_STATION_STROKE_COLOR,
|
width: 2,
|
dottedLine: true,
|
arrowLine: false
|
});
|
return acc;
|
}, []);
|
};
|
|
/**
|
* 按统一规则格式化围栏覆盖物,供首页和选点页复用同一套渲染结果。
|
*/
|
export const formatGeofenceByType = (geofenceList: any[]) => {
|
return {
|
polygons: formatGeofencePolygons(geofenceList),
|
fencePolylines: formatGeofenceFencePolylines(geofenceList)
|
};
|
};
|
|
/**
|
* 从围栏数据提取场站元信息映射,避免首页与选点页重复维护场站元数据格式。
|
*/
|
export const buildStationMetaMap = (geofenceList: any[]) => {
|
const stationMetaMap: Record<string, any> = {};
|
if (!Array.isArray(geofenceList)) {
|
return stationMetaMap;
|
}
|
geofenceList.forEach((item) => {
|
const stationId = item?.stationId; // 围栏关联的场站 ID
|
if (!stationId) {
|
return;
|
}
|
stationMetaMap[String(stationId)] = {
|
stationId: String(stationId),
|
name: item?.name || '',
|
isCollect: !!item?.stationIsCollect,
|
};
|
});
|
return stationMetaMap;
|
};
|
|
// 提取多边形所有顶点
|
export const extractPointsFromPolygons = (polygons: any[]) => {
|
const points: any[] = [];
|
if (!polygons || !Array.isArray(polygons)) return points;
|
|
polygons.forEach(polygon => {
|
if (polygon.points && Array.isArray(polygon.points)) {
|
points.push(...polygon.points);
|
}
|
});
|
return points;
|
};
|
|
// 转换 adcode 为 cityCode
|
export const convertAdcodeToCityCode = (adcode: string, cityName: string, province: string) => {
|
let cityCode = adcode;
|
if (adcode && typeof adcode === 'string' && adcode.length === 6) {
|
if (cityName === province) {
|
// 直辖市:前2位 + 0000
|
cityCode = adcode.substring(0, 2) + '0000';
|
} else {
|
// 普通城市:前4位 + 00
|
cityCode = adcode.substring(0, 4) + '00';
|
}
|
}
|
return cityCode;
|
};
|
|
// 处理 Banner 点击跳转
|
export const handleBannerJump = (item: any) => {
|
if (!item) return;
|
const { jumpType, jumpUrl } = item;
|
// jumpType: 1小程序内页面 2小程序地址 3网页地址
|
if (jumpType === 1) {
|
if (jumpUrl) {
|
Taro.navigateTo({
|
url: jumpUrl
|
});
|
}
|
} else if (jumpType === 2) {
|
if (jumpUrl) {
|
Taro.navigateToMiniProgram({
|
appId: jumpUrl,
|
// path: '',
|
success(res) {
|
console.log('打开小程序成功', res);
|
},
|
fail(err) {
|
console.error('打开小程序失败', err);
|
Taro.showToast({ title: '跳转失败', icon: 'none' });
|
}
|
});
|
}
|
} else if (jumpType === 3) {
|
if (jumpUrl) {
|
Taro.navigateTo({
|
url: `/pages/webpage/index?pageUrl=${encodeURIComponent(jumpUrl)}`
|
});
|
}
|
}
|
};
|
|
// 防抖函数
|
export const debounce = (fn: Function, wait: number) => {
|
let timer: any = null;
|
return function (this: any, ...args: any[]) {
|
if (timer) clearTimeout(timer);
|
timer = setTimeout(() => {
|
fn.apply(this, args);
|
}, wait);
|
}
|
}
|
|
// 节流函数
|
export const throttle = (fn: Function, wait: number) => {
|
let _lastTime: any = null;
|
return function (this: any, ...args: any[]) {
|
let _nowTime = +new Date();
|
if (!_lastTime || _nowTime - _lastTime > wait) {
|
fn.apply(this, args);
|
_lastTime = _nowTime;
|
}
|
}
|
}
|
|
// 预下载图片,返回本地临时路径(WeApp),失败或非WeApp环境返回原始URL
|
export const preloadImageToTemp = async (url: string): Promise<string> => {
|
try {
|
if (typeof Taro.getEnv !== 'function' || Taro.getEnv() !== Taro.ENV_TYPE.WEAPP) {
|
return url;
|
}
|
const res = await Taro.downloadFile({ url });
|
if (res?.statusCode === 200 && res.tempFilePath) {
|
return res.tempFilePath;
|
}
|
const info = await Taro.getImageInfo({ src: url });
|
if (info?.path) {
|
return info.path;
|
}
|
} catch (e) {
|
}
|
return url;
|
}
|
|
// 获取地图数据(围栏检查+附近站点)
|
export const fetchMapData = async (lat: number, lng: number, cityCode?: string, pointOnOff?: number) => {
|
try {
|
// 1. 先检查是否在运营区域内
|
const fenceRes: any = await getFenceCheck({ latitude: lat, longitude: lng, cityCode }).catch(e => {
|
return { status: 'inside' }; // 默认兜底
|
});
|
|
const isInside = fenceRes.status === 'inside';
|
const electronicAreaId = fenceRes?.electronicAreaId || '';
|
|
// 2. 只有在运营区域内才获取附近站点 type 传1 地图查询附近站点 不传 查搜索页附近站点
|
let sites: any = { points: [] };
|
if (isInside) {
|
sites = await getNearestSite({ latitude: lat, longitude: lng, electronicAreaId, pointOnOff, type:1 }).catch(e => {
|
return { points: [] };
|
});
|
// 兼容处理
|
if (!sites || !sites.points) {
|
sites = { points: [] };
|
}
|
}
|
|
const newMarkers = formatSiteToMarker(sites.points);
|
|
return { isInside, electronicAreaId, newMarkers, sites: sites.points };
|
} catch (error) {
|
console.error('fetchMapData error', error);
|
return { isInside: true, electronicAreaId: '', newMarkers: [], sites: [] };
|
}
|
}
|
|
// 获取步行规划路线
|
export const getWalkingRoute = (origin: string, destination: string): Promise<{ points: any[], duration: number, distance: number }> => {
|
return new Promise((resolve, reject) => {
|
const myAmapFun = new amapFile.AMapWX({ key: _AMAP_KEY });
|
myAmapFun.getWalkingRoute({
|
origin: origin,
|
destination: destination,
|
success: (data) => {
|
const points = parseRouteToPoints(data);
|
let duration = 0;
|
let distance = 0;
|
if (data.paths && data.paths[0]) {
|
duration = Number(data.paths[0].duration);
|
distance = Number(data.paths[0].distance);
|
}
|
resolve({ points, duration, distance });
|
},
|
fail: (info) => {
|
reject(info);
|
}
|
});
|
});
|
};
|
|
// 获取城市中心经纬度
|
export const getCityLocation = (cityName: string): Promise<{ latitude: number, longitude: number }> => {
|
return new Promise((resolve, reject) => {
|
const myAmapFun = new amapFile.AMapWX({ key: _AMAP_KEY });
|
myAmapFun.getInputtips({
|
keywords: cityName,
|
city: cityName,
|
success: (data) => {
|
if (data && data.tips && data.tips.length > 0) {
|
// 优先取第一个有坐标的结果
|
const tip = data.tips.find(t => t.location && t.location.length > 0);
|
if (tip) {
|
const locationArr = tip.location.split(',');
|
if (locationArr.length === 2) {
|
resolve({
|
longitude: Number(locationArr[0]),
|
latitude: Number(locationArr[1])
|
});
|
return;
|
}
|
}
|
reject('No valid location found');
|
} else {
|
reject('No data found');
|
}
|
},
|
fail: (info) => {
|
reject(info);
|
}
|
});
|
});
|
};
|
|
// 获取包含 ID 的城市信息
|
export const getCityInfoWithId = async (cityCode: string, cityName: string) => {
|
|
try {
|
const res: any = await getOpenCity();
|
|
let cityId = 0;
|
if (Array.isArray(res.cities)) {
|
// 优先匹配 adcode
|
let target = res.cities.find(item => item.adcode === cityCode);
|
// 如果 adcode 匹配失败,尝试匹配 cityName
|
if (!target && cityName) {
|
// 移除"市"字进行模糊匹配,增加匹配成功率
|
const normalizedCityName = cityName.replace(/市$/, '');
|
target = res.cities.find(item => {
|
const itemName = item.name.replace(/市$/, '');
|
return itemName === normalizedCityName || item.name.includes(normalizedCityName) || normalizedCityName.includes(item.name);
|
});
|
}
|
|
if (target) {
|
cityId = target.id;
|
// 修正返回的 code,确保使用的是系统匹配到的城市的标准 adcode
|
if (target.adcode) {
|
cityCode = target.adcode;
|
}
|
}
|
}
|
return {
|
name: cityName,
|
code: cityCode,
|
id: cityId
|
};
|
} catch (error) {
|
console.error('getOpenCity error', error);
|
return {
|
name: cityName,
|
code: cityCode,
|
id: 0
|
};
|
}
|
};
|
|
// 格式化秒数为时钟时间(分钟:秒)
|
export const formatDurationToClock = (sec: number) => {
|
const safeSeconds = Math.max(0, sec);
|
const m = Math.floor(safeSeconds / 60);
|
const s = safeSeconds % 60;
|
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
};
|
|
// 格式化日期时间
|
export const formatDateTime = (date: Date) => {
|
const y = date.getFullYear();
|
const mo = String(date.getMonth() + 1).padStart(2, '0');
|
const d = String(date.getDate()).padStart(2, '0');
|
const h = String(date.getHours()).padStart(2, '0');
|
const mi = String(date.getMinutes()).padStart(2, '0');
|
const s = String(date.getSeconds()).padStart(2, '0');
|
return `${y}-${mo}-${d} ${h}:${mi}:${s}`;
|
};
|
|
export const formatMoney = (value: any) => {
|
const num = Number(value);
|
if (Number.isNaN(num)) {
|
return '--';
|
}
|
return num.toFixed(2);
|
};
|
|
export const formatNumber = (value: any, digits = 1) => {
|
const num = Number(value);
|
if (Number.isNaN(num)) {
|
return '--';
|
}
|
return digits === 0 ? Math.round(num).toString() : num.toFixed(digits);
|
};
|
|
export const formatTripDistance = (value: any) => {
|
const num = Number(value);
|
if (!Number.isFinite(num)) {
|
return '--';
|
}
|
const safe = Math.max(0, num);
|
if (safe < 1000) {
|
return `${Math.round(safe)}米`;
|
}
|
return `${(safe / 1000).toFixed(1)}公里`;
|
};
|
|
export const formatTripDurationFromSeconds = (value: any) => {
|
const num = Number(value);
|
if (!Number.isFinite(num)) {
|
return '--';
|
}
|
const safe = Math.max(0, Math.round(num));
|
if (safe < 60) {
|
return `${safe}秒`;
|
}
|
if (safe < 3600) {
|
return `${Math.floor(safe / 60)}分钟`;
|
}
|
const hours = Math.floor(safe / 3600);
|
const minutes = Math.floor((safe % 3600) / 60);
|
if (minutes === 0) {
|
return `${hours}小时`;
|
}
|
return `${hours}小时${minutes}分钟`;
|
};
|
|
export const formatDurationDisplay = (value: any) => {
|
const num = Number(value);
|
if (Number.isNaN(num)) {
|
return { value: 0, unit: '分钟' };
|
}
|
if (num < 60) {
|
return { value: Math.max(0, Math.round(num)), unit: '秒' };
|
}
|
return { value: Math.ceil(num / 60), unit: '分钟' };
|
};
|
|
export const getTypeLabel = (type: any) => {
|
if (type === 2) return '夜间行驶时段';
|
if (type === 3) return '节假日时段';
|
return '普通时段';
|
};
|
|
export const formatCountdown = (seconds: number) => {
|
const safeSeconds = Math.max(0, seconds);
|
const minutes = Math.floor(safeSeconds / 60);
|
const restSeconds = safeSeconds % 60;
|
const mm = String(minutes).padStart(2, '0');
|
const ss = String(restSeconds).padStart(2, '0');
|
return `${mm}:${ss}`;
|
};
|
|
export const getRemainingSeconds = (endTimeMs: number | null) => {
|
if (!endTimeMs) {
|
return null;
|
}
|
const diff = endTimeMs - Date.now();
|
if (diff <= 0) {
|
return 0;
|
}
|
return Math.ceil(diff / 1000);
|
};
|
|
export const parseFlexibleEndTimeMs = (value: any) => {
|
const num = Number(value);
|
if (Number.isNaN(num) || num <= 0) {
|
return null;
|
}
|
if (num > 1000000000000) {
|
return num;
|
}
|
if (num > 1000000000) {
|
return num * 1000;
|
}
|
if (num <= 360) {
|
return Date.now() + num * 60 * 1000;
|
}
|
return Date.now() + num * 1000;
|
};
|
|
// 清除订单行程存储
|
export const clearOrderTripStorage = () => {
|
Taro.removeStorageSync('orderId');
|
Taro.removeStorageSync('tripStatus');
|
};
|
|
/**
|
* 通过 IP 获取当前位置信息(用于定位授权失败时的兜底)
|
*/
|
export const getLocationByIP = (): Promise<any> => {
|
return new Promise(async (resolve, reject) => {
|
try {
|
// 1. 获取当前 network IP
|
const res: any = await request({
|
url: _ROBOTAXI_HOST + '/map/base/v1/mini-program/point/getPointByIp',
|
method: 'GET',
|
});
|
|
if (res && res.point && typeof res.point.latitude === 'number' && typeof res.point.longitude === 'number') {
|
resolve({
|
longitude: res.point.longitude,
|
latitude: res.point.latitude,
|
city: res.city || '',
|
adcode: res.adcode || ''
|
});
|
} else {
|
// 如果后端接口不可用或未返回数据,尝试使用第三方接口作为备选获取 IP
|
Taro.request({
|
url: 'https://api.ipify.org?format=json',
|
success: (ipRes) => {
|
const ip = ipRes.data?.ip;
|
if (ip) {
|
// 拿着获取到的 IP 去请求服务端接口
|
request({
|
url: _ROBOTAXI_HOST + '/map/base/v1/mini-program/point/getPointByIp',
|
method: 'POST',
|
payload: { ip }
|
}).then((businessRes: any) => {
|
if (businessRes && businessRes.point && typeof businessRes.point.latitude === 'number' && typeof businessRes.point.longitude === 'number') {
|
resolve({
|
longitude: businessRes.point.longitude,
|
latitude: businessRes.point.latitude,
|
city: businessRes.city || '',
|
adcode: businessRes.adcode || ''
|
});
|
} else {
|
reject('Business IP location failed');
|
}
|
}).catch(reject);
|
} else {
|
reject('Failed to get network IP');
|
}
|
},
|
fail: reject
|
});
|
}
|
} catch (error) {
|
reject(error);
|
}
|
});
|
};
|
|
export const mapOrderStatusToTripStatus = (status: any) => {
|
switch (Number(status)) {
|
case 0:
|
return 1;
|
case 1:
|
return 3;
|
case 2:
|
case 3:
|
return 4;
|
case 4:
|
return 5;
|
case 5:
|
case 6:
|
return 6;
|
case 7:
|
return 6;
|
case 8:
|
return 8;
|
case 9:
|
case 10:
|
return 9;
|
default:
|
return 0;
|
}
|
}
|
|
/** 埋点操作类型。 */
|
type ReportEventType = 'pageView' | 'track';
|
|
/** 默认埋点操作类型。 */
|
const DEFAULT_REPORT_EVENT_TYPE: ReportEventType = 'track';
|
|
/** 页面路径兜底值。 */
|
const DEFAULT_PAGE_PATH = '/';
|
|
/** 来源页路径兜底值。 */
|
const DEFAULT_REFERRER_PATH = '';
|
|
/** Unix 秒时间戳的毫秒阈值。 */
|
const UNIX_SECONDS_MILLISECOND_THRESHOLD = 1000000000000;
|
|
/** 毫秒转秒的换算系数。 */
|
const MILLISECONDS_PER_SECOND = 1000;
|
|
/** 标准化埋点页面路径。 */
|
const normalizeReportPagePath = (route: any, fallbackPath: string) => {
|
const normalizedRoute = String(route || '').trim();
|
if (!normalizedRoute) {
|
return fallbackPath;
|
}
|
return normalizedRoute.startsWith('/') ? normalizedRoute : `/${normalizedRoute}`;
|
};
|
|
/** 读取当前页与来源页路径。 */
|
const getReportPageContext = () => {
|
const pages = Taro.getCurrentPages();
|
const currentPage = pages[pages.length - 1];
|
const referrerPage = pages[pages.length - 2];
|
return {
|
pagePath: normalizeReportPagePath(currentPage?.route, DEFAULT_PAGE_PATH),
|
referrerPath: normalizeReportPagePath(referrerPage?.route, DEFAULT_REFERRER_PATH)
|
};
|
};
|
|
/** 将任意时间字段安全转换为 Unix 秒时间戳。 */
|
export const toUnixSeconds = (value: any) => {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
return value > UNIX_SECONDS_MILLISECOND_THRESHOLD
|
? Math.floor(value / MILLISECONDS_PER_SECOND)
|
: Math.floor(value);
|
}
|
if (typeof value === 'string') {
|
const normalizedValue = value.trim();
|
const numericValue = Number(normalizedValue);
|
if (normalizedValue && Number.isFinite(numericValue)) {
|
return numericValue > UNIX_SECONDS_MILLISECOND_THRESHOLD
|
? Math.floor(numericValue / MILLISECONDS_PER_SECOND)
|
: Math.floor(numericValue);
|
}
|
const parsedTime = Date.parse(value);
|
if (!Number.isNaN(parsedTime)) {
|
return Math.floor(parsedTime / MILLISECONDS_PER_SECOND);
|
}
|
}
|
return 0;
|
};
|
|
/** 判断埋点字段是否为需要自动 JSON 化的结构化数据。 */
|
const isStructuredReportValue = (value: any) => {
|
if (Array.isArray(value)) {
|
return true;
|
}
|
return Object.prototype.toString.call(value) === '[object Object]';
|
};
|
|
/** nearby_pickup_stations 埋点允许保留的字段。 */
|
const NEARBY_PICKUP_STATION_REPORT_KEYS = ['id', 'name', 'latitude', 'longitude', 'electronicAreaId'] as const;
|
|
/** nearby_pickup_stations 埋点最多保留的站点数量。 */
|
const MAX_NEARBY_PICKUP_STATIONS_REPORT_COUNT = 5;
|
|
/** 过滤 nearby_pickup_stations,仅保留约定字段。 */
|
const filterNearbyPickupStationsForReport = (value: any) => {
|
if (!Array.isArray(value)) {
|
return value;
|
}
|
return value.slice(0, MAX_NEARBY_PICKUP_STATIONS_REPORT_COUNT).map((item) => {
|
if (Object.prototype.toString.call(item) !== '[object Object]') {
|
return item;
|
}
|
return NEARBY_PICKUP_STATION_REPORT_KEYS.reduce((result: Record<string, any>, key) => {
|
result[key] = item?.[key];
|
return result;
|
}, {});
|
});
|
};
|
|
/** 预处理埋点字段:按字段规则裁剪后再进入序列化流程。 */
|
const preprocessReportValue = (key: string, value: any) => {
|
if (key === 'nearby_pickup_stations') {
|
return filterNearbyPickupStationsForReport(value);
|
}
|
return value;
|
};
|
|
/** 序列化单个埋点字段,避免对象被底层隐式转成 [object Object]。 */
|
const serializeReportValue = (value: any) => {
|
if (!isStructuredReportValue(value)) {
|
return value;
|
}
|
try {
|
return JSON.stringify(value);
|
} catch (error) {
|
return Array.isArray(value) ? '[]' : '{}';
|
}
|
};
|
|
/** 归一化自定义埋点参数:仅自动 JSON 化顶层对象/数组字段。 */
|
const normalizeReportParams = (para: any) => {
|
if (!para || Object.prototype.toString.call(para) !== '[object Object]') {
|
return {};
|
}
|
return Object.keys(para).reduce((result: Record<string, any>, key: string) => {
|
result[key] = serializeReportValue(preprocessReportValue(key, para[key]));
|
return result;
|
}, {});
|
};
|
|
// 埋点
|
export const reportEvent = async (
|
eventId: string,
|
para: any,
|
type: ReportEventType = DEFAULT_REPORT_EVENT_TYPE
|
) => {
|
let appVersion = '';
|
const $app: any = Taro.getApp().$app;
|
const { openId } = await $app.getIdData() || {};
|
const { pagePath, referrerPath } = getReportPageContext();
|
const normalizedPara = normalizeReportParams(para);
|
try {
|
const accountInfo = Taro.getAccountInfoSync();
|
appVersion = accountInfo?.miniProgram?.version || accountInfo?.miniProgram?.envVersion || '';
|
} catch (e) {
|
console.warn('获取小程序版本号失败:', e);
|
}
|
|
getStorage(_UID).then((uid) => {
|
$app.xpReportSend({
|
eventId,
|
para: {
|
...normalizedPara, // 自定义参数
|
page_path: pagePath, // 页面路径
|
referrer_path: referrerPath, // 来源页路径
|
type, // 埋点操作类型
|
user_id: uid || '-1', // 用户ID
|
masked_device_id: openId, // 设备ID
|
device_id: openId, // 设备ID
|
event_time: Date.now(), // 事件时间
|
city: Taro.getStorageSync('selectedCity').name || '', // 城市
|
orderId: Taro.getStorageSync('orderId') || '', // 订单ID
|
os: Taro.getSystemInfoSync().system || '', // 操作系统
|
app_id: APPID, // 微信小程序appid
|
app_ver: appVersion, // 微信小程序版本
|
session_id: '', // 会话ID
|
},
|
});
|
});
|
}
|