fix(mini-user): 门店详情加载、标题居中与走马灯展示

修复仅用 useDidShow 导致一直加载中;顶栏标题左右等宽居中;走马灯改用 View 双份文本动画。发版默认改生产。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 14:28:59 +08:00
parent 4a5b9eaffa
commit 2ebdb08a2f
5 changed files with 145 additions and 44 deletions
+123 -28
View File
@@ -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:30Asia/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}