283e6651aa
Co-authored-by: Cursor <cursoragent@cursor.com>
249 lines
6.5 KiB
TypeScript
249 lines
6.5 KiB
TypeScript
import Taro from '@tarojs/taro';
|
|
import { request } from './api';
|
|
import { DEFAULT_REGION, REGION_ALL, regionFromGeo, type RegionSelection } from './region-data';
|
|
import { FALLBACK_CITY_CODE } from './product-images';
|
|
|
|
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
|
|
const USER_COORDS_KEY = 'dukang_user_coords';
|
|
const LOCATION_DENIED_KEY = 'dukang_location_denied';
|
|
|
|
export type ResolvedUserCity = {
|
|
province: string;
|
|
city: string;
|
|
district: string;
|
|
cityCode?: string;
|
|
cityName?: string;
|
|
openCity: boolean;
|
|
region: RegionSelection;
|
|
displayCity: string;
|
|
};
|
|
|
|
export type UserCoords = { latitude: number; longitude: number };
|
|
|
|
type GpsCityCache = ResolvedUserCity & { timestamp: number };
|
|
|
|
const FALLBACK_CITY: ResolvedUserCity = {
|
|
province: DEFAULT_REGION.province,
|
|
city: DEFAULT_REGION.city,
|
|
district: REGION_ALL,
|
|
cityCode: FALLBACK_CITY_CODE,
|
|
cityName: '郑州市',
|
|
openCity: true,
|
|
region: DEFAULT_REGION,
|
|
displayCity: '郑州市',
|
|
};
|
|
|
|
function isLocationDenied(): boolean {
|
|
try {
|
|
return Taro.getStorageSync(LOCATION_DENIED_KEY) === '1';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function markLocationDenied() {
|
|
try {
|
|
Taro.setStorageSync(LOCATION_DENIED_KEY, '1');
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function clearLocationDenied() {
|
|
try {
|
|
Taro.removeStorageSync(LOCATION_DENIED_KEY);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
function wxErrorMessage(err: unknown): string {
|
|
if (!err) return '';
|
|
if (typeof err === 'string') return err;
|
|
if (err instanceof Error) return err.message;
|
|
if (typeof err === 'object') {
|
|
const o = err as { errMsg?: unknown; message?: unknown };
|
|
if (typeof o.errMsg === 'string' && o.errMsg) return o.errMsg;
|
|
if (typeof o.message === 'string' && o.message) return o.message;
|
|
try {
|
|
return JSON.stringify(err);
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
return String(err);
|
|
}
|
|
|
|
function isDenyMessage(errMsg?: string): boolean {
|
|
return /auth deny|authorize|permission|denied|拒绝|用户拒绝|getLocation:fail/i.test(
|
|
errMsg || '',
|
|
);
|
|
}
|
|
|
|
function readCache(): GpsCityCache | null {
|
|
try {
|
|
const raw = Taro.getStorageSync(GPS_CITY_STORAGE_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(String(raw)) as GpsCityCache;
|
|
if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function writeCache(data: ResolvedUserCity) {
|
|
try {
|
|
Taro.setStorageSync(
|
|
GPS_CITY_STORAGE_KEY,
|
|
JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache),
|
|
);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
export function writeUserCoords(latitude: number, longitude: number) {
|
|
try {
|
|
Taro.setStorageSync(
|
|
USER_COORDS_KEY,
|
|
JSON.stringify({ latitude, longitude, timestamp: Date.now() }),
|
|
);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
export function readCachedUserCoords(): UserCoords | null {
|
|
try {
|
|
const raw = Taro.getStorageSync(USER_COORDS_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(String(raw)) as UserCoords & { timestamp?: number };
|
|
if (parsed.timestamp && Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
|
|
if (!Number.isFinite(parsed.latitude) || !Number.isFinite(parsed.longitude)) return null;
|
|
return { latitude: parsed.latitude, longitude: parsed.longitude };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function toCityWideRegion(region: RegionSelection): RegionSelection {
|
|
return {
|
|
province: region.province,
|
|
city: region.city,
|
|
district: REGION_ALL,
|
|
};
|
|
}
|
|
|
|
function cacheFallbackAndMaybeDeny(denied: boolean) {
|
|
if (denied) markLocationDenied();
|
|
writeCache(FALLBACK_CITY);
|
|
}
|
|
|
|
async function reportLocationToServer(payload: {
|
|
latitude?: number;
|
|
longitude?: number;
|
|
sdk: 'jssdk' | 'geolocation';
|
|
status: 'success' | 'fail';
|
|
errMsg?: string;
|
|
}) {
|
|
return request<{
|
|
province?: string;
|
|
city?: string;
|
|
district?: string;
|
|
cityCode?: string;
|
|
cityName?: string;
|
|
openCity?: boolean;
|
|
}>('/common/wechat/location', {
|
|
method: 'POST',
|
|
data: payload,
|
|
});
|
|
}
|
|
|
|
function toResolved(data: {
|
|
province?: string;
|
|
city?: string;
|
|
district?: string;
|
|
cityCode?: string;
|
|
cityName?: string;
|
|
openCity?: boolean;
|
|
}): ResolvedUserCity | null {
|
|
if (!data.province || !data.city) return null;
|
|
const region = regionFromGeo(data.province, data.city, data.district);
|
|
const displayCity = data.cityName ?? (data.city.endsWith('市') ? data.city : `${data.city}市`);
|
|
return {
|
|
province: data.province,
|
|
city: data.city,
|
|
district: data.district ?? '',
|
|
cityCode: data.cityCode,
|
|
cityName: data.cityName,
|
|
openCity: !!data.openCity,
|
|
region,
|
|
displayCity,
|
|
};
|
|
}
|
|
|
|
async function promptLocationAuthOnce() {
|
|
await Taro.showModal({
|
|
title: '位置授权',
|
|
content: '需要获取您的位置以展示所在城市的商品与门店。拒绝后将默认使用郑州市,不会再次弹窗。',
|
|
confirmText: '知道了',
|
|
showCancel: false,
|
|
}).catch(() => {});
|
|
}
|
|
|
|
async function getMiniLocation(): Promise<Taro.getLocation.SuccessCallbackResult> {
|
|
const setting = await Taro.getSetting().catch(() => null);
|
|
if (setting?.authSetting?.['scope.userLocation'] === false) {
|
|
throw new Error('getLocation:fail auth deny');
|
|
}
|
|
// 只用返回的 Promise,避免 callback + Promise 双重 reject 变成未捕获
|
|
return Taro.getLocation({ type: 'gcj02' });
|
|
}
|
|
|
|
export async function resolveUserCity(force = false): Promise<ResolvedUserCity> {
|
|
if (!force) {
|
|
if (isLocationDenied()) {
|
|
const cached = readCache();
|
|
return cached ?? FALLBACK_CITY;
|
|
}
|
|
const cached = readCache();
|
|
if (cached) return cached;
|
|
}
|
|
|
|
try {
|
|
const loc = await getMiniLocation();
|
|
writeUserCoords(loc.latitude, loc.longitude);
|
|
const data = await reportLocationToServer({
|
|
latitude: loc.latitude,
|
|
longitude: loc.longitude,
|
|
sdk: 'jssdk',
|
|
status: 'success',
|
|
});
|
|
const resolved = toResolved(data);
|
|
if (resolved) {
|
|
clearLocationDenied();
|
|
writeCache(resolved);
|
|
return resolved;
|
|
}
|
|
} catch (err) {
|
|
const errMsg = wxErrorMessage(err);
|
|
const denied = isDenyMessage(errMsg);
|
|
if (denied && !isLocationDenied()) {
|
|
await promptLocationAuthOnce();
|
|
}
|
|
await reportLocationToServer({
|
|
sdk: 'jssdk',
|
|
status: 'fail',
|
|
errMsg: errMsg.slice(0, 200),
|
|
}).catch(() => {});
|
|
cacheFallbackAndMaybeDeny(denied);
|
|
}
|
|
|
|
return FALLBACK_CITY;
|
|
}
|
|
|
|
export function getCityCodeForCatalog(resolved: ResolvedUserCity): string {
|
|
return resolved.openCity && resolved.cityCode ? resolved.cityCode : FALLBACK_CITY_CODE;
|
|
}
|