diff --git a/apps/h5-user/src/lib/region-data.ts b/apps/h5-user/src/lib/region-data.ts index 51bdf00..55dc928 100644 --- a/apps/h5-user/src/lib/region-data.ts +++ b/apps/h5-user/src/lib/region-data.ts @@ -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) { diff --git a/apps/h5-user/src/lib/wechat-location.ts b/apps/h5-user/src/lib/wechat-location.ts new file mode 100644 index 0000000..d47f26e --- /dev/null +++ b/apps/h5-user/src/lib/wechat-location.ts @@ -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 = { + '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 { + 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); + } +} diff --git a/apps/h5-user/src/pages/HomePage.tsx b/apps/h5-user/src/pages/HomePage.tsx index c7db908..0905d06 100644 --- a/apps/h5-user/src/pages/HomePage.tsx +++ b/apps/h5-user/src/pages/HomePage.tsx @@ -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([]); const [cities, setCities] = useState([]); - 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('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={(
location_on + {headerCityLabel}