定位获取城市功能,需要微信地图的key?
This commit is contained in:
@@ -70,6 +70,31 @@ export const DEFAULT_REGION: RegionSelection = {
|
||||
district: '金水区',
|
||||
};
|
||||
|
||||
export const FALLBACK_CITY_REGION: RegionSelection = {
|
||||
province: '河南省',
|
||||
city: '郑州市',
|
||||
district: REGION_ALL,
|
||||
};
|
||||
|
||||
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
|
||||
const cityName = city.endsWith('市') ? city : `${city}市`;
|
||||
const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province;
|
||||
const cities = getCities(provinceInTree);
|
||||
const matchedCity = cities.includes(cityName)
|
||||
? cityName
|
||||
: cities.find((c) => c.replace(/市$/, '') === city.replace(/市$/, '')) ?? cityName;
|
||||
const districts = getDistricts(provinceInTree, matchedCity);
|
||||
const districtName =
|
||||
district && districts.includes(district)
|
||||
? district
|
||||
: REGION_ALL;
|
||||
return normalizeRegionSelection({
|
||||
province: provinceInTree,
|
||||
city: cities.includes(matchedCity) ? matchedCity : matchedCity,
|
||||
district: districtName,
|
||||
});
|
||||
}
|
||||
|
||||
/** 校验已选地区是否仍存在于数据源中 */
|
||||
export function normalizeRegionSelection(selection: RegionSelection): RegionSelection {
|
||||
if (selection.province === REGION_ALL) {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
|
||||
import { apiBase } from './api';
|
||||
import { weixinSdk } from './weixin';
|
||||
import { regionFromGeo, type RegionSelection } from './region-data';
|
||||
|
||||
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
|
||||
export const CITY_STORAGE_KEY = 'dukang_selected_city';
|
||||
export const FALLBACK_CITY_CODE = '410100';
|
||||
|
||||
export type ResolvedUserCity = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity: boolean;
|
||||
region: RegionSelection;
|
||||
displayCity: string;
|
||||
};
|
||||
|
||||
type GpsCityCache = ResolvedUserCity & { timestamp: number };
|
||||
|
||||
function readCache(): GpsCityCache | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(GPS_CITY_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as GpsCityCache;
|
||||
if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(data: ResolvedUserCity) {
|
||||
sessionStorage.setItem(
|
||||
GPS_CITY_STORAGE_KEY,
|
||||
JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache),
|
||||
);
|
||||
}
|
||||
|
||||
async function reportLocationToServer(payload: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
sdk: 'jssdk' | 'geolocation';
|
||||
status: 'success' | 'fail';
|
||||
errMsg?: string;
|
||||
}) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': 'USER_H5',
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/common/wechat/location`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) {
|
||||
throw new Error(json.message || '定位上报失败');
|
||||
}
|
||||
return json.data as {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取并解析用户当前城市(微信 JSSDK 优先),失败返回 null */
|
||||
export async function resolveUserCity(force = false): Promise<ResolvedUserCity | null> {
|
||||
if (!force) {
|
||||
const cached = readCache();
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const outcome = await getWechatLocationDetailed({
|
||||
apiBase,
|
||||
clientApp: 'USER_H5',
|
||||
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||
});
|
||||
|
||||
if (!outcome.location) {
|
||||
await reportLocationToServer({
|
||||
sdk: outcome.sdk,
|
||||
status: 'fail',
|
||||
errMsg: outcome.errMsg,
|
||||
}).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await reportLocationToServer({
|
||||
latitude: outcome.location.latitude,
|
||||
longitude: outcome.location.longitude,
|
||||
sdk: outcome.sdk,
|
||||
status: 'success',
|
||||
});
|
||||
const resolved = toResolved(data);
|
||||
if (resolved) {
|
||||
writeCache(resolved);
|
||||
if (resolved.openCity && resolved.cityCode) {
|
||||
localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function syncCityCodeFromGps(resolved: ResolvedUserCity) {
|
||||
if (resolved.openCity && resolved.cityCode) {
|
||||
localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import TabMainHeader from '../components/TabMainHeader';
|
||||
import AppToast from '../components/AppToast';
|
||||
import { getProductImages } from '../lib/product-images';
|
||||
import { track } from '../lib/analytics';
|
||||
import {
|
||||
CITY_STORAGE_KEY,
|
||||
FALLBACK_CITY_CODE,
|
||||
resolveUserCity,
|
||||
} from '../lib/wechat-location';
|
||||
import CouponBadge from '@dukang/shared-ui/CouponBadge';
|
||||
|
||||
type Product = {
|
||||
@@ -34,18 +39,18 @@ const AROMA_TABS = [
|
||||
{ key: 'NONGXIANG', label: '浓香型', open: false },
|
||||
];
|
||||
|
||||
const CITY_STORAGE_KEY = 'dukang_selected_city';
|
||||
|
||||
export default function HomePage() {
|
||||
const [tab, setTab] = useState('QINGXIANG');
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [cities, setCities] = useState<City[]>([]);
|
||||
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || '410100');
|
||||
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || FALLBACK_CITY_CODE);
|
||||
const [locatedCityLabel, setLocatedCityLabel] = useState('');
|
||||
const [citySource, setCitySource] = useState<'auto' | 'manual'>('auto');
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
track('home_view', { pagePath: '/' });
|
||||
}, []);
|
||||
track('home_view', { pagePath: '/', cityCode });
|
||||
}, [cityCode]);
|
||||
|
||||
useEffect(() => {
|
||||
request<City[]>('USER_H5', '/catalog/cities').then((list) => {
|
||||
@@ -56,6 +61,21 @@ export default function HomePage() {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (citySource !== 'auto') return;
|
||||
resolveUserCity().then((resolved) => {
|
||||
if (!resolved) return;
|
||||
setLocatedCityLabel(resolved.displayCity);
|
||||
if (resolved.openCity && resolved.cityCode) {
|
||||
setCityCode(resolved.cityCode);
|
||||
} else {
|
||||
setCityCode(FALLBACK_CITY_CODE);
|
||||
setToast('当前城市暂未开城,已展示郑州商品');
|
||||
window.setTimeout(() => setToast(''), 2200);
|
||||
}
|
||||
});
|
||||
}, [citySource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cityCode) return;
|
||||
localStorage.setItem(CITY_STORAGE_KEY, cityCode);
|
||||
@@ -76,6 +96,7 @@ export default function HomePage() {
|
||||
}
|
||||
|
||||
const selectedCity = cities.find((c) => c.code === cityCode);
|
||||
const headerCityLabel = locatedCityLabel || selectedCity?.name || '郑州市';
|
||||
const filtered = products.filter((p) => p.aromaType === tab);
|
||||
const onSale = tab === 'QINGXIANG';
|
||||
|
||||
@@ -86,10 +107,15 @@ export default function HomePage() {
|
||||
extra={(
|
||||
<div className="tab-main-city">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span className="tab-main-city-label">{headerCityLabel}</span>
|
||||
<select
|
||||
value={cityCode}
|
||||
onChange={(e) => setCityCode(e.target.value)}
|
||||
style={{ border: 'none', background: 'transparent', font: 'inherit', color: 'inherit' }}
|
||||
onChange={(e) => {
|
||||
setCitySource('manual');
|
||||
setCityCode(e.target.value);
|
||||
}}
|
||||
className="tab-main-city-select"
|
||||
aria-label="选择开城城市"
|
||||
>
|
||||
{cities.map((c) => (
|
||||
<option key={c.code} value={c.code}>{c.name}</option>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
@@ -7,10 +7,12 @@ import TabMainHeader from '../components/TabMainHeader';
|
||||
import RegionPicker from '../components/RegionPicker';
|
||||
import {
|
||||
DEFAULT_REGION,
|
||||
FALLBACK_CITY_REGION,
|
||||
formatRegion,
|
||||
REGION_ALL,
|
||||
type RegionSelection,
|
||||
} from '../lib/region-data';
|
||||
import { FALLBACK_CITY_CODE, resolveUserCity } from '../lib/wechat-location';
|
||||
|
||||
type OpenCity = {
|
||||
code: string;
|
||||
@@ -62,6 +64,9 @@ export default function StoreListPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
||||
const [regionPickerOpen, setRegionPickerOpen] = useState(false);
|
||||
const [filterMode, setFilterMode] = useState<'auto' | 'manual'>('auto');
|
||||
const [usedFallback, setUsedFallback] = useState(false);
|
||||
const [geoReady, setGeoReady] = useState(false);
|
||||
|
||||
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
|
||||
|
||||
@@ -74,15 +79,45 @@ export default function StoreListPage() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (filterMode !== 'auto' || geoReady) return;
|
||||
resolveUserCity().then((resolved) => {
|
||||
if (resolved) {
|
||||
setRegion(resolved.region);
|
||||
}
|
||||
setGeoReady(true);
|
||||
});
|
||||
}, [filterMode, geoReady]);
|
||||
|
||||
const fetchStores = useCallback(
|
||||
async (targetRegion: RegionSelection, allowFallback: boolean) => {
|
||||
const code = resolveCityCode(targetRegion, cities);
|
||||
const qs = code ? `?cityCode=${encodeURIComponent(code)}` : '';
|
||||
const data = await request<StoreItem[]>('USER_H5', `/stores${qs}`).catch(() => [] as StoreItem[]);
|
||||
|
||||
if (
|
||||
allowFallback &&
|
||||
filterMode === 'auto' &&
|
||||
data.length === 0 &&
|
||||
!usedFallback &&
|
||||
code &&
|
||||
code !== FALLBACK_CITY_CODE
|
||||
) {
|
||||
setUsedFallback(true);
|
||||
setRegion(FALLBACK_CITY_REGION);
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
[cities, filterMode, usedFallback],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (filterMode === 'auto' && !geoReady) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const qs = cityCode ? `?cityCode=${encodeURIComponent(cityCode)}` : '';
|
||||
request<StoreItem[]>('USER_H5', `/stores${qs}`)
|
||||
fetchStores(region, true)
|
||||
.then((data) => {
|
||||
if (!cancelled) setStores(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStores([]);
|
||||
if (!cancelled && data) setStores(data);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
@@ -90,7 +125,7 @@ export default function StoreListPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cityCode]);
|
||||
}, [region, cities, filterMode, geoReady, fetchStores, usedFallback]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = stores;
|
||||
@@ -122,6 +157,10 @@ export default function StoreListPage() {
|
||||
}, [stores, categoryTab, keyword, region, cityCode]);
|
||||
|
||||
const regionLabel = formatRegion(region.province, region.city, region.district);
|
||||
const emptyMessage =
|
||||
filterMode === 'manual' && filtered.length === 0
|
||||
? '未找到匹配门店'
|
||||
: '暂无门店';
|
||||
|
||||
return (
|
||||
<div className="page store-page">
|
||||
@@ -144,7 +183,10 @@ export default function StoreListPage() {
|
||||
type="search"
|
||||
placeholder="搜索门店名称或地址"
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setFilterMode('manual');
|
||||
setKeyword(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,7 +198,10 @@ export default function StoreListPage() {
|
||||
key={tab}
|
||||
type="button"
|
||||
className={`store-category-tab${categoryTab === tab ? ' active' : ''}`}
|
||||
onClick={() => setCategoryTab(tab)}
|
||||
onClick={() => {
|
||||
setFilterMode('manual');
|
||||
setCategoryTab(tab);
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
@@ -215,7 +260,7 @@ export default function StoreListPage() {
|
||||
))}
|
||||
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="store-empty">{stores.length === 0 ? '暂无门店' : '未找到匹配门店'}</div>
|
||||
<div className="store-empty">{emptyMessage}</div>
|
||||
)}
|
||||
|
||||
{!loading && filtered.length > 0 && (
|
||||
@@ -228,6 +273,7 @@ export default function StoreListPage() {
|
||||
value={region}
|
||||
onClose={() => setRegionPickerOpen(false)}
|
||||
onConfirm={(next) => {
|
||||
setFilterMode('manual');
|
||||
setRegion(next);
|
||||
setRegionPickerOpen(false);
|
||||
}}
|
||||
|
||||
@@ -603,6 +603,24 @@
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
max-width: 46vw;
|
||||
}
|
||||
|
||||
.tab-main-city-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 72px;
|
||||
}
|
||||
|
||||
.tab-main-city-select {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
max-width: 72px;
|
||||
opacity: 0.85;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tab-main-city .material-symbols-outlined {
|
||||
|
||||
Reference in New Issue
Block a user