fix(mini-user): 门店详情加载、标题居中与走马灯展示
修复仅用 useDidShow 导致一直加载中;顶栏标题左右等宽居中;走马灯改用 View 双份文本动画。发版默认改生产。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,11 +18,13 @@ description: >-
|
||||
| `dev` | **staging 测试** | `/opt/dukang-staging` | 8190–8194 | `*-test.dukanghaoke.com` |
|
||||
| `main` | **production 生产** | `/opt/dukang` | 8090–8094 | `*.dukanghaoke.com` |
|
||||
|
||||
默认:`dev_jacy` → merge → `dev` → **发测试**。
|
||||
生产:`dev` 验收通过 → merge → `main` → **发生产**。勿 force push `main`/`dev`。
|
||||
默认:`dev_jacy` → merge → `dev` → merge → `main` → **发生产**。勿 force push `main`/`dev`。
|
||||
|
||||
用户只说「发布」且未指明生产时 → **发测试(staging)**。
|
||||
用户说「发生产 / 上线生产」→ 用 `deploy-prod.sh`。
|
||||
> **团队约定(2026-08)**:用户说「发布 / 发版 / 直接发版」且**未特别强调**时 → **发生产(production)**。
|
||||
> 仅当用户明确说「发测试 / staging / 测试环境」时 → 发 staging。
|
||||
|
||||
用户说「发生产 / 上线生产 / 发版」→ 用 `deploy-prod.sh`。
|
||||
用户说「发测试」→ 用 `deploy-staging.sh`。
|
||||
|
||||
## 标准流程(Windows)
|
||||
|
||||
|
||||
@@ -106,6 +106,8 @@ export function tabNavContentStyle(metrics: NavBarMetrics): Record<string, strin
|
||||
/** 子页/内页顶栏:标题全屏居中,内容区单独留白 */
|
||||
export function subPageNavBarStyle(metrics: NavBarMetrics): Record<string, string | number> {
|
||||
const pagePad = 20;
|
||||
// 标题两侧取「右侧避让胶囊」宽度,保证相对屏幕视觉居中
|
||||
const titlePad = Math.max(metrics.navBarPaddingRight, 72);
|
||||
return {
|
||||
paddingTop: `${metrics.statusBarHeight}px`,
|
||||
height: `${metrics.navBarHeight}px`,
|
||||
@@ -113,7 +115,7 @@ export function subPageNavBarStyle(metrics: NavBarMetrics): Record<string, strin
|
||||
'--nav-content-height': `${metrics.navContentHeight}px`,
|
||||
'--nav-status-bar-height': `${metrics.statusBarHeight}px`,
|
||||
'--nav-padding-left': `${pagePad}px`,
|
||||
'--nav-padding-right': `${metrics.navBarPaddingRight}px`,
|
||||
'--nav-padding-right': `${titlePad}px`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -203,7 +203,8 @@
|
||||
height: var(--nav-content-height);
|
||||
line-height: var(--nav-content-height);
|
||||
text-align: center;
|
||||
padding: 0 88px 0 56px;
|
||||
/* 左右等宽留白,避免右侧分享/胶囊导致标题视觉偏左 */
|
||||
padding: 0 max(72px, var(--nav-padding-right, 96px));
|
||||
box-sizing: border-box;
|
||||
opacity: 0;
|
||||
font-family: var(--font-headline);
|
||||
|
||||
@@ -129,16 +129,16 @@
|
||||
|
||||
.store-detail-marquee {
|
||||
margin: 0 var(--space-page) 12px;
|
||||
padding: 8px 12px;
|
||||
padding: 8px 0;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(166, 29, 36, 0.06);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-detail-marquee-track {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
width: max-content;
|
||||
animation-name: store-detail-marquee-scroll;
|
||||
animation-timing-function: linear;
|
||||
@@ -146,20 +146,21 @@
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.store-detail-marquee-text,
|
||||
.store-detail-marquee-gap {
|
||||
flex-shrink: 0;
|
||||
.store-detail-marquee-item {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 24px;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
line-height: 1.5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@keyframes store-detail-marquee-scroll {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user