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` |
|
| `dev` | **staging 测试** | `/opt/dukang-staging` | 8190–8194 | `*-test.dukanghaoke.com` |
|
||||||
| `main` | **production 生产** | `/opt/dukang` | 8090–8094 | `*.dukanghaoke.com` |
|
| `main` | **production 生产** | `/opt/dukang` | 8090–8094 | `*.dukanghaoke.com` |
|
||||||
|
|
||||||
默认:`dev_jacy` → merge → `dev` → **发测试**。
|
默认:`dev_jacy` → merge → `dev` → merge → `main` → **发生产**。勿 force push `main`/`dev`。
|
||||||
生产:`dev` 验收通过 → merge → `main` → **发生产**。勿 force push `main`/`dev`。
|
|
||||||
|
|
||||||
用户只说「发布」且未指明生产时 → **发测试(staging)**。
|
> **团队约定(2026-08)**:用户说「发布 / 发版 / 直接发版」且**未特别强调**时 → **发生产(production)**。
|
||||||
用户说「发生产 / 上线生产」→ 用 `deploy-prod.sh`。
|
> 仅当用户明确说「发测试 / staging / 测试环境」时 → 发 staging。
|
||||||
|
|
||||||
|
用户说「发生产 / 上线生产 / 发版」→ 用 `deploy-prod.sh`。
|
||||||
|
用户说「发测试」→ 用 `deploy-staging.sh`。
|
||||||
|
|
||||||
## 标准流程(Windows)
|
## 标准流程(Windows)
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export function tabNavContentStyle(metrics: NavBarMetrics): Record<string, strin
|
|||||||
/** 子页/内页顶栏:标题全屏居中,内容区单独留白 */
|
/** 子页/内页顶栏:标题全屏居中,内容区单独留白 */
|
||||||
export function subPageNavBarStyle(metrics: NavBarMetrics): Record<string, string | number> {
|
export function subPageNavBarStyle(metrics: NavBarMetrics): Record<string, string | number> {
|
||||||
const pagePad = 20;
|
const pagePad = 20;
|
||||||
|
// 标题两侧取「右侧避让胶囊」宽度,保证相对屏幕视觉居中
|
||||||
|
const titlePad = Math.max(metrics.navBarPaddingRight, 72);
|
||||||
return {
|
return {
|
||||||
paddingTop: `${metrics.statusBarHeight}px`,
|
paddingTop: `${metrics.statusBarHeight}px`,
|
||||||
height: `${metrics.navBarHeight}px`,
|
height: `${metrics.navBarHeight}px`,
|
||||||
@@ -113,7 +115,7 @@ export function subPageNavBarStyle(metrics: NavBarMetrics): Record<string, strin
|
|||||||
'--nav-content-height': `${metrics.navContentHeight}px`,
|
'--nav-content-height': `${metrics.navContentHeight}px`,
|
||||||
'--nav-status-bar-height': `${metrics.statusBarHeight}px`,
|
'--nav-status-bar-height': `${metrics.statusBarHeight}px`,
|
||||||
'--nav-padding-left': `${pagePad}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 { 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 PageShell from '../../components/PageShell';
|
||||||
import PageNavBar from '../../components/PageNavBar';
|
import PageNavBar from '../../components/PageNavBar';
|
||||||
import ProductCarousel from '../../components/ProductCarousel';
|
import ProductCarousel from '../../components/ProductCarousel';
|
||||||
@@ -45,7 +52,7 @@ type Store = {
|
|||||||
|
|
||||||
type RecentRedeem = {
|
type RecentRedeem = {
|
||||||
userLabel: string;
|
userLabel: string;
|
||||||
amount: number;
|
amount: number | string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,10 +81,21 @@ function fullAddress(store: Store) {
|
|||||||
return `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
|
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) */
|
/** 与历史记录一致:2026-08-03 15:14:30(Asia/Shanghai) */
|
||||||
function formatRedeemTime(input?: string | null) {
|
function formatRedeemTime(input?: string | null) {
|
||||||
const d = input ? new Date(input) : new Date();
|
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', {
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||||
timeZone: 'Asia/Shanghai',
|
timeZone: 'Asia/Shanghai',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -93,38 +111,114 @@ function formatRedeemTime(input?: string | null) {
|
|||||||
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
|
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRedeemAmountYuan(amount: number) {
|
function formatRedeemAmountYuan(amount: number | string) {
|
||||||
if (!Number.isFinite(amount)) return '0';
|
const n = typeof amount === 'number' ? amount : Number(amount);
|
||||||
if (Number.isInteger(amount)) return String(amount);
|
if (!Number.isFinite(n)) return '0';
|
||||||
return amount.toFixed(2).replace(/\.?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) {
|
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() {
|
export default function StoreDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const storeId = router.params.id ?? '';
|
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.id));
|
||||||
const [store, setStore] = useState<Store | null>(null);
|
const [store, setStore] = useState<Store | null>(null);
|
||||||
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
|
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState('');
|
||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
|
const storeRef = useRef<Store | null>(null);
|
||||||
|
storeRef.current = store;
|
||||||
|
|
||||||
usePageScroll(({ scrollTop }) => {
|
usePageScroll(({ scrollTop }) => {
|
||||||
setHeaderSolid(scrollTop > 100);
|
setHeaderSolid(scrollTop > 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
useDidShow(() => {
|
const loadStore = useCallback(async (id: string) => {
|
||||||
if (!storeId) return;
|
if (!id) {
|
||||||
request<Store>(`/stores/${storeId}`)
|
setLoading(false);
|
||||||
.then(setStore)
|
setLoadError('缺少门店参数');
|
||||||
.catch(() => {
|
return;
|
||||||
|
}
|
||||||
|
setLoadError('');
|
||||||
|
if (!storeRef.current) setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await request<Store>(`/stores/${id}`);
|
||||||
|
if (!data || !data.id) {
|
||||||
setStore(null);
|
setStore(null);
|
||||||
|
setLoadError('门店不存在或暂不可见');
|
||||||
toast('门店不存在或暂不可见');
|
toast('门店不存在或暂不可见');
|
||||||
|
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 || '');
|
||||||
});
|
});
|
||||||
request<RecentRedeem[]>(`/stores/${storeId}/recent-redeems?limit=20`)
|
|
||||||
.then((list) => setRecentRedeems(Array.isArray(list) ? list : []))
|
useEffect(() => {
|
||||||
.catch(() => setRecentRedeems([]));
|
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(
|
const sharePayload = useMemo(
|
||||||
@@ -200,7 +294,9 @@ export default function StoreDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<PageShell variant="scroll" className="store-detail-page">
|
<PageShell variant="scroll" className="store-detail-page">
|
||||||
<PageNavBar title="门店详情" solid onBack={goBack} />
|
<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>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -225,8 +321,7 @@ export default function StoreDetailPage() {
|
|||||||
}).catch(() => toast('无法预览图片'));
|
}).catch(() => toast('无法预览图片'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文案过短时放慢动画,过长时加快一点(仍循环)
|
const marqueeDurationSec = Math.max(18, Math.min(60, Math.round((marqueeText.length || 24) / 2.2)));
|
||||||
const marqueeDurationSec = Math.max(18, Math.min(60, Math.round(marqueeText.length / 2.2)));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
|
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
|
||||||
@@ -288,15 +383,15 @@ export default function StoreDetailPage() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{marqueeText ? (
|
{marqueeText ? (
|
||||||
<View className="store-detail-marquee" aria-hidden>
|
<View className="store-detail-marquee">
|
||||||
<View
|
<View
|
||||||
className="store-detail-marquee-track"
|
className="store-detail-marquee-track"
|
||||||
style={{ animationDuration: `${marqueeDurationSec}s` }}
|
style={{
|
||||||
|
animationDuration: `${marqueeDurationSec}s`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text className="store-detail-marquee-text">{marqueeText}</Text>
|
<View className="store-detail-marquee-item">{marqueeText}</View>
|
||||||
<Text className="store-detail-marquee-gap"> </Text>
|
<View className="store-detail-marquee-item">{marqueeText}</View>
|
||||||
<Text className="store-detail-marquee-text">{marqueeText}</Text>
|
|
||||||
<Text className="store-detail-marquee-gap"> </Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -203,7 +203,8 @@
|
|||||||
height: var(--nav-content-height);
|
height: var(--nav-content-height);
|
||||||
line-height: var(--nav-content-height);
|
line-height: var(--nav-content-height);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 0 88px 0 56px;
|
/* 左右等宽留白,避免右侧分享/胶囊导致标题视觉偏左 */
|
||||||
|
padding: 0 max(72px, var(--nav-padding-right, 96px));
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
font-family: var(--font-headline);
|
font-family: var(--font-headline);
|
||||||
|
|||||||
@@ -129,16 +129,16 @@
|
|||||||
|
|
||||||
.store-detail-marquee {
|
.store-detail-marquee {
|
||||||
margin: 0 var(--space-page) 12px;
|
margin: 0 var(--space-page) 12px;
|
||||||
padding: 8px 12px;
|
padding: 8px 0;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
background: rgba(166, 29, 36, 0.06);
|
background: rgba(166, 29, 36, 0.06);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-marquee-track {
|
.store-detail-marquee-track {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: row;
|
||||||
|
flex-wrap: nowrap;
|
||||||
width: max-content;
|
width: max-content;
|
||||||
animation-name: store-detail-marquee-scroll;
|
animation-name: store-detail-marquee-scroll;
|
||||||
animation-timing-function: linear;
|
animation-timing-function: linear;
|
||||||
@@ -146,20 +146,21 @@
|
|||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-marquee-text,
|
.store-detail-marquee-item {
|
||||||
.store-detail-marquee-gap {
|
flex: 0 0 auto;
|
||||||
flex-shrink: 0;
|
padding: 0 24px;
|
||||||
|
white-space: nowrap;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.4;
|
line-height: 1.5;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes store-detail-marquee-scroll {
|
@keyframes store-detail-marquee-scroll {
|
||||||
from {
|
from {
|
||||||
transform: translateX(0);
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: translateX(-50%);
|
transform: translate3d(-50%, 0, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user