Files
yxe-mp/util/index.js
T
weicw-dshsa 8816dd2867 fix(visit): 修复客户拜访页定位时好时坏
根因:定位链路用区县「中文名」查 sys_area,重名区县(白云区=广州/贵阳、
朝阳区=北京/长春、南山区=深圳/鹤岗…)命中多条,后端 getOne 默认
throwEx=true 抛 TooManyResultsException,页面表现为「有时能定位、有时不能」
——取决于用户当前所在区县名是否唯一。

改动:
- 新增 util/geo.js(纯函数):buildGeocodeUrl / normalizeAdcode /
  parseGeocodeResult / formatLocationText
  · 天地图 postStr 补 URL 编码:实测未编码时接口返回 HTTP 400
    「参数格式错误」(admin 端 merchantEdit.vue 已是正确写法)
  · 严格校验响应结构,限流(429)/参数错误(400) 一律返回 null
  · 补直辖市 city 为空时用省名兜底(天地图对上海/北京/天津/重庆返回 city:"")
- util/index.js:抽出 locateOnce / fetchGeocode / resolveAreaInfo 共用
  · 清除两处模块作用域的 this.areaId 误用(严格模式下抛 TypeError 会让
    Promise 永久悬空,locating 再也回不到 false,即「反复点没反应」)
  · 优先按天地图 county_code(去 156 前缀 = sys_area.id)精确查询,
    从根上绕开重名;按中文名仅作降级,并注释其歧义风险
  · 行政区划查不到不再阻断定位:返回 null,仍给出经纬度与地址
    (area_id/city_id/province_id 全仓库无消费方)
- api/request.js:get/del 不再吞掉非 2xx 的 reject,避免调用方状态永久悬空
- pages/visit/index.vue:fetchLocation 加 15s 超时兜底 + 运行号丢弃迟到结果,
  保证失败后仍可重试;静默失败也给出可见提示
- 新增 util/geo.test.mjs(10 个单测),package.json 补 test 脚本

验证:前端单测 10/10;真实天地图 + 生产后端端到端 5 个重名区县全部可正常
定位(后端修复尚未部署即已生效)。
2026-10-16 21:23:48 +08:00

