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>
This commit is contained in:
@@ -25,8 +25,16 @@ import {
|
||||
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,
|
||||
@@ -54,42 +62,48 @@ type Store = {
|
||||
distanceMeters?: number | null;
|
||||
};
|
||||
|
||||
/** 筛选城市键(省+市);同城不重复请求 */
|
||||
function makeCityKey(region: Pick<RegionSelection, 'province' | 'city'>): string {
|
||||
return `${region.province}|${region.city}`;
|
||||
}
|
||||
|
||||
function sameFilterCity(a: RegionSelection, b: RegionSelection): boolean {
|
||||
return a.province === b.province && a.city === b.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) };
|
||||
}
|
||||
|
||||
/** 跨 tab 切换 / 页面重建仍复用,避免同城反复打 /stores */
|
||||
type StoresListCache = {
|
||||
cityKey: string;
|
||||
cityCode: string;
|
||||
region: RegionSelection;
|
||||
items: Store[];
|
||||
};
|
||||
let storesListCache: StoresListCache | null = null;
|
||||
|
||||
export default function StoresPage() {
|
||||
const [stores, setStores] = useState<Store[]>(() => storesListCache?.items ?? []);
|
||||
const [loading, setLoading] = useState(() => !storesListCache);
|
||||
const [keywordInput, setKeywordInput] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
// 必须与缓存城市对齐,否则 remount 时用默认「郑州」筛掉缓存列表会闪「暂无」
|
||||
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>(
|
||||
() => storesListCache?.region ?? DEFAULT_REGION,
|
||||
() => cached?.filterRegion ?? cached?.listRegion ?? DEFAULT_REGION,
|
||||
);
|
||||
const [regionOpen, setRegionOpen] = useState(false);
|
||||
const [category, setCategory] = useState<CategorySelection>(EMPTY_CATEGORY);
|
||||
const [category, setCategory] = useState<CategorySelection>(
|
||||
() => cached?.category ?? EMPTY_CATEGORY,
|
||||
);
|
||||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||
const fetchCityKeyRef = useRef<string | null>(storesListCache?.cityKey ?? null);
|
||||
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(() => {
|
||||
@@ -107,7 +121,9 @@ export default function StoresPage() {
|
||||
nextCode: string,
|
||||
coords: UserCoords | null,
|
||||
cityKey: string,
|
||||
nextRegion: RegionSelection,
|
||||
listRegion: RegionSelection,
|
||||
/** 写入会话的筛选器;默认保留用户当前选择 */
|
||||
filterRegion: RegionSelection = regionRef.current,
|
||||
) {
|
||||
const seq = ++fetchSeqRef.current;
|
||||
const qs = new URLSearchParams();
|
||||
@@ -123,12 +139,17 @@ export default function StoresPage() {
|
||||
const items = Array.isArray(list) ? list : [];
|
||||
setStores(items);
|
||||
fetchCityKeyRef.current = cityKey;
|
||||
storesListCache = {
|
||||
const prev = getStoresListCache();
|
||||
setStoresListCache({
|
||||
cityKey,
|
||||
cityCode: nextCode,
|
||||
region: toCityWideRegion(nextRegion),
|
||||
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 : '加载失败');
|
||||
@@ -138,46 +159,48 @@ export default function StoresPage() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 先稳住缓存画面 → 再定位;同城不请求;换城再拉。
|
||||
* 首次进入:弹窗 + 定位 + 拉列表。
|
||||
* 同次再切回:只同步 tab 选中态,不改筛选、不拉接口、不 setState。
|
||||
*/
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(1);
|
||||
|
||||
// 同步恢复缓存,避免 await 定位期间 region 不对导致列表被滤空
|
||||
if (storesListCache) {
|
||||
fetchCityKeyRef.current = storesListCache.cityKey;
|
||||
setStores((prev) => (prev.length > 0 ? prev : storesListCache!.items));
|
||||
setRegion((prev) =>
|
||||
sameFilterCity(prev, storesListCache!.region) ? prev : storesListCache!.region,
|
||||
);
|
||||
setLoading(false);
|
||||
if (isStoresSessionBootstrapped()) {
|
||||
return;
|
||||
}
|
||||
markStoresSessionBootstrapped();
|
||||
|
||||
void (async () => {
|
||||
const resolved = await resolveUserCity();
|
||||
const nextCode = getCityCodeForCatalog(resolved);
|
||||
const nextRegion = toCityWideRegion(resolved.region);
|
||||
const nextCityKey = makeCityKey(nextRegion);
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '获取当前位置',
|
||||
content: '是否允许获取当前位置来搜索附近门店?拒绝后将按默认城市展示,可下拉刷新重新定位。',
|
||||
confirmText: '允许',
|
||||
cancelText: '暂不',
|
||||
}).catch(() => ({ confirm: false, cancel: true }));
|
||||
|
||||
if (
|
||||
fetchCityKeyRef.current === nextCityKey ||
|
||||
storesListCache?.cityKey === nextCityKey
|
||||
) {
|
||||
if (storesListCache?.cityKey === nextCityKey) {
|
||||
fetchCityKeyRef.current = nextCityKey;
|
||||
setStores((prev) => (prev.length > 0 ? prev : storesListCache!.items));
|
||||
// 同城不覆盖用户已选区县
|
||||
setRegion((prev) => (sameFilterCity(prev, nextRegion) ? prev : nextRegion));
|
||||
}
|
||||
setLoading(false);
|
||||
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;
|
||||
}
|
||||
|
||||
// 换城:先清空再 loading,避免旧城数据 + 新城筛选交叉闪一下
|
||||
setStores([]);
|
||||
const nextRegion = toCityWideRegion(DEFAULT_REGION);
|
||||
const nextCityKey = makeCityKey(nextRegion);
|
||||
setRegion(nextRegion);
|
||||
regionRef.current = nextRegion;
|
||||
setLoading(true);
|
||||
await fetchStores(nextCode, readCachedUserCoords(), nextCityKey, nextRegion);
|
||||
await fetchStores(FALLBACK_CITY_CODE, null, nextCityKey, nextRegion, nextRegion);
|
||||
})();
|
||||
});
|
||||
|
||||
@@ -191,15 +214,21 @@ export default function StoresPage() {
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await resolveUserCity(true);
|
||||
const nextCode = getCityCodeForCatalog(resolved);
|
||||
const nextRegion = toCityWideRegion(resolved.region);
|
||||
const { cityCode, region: nextRegion } = regionForCatalogFetch(resolved);
|
||||
const nextCityKey = makeCityKey(nextRegion);
|
||||
setRegion(nextRegion);
|
||||
regionRef.current = nextRegion;
|
||||
if (fetchCityKeyRef.current !== nextCityKey) {
|
||||
setStores([]);
|
||||
setLoading(true);
|
||||
}
|
||||
await fetchStores(nextCode, readCachedUserCoords(), nextCityKey, nextRegion);
|
||||
await fetchStores(
|
||||
cityCode,
|
||||
readCachedUserCoords(),
|
||||
nextCityKey,
|
||||
nextRegion,
|
||||
nextRegion,
|
||||
);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
setLoading(false);
|
||||
@@ -230,7 +259,9 @@ export default function StoresPage() {
|
||||
});
|
||||
|
||||
function applySearch() {
|
||||
setKeyword(keywordInput.trim());
|
||||
const next = keywordInput.trim();
|
||||
setKeyword(next);
|
||||
patchStoresFilterCache({ keyword: next, keywordInput });
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
@@ -238,6 +269,49 @@ export default function StoresPage() {
|
||||
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) {
|
||||
@@ -291,9 +365,23 @@ export default function StoresPage() {
|
||||
<Text className="store-filter-chip-text">{categoryLabel}</Text>
|
||||
<Text className="store-filter-chip-arrow">▾</Text>
|
||||
</View>
|
||||
<Text className="store-filter-reset" onClick={resetFilters}>
|
||||
重置
|
||||
</Text>
|
||||
<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>
|
||||
|
||||
@@ -349,14 +437,21 @@ export default function StoresPage() {
|
||||
value={region}
|
||||
levels={3}
|
||||
onClose={() => setRegionOpen(false)}
|
||||
onConfirm={(next) => setRegion(next)}
|
||||
onConfirm={(next) => {
|
||||
setRegion(next);
|
||||
regionRef.current = next;
|
||||
patchStoresFilterCache({ filterRegion: next });
|
||||
}}
|
||||
/>
|
||||
<CategoryPicker
|
||||
open={categoryOpen}
|
||||
tree={categoryTree}
|
||||
value={category}
|
||||
onClose={() => setCategoryOpen(false)}
|
||||
onConfirm={(next) => setCategory(next)}
|
||||
onConfirm={(next) => {
|
||||
setCategory(next);
|
||||
patchStoresFilterCache({ category: next });
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user