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 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, type StoreSortKey, } from '../../lib/stores-session'; import { buildSceneSharePayload, toWeappShareMessage, toWeappShareTimeline, } from '../../lib/wechat-share'; import BenefitSloganBar from '../../components/BenefitSloganBar'; import { storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display'; import openBadgeImg from '../../assets/icons/store-open-badge.png'; 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; parent?: { name?: string } | null; } | null; categories?: { id?: string; name?: string; parentId?: string | null; parent?: { name?: string } | null; }[] | null; tags?: unknown; rating?: number | string | null; latitude?: number | string | null; longitude?: number | string | null; distanceMeters?: number | null; redeemCount?: number | null; }; const STORE_SORT_OPTIONS: { key: StoreSortKey; label: string }[] = [ { key: 'nearby', label: '附近优先' }, { key: 'rating', label: '好评优先' }, { key: 'redeem', label: '核销次数' }, ]; 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 [sort, setSort] = useState(() => cached?.sort ?? 'nearby'); const [sortOpen, setSortOpen] = 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 sortLabel = STORE_SORT_OPTIONS.find((o) => o.key === sort)?.label ?? '附近优先'; 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, sort: prev?.sort ?? sort, }); } 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 leafIds = storeLeafCategoryIds(store); const parentIds = new Set(); if (Array.isArray(store.categories)) { for (const cat of store.categories) { if (cat.parentId) parentIds.add(String(cat.parentId)); } } const legacyParent = String(store.category?.parentId || ''); if (legacyParent) parentIds.add(legacyParent); if (category.childId) { return leafIds.includes(category.childId); } if ([...parentIds].some((id) => id === category.parentId)) return true; const siblings = childIdsByParent.get(category.parentId) ?? []; return leafIds.some((id) => siblings.includes(id)); } const filtered = useMemo(() => { const list = 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); }); const next = [...list]; next.sort((a, b) => { if (sort === 'rating') { const diff = storeStarCount(b.rating) - storeStarCount(a.rating); if (diff !== 0) return diff; } else if (sort === 'redeem') { const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0); if (diff !== 0) return diff; } const da = a.distanceMeters ?? Number.POSITIVE_INFINITY; const db = b.distanceMeters ?? Number.POSITIVE_INFINITY; return da - db; }); return next; }, [stores, region, category, keyword, sort, childIdsByParent]); function applySearch() { const next = keywordInput.trim(); setKeyword(next); patchStoresFilterCache({ keyword: next, keywordInput }); } function resetFilters() { setKeywordInput(''); setKeyword(''); setCategory(EMPTY_CATEGORY); setSort('nearby'); setRegion(DEFAULT_REGION); regionRef.current = DEFAULT_REGION; patchStoresFilterCache({ keyword: '', keywordInput: '', category: EMPTY_CATEGORY, sort: 'nearby', filterRegion: DEFAULT_REGION, }); } function hoursText(store: Store): string { const parts: string[] = []; if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`); if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`); if (!parts.length) parts.push('10:00-22:00'); return `营业时间: ${parts.join(' ')}`; } const sharePayload = useMemo( () => buildSceneSharePayload('stores', { path: '/pages/stores/index', }), [], ); useShareAppMessage(() => toWeappShareMessage(sharePayload)); useShareTimeline(() => toWeappShareTimeline(sharePayload)); return ( setKeywordInput(e.detail.value)} onConfirm={applySearch} /> 搜索 { setCategoryOpen(false); setSortOpen(false); setRegionOpen(true); }} > {regionLabel} { setRegionOpen(false); setSortOpen(false); setCategoryOpen(true); }} > {categoryLabel} { setRegionOpen(false); setCategoryOpen(false); setSortOpen(true); }} > {sortLabel} {/* 小程序 View 伪元素不稳定,用 Text 保证真机可见 */} {showBootLoading ? 加载中… : null} {!showBootLoading && filtered.length === 0 ? ( 暂无营业中门店 ) : null} {!showBootLoading && filtered.map((s) => ( Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })} > {s.coverUrl ? ( ) : ( )} {s.name} {(() => { const categoryLabels = storeCategoryTags(s, categoryTree); return categoryLabels.length ? ( {categoryLabels.join('、')} ) : null; })()} {[1, 2, 3, 4, 5].map((n) => ( ))} 核销{s.redeemCount ?? 0}次 {s.address || (s.district ? `${s.district}` : '地址待完善')} {formatDistanceMeters(s.distanceMeters)} {hoursText(s)} ))} {shouldRenderPageTabBar() ? : null} setRegionOpen(false)} onConfirm={(next) => { setRegion(next); regionRef.current = next; patchStoresFilterCache({ filterRegion: next }); }} /> setCategoryOpen(false)} onConfirm={(next) => { setCategory(next); patchStoresFilterCache({ category: next }); }} /> {sortOpen ? ( setSortOpen(false)}> e.stopPropagation()}> 排序规则 setSortOpen(false)}> 关闭 {STORE_SORT_OPTIONS.map((opt) => ( { setSort(opt.key); patchStoresFilterCache({ sort: opt.key }); setSortOpen(false); }} > {opt.label} ))} ) : null} ); }