315 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {getLocation, getUserInfo, setLocation} from "@/util/user";
import {API, MAIN_API} from "@/config";
import {get} from "@/api/request";
import {areaInfoByNameReq, areaInfoReq} from "@/api/common";
import {buildGeocodeUrl, formatLocationText, parseGeocodeResult} from "./geo";
import dayjs from "dayjs";
import md5 from "md5";
export const ORDER_STATUS_LIST = ['已取消', '进行中', '已完成', '申请退款中', '退款完成', '退款失败']
export const CHECK_STATUS_LIST = [
{
label: '审核中',
color: 'info'
},
{
label: '审核通过',
color: 'success'
},
{
label: '审核拒绝',
color: 'error'
},
{
label: '已到账',
color: 'success'
},
]
export const chooseImg = (limit = 1, sourceType = ['album', 'camera']) => {
return new Promise(resolveMain => {
uni.chooseImage({
count: limit,
sourceType,
extension: ['png', 'jpg', 'jpeg'],
sizeType: ['original'],
success: async ({tempFiles}) => {
const result = await Promise.all(tempFiles.map(async file => {
const uploadRes = await uploadFileToAliOss(file.path)
return {
path: uploadRes,
}
}))
resolveMain(result)
},
complete: () => {
uni.hideLoading()
}
})
})
}
let ossUploadSeed = 0
const createOssKey = (filePath) => {
ossUploadSeed += 1
const suffix = filePath.substring(filePath.lastIndexOf("."));
const hash = md5(`${filePath}_${Date.now()}_${ossUploadSeed}_${Math.random()}`)
return `${dayjs().format('YYYYMMDD')}/${hash}${suffix}`
}
export const uploadImg = filePath => {
const {token} = getUserInfo()
uni.showLoading({title: '上传中'})
return new Promise(async resolve => {
// uni.uploadFile({
// url: `${MAIN_API}/api/file/upload`,
// name: 'file',
// filePath,
// header: {
// Authorization: token,
// TenantId: 10325
// },
// formData: {
// type: 'header'
// },
// success: uploadRes => {
// uni.hideLoading()
// const {data: {url}} = JSON.parse(uploadRes.data)
// resolve({
// path: url,
// })
// }
// })
const uploadRes = await uploadFileToAliOss(filePath)
uni.hideLoading()
resolve({
path: uploadRes,
})
})
}
const getSts = () => get('/oss/getSTSToken')
export const uploadFileToAliOss = async (filePath) => {
const stsRes = await getSts()
const sts = stsRes.data
const key = createOssKey(filePath)
const url = 'https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com';
return new Promise(resolve => {
uni.uploadFile({
url,
name: 'file',
filePath,
formData: {
key,
OSSAccessKeyId: sts.accessKeyId,
signature: sts.signature,
security_token: sts.token,
policy: sts.policy,
},
success: () => {
resolve(`${url}/${key}`)
}
})
})
}
/**
* 调天地图逆地理编码,返回结构化结果(见 util/geo.js)。
* 网络失败 / 限流(429) / 参数错误(400) 一律 reject,不再静默吞掉。
*/
const fetchGeocode = async (longitude, latitude) => {
const body = await get(buildGeocodeUrl(longitude, latitude))
const geo = parseGeocodeResult(body)
if (!geo) throw new Error('天地图逆地理编码返回异常')
return geo
}
/**
* 把天地图结果映射到 sys_area,取 area_id / city_id / province_id。
*
* 关键修复:**优先按 adcode 精确查**(天地图 county_code 去 156 前缀即
* sys_area.id),从根上绕开区县重名问题。
*
* 降级说明:拿不到 adcode 时才按中文名查,此时若区县重名(如「南山区」
* 深圳/鹤岗都有),后端只能稳定返回 id 最小的那条,**可能不是用户实际
* 所在的那个区县**。因此这里的结果仅用于 area_id/city_id/province_id;
* 用于展示的省市区文本一律取天地图原始字段(geo.province/city/county),
* 不受该歧义影响。
*
* 无论哪种方式查不到都返回 null 而不是让整条定位失败——页面提交只需要
* 经纬度与地址文本,行政区划编码缺失不应阻断用户。
*/
const resolveAreaInfo = async geo => {
if (geo.adcode) {
const byCode = await areaInfoByNameReq({id: Number(geo.adcode)}).catch(() => null)
if (byCode && byCode.data) return byCode.data
}
if (geo.county) {
// 有歧义风险(见上),仅作为 adcode 缺失时的兜底
const byName = await areaInfoByNameReq({name: geo.county}).catch(() => null)
if (byName && byName.data) return byName.data
}
return null
}
/** 引导用户开启定位授权。用户此前已拒绝过时 authorize 会直接失败,这里兜底提示。 */
const showLocationSettingGuide = () => {
uni.showModal({
title: '提示',
content: '为了更好的提供服务,请开启定位服务',
success: ({confirm}) => {
if (confirm) uni.openSetting({})
}
})
}
/**
* 定位一次并返回结果:授权 → 取经纬度 → 逆地理编码 → 映射行政区划。
*
* 所有失败路径都显式 reject,保证调用方的 await 一定结算。
* (原实现在 catch 回调里用了模块作用域的 this.areaId,在严格模式下会抛
* TypeError 并让 Promise 永久悬空,调用方的 locating 标志再也回不到 false,
* 表现为「点定位没反应、反复点也没用」。)
*
* @param {boolean} save 是否写入本地缓存(首页等复用缓存时用)
*/
const locateOnce = ({save = false} = {}) => {
return new Promise((resolve, reject) => {
uni.authorize({
scope: 'scope.userLocation',
success: () => {
uni.getLocation({
isHighAccuracy: true,
success: async ({longitude, latitude}) => {
try {
const geo = await fetchGeocode(longitude, latitude)
const area = await resolveAreaInfo(geo)
const payload = {
longitude,
latitude,
area_id: area ? area.id : '',
city_id: area ? area.fid : '',
province_id: area && area.city ? area.city.fid : '',
province_str: geo.province,
city_str: geo.city,
area_str: geo.county,
full: formatLocationText(geo)
}
if (save) setLocation(payload)
resolve(payload)
} catch (error) {
reject(false)
}
},
fail: () => {
reject(false)
}
})
},
fail: () => {
showLocationSettingGuide()
reject(false)
}
})
})
}
export const getLocationAction = (save = true) => {
// 保持原有判据:缓存中已有 area_id 才复用。刻意不改判据,避免悄悄改变
// 首页/业绩/分享/我的这 4 个调用方的刷新行为(它们只消费 city_str,但
// 「有坐标就跳过重定位」会让跨城移动后的 currentCity 长期陈旧)。
const cached = getLocation()
if (cached.area_id) return Promise.resolve(cached)
return locateOnce({save})
}
export const chooseVideo = (sourceType = ['album', 'camera']) => {
return new Promise((resolveMain, reject) => {
uni.chooseVideo({
sourceType,
compressed: true,
maxDuration: 60,
success: async ({tempFilePath, duration, size}) => {
if (duration > 180) {
uni.showToast({
title: '暂时不支持3分钟以上的视频',
icon: 'none'
})
return reject(false)
}
// 50M限制
if (size > 50 * 1024 * 1024) {
uni.showToast({
title: '暂时不支持50M以上的视频',
icon: 'none'
})
return reject(false)
}
const {token} = getUserInfo()
uni.showLoading({title: '上传中...'})
// uni.uploadFile({
// url: `${MAIN_API}/api/file/upload`,
// name: 'file',
// filePath: tempFilePath,
// header: {
// Authorization: token,
// TenantId: 10325
// },
// success: uploadRes => {
// const {data} = JSON.parse(uploadRes.data)
// uni.hideLoading()
// resolveMain({
// path: data.url,
// })
// }
// })
const uploadRes = await uploadFileToAliOss(tempFilePath)
resolveMain({
path: uploadRes,
})
}
})
})
}
/**
* 强制实时定位(不读缓存),供拜访页等「每次都要当前位置」的场景使用。
*
* 原实现的问题:把天地图 URL 的 postStr 裸拼在查询串里(实测接口返回
* HTTP 400),且 catch 回调里误用 this.areaId 导致 Promise 可能永不结算。
* 现已统一走 locateOnce,保证一定 reject。
*/
export const getLocationOnly = () => locateOnce({save: false})
export const getExpressMode = () => parseInt(uni.getStorageSync("express_mode"))
export const calcDistanceMeters = (lat1, lng1, lat2, lng2) => {
const nLat1 = Number(lat1)
const nLng1 = Number(lng1)
const nLat2 = Number(lat2)
const nLng2 = Number(lng2)
if (![nLat1, nLng1, nLat2, nLng2].every(Number.isFinite)) return NaN
const rad = value => value * Math.PI / 180
const r = 6371000
const dLat = rad(nLat2 - nLat1)
const dLng = rad(nLng2 - nLng1)
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(rad(nLat1)) * Math.cos(rad(nLat2))
* Math.sin(dLng / 2) * Math.sin(dLng / 2)
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return r * c
}
export const formatDistance = meters => {
if (!Number.isFinite(meters)) return ''
if (meters < 1000) return `${Math.round(meters)}m`
const km = (meters / 1000).toFixed(1).replace(/\.0$/, '')
return `${km}km`
}