8816dd2867
根因:定位链路用区县「中文名」查 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 个重名区县全部可正常
定位(后端修复尚未部署即已生效)。
315 lines
11 KiB
JavaScript
315 lines
11 KiB
JavaScript
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`
|
||
}
|