502 lines
17 KiB
TypeScript
502 lines
17 KiB
TypeScript
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<RegionSelection, 'province' | 'city'>): 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<Store[]>(() => (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<RegionSelection>(
|
||
() => cached?.filterRegion ?? cached?.listRegion ?? DEFAULT_REGION,
|
||
);
|
||
const [regionOpen, setRegionOpen] = useState(false);
|
||
const [category, setCategory] = useState<CategorySelection>(
|
||
() => cached?.category ?? EMPTY_CATEGORY,
|
||
);
|
||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||
const [locating, setLocating] = useState(false);
|
||
const fetchCityKeyRef = useRef<string | null>(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<string, string[]>();
|
||
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<Store[]>(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<StoreCategoryNode[]>('/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 (
|
||
<PageShell variant="tab" className="store-page no-tab-header">
|
||
<WechatShareReady payload={sharePayload} />
|
||
<TabMainHeader title="门店" />
|
||
|
||
<View className="store-filter">
|
||
<View className="store-search-row">
|
||
<Input
|
||
className="store-search-input"
|
||
placeholder="搜索门店名称/地址"
|
||
value={keywordInput}
|
||
confirmType="search"
|
||
onInput={(e) => setKeywordInput(e.detail.value)}
|
||
onConfirm={applySearch}
|
||
/>
|
||
<View className="store-search-btn" onClick={applySearch} aria-label="搜索">
|
||
<View className="store-search-icon" />
|
||
</View>
|
||
</View>
|
||
|
||
<View className="store-filter-row">
|
||
<View className="store-filter-chip" onClick={() => setRegionOpen(true)}>
|
||
<Text className="store-filter-chip-text">{regionLabel}</Text>
|
||
<Text className="store-filter-chip-arrow">▾</Text>
|
||
</View>
|
||
<View className="store-filter-chip" onClick={() => setCategoryOpen(true)}>
|
||
<Text className="store-filter-chip-text">{categoryLabel}</Text>
|
||
<Text className="store-filter-chip-arrow">▾</Text>
|
||
</View>
|
||
<View
|
||
className="store-filter-icon-btn"
|
||
onClick={resetFilters}
|
||
aria-label="重置筛选"
|
||
>
|
||
{/* 小程序 View 伪元素不稳定,用 Text 保证真机可见 */}
|
||
<Text className="store-filter-icon-glyph">↺</Text>
|
||
</View>
|
||
<View
|
||
className={`store-filter-icon-btn${locating ? ' store-filter-icon-btn--busy' : ''}`}
|
||
onClick={() => {
|
||
void locateToUserRegion();
|
||
}}
|
||
aria-label="获取当前位置"
|
||
>
|
||
<Text className="store-filter-icon-glyph">⌖</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
|
||
<View className="store-list">
|
||
{showBootLoading ? <View className="u-empty">加载中…</View> : null}
|
||
{!showBootLoading && filtered.length === 0 ? (
|
||
<View className="u-empty">暂无营业中门店</View>
|
||
) : null}
|
||
{!showBootLoading &&
|
||
filtered.map((s) => (
|
||
<View
|
||
key={s.id}
|
||
className="store-card"
|
||
onClick={() => Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })}
|
||
>
|
||
{s.coverUrl ? (
|
||
<Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" />
|
||
) : (
|
||
<View className="store-card-cover store-card-cover--empty" />
|
||
)}
|
||
<View className="store-card-body">
|
||
{/* 第1行:标题 + 距离 */}
|
||
<View className="store-card-row store-card-row--head">
|
||
<Text className="store-card-name" numberOfLines={1}>
|
||
{s.name}
|
||
</Text>
|
||
<Text className="store-card-distance">
|
||
{formatDistanceMeters(s.distanceMeters)}
|
||
</Text>
|
||
</View>
|
||
{/* 第2行:状态 + 营业时间(仅第一段) */}
|
||
<View className="store-card-row store-card-row--meta">
|
||
<Text className="store-card-status">{formatStatus(s)}</Text>
|
||
<Text className="store-card-hours" numberOfLines={1}>
|
||
{formatHours(s)}
|
||
</Text>
|
||
</View>
|
||
{/* 第3行:地址 + 去核销 */}
|
||
<View className="store-card-row store-card-row--foot">
|
||
<Text className="store-card-address" numberOfLines={1}>
|
||
{s.address || (s.district ? `${s.district}` : '地址待完善')}
|
||
</Text>
|
||
<View
|
||
className="store-card-cta"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
Taro.navigateTo({ url: '/pages/redeem/index' });
|
||
}}
|
||
>
|
||
<Text className="store-card-cta-text">去核销</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
))}
|
||
</View>
|
||
|
||
{shouldRenderPageTabBar() ? <UserTabBar selected={1} /> : null}
|
||
<RegionPicker
|
||
open={regionOpen}
|
||
value={region}
|
||
levels={3}
|
||
onClose={() => setRegionOpen(false)}
|
||
onConfirm={(next) => {
|
||
setRegion(next);
|
||
regionRef.current = next;
|
||
patchStoresFilterCache({ filterRegion: next });
|
||
}}
|
||
/>
|
||
<CategoryPicker
|
||
open={categoryOpen}
|
||
tree={categoryTree}
|
||
value={category}
|
||
onClose={() => setCategoryOpen(false)}
|
||
onConfirm={(next) => {
|
||
setCategory(next);
|
||
patchStoresFilterCache({ category: next });
|
||
}}
|
||
/>
|
||
</PageShell>
|
||
);
|
||
}
|