feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
This commit is contained in:
@@ -1,485 +0,0 @@
|
||||
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,
|
||||
} 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) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user