首页商品:对齐门店「当次会话」——首次进入 / 城市变化 / 登录态变化 / 下拉刷新才拉列表,切 Tab 不再重复请求;退出登录会清缓存。

核销成功时间:按 Asia/Shanghai 格式化为「2026年8月3日 13点45分」。
核销码编号:单行省略显示,双击复制(有「双击复制」提示)。
This commit is contained in:
2026-08-03 13:51:29 +08:00
parent ebd8c07147
commit 06cbcdeb9c
6 changed files with 185 additions and 30 deletions
+3 -1
View File
@@ -2,6 +2,7 @@ import Taro from '@tarojs/taro';
import { ClientApp } from '@dukang/shared-types'; import { ClientApp } from '@dukang/shared-types';
import { forceReloadAfterAccountMerge } from './auth-nav'; import { forceReloadAfterAccountMerge } from './auth-nav';
import { resetStoresSessionBootstrap } from './stores-session'; import { resetStoresSessionBootstrap } from './stores-session';
import { resetHomeCatalogBootstrap } from './home-catalog-session';
function resolveApiBase(): string { function resolveApiBase(): string {
const origin = const origin =
@@ -57,8 +58,9 @@ export function isLoggedIn(): boolean {
export function logout() { export function logout() {
clearAuth(); clearAuth();
// 主动退出才重置门店「当次登录」会话;401 清 token 不要打断门店筛选 // 主动退出才重置门店/首页「当次登录」会话;401 清 token 不要打断筛选
resetStoresSessionBootstrap(); resetStoresSessionBootstrap();
resetHomeCatalogBootstrap();
Taro.reLaunch({ url: '/pages/home/index' }); Taro.reLaunch({ url: '/pages/home/index' });
} }
@@ -0,0 +1,75 @@
/**
* 首页商品列表「当次登录」会话 —— 切 tab 不重复拉商品;
* 城市 / 登录态变化或下拉刷新时再请求。logout 时 clear。
*/
import Taro from '@tarojs/taro';
export type HomeCatalogCache = {
cityCode: string;
authKey: string;
products: unknown[];
};
type HomeSession = {
bootstrapped: boolean;
cache: HomeCatalogCache | null;
};
const STORAGE_KEY = 'dukang_home_catalog_session_v1';
let memory: HomeSession | null = null;
function emptySession(): HomeSession {
return { bootstrapped: false, cache: null };
}
function readSession(): HomeSession {
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<HomeSession>;
memory = {
bootstrapped: !!parsed.bootstrapped,
cache: (parsed.cache as HomeCatalogCache | null) ?? null,
};
return memory;
} catch {
memory = emptySession();
return memory;
}
}
function writeSession(next: HomeSession) {
memory = next;
try {
Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
export function isHomeCatalogBootstrapped(): boolean {
return readSession().bootstrapped;
}
export function getHomeCatalogCache(): HomeCatalogCache | null {
return readSession().cache;
}
export function setHomeCatalogCache(cache: HomeCatalogCache): void {
writeSession({ bootstrapped: true, cache });
}
export function resetHomeCatalogBootstrap(): void {
memory = emptySession();
try {
Taro.removeStorageSync(STORAGE_KEY);
} catch {
/* ignore */
}
}
+56 -24
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components'; import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
import Taro, { import Taro, {
useDidShow, useDidShow,
@@ -13,7 +13,12 @@ import CouponBadge from '../../components/CouponBadge';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { isLoggedIn, request, toast } from '../../lib/api'; import { getToken, isLoggedIn, request, toast } from '../../lib/api';
import {
getHomeCatalogCache,
isHomeCatalogBootstrapped,
setHomeCatalogCache,
} from '../../lib/home-catalog-session';
import { ensurePayReady } from '../../lib/pay-ready'; import { ensurePayReady } from '../../lib/pay-ready';
import { capturePromoSceneAndTouchScan } from '../../lib/promo'; import { capturePromoSceneAndTouchScan } from '../../lib/promo';
import { getProductMainImage } from '../../lib/product-images'; import { getProductMainImage } from '../../lib/product-images';
@@ -29,7 +34,6 @@ import {
DEFAULT_SHARE_TITLE, DEFAULT_SHARE_TITLE,
toWeappShareMessage, toWeappShareMessage,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
type Product = { type Product = {
id: string; id: string;
name: string; name: string;
@@ -110,35 +114,60 @@ export default function HomePage() {
}); });
}, []); }, []);
const loadProducts = useCallback(() => { const applyProductList = useCallback((list: Product[], nextCode: string, authKey: string) => {
setLoading(true); const normalized = Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : [];
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`) setProducts(normalized);
.then((list) => setHomeCatalogCache({ cityCode: nextCode, authKey, products: normalized });
setProducts(Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : []), }, []);
)
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, [cityCode]);
const fetchProducts = useCallback(
(nextCode: string, authKey: string) => {
setLoading(true);
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`)
.then((list) => applyProductList(list, nextCode, authKey))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
},
[applyProductList],
);
/**
* 首次进入 / 城市或登录态变化:拉商品。
* 同次再切 tab:只同步选中态,不重复请求(对齐门店页)。
*/
useDidShow(() => { useDidShow(() => {
syncTabBarSelected(0); syncTabBarSelected(0);
void capturePromoSceneAndTouchScan(); void capturePromoSceneAndTouchScan();
void loadMiniHome(); void loadMiniHome();
// 白名单商品按登录手机号过滤;登录后 switchTab 回首页不会卸载页面,须重新拉列表
void loadProducts();
void resolveUserCity().then((resolved) => {
setDisplayCity(resolved.displayCity);
setCityCode(getCityCodeForCatalog(resolved));
});
});
useEffect(() => { const authKey = getToken() || '';
void loadProducts(); void (async () => {
}, [loadProducts]); const resolved = await resolveUserCity();
const nextCode = getCityCodeForCatalog(resolved);
setDisplayCity(resolved.displayCity);
setCityCode(nextCode);
const cache = getHomeCatalogCache();
if (
isHomeCatalogBootstrapped() &&
cache &&
cache.cityCode === nextCode &&
cache.authKey === authKey &&
Array.isArray(cache.products)
) {
setProducts(cache.products as Product[]);
setLoading(false);
return;
}
await fetchProducts(nextCode, authKey);
})();
});
usePullDownRefresh(() => { usePullDownRefresh(() => {
void (async () => { void (async () => {
try { try {
const authKey = getToken() || '';
const resolved = await resolveUserCity(); const resolved = await resolveUserCity();
setDisplayCity(resolved.displayCity); setDisplayCity(resolved.displayCity);
const nextCode = getCityCodeForCatalog(resolved); const nextCode = getCityCodeForCatalog(resolved);
@@ -148,7 +177,11 @@ export default function HomePage() {
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`), request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`),
loadMiniHome(), loadMiniHome(),
]); ]);
setProducts(Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : []); applyProductList(
Array.isArray(list) ? list : [],
nextCode,
authKey,
);
} catch (e) { } catch (e) {
toast(e instanceof Error ? e.message : '加载失败'); toast(e instanceof Error ? e.message : '加载失败');
} finally { } finally {
@@ -157,7 +190,6 @@ export default function HomePage() {
} }
})(); })();
}); });
function openProductDetail(id: string) { function openProductDetail(id: string) {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` }); Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
} }
+16 -1
View File
@@ -44,6 +44,20 @@ export default function RedeemCodePage() {
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS); const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const successHandled = useRef(false); const successHandled = useRef(false);
const lastTokenTapAt = useRef(0);
function onTokenTap() {
if (!token) return;
const now = Date.now();
if (now - lastTokenTapAt.current < 350) {
lastTokenTapAt.current = 0;
void Taro.setClipboardData({ data: token })
.then(() => toast('核销码编号已复制', 'success'))
.catch(() => toast('复制失败'));
return;
}
lastTokenTapAt.current = now;
}
const stopTimer = useCallback(() => { const stopTimer = useCallback(() => {
if (timerRef.current != null) { if (timerRef.current != null) {
@@ -144,9 +158,10 @@ export default function RedeemCodePage() {
<Text className="u-muted"></Text> <Text className="u-muted"></Text>
<Text className="redeem-code-amount">¥ {formatMoney(amount)}</Text> <Text className="redeem-code-amount">¥ {formatMoney(amount)}</Text>
{token ? ( {token ? (
<View className="redeem-code-token-wrap"> <View className="redeem-code-token-wrap" onClick={onTokenTap}>
<Text className="redeem-code-token-label"></Text> <Text className="redeem-code-token-label"></Text>
<Text className="redeem-code-token">{token}</Text> <Text className="redeem-code-token">{token}</Text>
<Text className="redeem-code-token-hint"></Text>
</View> </View>
) : null} ) : null}
</View> </View>
@@ -20,6 +20,29 @@ function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
/** 中国时区展示:2026年8月3日 13点45分 */
function formatChinaDateTime(input?: string | null) {
const d = input ? new Date(input) : new Date();
if (Number.isNaN(d.getTime())) return '—';
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: false,
}).formatToParts(d);
const get = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((p) => p.type === type)?.value ?? '';
const year = get('year');
const month = String(Number(get('month')));
const day = String(Number(get('day')));
const hour = String(Number(get('hour')));
const minute = get('minute').padStart(2, '0');
return `${year}${month}${day}${hour}${minute}`;
}
function StarRating({ function StarRating({
label, label,
value, value,
@@ -65,9 +88,7 @@ export default function RedeemSuccessPage() {
const amount = Number(record?.amount ?? router.params.amount ?? 0); const amount = Number(record?.amount ?? router.params.amount ?? 0);
const storeName = record?.storeName || '门店'; const storeName = record?.storeName || '门店';
const redeemNo = record?.redeemNo || '—'; const redeemNo = record?.redeemNo || '—';
const redeemedAt = record?.createdAt const redeemedAt = formatChinaDateTime(record?.createdAt);
? new Date(record.createdAt).toLocaleString('zh-CN')
: new Date().toLocaleString('zh-CN');
function clearCache() { function clearCache() {
try { try {
+11 -1
View File
@@ -304,9 +304,19 @@
display: block; display: block;
font-family: ui-monospace, monospace; font-family: ui-monospace, monospace;
font-size: 11px; font-size: 11px;
word-break: break-all;
color: var(--color-on-surface); color: var(--color-on-surface);
line-height: 1.5; line-height: 1.5;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.redeem-code-token-hint {
display: block;
margin-top: 4px;
font-size: 11px;
color: var(--color-subtle-gray);
} }
.redeem-success-icon { .redeem-success-icon {