From 06cbcdeb9c45275708619e294de6dca86e7510da Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Mon, 3 Aug 2026 13:51:29 +0800 Subject: [PATCH] =?UTF-8?q?=E9=A6=96=E9=A1=B5=E5=95=86=E5=93=81=EF=BC=9A?= =?UTF-8?q?=E5=AF=B9=E9=BD=90=E9=97=A8=E5=BA=97=E3=80=8C=E5=BD=93=E6=AC=A1?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E3=80=8D=E2=80=94=E2=80=94=E9=A6=96=E6=AC=A1?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=20/=20=E5=9F=8E=E5=B8=82=E5=8F=98=E5=8C=96?= =?UTF-8?q?=20/=20=E7=99=BB=E5=BD=95=E6=80=81=E5=8F=98=E5=8C=96=20/=20?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E5=88=B7=E6=96=B0=E6=89=8D=E6=8B=89=E5=88=97?= =?UTF-8?q?=E8=A1=A8=EF=BC=8C=E5=88=87=20Tab=20=E4=B8=8D=E5=86=8D=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E8=AF=B7=E6=B1=82=EF=BC=9B=E9=80=80=E5=87=BA=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=BC=9A=E6=B8=85=E7=BC=93=E5=AD=98=E3=80=82=20?= =?UTF-8?q?=E6=A0=B8=E9=94=80=E6=88=90=E5=8A=9F=E6=97=B6=E9=97=B4=EF=BC=9A?= =?UTF-8?q?=E6=8C=89=20Asia/Shanghai=20=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E4=B8=BA=E3=80=8C2026=E5=B9=B48=E6=9C=883=E6=97=A5=2013?= =?UTF-8?q?=E7=82=B945=E5=88=86=E3=80=8D=E3=80=82=20=E6=A0=B8=E9=94=80?= =?UTF-8?q?=E7=A0=81=E7=BC=96=E5=8F=B7=EF=BC=9A=E5=8D=95=E8=A1=8C=E7=9C=81?= =?UTF-8?q?=E7=95=A5=E6=98=BE=E7=A4=BA=EF=BC=8C=E5=8F=8C=E5=87=BB=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=EF=BC=88=E6=9C=89=E3=80=8C=E5=8F=8C=E5=87=BB=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E3=80=8D=E6=8F=90=E7=A4=BA=EF=BC=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/mini-user/src/lib/api.ts | 4 +- .../mini-user/src/lib/home-catalog-session.ts | 75 +++++++++++++++++ apps/mini-user/src/pages/home/index.tsx | 80 +++++++++++++------ .../mini-user/src/pages/redeem-code/index.tsx | 17 +++- .../src/pages/redeem-success/index.tsx | 27 ++++++- apps/mini-user/src/styles/redeem.css | 12 ++- 6 files changed, 185 insertions(+), 30 deletions(-) create mode 100644 apps/mini-user/src/lib/home-catalog-session.ts diff --git a/apps/mini-user/src/lib/api.ts b/apps/mini-user/src/lib/api.ts index 211d905..1f27fe9 100644 --- a/apps/mini-user/src/lib/api.ts +++ b/apps/mini-user/src/lib/api.ts @@ -2,6 +2,7 @@ import Taro from '@tarojs/taro'; import { ClientApp } from '@dukang/shared-types'; import { forceReloadAfterAccountMerge } from './auth-nav'; import { resetStoresSessionBootstrap } from './stores-session'; +import { resetHomeCatalogBootstrap } from './home-catalog-session'; function resolveApiBase(): string { const origin = @@ -57,8 +58,9 @@ export function isLoggedIn(): boolean { export function logout() { clearAuth(); - // 主动退出才重置门店「当次登录」会话;401 清 token 不要打断门店筛选 + // 主动退出才重置门店/首页「当次登录」会话;401 清 token 不要打断筛选 resetStoresSessionBootstrap(); + resetHomeCatalogBootstrap(); Taro.reLaunch({ url: '/pages/home/index' }); } diff --git a/apps/mini-user/src/lib/home-catalog-session.ts b/apps/mini-user/src/lib/home-catalog-session.ts new file mode 100644 index 0000000..72eca7a --- /dev/null +++ b/apps/mini-user/src/lib/home-catalog-session.ts @@ -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; + 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 */ + } +} diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx index a4ef76d..6657198 100644 --- a/apps/mini-user/src/pages/home/index.tsx +++ b/apps/mini-user/src/pages/home/index.tsx @@ -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 Taro, { useDidShow, @@ -13,7 +13,12 @@ import CouponBadge from '../../components/CouponBadge'; import WechatShareReady from '../../components/WechatShareReady'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; 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 { capturePromoSceneAndTouchScan } from '../../lib/promo'; import { getProductMainImage } from '../../lib/product-images'; @@ -29,7 +34,6 @@ import { DEFAULT_SHARE_TITLE, toWeappShareMessage, } from '../../lib/wechat-share'; - type Product = { id: string; name: string; @@ -110,35 +114,60 @@ export default function HomePage() { }); }, []); - const loadProducts = useCallback(() => { - setLoading(true); - return request(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`) - .then((list) => - setProducts(Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : []), - ) - .catch((e) => toast(e instanceof Error ? e.message : '加载失败')) - .finally(() => setLoading(false)); - }, [cityCode]); + 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); + return request(`/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(() => { syncTabBarSelected(0); void capturePromoSceneAndTouchScan(); void loadMiniHome(); - // 白名单商品按登录手机号过滤;登录后 switchTab 回首页不会卸载页面,须重新拉列表 - void loadProducts(); - void resolveUserCity().then((resolved) => { - setDisplayCity(resolved.displayCity); - setCityCode(getCityCodeForCatalog(resolved)); - }); - }); - useEffect(() => { - void loadProducts(); - }, [loadProducts]); + const authKey = getToken() || ''; + void (async () => { + 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(() => { void (async () => { try { + const authKey = getToken() || ''; const resolved = await resolveUserCity(); setDisplayCity(resolved.displayCity); const nextCode = getCityCodeForCatalog(resolved); @@ -148,7 +177,11 @@ export default function HomePage() { request(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`), loadMiniHome(), ]); - setProducts(Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : []); + applyProductList( + Array.isArray(list) ? list : [], + nextCode, + authKey, + ); } catch (e) { toast(e instanceof Error ? e.message : '加载失败'); } finally { @@ -157,7 +190,6 @@ export default function HomePage() { } })(); }); - function openProductDetail(id: string) { Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` }); } diff --git a/apps/mini-user/src/pages/redeem-code/index.tsx b/apps/mini-user/src/pages/redeem-code/index.tsx index 741851b..e7cde41 100644 --- a/apps/mini-user/src/pages/redeem-code/index.tsx +++ b/apps/mini-user/src/pages/redeem-code/index.tsx @@ -44,6 +44,20 @@ export default function RedeemCodePage() { const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS); const timerRef = useRef | null>(null); 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(() => { if (timerRef.current != null) { @@ -144,9 +158,10 @@ export default function RedeemCodePage() { 待核销金额 ¥ {formatMoney(amount)} {token ? ( - + 核销码编号(供追查) {token} + 双击复制 ) : null} diff --git a/apps/mini-user/src/pages/redeem-success/index.tsx b/apps/mini-user/src/pages/redeem-success/index.tsx index d7b07f9..0a336f4 100644 --- a/apps/mini-user/src/pages/redeem-success/index.tsx +++ b/apps/mini-user/src/pages/redeem-success/index.tsx @@ -20,6 +20,29 @@ function formatMoney(amount: number) { 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({ label, value, @@ -65,9 +88,7 @@ export default function RedeemSuccessPage() { const amount = Number(record?.amount ?? router.params.amount ?? 0); const storeName = record?.storeName || '门店'; const redeemNo = record?.redeemNo || '—'; - const redeemedAt = record?.createdAt - ? new Date(record.createdAt).toLocaleString('zh-CN') - : new Date().toLocaleString('zh-CN'); + const redeemedAt = formatChinaDateTime(record?.createdAt); function clearCache() { try { diff --git a/apps/mini-user/src/styles/redeem.css b/apps/mini-user/src/styles/redeem.css index a6d1891..45342a9 100644 --- a/apps/mini-user/src/styles/redeem.css +++ b/apps/mini-user/src/styles/redeem.css @@ -304,9 +304,19 @@ display: block; font-family: ui-monospace, monospace; font-size: 11px; - word-break: break-all; color: var(--color-on-surface); 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 {