Files
dukang/apps/mini-user/src/pages/stores/index.tsx
T
jacy 2cd4e25682 feat(ops): add HQ admin proxy order and mini-user store session fixes
Align HQ orders page with partner dual-SMS offline proxy flow; improve mini-user stores session and WeChat confirm-receive handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 09:18:19 +08:00

459 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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,
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 选中态,不改筛选、不拉接口、不 setState。
*/
useDidShow(() => {
syncTabBarSelected(1);
if (isStoresSessionBootstrapped()) {
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) {
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}`);
return parts.length ? `营业时间: ${parts.join('')}` : '营业时间: 10:00-22:00';
}
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--empty" />
)}
<View className="store-card-body">
<Text className="store-card-name">{s.name}</Text>
<Text className="store-card-meta">
{s.district ? `${s.district} · ` : ''}
{s.address || '地址待完善'}
</Text>
<Text className="store-card-meta">{formatHours(s)}</Text>
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
<Text className="store-card-meta">人均¥{Number(s.avgPrice).toFixed(0)}</Text>
) : null}
<View className="store-card-footer">
<Text className="store-card-distance">
{formatDistanceMeters(s.distanceMeters)}
</Text>
<Text
className="store-card-cta"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/redeem/index' });
}}
>
去核销
</Text>
</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>
);
}