Files
dukang/apps/mini-user/src/pages/home/index.tsx
T
jacy 06cbcdeb9c 首页商品:对齐门店「当次会话」——首次进入 / 城市变化 / 登录态变化 / 下拉刷新才拉列表,切 Tab 不再重复请求;退出登录会清缓存。
核销成功时间:按 Asia/Shanghai 格式化为「2026年8月3日 13点45分」。
核销码编号:单行省略显示,双击复制(有「双击复制」提示)。
2026-08-03 13:51:29 +08:00

427 lines
14 KiB
TypeScript

import { useCallback, useMemo, useRef, useState } from 'react';
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
import Taro, {
useDidShow,
usePageScroll,
usePullDownRefresh,
useShareAppMessage,
useShareTimeline,
} from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import CouponBadge from '../../components/CouponBadge';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { goLogin } from '../../lib/auth-nav';
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';
import {
canBuyOnline,
canCrossCity,
canPickupOnSite,
normalizeFulfillmentFlags,
} from '../../lib/product-fulfillment';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
type Product = {
id: string;
name: string;
subtitle?: string;
spec?: string;
price: number;
benefitDisplay?: number;
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
aromaType: string;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
};
type FulfillmentFilter = 'ALL' | 'ONLINE' | 'CROSS_CITY' | 'ON_SITE';
const FULFILLMENT_FILTERS: Array<{ key: FulfillmentFilter; label: string }> = [
{ key: 'ALL', label: '全部' },
{ key: 'ONLINE', label: '线上' },
{ key: 'CROSS_CITY', label: '跨城' },
{ key: 'ON_SITE', label: '现场' },
];
function matchFulfillmentFilter(p: Product, filter: FulfillmentFilter): boolean {
if (filter === 'ALL') return true;
if (filter === 'ONLINE') return canBuyOnline(p);
if (filter === 'CROSS_CITY') return canCrossCity(p);
return canPickupOnSite(p);
}
type MiniHomeConfig = {
banners: string[];
footerUrl: string | null;
};
const AROMA_TABS = [
{ key: 'QINGXIANG', label: '清香型' },
{ key: 'JIANGXIANG', label: '酱香型' },
{ key: 'NONGXIANG', label: '浓香型' },
] as const;
type AromaKey = (typeof AROMA_TABS)[number]['key'];
/** sticky 香型导航高度(与 CSS 大致一致),锚点滚动时预留 */
const AROMA_NAV_OFFSET_PX = 44;
function aromaSectionId(key: AromaKey) {
return `aroma-section-${key}`;
}
export default function HomePage() {
const [activeAroma, setActiveAroma] = useState<AromaKey>('QINGXIANG');
const [fulfillmentFilter, setFulfillmentFilter] = useState<FulfillmentFilter>('ALL');
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [displayCity, setDisplayCity] = useState('郑州市');
const [cityCode, setCityCode] = useState('410100');
const [miniHome, setMiniHome] = useState<MiniHomeConfig>({ banners: [], footerUrl: null });
const scrollingToRef = useRef<AromaKey | null>(null);
const scrollLockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastScrollSyncAtRef = useRef(0);
const loadMiniHome = useCallback(() => {
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config')
.then((cfg) => {
const banners = Array.isArray(cfg.miniHome?.banners)
? cfg.miniHome!.banners.filter((u) => typeof u === 'string' && !!u.trim())
: [];
const footerUrl =
typeof cfg.miniHome?.footerUrl === 'string' && cfg.miniHome.footerUrl.trim()
? cfg.miniHome.footerUrl.trim()
: null;
setMiniHome({ banners, footerUrl });
})
.catch(() => {
/* 首页装饰图失败不阻断商品列表 */
});
}, []);
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<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(() => {
syncTabBarSelected(0);
void capturePromoSceneAndTouchScan();
void loadMiniHome();
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);
setCityCode(nextCode);
setLoading(true);
const [list] = await Promise.all([
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`),
loadMiniHome(),
]);
applyProductList(
Array.isArray(list) ? list : [],
nextCode,
authKey,
);
} catch (e) {
toast(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
Taro.stopPullDownRefresh();
}
})();
});
function openProductDetail(id: string) {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
}
async function goOnSitePickup(productId: string) {
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
if (!isLoggedIn()) {
goLogin(returnPath);
return;
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath });
}
const filteredProducts = useMemo(
() => products.filter((p) => matchFulfillmentFilter(p, fulfillmentFilter)),
[products, fulfillmentFilter],
);
const productsByAroma = useMemo(() => {
const map: Record<AromaKey, Product[]> = {
QINGXIANG: [],
JIANGXIANG: [],
NONGXIANG: [],
};
for (const p of filteredProducts) {
const key = p.aromaType as AromaKey;
if (key in map) map[key].push(p);
}
return map;
}, [filteredProducts]);
const banners = miniHome.banners;
const footerUrl = miniHome.footerUrl;
const sharePayload = useMemo(
() => ({
title: DEFAULT_SHARE_TITLE,
desc: DEFAULT_SHARE_DESC,
path: '/pages/home/index',
imgUrl: banners[0] || undefined,
}),
[banners],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
imageUrl: sharePayload.imgUrl,
}));
function scrollToAroma(key: AromaKey) {
setActiveAroma(key);
scrollingToRef.current = key;
if (scrollLockTimerRef.current) clearTimeout(scrollLockTimerRef.current);
scrollLockTimerRef.current = setTimeout(() => {
scrollingToRef.current = null;
}, 450);
const query = Taro.createSelectorQuery();
query.select(`#${aromaSectionId(key)}`).boundingClientRect();
query.selectViewport().scrollOffset();
query.exec((res) => {
const rect = res?.[0] as { top?: number } | undefined;
const viewport = res?.[1] as { scrollTop?: number } | undefined;
if (rect?.top == null || viewport?.scrollTop == null) return;
const scrollTop = Math.max(0, viewport.scrollTop + rect.top - AROMA_NAV_OFFSET_PX);
void Taro.pageScrollTo({ scrollTop, duration: 280 });
});
}
usePageScroll(() => {
if (scrollingToRef.current) return;
const now = Date.now();
if (now - lastScrollSyncAtRef.current < 80) return;
lastScrollSyncAtRef.current = now;
const query = Taro.createSelectorQuery();
AROMA_TABS.forEach((t) => {
query.select(`#${aromaSectionId(t.key)}`).boundingClientRect();
});
query.exec((rects) => {
if (!Array.isArray(rects) || rects.length === 0) return;
let next: AromaKey = AROMA_TABS[0].key;
for (let i = 0; i < AROMA_TABS.length; i++) {
const rect = rects[i] as { top?: number } | null;
if (!rect || rect.top == null) continue;
// 区块顶进入导航下方一带时视为当前香型
if (rect.top <= AROMA_NAV_OFFSET_PX + 24) {
next = AROMA_TABS[i].key;
}
}
setActiveAroma((prev) => (prev === next ? prev : next));
});
});
function renderProductCard(p: Product) {
const thumb = getProductMainImage(p);
const spec = p.subtitle || p.spec || '';
return (
<View key={p.id} className="home-product-card">
<View className="home-product-card-inner" onClick={() => openProductDetail(p.id)}>
<View className="home-product-thumb-wrap">
{thumb ? (
<Image className="home-product-thumb" src={thumb} mode="aspectFill" />
) : (
<View className="home-product-thumb home-product-thumb--empty" />
)}
</View>
<View className="home-product-main">
<View className="home-product-row">
<Text className="home-product-name">{p.name}</Text>
<Text className="home-product-price">¥{Number(p.price).toFixed(0)}</Text>
</View>
{spec ? <Text className="home-product-sub">{spec}</Text> : null}
<View className="home-product-footer">
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
</View>
<View className="home-product-actions">
{canPickupOnSite(p) ? (
<Text
className="home-pickup-btn"
onClick={(e) => {
e.stopPropagation?.();
void goOnSitePickup(p.id);
}}
>
现场取货
</Text>
) : null}
{canBuyOnline(p) ? (
<Text
className="home-buy-btn"
onClick={(e) => {
e.stopPropagation?.();
openProductDetail(p.id);
}}
>
立即购买
</Text>
) : null}
</View>
</View>
</View>
</View>
);
}
return (
<PageShell variant="tab" className="home-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="杜康好客" />
{banners.length > 0 ? (
<View className="home-promo-banner">
<Swiper
className="home-promo-banner-swiper"
indicatorDots={banners.length > 1}
autoplay={banners.length > 1}
circular={banners.length > 1}
interval={2500}
>
{banners.map((url) => (
<SwiperItem key={url}>
<Image className="home-promo-banner-img" src={url} mode="aspectFill" />
</SwiperItem>
))}
</Swiper>
</View>
) : null}
<View className="home-aroma-nav">
<View className="home-aroma-tabs">
{AROMA_TABS.map((t) => (
<Text
key={t.key}
className={`home-aroma-tab${activeAroma === t.key ? ' home-aroma-tab--active' : ''}`}
onClick={() => scrollToAroma(t.key)}
>
{t.label}
</Text>
))}
</View>
<Text className="home-aroma-city">{displayCity}</Text>
</View>
<View className="home-fulfillment-filters">
{FULFILLMENT_FILTERS.map((f) => (
<Text
key={f.key}
className={`home-fulfillment-chip${fulfillmentFilter === f.key ? ' home-fulfillment-chip--active' : ''}`}
onClick={() => setFulfillmentFilter(f.key)}
>
{f.label}
</Text>
))}
</View>
<View className="home-product-list">
{loading ? <View className="home-empty">加载中…</View> : null}
{!loading && products.length === 0 ? (
<View className="home-empty">当前城市暂无在售商品</View>
) : null}
{!loading && products.length > 0 && filteredProducts.length === 0 ? (
<View className="home-empty">暂无符合履约方式的商品</View>
) : null}
{!loading &&
filteredProducts.length > 0 &&
AROMA_TABS.map((t) => {
const list = productsByAroma[t.key];
return (
<View key={t.key} id={aromaSectionId(t.key)} className="home-aroma-section">
<Text className="home-aroma-section-title">{t.label}</Text>
{list.length === 0 ? (
<View className="home-empty home-empty--section">该香型暂未上线</View>
) : (
list.map((p) => renderProductCard(p))
)}
</View>
);
})}
</View>
{footerUrl ? (
<View className="home-promo-footer">
<Image className="home-promo-footer-img" src={footerUrl} mode="aspectFill" />
</View>
) : null}
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
</PageShell>
);
}