Files
dukang/apps/mini-user/src/pages/store-detail/index.tsx
T
jacy fe557c912d
CI / verify (pull_request) Has been cancelled
v4.0.9版本更新-门店分类支持多选
2026-09-02 11:12:51 +08:00

543 lines
17 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, ScrollView } from '@tarojs/components';
import '../../styles/store-detail.css';
import '../../styles/benefit-promo.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 StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../components/StoreRedeemMarquee';
import BenefitIntroCard from '../../components/BenefitIntroCard';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api';
import { toMoneyNumber } from '../../lib/money';
import { maskPhone, toDialablePhone } from '../../lib/phone';
import { track } from '../../lib/analytics';
import {
storeCategoryTags,
storeStarCount,
type StoreCategoryTreeNode,
} from '../../lib/store-display';
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;
rating?: number | string | null;
tags?: unknown;
redeemCount?: number | null;
categoryId?: string | null;
category?: {
id?: string;
name: string;
parentId?: string | null;
parent?: { name?: string } | null;
} | null;
categories?: {
id?: string;
name?: string;
parentId?: string | null;
parent?: { name?: string } | null;
}[] | 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, '');
}
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: unknown) {
const n = toMoneyNumber(amount);
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return n.toFixed(2).replace(/\.?0+$/, '');
}
function SectionTitle({
children,
className,
}: {
children: string;
className?: string;
}) {
return (
<View className={`store-detail-section-title${className ? ` ${className}` : ''}`}>
<View className="store-detail-section-title-bar" />
<Text className="store-detail-section-title-text">{children}</Text>
</View>
);
}
function toMarqueeItem(row: RecentRedeem): StoreRedeemMarqueeItem | null {
try {
const userLabel = String(row.userLabel || '用户***').trim() || '用户***';
const amount = formatRedeemAmountYuan(row.amount);
if (!amount) return null;
return { userLabel, amount };
} catch {
return null;
}
}
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 [categoryTree, setCategoryTree] = useState<StoreCategoryTreeNode[]>([]);
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]);
useEffect(() => {
void request<StoreCategoryTreeNode[]>('/store-categories')
.then((tree) => setCategoryTree(Array.isArray(tree) ? tree : []))
.catch(() => setCategoryTree([]));
}, []);
// 登录态变化后回到本页:重拉详情与走马灯
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 marqueeItems = useMemo(
() => recentRedeems.map(toMarqueeItem).filter((row): row is StoreRedeemMarqueeItem => !!row),
[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 previewAlbum = uniqueUrls([store.coverUrl, ...envPhotos]);
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) {
const current = envPhotos[index];
if (!current) return;
const urls = previewAlbum.length ? previewAlbum : envPhotos;
Taro.previewImage({
current,
urls,
}).catch(() => toast('无法预览图片'));
}
return (
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
<WechatShareReady payload={sharePayload} />
<PageNavBar
title={store.name}
solid={headerSolid}
titleVisible={headerSolid}
onBack={goBack}
/>
<View className="store-detail-hero full-bleed">
<ProductCarousel
images={heroImages}
alt={store.name}
variant="store"
previewable
imageFit="contain"
previewUrls={previewAlbum}
/>
</View>
<View className="store-detail-info-card">
{marqueeItems.length > 0 ? (
<View className="store-detail-marquee-wrap">
<StoreRedeemMarquee
key={marqueeItems.map((r) => `${r.userLabel}|${r.amount}`).join('|')}
items={marqueeItems}
/>
</View>
) : null}
<View className="store-detail-title-row">
<Text className="store-detail-name">{store.name}</Text>
</View>
{(() => {
const categoryLabels = storeCategoryTags(store, categoryTree);
return categoryLabels.length ? (
<View className="store-detail-tags-row">
<Text className="store-detail-category-line">{categoryLabels.join('、')}</Text>
</View>
) : null;
})()}
<View className="store-detail-rating-row">
<View className="store-detail-stars">
{[1, 2, 3, 4, 5].map((n) => (
<Text
key={n}
className={`store-detail-star${n <= storeStarCount(store.rating) ? ' store-detail-star--on' : ''}`}
>
</Text>
))}
</View>
{Number(store.redeemCount) > 0 ? (
<Text className="store-detail-redeem">核销{store.redeemCount}</Text>
) : null}
</View>
<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>
<BenefitIntroCard showLink accent className="store-detail-benefit-intro" />
{benefitRule ? (
<View className="store-detail-section">
<SectionTitle className="store-detail-section-title--rule">使用规则</SectionTitle>
<Text className="store-detail-intro">{benefitRule}</Text>
</View>
) : null}
{packages.length > 0 ? (
<View className="store-detail-section store-detail-section--packages">
<SectionTitle>门店套餐</SectionTitle>
<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">
<SectionTitle>门店详情</SectionTitle>
<ScrollView className="store-detail-intro-scroll" scrollY showScrollbar>
<Text className="store-detail-intro">{intro}</Text>
</ScrollView>
</View>
) : null}
{envPhotos.length > 0 ? (
<View className="store-detail-section">
<SectionTitle>店内环境</SectionTitle>
<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>
);
}