Files
dukang/apps/mini-user/src/pages/store-detail/index.tsx
T
jacy 233ed0af3b
CI / verify (pull_request) Has been cancelled
v3.5.1 版本更新
2026-08-19 15:54:51 +08:00

484 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import '../../styles/store-detail.css';
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';
import ShareNavButton from '../../components/ShareNavButton';
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api';
import { maskPhone, toDialablePhone } from '../../lib/phone';
import { track } from '../../lib/analytics';
import { formatShanghaiDateTime } from '../../lib/datetime';
import {
buildSceneSharePayload,
toWeappShareMessage,
toWeappShareTimeline,
} from '../../lib/wechat-share';
type StoreMedia = {
url?: string | null;
bizType?: string | null;
mediaType?: string | null;
};
type StorePackage = {
name: string;
price: string | number;
dishes: string;
usableTime?: string | null;
otherNotes?: string | null;
imageUrl?: string | null;
sortOrder?: number;
};
type Store = {
id: string;
name: string;
address?: string;
province?: string;
cityName?: string;
city?: string;
district?: string;
phone?: string;
intro?: string | null;
benefitUsageRule?: string | null;
coverUrl?: string | null;
carouselUrls?: string[] | null;
media?: StoreMedia[] | null;
packages?: StorePackage[] | null;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
latitude?: number | string | null;
longitude?: number | string | null;
category?: { name: string } | null;
};
type RecentRedeem = {
userLabel: string;
amount: number | string;
createdAt: string;
text?: string;
};
function uniqueUrls(urls: Array<string | null | undefined>) {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of urls) {
const url = String(raw || '').trim();
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(url);
}
return out;
}
function envPhotoUrls(store: Store) {
return uniqueUrls(
(store.media || [])
.filter((m) => !m.bizType || m.bizType === 'ENV')
.map((m) => m.url),
);
}
function fullAddress(store: Store) {
const city = store.cityName || store.city || '';
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) {
return formatShanghaiDateTime(input);
}
function formatPackagePriceYuan(price: string | number) {
const n = typeof price === 'number' ? price : Number(price);
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 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) {
try {
// 优先用服务端拼好的 text,避免客户端时区 / Intl 差异
if (row.text?.trim()) return row.text.trim();
const label = String(row.userLabel || '用户***').trim() || '用户***';
const rawTime = String(row.createdAt || '').trim();
const time =
/^\d{4}-\d{2}-\d{2}/.test(rawTime)
? rawTime.slice(0, 19).replace('T', ' ')
: formatRedeemTime(row.createdAt);
const amount = formatRedeemAmountYuan(row.amount);
return `${label} ${time} 核销${amount}元`;
} catch {
return '';
}
}
function normalizeRecentRedeems(payload: unknown): RecentRedeem[] {
try {
if (Array.isArray(payload)) return payload as RecentRedeem[];
if (payload && typeof payload === 'object') {
const list =
(payload as { list?: unknown; items?: unknown; data?: unknown }).list ??
(payload as { items?: unknown }).items ??
(payload as { data?: unknown }).data;
if (Array.isArray(list)) return list as RecentRedeem[];
}
} catch {
/* ignore */
}
return [];
}
export default function StoreDetailPage() {
const router = useRouter();
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);
});
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('门店不存在或暂不可见');
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(() => {
const envFirst = store ? envPhotoUrls(store)[0] : undefined;
return buildSceneSharePayload('storeDetail', {
path: `/pages/store-detail/index?id=${storeId}`,
dynamicTitle: store?.name,
dynamicDesc: store?.intro?.trim() || store?.address,
dynamicImageUrl: store?.coverUrl || envFirst || store?.carouselUrls?.[0],
});
}, [store, storeId]);
const marqueeLines = useMemo(
() => recentRedeems.map(formatRecentRedeemLine).filter(Boolean),
[recentRedeems],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() =>
toWeappShareTimeline(sharePayload, storeId ? `id=${storeId}` : ''),
);
function goBack() {
const pages = Taro.getCurrentPages();
if (pages.length > 1) Taro.navigateBack();
else Taro.switchTab({ url: '/pages/stores/index' });
}
function callStore() {
if (!store?.phone) {
toast('暂无门店电话');
return;
}
track('store_phone_call', { storeId: store.id });
Taro.makePhoneCall({ phoneNumber: toDialablePhone(store.phone) }).catch(() => toast('无法拨打电话'));
}
function openMap() {
if (!store) return;
const lat = store.latitude != null ? Number(store.latitude) : NaN;
const lng = store.longitude != null ? Number(store.longitude) : NaN;
const address = fullAddress(store) || store.address || store.name;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
Taro.openLocation({
latitude: lat,
longitude: lng,
name: store.name,
address,
scale: 16,
}).catch(() => {
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
window.location.href = `https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(store.name)}&address=${encodeURIComponent(address)}`;
return;
}
toast('无法打开地图导航');
});
return;
}
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' && address) {
window.location.href = `https://uri.amap.com/search?keyword=${encodeURIComponent(address)}&src=dukang`;
return;
}
toast('门店位置待完善,暂无法导航');
}
if (!store) {
return (
<PageShell variant="scroll" className="store-detail-page">
<PageNavBar title="门店详情" solid onBack={goBack} />
<View className="page-with-nav-bar u-empty">
{loading ? '加载中…' : loadError || '门店不存在或暂不可见'}
</View>
</PageShell>
);
}
const envPhotos = envPhotoUrls(store);
const heroImages = uniqueUrls([store.coverUrl, ...(store.carouselUrls || [])]);
const packages = store.packages ?? [];
const intro = store.intro?.trim() || '';
const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
const benefitRule =
benefitRuleRaw && !/^null$/i.test(benefitRuleRaw) ? benefitRuleRaw : '';
function openPackageDetail(index: number) {
Taro.navigateTo({
url: `/pages/store-package-detail/index?storeId=${storeId}&index=${index}`,
});
}
function previewEnv(index: number) {
if (!envPhotos.length) return;
Taro.previewImage({
current: envPhotos[index],
urls: envPhotos,
}).catch(() => toast('无法预览图片'));
}
return (
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
<WechatShareReady payload={sharePayload} />
<PageNavBar
title={store.name}
solid={headerSolid}
titleVisible={headerSolid}
onBack={goBack}
right={<ShareNavButton payload={sharePayload} />}
/>
<View className="store-detail-hero full-bleed">
<ProductCarousel
images={heroImages}
alt={store.name}
variant="store"
previewable
imageFit="contain"
/>
</View>
<View className="store-detail-info-card">
<Text className="store-detail-name">{store.name}</Text>
<View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex">
{store.district ? `${store.district} · ` : ''}
{store.address || '地址待完善'}
</Text>
<Text className="store-detail-action" onClick={openMap}>
导航
</Text>
</View>
<Text className="store-detail-meta">
营业时间:{' '}
{(() => {
const parts: string[] = [];
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
return parts.length ? parts.join('') : '10:00-22:00';
})()}
</Text>
{store.avgPrice != null && Number(store.avgPrice) > 0 ? (
<Text className="store-detail-meta">人均约 ¥{Number(store.avgPrice).toFixed(0)}</Text>
) : null}
{store.phone ? (
<View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex">
电话: {maskPhone(store.phone)}
</Text>
<Text className="store-detail-action" onClick={callStore}>
拨打
</Text>
</View>
) : null}
<View className="store-detail-tags">
{store.category?.name ? (
<Text className="store-detail-tag">{store.category.name}</Text>
) : null}
<Text className="store-detail-tag">可核销</Text>
<Text className="store-detail-tag">好客门店</Text>
</View>
</View>
{marqueeLines.length > 0 ? (
<View className="store-detail-marquee-wrap">
<StoreRedeemMarquee key={marqueeLines.join('|')} lines={marqueeLines} />
</View>
) : null}
{packages.length > 0 ? (
<View className="store-detail-section store-detail-section--packages">
<Text className="store-detail-section-title">门店套餐</Text>
<View className="store-detail-package-list">
{packages.map((pkg, index) => (
<View
key={`${pkg.name}-${index}`}
className="store-detail-package-list-item"
onClick={() => openPackageDetail(index)}
>
<View className="store-detail-package-list-row">
<Text className="store-detail-package-list-title">{pkg.name}</Text>
<Text className="store-detail-package-list-price">
&nbsp;&nbsp;&nbsp;&nbsp;¥{formatPackagePriceYuan(pkg.price)}
</Text>
</View>
</View>
))}
</View>
</View>
) : null}
{intro ? (
<View className="store-detail-section">
<Text className="store-detail-section-title">门店详情</Text>
<Text className="store-detail-intro">{intro}</Text>
</View>
) : null}
{benefitRule ? (
<View className="store-detail-section">
<Text className="store-detail-section-title">好客权益券使用规则</Text>
<Text className="store-detail-intro">{benefitRule}</Text>
</View>
) : null}
{envPhotos.length > 0 ? (
<View className="store-detail-section">
<Text className="store-detail-section-title">店内环境</Text>
<View className="store-detail-env-grid">
{envPhotos.map((url, index) => (
<View
key={`${url}-${index}`}
className="store-detail-env-item"
onClick={() => previewEnv(index)}
>
<Image className="store-detail-env-img" src={url} mode="widthFix" />
</View>
))}
</View>
</View>
) : null}
<View className="store-detail-bar">
<View
className="u-btn u-btn--block"
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
>
<Text>到店核销</Text>
</View>
</View>
</PageShell>
);
}