merge(dev): 门店详情修复
CI / verify (push) Has been cancelled

This commit is contained in:
2026-08-03 14:29:03 +08:00
5 changed files with 145 additions and 44 deletions
+6 -4
View File
@@ -18,11 +18,13 @@ description: >-
| `dev` | **staging 测试** | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| `main` | **production 生产** | `/opt/dukang` | 80908094 | `*.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
+3 -1
View File
@@ -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`,
};
}
+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}
+2 -1
View File
@@ -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);
+11 -10
View File
@@ -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);
}
}