import { useEffect, useMemo, useRef, useState } from 'react'; import { View, Text, Image, Input } from '@tarojs/components'; import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro'; import { StoreStatus, STORE_STATUS_LABELS } from '@dukang/shared-types'; import PageShell from '../../components/PageShell'; import TabMainHeader from '../../components/TabMainHeader'; import WechatShareReady from '../../components/WechatShareReady'; import RegionPicker from '../../components/RegionPicker'; import CategoryPicker, { EMPTY_CATEGORY, formatCategoryLabel, type CategorySelection, type StoreCategoryNode, } from '../../components/CategoryPicker'; import { DEFAULT_REGION, formatRegionLabel, matchesRegionFilter, type RegionSelection, } from '../../lib/region-data'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import { getCityCodeForCatalog, readCachedUserCoords, resolveUserCity, toCityWideRegion, type UserCoords, } from '../../lib/user-location'; import { FALLBACK_CITY_CODE } from '../../lib/product-images'; import { formatDistanceMeters } from '../../lib/geo'; import { getToken, request, toast } from '../../lib/api'; import { getStoresListCache, isStoresSessionBootstrapped, markStoresSessionBootstrapped, patchStoresFilterCache, setStoresListCache, } from '../../lib/stores-session'; import { DEFAULT_SHARE_DESC, DEFAULT_SHARE_TITLE, toWeappShareMessage, } from '../../lib/wechat-share'; type Store = { id: string; name: string; address?: string; province?: string; cityName?: string; district?: string; coverUrl?: string | null; openTime?: string | null; closeTime?: string | null; openTime2?: string | null; closeTime2?: string | null; avgPrice?: number | null; status?: string; categoryId?: string | null; category?: { id?: string; name?: string; parentId?: string | null } | null; latitude?: number | string | null; longitude?: number | string | null; distanceMeters?: number | null; }; function makeCityKey(region: Pick): string { return `${region.province}|${region.city}`; } /** * 定位城市若未开城,接口会 fallback 到郑州 cityCode; * 筛选器必须与真实拉取城市一致,否则列表被客户端滤空。 */ function regionForCatalogFetch(resolved: { openCity: boolean; cityCode?: string; region: RegionSelection; }): { cityCode: string; region: RegionSelection } { const cityCode = getCityCodeForCatalog(resolved); if (resolved.openCity && resolved.cityCode) { return { cityCode, region: toCityWideRegion(resolved.region) }; } return { cityCode: FALLBACK_CITY_CODE, region: toCityWideRegion(DEFAULT_REGION) }; } export default function StoresPage() { const cached = getStoresListCache(); const [stores, setStores] = useState(() => (cached?.items as Store[] | undefined) ?? []); const [loading, setLoading] = useState(() => !cached && !isStoresSessionBootstrapped()); const [keywordInput, setKeywordInput] = useState(() => cached?.keywordInput ?? ''); const [keyword, setKeyword] = useState(() => cached?.keyword ?? ''); const [region, setRegion] = useState( () => cached?.filterRegion ?? cached?.listRegion ?? DEFAULT_REGION, ); const [regionOpen, setRegionOpen] = useState(false); const [category, setCategory] = useState( () => cached?.category ?? EMPTY_CATEGORY, ); const [categoryOpen, setCategoryOpen] = useState(false); const [categoryTree, setCategoryTree] = useState([]); const [locating, setLocating] = useState(false); const fetchCityKeyRef = useRef(cached?.cityKey ?? null); const fetchSeqRef = useRef(0); const regionRef = useRef(region); regionRef.current = region; const regionLabel = formatRegionLabel(region); const categoryLabel = formatCategoryLabel(category); const showBootLoading = loading && stores.length === 0; const childIdsByParent = useMemo(() => { const map = new Map(); for (const root of categoryTree) { map.set( root.id, (root.children ?? []).map((c) => c.id), ); } return map; }, [categoryTree]); async function fetchStores( nextCode: string, coords: UserCoords | null, cityKey: string, listRegion: RegionSelection, /** 写入会话的筛选器;默认保留用户当前选择 */ filterRegion: RegionSelection = regionRef.current, ) { const seq = ++fetchSeqRef.current; const qs = new URLSearchParams(); if (nextCode) qs.set('cityCode', nextCode); if (coords) { qs.set('lat', String(coords.latitude)); qs.set('lng', String(coords.longitude)); } const path = qs.toString() ? `/stores?${qs}` : '/stores'; try { const list = await request(path); if (seq !== fetchSeqRef.current) return; const items = Array.isArray(list) ? list : []; setStores(items); fetchCityKeyRef.current = cityKey; const prev = getStoresListCache(); setStoresListCache({ cityKey, cityCode: nextCode, authKey: getToken() || '', listRegion: toCityWideRegion(listRegion), items, filterRegion, keyword: prev?.keyword ?? keyword, keywordInput: prev?.keywordInput ?? keywordInput, category: prev?.category ?? category, }); } catch (e) { if (seq !== fetchSeqRef.current) return; toast(e instanceof Error ? e.message : '加载失败'); } finally { if (seq === fetchSeqRef.current) setLoading(false); } } /** * 首次进入:弹窗 + 定位 + 拉列表。 * 同次再切回:只同步 tab 选中态(登录态未变)。 * 登录/退出后 token 变化:按缓存失效重新拉列表(白名单)。 */ useDidShow(() => { syncTabBarSelected(1); const authKey = getToken() || ''; const cache = getStoresListCache(); if (isStoresSessionBootstrapped()) { if (cache && (cache.authKey ?? '') === authKey) { return; } // 登录态变了:保留筛选,重新拉列表 void (async () => { setLoading(true); const nextCode = cache?.cityCode || FALLBACK_CITY_CODE; const listRegion = cache?.listRegion ? { province: cache.listRegion.province, city: cache.listRegion.city, district: cache.listRegion.district || '全部', } : regionRef.current; const nextCityKey = cache?.cityKey || makeCityKey(listRegion); await fetchStores( nextCode, readCachedUserCoords(), nextCityKey, listRegion, regionRef.current, ); })(); return; } markStoresSessionBootstrapped(); void (async () => { const { confirm } = await Taro.showModal({ title: '获取当前位置', content: '是否允许获取当前位置来搜索附近门店?拒绝后将按默认城市展示,可下拉刷新重新定位。', confirmText: '允许', cancelText: '暂不', }).catch(() => ({ confirm: false, cancel: true })); if (confirm) { setLoading(true); const resolved = await resolveUserCity(true); const { cityCode, region: nextRegion } = regionForCatalogFetch(resolved); const nextCityKey = makeCityKey(nextRegion); setRegion(nextRegion); regionRef.current = nextRegion; await fetchStores( cityCode, readCachedUserCoords(), nextCityKey, nextRegion, nextRegion, ); return; } const nextRegion = toCityWideRegion(DEFAULT_REGION); const nextCityKey = makeCityKey(nextRegion); setRegion(nextRegion); regionRef.current = nextRegion; setLoading(true); await fetchStores(FALLBACK_CITY_CODE, null, nextCityKey, nextRegion, nextRegion); })(); }); useEffect(() => { void request('/store-categories') .then((tree) => setCategoryTree(Array.isArray(tree) ? tree : [])) .catch(() => setCategoryTree([])); }, []); usePullDownRefresh(() => { void (async () => { try { const resolved = await resolveUserCity(true); const { cityCode, region: nextRegion } = regionForCatalogFetch(resolved); const nextCityKey = makeCityKey(nextRegion); setRegion(nextRegion); regionRef.current = nextRegion; if (fetchCityKeyRef.current !== nextCityKey) { setStores([]); setLoading(true); } await fetchStores( cityCode, readCachedUserCoords(), nextCityKey, nextRegion, nextRegion, ); } catch (e) { toast(e instanceof Error ? e.message : '加载失败'); setLoading(false); } finally { Taro.stopPullDownRefresh(); } })(); }); function matchesCategory(store: Store): boolean { if (!category.parentId) return true; const storeCatId = String(store.categoryId || store.category?.id || ''); const storeParentId = String(store.category?.parentId || ''); if (category.childId) { return storeCatId === category.childId; } if (storeParentId && storeParentId === category.parentId) return true; const siblings = childIdsByParent.get(category.parentId) ?? []; return siblings.includes(storeCatId); } const filtered = stores.filter((s) => { if (!matchesRegionFilter(s, region)) return false; if (!matchesCategory(s)) return false; if (!keyword.trim()) return true; const q = keyword.trim(); return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q); }); function applySearch() { const next = keywordInput.trim(); setKeyword(next); patchStoresFilterCache({ keyword: next, keywordInput }); } function resetFilters() { setKeywordInput(''); setKeyword(''); setCategory(EMPTY_CATEGORY); setRegion(DEFAULT_REGION); regionRef.current = DEFAULT_REGION; patchStoresFilterCache({ keyword: '', keywordInput: '', category: EMPTY_CATEGORY, filterRegion: DEFAULT_REGION, }); } async function locateToUserRegion() { if (locating) return; const { confirm } = await Taro.showModal({ title: '获取当前位置', content: '是否允许获取当前位置,并将筛选定位到您所在的城市与区县?', confirmText: '允许', cancelText: '暂不', }).catch(() => ({ confirm: false, cancel: true })); if (!confirm) return; setLocating(true); setLoading(true); try { const resolved = await resolveUserCity(true); // 筛选器用真实省市+区县;拉数仍按开城 cityCode(未开城则郑州) const filterRegion = resolved.region; const { cityCode, region: listRegion } = regionForCatalogFetch(resolved); const nextCityKey = makeCityKey(listRegion); setRegion(filterRegion); regionRef.current = filterRegion; await fetchStores( cityCode, readCachedUserCoords(), nextCityKey, listRegion, filterRegion, ); toast(`已定位到${formatRegionLabel(filterRegion)}`, 'success'); } catch (e) { toast(e instanceof Error ? e.message : '定位失败'); setLoading(false); } finally { setLocating(false); } } function formatHours(store: Store) { // 列表只展示第一段营业时间,避免挤占一行 if (store.openTime && store.closeTime) { return `营业时间: ${store.openTime}-${store.closeTime}`; } return '营业时间: 10:00-22:00'; } function formatStatus(store: Store) { const status = store.status as StoreStatus | undefined; if (status && STORE_STATUS_LABELS[status]) return STORE_STATUS_LABELS[status]; return STORE_STATUS_LABELS[StoreStatus.OPEN]; } const sharePayload = useMemo( () => ({ title: '杜康好客门店', desc: DEFAULT_SHARE_DESC, path: '/pages/stores/index', }), [], ); useShareAppMessage(() => toWeappShareMessage(sharePayload)); useShareTimeline(() => ({ title: sharePayload.title || DEFAULT_SHARE_TITLE, query: '', })); return ( setKeywordInput(e.detail.value)} onConfirm={applySearch} /> setRegionOpen(true)}> {regionLabel} setCategoryOpen(true)}> {categoryLabel} {/* 小程序 View 伪元素不稳定,用 Text 保证真机可见 */} { void locateToUserRegion(); }} aria-label="获取当前位置" > {showBootLoading ? 加载中… : null} {!showBootLoading && filtered.length === 0 ? ( 暂无营业中门店 ) : null} {!showBootLoading && filtered.map((s) => ( Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })} > {s.coverUrl ? ( ) : ( )} {/* 第1行:标题 + 距离 */} {s.name} {formatDistanceMeters(s.distanceMeters)} {/* 第2行:状态 + 营业时间(仅第一段) */} {formatStatus(s)} {formatHours(s)} {/* 第3行:地址 + 去核销 */} {s.address || (s.district ? `${s.district}` : '地址待完善')} { e.stopPropagation(); Taro.navigateTo({ url: '/pages/redeem/index' }); }} > 去核销 ))} {shouldRenderPageTabBar() ? : null} setRegionOpen(false)} onConfirm={(next) => { setRegion(next); regionRef.current = next; patchStoresFilterCache({ filterRegion: next }); }} /> setCategoryOpen(false)} onConfirm={(next) => { setCategory(next); patchStoresFilterCache({ category: next }); }} /> ); }