fix(mini-user): 门店详情加载、标题居中与走马灯展示
修复仅用 useDidShow 导致一直加载中;顶栏标题左右等宽居中;走马灯改用 View 双份文本动画。发版默认改生产。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePageScroll, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import Taro, {
|
||||
useDidShow,
|
||||
useLoad,
|
||||
usePageScroll,
|
||||
useRouter,
|
||||
useShareAppMessage,
|
||||
useShareTimeline,
|
||||
} from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import PageNavBar from '../../components/PageNavBar';
|
||||
import ProductCarousel from '../../components/ProductCarousel';
|
||||
@@ -45,7 +52,7 @@ type Store = {
|
||||
|
||||
type RecentRedeem = {
|
||||
userLabel: string;
|
||||
amount: number;
|
||||
amount: number | string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -74,10 +81,21 @@ function fullAddress(store: Store) {
|
||||
return `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
|
||||
}
|
||||
|
||||
function pickStoreId(raw?: string | null) {
|
||||
return String(raw || '')
|
||||
.trim()
|
||||
.replace(/[^\d]/g, '');
|
||||
}
|
||||
|
||||
/** 与历史记录一致:2026-08-03 15:14:30(Asia/Shanghai) */
|
||||
function formatRedeemTime(input?: string | null) {
|
||||
const d = input ? new Date(input) : new Date();
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
if (Number.isNaN(d.getTime())) {
|
||||
// 后端若已是 "2026-08-03 15:14:30" 直接展示
|
||||
const s = String(input || '').trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 19).replace('T', ' ');
|
||||
return '—';
|
||||
}
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
@@ -93,38 +111,114 @@ function formatRedeemTime(input?: string | null) {
|
||||
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
|
||||
}
|
||||
|
||||
function formatRedeemAmountYuan(amount: number) {
|
||||
if (!Number.isFinite(amount)) return '0';
|
||||
if (Number.isInteger(amount)) return String(amount);
|
||||
return amount.toFixed(2).replace(/\.?0+$/, '');
|
||||
function formatRedeemAmountYuan(amount: number | string) {
|
||||
const n = typeof amount === 'number' ? amount : Number(amount);
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
|
||||
return n.toFixed(2).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
function formatRecentRedeemLine(row: RecentRedeem) {
|
||||
return `${row.userLabel || '用户***'} ${formatRedeemTime(row.createdAt)} 核销${formatRedeemAmountYuan(Number(row.amount))}元`;
|
||||
return `${row.userLabel || '用户***'} ${formatRedeemTime(row.createdAt)} 核销${formatRedeemAmountYuan(row.amount)}元`;
|
||||
}
|
||||
|
||||
function normalizeRecentRedeems(payload: unknown): RecentRedeem[] {
|
||||
if (Array.isArray(payload)) return payload as RecentRedeem[];
|
||||
if (payload && typeof payload === 'object') {
|
||||
const list = (payload as { list?: unknown; items?: unknown }).list
|
||||
?? (payload as { items?: unknown }).items;
|
||||
if (Array.isArray(list)) return list as RecentRedeem[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export default function StoreDetailPage() {
|
||||
const router = useRouter();
|
||||
const storeId = router.params.id ?? '';
|
||||
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.id));
|
||||
const [store, setStore] = useState<Store | null>(null);
|
||||
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const storeRef = useRef<Store | null>(null);
|
||||
storeRef.current = store;
|
||||
|
||||
usePageScroll(({ scrollTop }) => {
|
||||
setHeaderSolid(scrollTop > 100);
|
||||
});
|
||||
|
||||
useDidShow(() => {
|
||||
if (!storeId) return;
|
||||
request<Store>(`/stores/${storeId}`)
|
||||
.then(setStore)
|
||||
.catch(() => {
|
||||
const loadStore = useCallback(async (id: string) => {
|
||||
if (!id) {
|
||||
setLoading(false);
|
||||
setLoadError('缺少门店参数');
|
||||
return;
|
||||
}
|
||||
setLoadError('');
|
||||
if (!storeRef.current) setLoading(true);
|
||||
try {
|
||||
const data = await request<Store>(`/stores/${id}`);
|
||||
if (!data || !data.id) {
|
||||
setStore(null);
|
||||
setLoadError('门店不存在或暂不可见');
|
||||
toast('门店不存在或暂不可见');
|
||||
});
|
||||
request<RecentRedeem[]>(`/stores/${storeId}/recent-redeems?limit=20`)
|
||||
.then((list) => setRecentRedeems(Array.isArray(list) ? list : []))
|
||||
.catch(() => setRecentRedeems([]));
|
||||
return;
|
||||
}
|
||||
setStore(data);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '加载失败';
|
||||
setLoadError(msg);
|
||||
if (!storeRef.current) toast(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRecentRedeems = useCallback(async (id: string) => {
|
||||
if (!id) {
|
||||
setRecentRedeems([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const list = await request<unknown>(`/stores/${id}/recent-redeems?limit=20`);
|
||||
setRecentRedeems(normalizeRecentRedeems(list));
|
||||
} catch {
|
||||
setRecentRedeems([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const bootstrap = useCallback(
|
||||
(id: string) => {
|
||||
const nextId = pickStoreId(id);
|
||||
if (!nextId) {
|
||||
setLoading(false);
|
||||
setLoadError('缺少门店参数');
|
||||
return;
|
||||
}
|
||||
setStoreId(nextId);
|
||||
void loadStore(nextId);
|
||||
void loadRecentRedeems(nextId);
|
||||
},
|
||||
[loadStore, loadRecentRedeems],
|
||||
);
|
||||
|
||||
// 首屏:useLoad 带 options.id,比仅用 useDidShow 更稳(H5/小程序都覆盖)
|
||||
useLoad((options) => {
|
||||
bootstrap(options?.id || router.params.id || '');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const fromRouter = pickStoreId(router.params.id);
|
||||
if (fromRouter && fromRouter !== storeId) {
|
||||
bootstrap(fromRouter);
|
||||
}
|
||||
}, [router.params.id, storeId, bootstrap]);
|
||||
|
||||
// 登录态变化后回到本页:重拉详情与走马灯
|
||||
useDidShow(() => {
|
||||
const id = pickStoreId(storeId || router.params.id);
|
||||
if (!id) return;
|
||||
void loadStore(id);
|
||||
void loadRecentRedeems(id);
|
||||
});
|
||||
|
||||
const sharePayload = useMemo(
|
||||
@@ -200,7 +294,9 @@ export default function StoreDetailPage() {
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-detail-page">
|
||||
<PageNavBar title="门店详情" solid onBack={goBack} />
|
||||
<View className="page-with-nav-bar u-empty">加载中…</View>
|
||||
<View className="page-with-nav-bar u-empty">
|
||||
{loading ? '加载中…' : loadError || '门店不存在或暂不可见'}
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -225,8 +321,7 @@ export default function StoreDetailPage() {
|
||||
}).catch(() => toast('无法预览图片'));
|
||||
}
|
||||
|
||||
// 文案过短时放慢动画,过长时加快一点(仍循环)
|
||||
const marqueeDurationSec = Math.max(18, Math.min(60, Math.round(marqueeText.length / 2.2)));
|
||||
const marqueeDurationSec = Math.max(18, Math.min(60, Math.round((marqueeText.length || 24) / 2.2)));
|
||||
|
||||
return (
|
||||
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
|
||||
@@ -288,15 +383,15 @@ export default function StoreDetailPage() {
|
||||
</View>
|
||||
|
||||
{marqueeText ? (
|
||||
<View className="store-detail-marquee" aria-hidden>
|
||||
<View className="store-detail-marquee">
|
||||
<View
|
||||
className="store-detail-marquee-track"
|
||||
style={{ animationDuration: `${marqueeDurationSec}s` }}
|
||||
style={{
|
||||
animationDuration: `${marqueeDurationSec}s`,
|
||||
}}
|
||||
>
|
||||
<Text className="store-detail-marquee-text">{marqueeText}</Text>
|
||||
<Text className="store-detail-marquee-gap"> </Text>
|
||||
<Text className="store-detail-marquee-text">{marqueeText}</Text>
|
||||
<Text className="store-detail-marquee-gap"> </Text>
|
||||
<View className="store-detail-marquee-item">{marqueeText}</View>
|
||||
<View className="store-detail-marquee-item">{marqueeText}</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user