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:
2026-07-31 09:18:19 +08:00
parent 1a0afb6d39
commit 2cd4e25682
21 changed files with 1438 additions and 143 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ function App({ children }: PropsWithChildren) {
const cur = pages[pages.length - 1] as { route?: string } | undefined;
const route = cur?.route || '';
if (route.includes('pickup-receive')) {
Taro.redirectTo({ url: '/pages/orders/index?tab=done' }).catch(() => {});
Taro.redirectTo({ url: '/pages/orders/index?tab=all' }).catch(() => {});
}
})
.finally(() => {
+3
View File
@@ -1,6 +1,7 @@
import Taro from '@tarojs/taro';
import { ClientApp } from '@dukang/shared-types';
import { forceReloadAfterAccountMerge } from './auth-nav';
import { resetStoresSessionBootstrap } from './stores-session';
function resolveApiBase(): string {
const origin =
@@ -56,6 +57,8 @@ export function isLoggedIn(): boolean {
export function logout() {
clearAuth();
// 主动退出才重置门店「当次登录」会话;401 清 token 不要打断门店筛选
resetStoresSessionBootstrap();
Taro.reLaunch({ url: '/pages/home/index' });
}
+135
View File
@@ -0,0 +1,135 @@
/**
* 门店列表「当次登录」会话 —— 用 Taro Storage 持久化,
* 避免模块多实例 / globalThis 不可靠导致切 tab 后当成首次进入。
* 仅 logout 时 clear。
*/
import Taro from '@tarojs/taro';
export type StoresSessionRegion = {
province: string;
city: string;
district: string;
};
export type StoresSessionCategory = {
parentId: string;
parentName: string;
childId: string;
childName: string;
};
export type StoresListCache = {
cityKey: string;
cityCode: string;
listRegion: StoresSessionRegion;
items: unknown[];
filterRegion: StoresSessionRegion;
keyword: string;
keywordInput: string;
category: StoresSessionCategory;
};
type StoresSession = {
bootstrapped: boolean;
cache: StoresListCache | null;
};
const STORAGE_KEY = 'dukang_stores_session_v1';
let memory: StoresSession | null = null;
function emptySession(): StoresSession {
return { bootstrapped: false, cache: null };
}
function readSession(): StoresSession {
if (memory) return memory;
try {
const raw = Taro.getStorageSync(STORAGE_KEY);
if (!raw) {
memory = emptySession();
return memory;
}
const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Partial<StoresSession>;
memory = {
bootstrapped: !!parsed.bootstrapped,
cache: (parsed.cache as StoresListCache | null) ?? null,
};
return memory;
} catch {
memory = emptySession();
return memory;
}
}
function writeSession(next: StoresSession) {
memory = next;
try {
Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
export function isStoresSessionBootstrapped(): boolean {
return readSession().bootstrapped;
}
export function markStoresSessionBootstrapped(): void {
const cur = readSession();
writeSession({ ...cur, bootstrapped: true });
}
export function getStoresListCache(): StoresListCache | null {
return readSession().cache;
}
export function setStoresListCache(cache: StoresListCache | null): void {
const cur = readSession();
writeSession({ ...cur, bootstrapped: true, cache });
}
export function patchStoresFilterCache(
patch: Partial<
Pick<StoresListCache, 'filterRegion' | 'keyword' | 'keywordInput' | 'category'>
>,
): void {
const cur = readSession();
if (!cur.cache) {
// 列表尚未写入时也要记下用户筛选,避免切回丢失
writeSession({
bootstrapped: true,
cache: {
cityKey: '',
cityCode: '',
listRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
items: [],
filterRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
keyword: patch.keyword ?? '',
keywordInput: patch.keywordInput ?? '',
category: patch.category ?? {
parentId: '',
parentName: '',
childId: '',
childName: '',
},
},
});
return;
}
writeSession({
...cur,
bootstrapped: true,
cache: { ...cur.cache, ...patch },
});
}
export function resetStoresSessionBootstrap(): void {
memory = emptySession();
try {
Taro.removeStorageSync(STORAGE_KEY);
} catch {
/* ignore */
}
}
+164 -38
View File
@@ -1,5 +1,6 @@
import Taro from '@tarojs/taro';
import { request, toast } from './api';
import { fetchClientConfig } from './pay-wechat';
/** 微信确认收货组件来源 AppId(官方固定) */
export const WECHAT_ORDER_CONFIRM_APPID = 'wx1183b055aeec94d1';
@@ -17,21 +18,54 @@ type PendingConfirm = {
redirectUrl?: string;
};
type OpenBusinessViewFn = (opts: {
type OpenBusinessViewOptions = {
businessType: string;
extraData: Record<string, string>;
success?: () => void;
fail?: (err: { errMsg?: string }) => void;
}) => void;
complete?: () => void;
};
function getOpenBusinessView(): OpenBusinessViewFn | null {
type MiniWx = {
openBusinessView?: (opts: OpenBusinessViewOptions) => void;
};
/**
* 取小程序原生 wx.openBusinessView。
* 官方兼容写法:`if (wx.openBusinessView) { ... }`(不要用 canIUse 挡业务组件)。
* Taro 未封装该 API;模块作用域下可能读不到全局 wx,需多重回退。
*/
function getOpenBusinessView(): ((opts: OpenBusinessViewOptions) => void) | null {
if (process.env.TARO_ENV !== 'weapp') return null;
const taroAny = Taro as unknown as { openBusinessView?: OpenBusinessViewFn };
if (typeof taroAny.openBusinessView === 'function') return taroAny.openBusinessView.bind(Taro);
const wxAny = (globalThis as { wx?: { openBusinessView?: OpenBusinessViewFn } }).wx;
if (wxAny && typeof wxAny.openBusinessView === 'function') {
return wxAny.openBusinessView.bind(wxAny);
const candidates: Array<MiniWx | null | undefined> = [];
try {
// eslint-disable-next-line no-undef
if (typeof wx !== 'undefined') candidates.push(wx as MiniWx);
} catch {
/* ignore */
}
const g = globalThis as typeof globalThis & { wx?: MiniWx };
candidates.push(g.wx);
try {
// 跳出 bundler 模块作用域,读微信运行时全局
const fromRuntime = new Function(
'return typeof wx !== "undefined" ? wx : null',
)() as MiniWx | null;
candidates.push(fromRuntime);
} catch {
/* ignore */
}
for (const api of candidates) {
if (api && typeof api.openBusinessView === 'function') {
return api.openBusinessView.bind(api);
}
}
return null;
}
@@ -51,8 +85,46 @@ export function takePendingWechatOrderConfirm(): PendingConfirm | null {
}
}
function normalizePayload(payload?: WechatConfirmPayload | null): WechatConfirmPayload {
return {
merchantId: payload?.merchantId?.trim() || undefined,
merchantTradeNo: payload?.merchantTradeNo?.trim() || undefined,
transactionId: payload?.transactionId?.trim() || undefined,
};
}
async function resolveConfirmPayload(
orderId: string,
hint?: WechatConfirmPayload | null,
): Promise<WechatConfirmPayload> {
const fromHint = normalizePayload(hint);
if (fromHint.transactionId || (fromHint.merchantId && fromHint.merchantTradeNo)) {
return fromHint;
}
const order = await request<{
orderNo?: string;
payExternalNo?: string | null;
payment?: { externalNo?: string | null } | null;
wechatConfirm?: WechatConfirmPayload | null;
}>(`/trade/orders/${orderId}`);
const fromApi = normalizePayload(order.wechatConfirm);
if (fromApi.transactionId || (fromApi.merchantId && fromApi.merchantTradeNo)) {
return fromApi;
}
const transactionId =
order.payExternalNo?.trim() || order.payment?.externalNo?.trim() || undefined;
return normalizePayload({
transactionId,
merchantTradeNo: order.orderNo,
merchantId: fromApi.merchantId,
});
}
/**
* 拉起微信「确认收货」半屏组件,资金侧确认与自家订单同步
* 拉起微信「确认收货」半屏组件。
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping-half.html
*/
export function openWechatOrderConfirm(opts: {
@@ -61,12 +133,19 @@ export function openWechatOrderConfirm(opts: {
redirectUrl?: string;
}): Promise<'opened' | 'unsupported' | 'missing_pay_ref'> {
const open = getOpenBusinessView();
if (!open) return Promise.resolve('unsupported');
if (!open) {
console.warn('[wechat-order-confirm] openBusinessView unavailable', {
taroEnv: process.env.TARO_ENV,
});
return Promise.resolve('unsupported');
}
const transactionId = opts.payload.transactionId?.trim();
const merchantId = opts.payload.merchantId?.trim();
const merchantTradeNo = opts.payload.merchantTradeNo?.trim();
const payload = normalizePayload(opts.payload);
const transactionId = payload.transactionId;
const merchantId = payload.merchantId;
const merchantTradeNo = payload.merchantTradeNo;
if (!transactionId && !(merchantId && merchantTradeNo)) {
console.warn('[wechat-order-confirm] missing pay ref', payload);
return Promise.resolve('missing_pay_ref');
}
@@ -81,16 +160,31 @@ export function openWechatOrderConfirm(opts: {
});
return new Promise((resolve) => {
open({
businessType: 'weappOrderConfirm',
extraData,
success: () => resolve('opened'),
fail: (err) => {
Taro.removeStorageSync(PENDING_KEY);
toast(err?.errMsg || '无法打开微信确认收货,请升级微信后重试');
resolve('unsupported');
},
});
let settled = false;
const done = (mode: 'opened' | 'unsupported' | 'missing_pay_ref') => {
if (settled) return;
settled = true;
resolve(mode);
};
try {
open({
businessType: 'weappOrderConfirm',
extraData,
success: () => done('opened'),
fail: (err) => {
Taro.removeStorageSync(PENDING_KEY);
console.error('[wechat-order-confirm] openBusinessView fail', err, extraData);
toast(err?.errMsg || '无法打开微信确认收货,请稍后重试');
done('unsupported');
},
});
} catch (err) {
Taro.removeStorageSync(PENDING_KEY);
console.error('[wechat-order-confirm] openBusinessView throw', err);
toast('无法打开微信确认收货组件');
done('unsupported');
}
});
}
@@ -143,24 +237,11 @@ export async function handleWechatOrderConfirmShow(options?: {
}
}
/** 统一入口:小程序走微信组件;H5/无能力时降级为本地确认 */
export async function confirmOrderReceive(opts: {
async function confirmLocally(opts: {
orderId: string;
wechatConfirm?: WechatConfirmPayload | null;
onSitePickup?: boolean;
redirectUrl?: string;
/** 降级本地确认成功后的回调(不经过微信回跳) */
onLocalSuccess?: () => void | Promise<void>;
}): Promise<'wechat' | 'local'> {
const mode = await openWechatOrderConfirm({
orderId: opts.orderId,
payload: opts.wechatConfirm || {},
redirectUrl: opts.redirectUrl,
});
if (mode === 'opened') return 'wechat';
// Mock / H5 / 缺支付单号:本地确认(不通知微信资金侧)
}): Promise<'local'> {
await request(`/trade/orders/${opts.orderId}/confirm-receive`, {
method: 'POST',
data: {
@@ -171,3 +252,48 @@ export async function confirmOrderReceive(opts: {
await opts.onLocalSuccess?.();
return 'local';
}
/**
* 统一入口:
* - 小程序 + 真实支付:必须拉起 weappOrderConfirm,禁止静默降级
* - Mock / H5:本地确认
*/
export async function confirmOrderReceive(opts: {
orderId: string;
wechatConfirm?: WechatConfirmPayload | null;
onSitePickup?: boolean;
redirectUrl?: string;
onLocalSuccess?: () => void | Promise<void>;
}): Promise<'wechat' | 'local'> {
const isWeapp = process.env.TARO_ENV === 'weapp';
if (!isWeapp) {
return confirmLocally(opts);
}
let mockPay = false;
try {
const cfg = await fetchClientConfig();
mockPay = !!cfg.mockPay;
} catch {
mockPay = false;
}
if (mockPay) {
return confirmLocally(opts);
}
const payload = await resolveConfirmPayload(opts.orderId, opts.wechatConfirm);
const mode = await openWechatOrderConfirm({
orderId: opts.orderId,
payload,
redirectUrl: opts.redirectUrl,
});
if (mode === 'opened') return 'wechat';
if (mode === 'missing_pay_ref') {
throw new Error('缺少微信支付单号,无法打开确认收货组件');
}
throw new Error('当前环境无法打开微信确认收货组件,请用微信最新版打开小程序后重试');
}
+3 -2
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import ShareNavButton from '../../components/ShareNavButton';
@@ -13,6 +13,7 @@ import {
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
import iconBenefit from '../../assets/tabbar/benefit-active.png';
type BenefitSummary = {
totalBalance: number;
@@ -153,7 +154,7 @@ export default function BenefitPage() {
</View>
</View>
<View className="benefit-hero-logo">
<Text></Text>
<Image className="benefit-hero-logo-img" src={iconBenefit} mode="aspectFit" />
</View>
</View>
<View
+5 -17
View File
@@ -38,8 +38,8 @@ type MiniHomeConfig = {
const AROMA_TABS = [
{ key: 'QINGXIANG', label: '清香型' },
{ key: 'NONGXIANG', label: '浓香型' },
{ key: 'JIANGXIANG', label: '酱香型' },
{ key: 'NONGXIANG', label: '浓香型' },
] as const;
export default function HomePage() {
@@ -111,21 +111,6 @@ export default function HomePage() {
})();
});
const availableAromas = useMemo(
() =>
AROMA_TABS.filter((item) =>
products.some((product) => product.aromaType === item.key),
),
[products],
);
useEffect(() => {
if (loading || availableAromas.length === 0) return;
if (!availableAromas.some((item) => item.key === tab)) {
setTab(availableAromas[0].key);
}
}, [availableAromas, loading, tab]);
function openProductDetail(id: string) {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
}
@@ -187,7 +172,7 @@ export default function HomePage() {
<View className="home-aroma-nav">
<View className="home-aroma-tabs">
{availableAromas.map((t) => (
{AROMA_TABS.map((t) => (
<Text
key={t.key}
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
@@ -205,6 +190,9 @@ export default function HomePage() {
{!loading && products.length === 0 ? (
<View className="home-empty"></View>
) : null}
{!loading && products.length > 0 && filtered.length === 0 ? (
<View className="home-empty">线</View>
) : null}
{!loading &&
filtered.map((p) => {
const thumb = getProductMainImage(p);
+17 -6
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { usePullDownRefresh, useRouter } from '@tarojs/taro';
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
@@ -15,10 +15,16 @@ const TABS = [
{ key: 'completed', label: '已完成' },
] as const;
/** 兼容历史链接 tab=done */
function normalizeOrdersTab(raw?: string): string {
if (!raw) return 'all';
if (raw === 'done') return 'completed';
return TABS.some((t) => t.key === raw) ? raw : 'all';
}
function orderStatusLabel(tab: string, status?: string): string {
if (tab !== 'all') {
return TABS.find((t) => t.key === tab)?.label || status || '';
}
const tabLabel = TABS.find((t) => t.key === tab)?.label;
if (tab !== 'all' && tabLabel) return tabLabel;
if (!status) return '';
return ORDER_STATUS_LABELS[status] || status;
}
@@ -47,8 +53,7 @@ type OrderRow = {
export default function OrdersPage() {
const router = useRouter();
const initialTab = (router.params.tab as string) || 'all';
const [tab, setTab] = useState(initialTab);
const [tab, setTab] = useState(() => normalizeOrdersTab(router.params.tab as string));
const [orders, setOrders] = useState<OrderRow[]>([]);
const [loading, setLoading] = useState(true);
@@ -72,6 +77,12 @@ export default function OrdersPage() {
void loadOrders();
}, [loadOrders]);
useDidShow(() => {
const next = normalizeOrdersTab(router.params.tab as string);
if (next !== tab) setTab(next);
else void loadOrders();
});
usePullDownRefresh(() => {
void loadOrders().finally(() => Taro.stopPullDownRefresh());
});
@@ -23,11 +23,19 @@ type OrderDetail = {
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
};
items?: Array<{
productName?: string;
productSpec?: string;
productImage?: string;
quantity?: number;
}>;
imageUrl?: string | null;
mainImageUrl?: string | null;
wechatConfirm?: WechatConfirmPayload | null;
};
const ORDERS_ALL_URL = '/pages/orders/index?tab=all';
export default function PickupReceivePage() {
const router = useRouter();
const orderId = router.params.id ?? router.params.orderId ?? '';
@@ -67,11 +75,11 @@ export default function PickupReceivePage() {
orderId,
wechatConfirm: order.wechatConfirm,
onSitePickup: true,
redirectUrl: '/pages/orders/index?tab=done',
redirectUrl: ORDERS_ALL_URL,
onLocalSuccess: async () => {
toast('确认收货成功', 'success');
setTimeout(() => {
Taro.redirectTo({ url: '/pages/orders/index?tab=done' });
Taro.redirectTo({ url: ORDERS_ALL_URL });
}, 500);
},
});
@@ -83,9 +91,11 @@ export default function PickupReceivePage() {
}
}
const name = order?.productName || order?.product?.name || '商品';
const spec = order?.productSpec || order?.product?.spec;
const item = order?.items?.[0];
const name = item?.productName || order?.productName || order?.product?.name || '商品';
const spec = item?.productSpec || order?.productSpec || order?.product?.spec;
const image =
(item?.productImage || '').trim() ||
order?.mainImageUrl ||
order?.imageUrl ||
(order?.product ? getProductMainImage(order.product) : '') ||
+156 -61
View File
@@ -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>
);
+7
View File
@@ -126,6 +126,13 @@
color: var(--color-heritage-red);
font-weight: 700;
font-size: 18px;
overflow: hidden;
}
.benefit-hero-logo-img {
width: 28px;
height: 28px;
display: block;
}
.benefit-hero-cta {
+16 -4
View File
@@ -116,15 +116,27 @@
color: var(--color-subtle-gray);
}
.store-filter-reset {
.store-filter-icon-btn {
flex-shrink: 0;
width: 36px;
height: 36px;
padding: 0 10px;
border-radius: var(--radius-md);
background: var(--color-surface-container-low);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: var(--color-heritage-red);
box-sizing: border-box;
}
.store-filter-icon-btn--busy {
opacity: 0.5;
}
.store-filter-icon-glyph {
font-size: 18px;
line-height: 1;
font-weight: 700;
color: var(--color-heritage-red);
}
+8
View File
@@ -14,6 +14,14 @@ declare const definePageConfig: (config: Record<string, unknown>) => Record<stri
declare const TARO_APP_API_ORIGIN: string;
/** 微信小程序全局(Taro 未封装的 API 如 openBusinessView 需直接调用) */
declare const wx: {
openBusinessView?: (opts: Record<string, unknown>) => void;
canIUse?: (schema: string) => boolean;
requestPayment?: (opts: Record<string, unknown>) => void;
[key: string]: unknown;
};
declare namespace NodeJS {
interface ProcessEnv {
TARO_ENV: 'weapp' | 'h5' | string;