首页商品:对齐门店「当次会话」——首次进入 / 城市变化 / 登录态变化 / 下拉刷新才拉列表,切 Tab 不再重复请求;退出登录会清缓存。
核销成功时间:按 Asia/Shanghai 格式化为「2026年8月3日 13点45分」。 核销码编号:单行省略显示,双击复制(有「双击复制」提示)。
This commit is contained in:
@@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) => {
|
||||||
|
const normalized = Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : [];
|
||||||
|
setProducts(normalized);
|
||||||
|
setHomeCatalogCache({ cityCode: nextCode, authKey, products: normalized });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchProducts = useCallback(
|
||||||
|
(nextCode: string, authKey: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`)
|
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`)
|
||||||
.then((list) =>
|
.then((list) => applyProductList(list, nextCode, authKey))
|
||||||
setProducts(Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : []),
|
|
||||||
)
|
|
||||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [cityCode]);
|
},
|
||||||
|
[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}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user