feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理
订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '好客权益使用说明',
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import '../../styles/legal.css';
|
||||
import Taro from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { toast } from '../../lib/api';
|
||||
import { getBrandAssetsSync, loadBrandAssets } from '../../lib/brand-assets';
|
||||
import {
|
||||
BENEFIT_RULES_FOOTER,
|
||||
BENEFIT_RULES_SECTIONS,
|
||||
BENEFIT_RULES_SUMMARY,
|
||||
BENEFIT_RULES_TITLE,
|
||||
} from '../../lib/benefit-copy';
|
||||
|
||||
export default function BenefitRulesPage() {
|
||||
const [phone, setPhone] = useState(() => getBrandAssetsSync().customerServicePhone);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBrandAssets().then((brand) => setPhone(brand.customerServicePhone));
|
||||
}, []);
|
||||
|
||||
function dial() {
|
||||
const tel = phone.replace(/-/g, '');
|
||||
Taro.makePhoneCall({ phoneNumber: tel }).catch(() => toast('无法拨打电话'));
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="legal-page benefit-rules-page">
|
||||
<SubPageHeader title={BENEFIT_RULES_TITLE} />
|
||||
<View className="sub-page-body inset-page legal-body">
|
||||
<View className="benefit-rules-summary">
|
||||
<Text className="benefit-rules-summary-text">
|
||||
{BENEFIT_RULES_SUMMARY.replace(/不可兑现、不可转卖。$/, '')}
|
||||
<Text className="benefit-rules-em">不可兑现、不可转卖。</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{BENEFIT_RULES_SECTIONS.map((section) => (
|
||||
<View key={section.heading} className="benefit-rules-section">
|
||||
<View className="benefit-rules-heading-row">
|
||||
<View className="benefit-rules-heading-bar" />
|
||||
<Text className="benefit-rules-heading">{section.heading}</Text>
|
||||
</View>
|
||||
{section.paragraphs.map((p, i) => {
|
||||
const emphasize = 'emphasize' in section ? section.emphasize : '';
|
||||
const emphasizeAll = 'emphasizeAll' in section && section.emphasizeAll;
|
||||
if (emphasizeAll) {
|
||||
return (
|
||||
<Text key={`${section.heading}-${i}`} className="benefit-rules-paragraph benefit-rules-em">
|
||||
{p}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (emphasize && p.includes(emphasize)) {
|
||||
const [before, after] = p.split(emphasize);
|
||||
return (
|
||||
<Text key={`${section.heading}-${i}`} className="benefit-rules-paragraph">
|
||||
{before}
|
||||
<Text className="benefit-rules-em">{emphasize}</Text>
|
||||
{after}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text key={`${section.heading}-${i}`} className="benefit-rules-paragraph">
|
||||
{p}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
{section.heading === '八、联系客服' ? (
|
||||
<Text className="benefit-rules-phone" onClick={dial}>
|
||||
{phone}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Text className="benefit-rules-footer">{BENEFIT_RULES_FOOTER}</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
@@ -7,14 +7,16 @@ import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../co
|
||||
import BenefitFigure from '../../components/BenefitFigure';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar';
|
||||
import { navBarStyle, useNavBarMetrics } from '../../lib/nav-bar';
|
||||
import {
|
||||
buildSceneSharePayload,
|
||||
toWeappShareMessage,
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
import { formatMoney } from '../../lib/money';
|
||||
import iconBenefit from '../../assets/tabbar/benefit-active.png';
|
||||
import { BENEFIT_SLOGAN } from '../../lib/benefit-copy';
|
||||
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
||||
import type { StoreRatingDto } from '@dukang/shared-types';
|
||||
|
||||
type BenefitSummary = {
|
||||
totalBalance: number;
|
||||
@@ -36,10 +38,30 @@ type RedeemHistoryItem = {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
storeId?: string;
|
||||
storeName: string;
|
||||
createdAt: string;
|
||||
rating?: StoreRatingDto | null;
|
||||
};
|
||||
|
||||
const BENEFIT_TAB_KEY = 'dukang_benefit_tab';
|
||||
|
||||
function readBenefitTab(): 'available' | 'history' {
|
||||
try {
|
||||
return Taro.getStorageSync(BENEFIT_TAB_KEY) === 'history' ? 'history' : 'available';
|
||||
} catch {
|
||||
return 'available';
|
||||
}
|
||||
}
|
||||
|
||||
function writeBenefitTab(next: 'available' | 'history') {
|
||||
try {
|
||||
Taro.setStorageSync(BENEFIT_TAB_KEY, next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function usagePercent(coupon: CouponItem) {
|
||||
const total = Number(coupon.totalAmount);
|
||||
if (total <= 0) return 0;
|
||||
@@ -52,7 +74,7 @@ export default function BenefitPage() {
|
||||
const [summary, setSummary] = useState<BenefitSummary | null>(null);
|
||||
const [coupons, setCoupons] = useState<CouponItem[]>([]);
|
||||
const [redeemHistory, setRedeemHistory] = useState<RedeemHistoryItem[]>([]);
|
||||
const [tab, setTab] = useState<'available' | 'history'>('available');
|
||||
const [tab, setTab] = useState<'available' | 'history'>(readBenefitTab);
|
||||
|
||||
const resetGuestState = useCallback(() => {
|
||||
setSummary(null);
|
||||
@@ -119,18 +141,7 @@ export default function BenefitPage() {
|
||||
<PageShell variant="tab" className="benefit-page">
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<View className="benefit-header" style={navBarStyle(metrics)} aria-label="好客权益">
|
||||
{process.env.TARO_ENV !== 'h5' ? (
|
||||
<Text className="benefit-header-title">好客权益</Text>
|
||||
) : null}
|
||||
<View
|
||||
className="benefit-header__content"
|
||||
style={tabNavContentStyle(metrics)}
|
||||
>
|
||||
<View className="benefit-header-city">
|
||||
<View className="benefit-header-city-pin" />
|
||||
<Text>郑州市</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="benefit-header-title">好客权益</Text>
|
||||
</View>
|
||||
|
||||
{!loggedIn ? (
|
||||
@@ -146,40 +157,50 @@ export default function BenefitPage() {
|
||||
</View>
|
||||
) : (
|
||||
<View className="benefit-main">
|
||||
<BenefitIntroCard className="benefit-intro-card--page" />
|
||||
<View className="benefit-hero">
|
||||
<View className="benefit-hero-top">
|
||||
<View>
|
||||
<Text className="benefit-hero-label">当前好客权益余额</Text>
|
||||
<View className="benefit-hero-amount">
|
||||
<BenefitFigure
|
||||
value={summary ? formatMoney(summary.totalBalance) : '--'}
|
||||
size="xl"
|
||||
className="benefit-hero-value"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View className="benefit-hero-logo">
|
||||
<Image className="benefit-hero-logo-img" src={iconBenefit} mode="aspectFit" />
|
||||
<Text className="benefit-hero-label">好客权益</Text>
|
||||
<View className="benefit-hero-amount">
|
||||
<BenefitFigure
|
||||
value={summary ? formatMoney(summary.totalBalance) : '--'}
|
||||
size="xl"
|
||||
className="benefit-hero-value"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
className="benefit-hero-cta"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||
>
|
||||
<Text>去使用</Text>
|
||||
<View className="benefit-hero-actions">
|
||||
<View
|
||||
className="benefit-hero-cta benefit-hero-cta--primary"
|
||||
onClick={() => Taro.switchTab({ url: '/pages/home/index' })}
|
||||
>
|
||||
<Text>去选好酒</Text>
|
||||
</View>
|
||||
<View
|
||||
className="benefit-hero-cta benefit-hero-cta--secondary"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||
>
|
||||
<Text>去使用</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="benefit-tabs">
|
||||
<Text
|
||||
className={`benefit-tab${tab === 'available' ? ' benefit-tab--active' : ''}`}
|
||||
onClick={() => setTab('available')}
|
||||
onClick={() => {
|
||||
setTab('available');
|
||||
writeBenefitTab('available');
|
||||
}}
|
||||
>
|
||||
可用权益
|
||||
</Text>
|
||||
<Text
|
||||
className={`benefit-tab${tab === 'history' ? ' benefit-tab--active' : ''}`}
|
||||
onClick={() => setTab('history')}
|
||||
onClick={() => {
|
||||
setTab('history');
|
||||
writeBenefitTab('history');
|
||||
}}
|
||||
>
|
||||
历史记录
|
||||
</Text>
|
||||
@@ -187,7 +208,7 @@ export default function BenefitPage() {
|
||||
|
||||
{tab === 'available' ? (
|
||||
available.length === 0 ? (
|
||||
<View className="u-empty">暂无可用权益</View>
|
||||
<View className="u-empty">{BENEFIT_SLOGAN}</View>
|
||||
) : (
|
||||
available.map((c) => (
|
||||
<View key={c.id} className="benefit-coupon">
|
||||
@@ -243,6 +264,17 @@ export default function BenefitPage() {
|
||||
<Text className="benefit-coupon-meta">
|
||||
{r.createdAt ? String(r.createdAt).slice(0, 19).replace('T', ' ') : '-'}
|
||||
</Text>
|
||||
<Text
|
||||
className={`benefit-coupon-btn${r.rating ? ' benefit-coupon-btn--ghost' : ''}`}
|
||||
onClick={() => {
|
||||
writeBenefitTab('history');
|
||||
Taro.navigateTo({
|
||||
url: `/pages/redeem-success/index?id=${r.id}&from=history`,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{r.rating ? '已评价' : '去评价'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
|
||||
@@ -9,10 +9,13 @@ import Taro, {
|
||||
} from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import CouponBadge from '../../components/CouponBadge';
|
||||
import BenefitSloganBar from '../../components/BenefitSloganBar';
|
||||
import { BENEFIT_GIFT_TAG, BENEFIT_TAG } from '../../lib/benefit-copy';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import JiuzuSplash from '../../components/JiuzuSplash';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { hasJiuzuSplashPlayed } from '../../lib/jiuzu-splash';
|
||||
import { getToken, isLoggedIn, request, toast } from '../../lib/api';
|
||||
import {
|
||||
getHomeCatalogCache,
|
||||
@@ -36,6 +39,7 @@ import {
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
import { trackPageView } from '../../lib/analytics';
|
||||
import iconStoreBenefit from '../../assets/icons/store-benefit-y.png';
|
||||
type Product = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -71,7 +75,15 @@ function aromaSectionId(key: AromaKey) {
|
||||
return `aroma-section-${key}`;
|
||||
}
|
||||
|
||||
/** 商品图左上角权益角标:权益额 = benefitDisplay ?? price */
|
||||
function formatBenefitCorner(p: Product): string {
|
||||
const n = Number(p.benefitDisplay ?? p.price);
|
||||
if (!Number.isFinite(n) || n <= 0) return '';
|
||||
return String(Math.round(n));
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [showSplash, setShowSplash] = useState(() => !hasJiuzuSplashPlayed());
|
||||
const [activeAroma, setActiveAroma] = useState<AromaKey>('QINGXIANG');
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -282,6 +294,7 @@ export default function HomePage() {
|
||||
function renderProductCard(p: Product) {
|
||||
const thumb = getProductMainImage(p);
|
||||
const spec = p.subtitle || p.spec || '';
|
||||
const benefitCorner = formatBenefitCorner(p);
|
||||
return (
|
||||
<View key={p.id} className="home-product-card">
|
||||
<View className="home-product-card-inner" onClick={() => openProductDetail(p.id)}>
|
||||
@@ -291,6 +304,20 @@ export default function HomePage() {
|
||||
) : (
|
||||
<View className="home-product-thumb home-product-thumb--empty" />
|
||||
)}
|
||||
{benefitCorner ? (
|
||||
<View className="home-benefit-ribbon-clip">
|
||||
<View className="home-benefit-ribbon">
|
||||
<View className="home-benefit-ribbon-dk">
|
||||
<Image
|
||||
className="home-benefit-ribbon-dk-icon"
|
||||
src={iconStoreBenefit}
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</View>
|
||||
<Text className="home-benefit-ribbon-num">{benefitCorner}</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View className="home-product-main">
|
||||
<View className="home-product-row">
|
||||
@@ -299,7 +326,10 @@ export default function HomePage() {
|
||||
</View>
|
||||
{spec ? <Text className="home-product-sub">{spec}</Text> : null}
|
||||
<View className="home-product-footer">
|
||||
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
|
||||
<View className="home-product-tags">
|
||||
<Text className="home-gift-tag">{BENEFIT_GIFT_TAG}</Text>
|
||||
{/* <Text className="home-benefit-tag">{BENEFIT_TAG}</Text> */}
|
||||
</View>
|
||||
</View>
|
||||
<View className="home-product-actions">
|
||||
{canPickupOnSite(p) ? (
|
||||
@@ -354,6 +384,10 @@ export default function HomePage() {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="home-slogan-wrap">
|
||||
<BenefitSloganBar />
|
||||
</View>
|
||||
|
||||
<View className="home-aroma-nav">
|
||||
<View className="home-aroma-tabs">
|
||||
{visibleAromaTabs.map((t) => (
|
||||
@@ -393,7 +427,8 @@ export default function HomePage() {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
|
||||
{shouldRenderPageTabBar() && !showSplash ? <UserTabBar selected={0} /> : null}
|
||||
{showSplash ? <JiuzuSplash onDone={() => setShowSplash(false)} /> : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ import iconCs from '../../assets/icons/联系客服.png';
|
||||
import iconQualification from '../../assets/icons/资质公示.png';
|
||||
import iconAbout from '../../assets/icons/关于我们.png';
|
||||
import { formatMoney } from '../../lib/money';
|
||||
import BenefitSloganBar from '../../components/BenefitSloganBar';
|
||||
import { BENEFIT_RULES_PATH } from '../../lib/benefit-copy';
|
||||
|
||||
const ORDER_SHORTCUTS = [
|
||||
{ tab: 'pending_pay', icon: iconPendingPay, label: '待付款' },
|
||||
@@ -56,7 +58,7 @@ const SERVICES = [
|
||||
{ icon: iconStores, label: '可用门店', tab: '/pages/stores/index' },
|
||||
{ icon: iconCs, label: '联系客服', url: '/pages/customer-service/index' },
|
||||
{ icon: iconQualification, label: '资质公示', action: 'qualification' as const },
|
||||
{ icon: iconAbout, label: '关于我们', action: 'about' as const },
|
||||
{ icon: iconAbout, label: '好客权益规则', url: BENEFIT_RULES_PATH },
|
||||
{ icon: iconAbout, label: '发票管理', url: '/pages/invoice-titles/index' },
|
||||
] as const;
|
||||
|
||||
@@ -309,10 +311,6 @@ export default function MinePage() {
|
||||
}
|
||||
if ('action' in item && item.action === 'qualification') {
|
||||
setQualificationOpen(true);
|
||||
return;
|
||||
}
|
||||
if ('action' in item && item.action === 'about') {
|
||||
toast('杜康好客 · 传承千年酒文化');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,6 +433,7 @@ export default function MinePage() {
|
||||
|
||||
<View className="mine-main">
|
||||
<View className="mine-card">
|
||||
<BenefitSloganBar className="mine-benefit-slogan" />
|
||||
<View className="mine-card-head">
|
||||
<Text className="mine-card-title">我的资产</Text>
|
||||
<Text
|
||||
@@ -446,7 +445,7 @@ export default function MinePage() {
|
||||
</View>
|
||||
<View className="mine-asset-panel">
|
||||
<View>
|
||||
<Text className="mine-asset-label">好客权益余额</Text>
|
||||
<Text className="mine-asset-label">好客权益</Text>
|
||||
<View className="mine-asset-amount">
|
||||
<BenefitFigure value={formatMoney(benefitBalance)} size="lg" className="mine-asset-value" />
|
||||
</View>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
import BenefitFigure from '../../components/BenefitFigure';
|
||||
import OrderQtyControls from '../../components/OrderQtyControls';
|
||||
|
||||
type PreviewProduct = {
|
||||
id: string;
|
||||
@@ -211,21 +212,11 @@ export default function OrderConfirmPickupPage() {
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-qty-row">
|
||||
<Text>购买数量</Text>
|
||||
<View className="order-qty-controls">
|
||||
<View
|
||||
className="order-qty-btn"
|
||||
onClick={() => updateQuantity(quantity - 1)}
|
||||
>
|
||||
<Text>−</Text>
|
||||
</View>
|
||||
<Text className="order-qty-value">{quantity}</Text>
|
||||
<View className="order-qty-btn" onClick={() => updateQuantity(quantity + 1)}>
|
||||
<Text>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<OrderQtyControls
|
||||
value={quantity}
|
||||
unitLabel={unitLabel}
|
||||
onChange={updateQuantity}
|
||||
/>
|
||||
{!quantityOk ? (
|
||||
<Text className="order-qty-hint">
|
||||
{`现场提货至少购买 ${minQty}${unitLabel},请调整数量`}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment'
|
||||
import { loadLocalDeliveries, matchLocalDelivery, resolveLocalDeliveryHintHtml } from '../../lib/local-delivery';
|
||||
import { getProductMainImage } from '../../lib/product-images';
|
||||
import BenefitFigure from '../../components/BenefitFigure';
|
||||
import OrderQtyControls from '../../components/OrderQtyControls';
|
||||
|
||||
type Address = {
|
||||
id: string;
|
||||
@@ -379,24 +380,11 @@ export default function OrderConfirmPage() {
|
||||
<Text className="order-product-price">¥{Number(preview.product.price).toFixed(2)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-qty-row">
|
||||
<Text>购买数量</Text>
|
||||
<View className="order-qty-controls">
|
||||
<View
|
||||
className="order-qty-btn"
|
||||
onClick={() => updateQuantity(quantity - 1)}
|
||||
>
|
||||
<Text>−</Text>
|
||||
</View>
|
||||
<Text className="order-qty-value">{quantity}</Text>
|
||||
<View
|
||||
className="order-qty-btn"
|
||||
onClick={() => updateQuantity(quantity + 1)}
|
||||
>
|
||||
<Text>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<OrderQtyControls
|
||||
value={quantity}
|
||||
unitLabel={unitLabel}
|
||||
onChange={updateQuantity}
|
||||
/>
|
||||
{!quantityOk ? (
|
||||
<Text className="order-qty-hint">
|
||||
{isCross
|
||||
|
||||
@@ -340,7 +340,7 @@ export default function OrderDetailPage() {
|
||||
<Text className="order-card-title">商品信息</Text>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">{productName}</Text>
|
||||
<Text className="order-row-value">x{quantity}</Text>
|
||||
<Text className="order-row-value">x{quantity}瓶</Text>
|
||||
</View>
|
||||
<View className="order-row">
|
||||
<Text className="order-row-label">实付金额</Text>
|
||||
|
||||
@@ -159,7 +159,7 @@ export default function OrdersPage() {
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text className="order-list-name">{productName}</Text>
|
||||
<View className="order-list-meta-row">
|
||||
<Text className="order-list-meta">数量 {qty}</Text>
|
||||
<Text className="order-list-meta">数量 {qty}瓶</Text>
|
||||
<Text className="order-list-meta">单价 ¥{unitPrice.toFixed(2)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { applyWechatLoginResult } from '../../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../../lib/weixin';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import payLogo from '../../assets/logo2.png';
|
||||
import { getBrandAssetsSync } from '../../lib/brand-assets';
|
||||
|
||||
export default function PayPage() {
|
||||
const router = useRouter();
|
||||
@@ -173,7 +173,7 @@ export default function PayPage() {
|
||||
<View className="sub-page-body">
|
||||
<View className="pay-status">
|
||||
<View className="pay-status-icon">
|
||||
<Image className="pay-status-brand" src={payLogo} mode="aspectFit" />
|
||||
<Image className="pay-status-brand" src={getBrandAssetsSync().brandLogoMarkUrl} mode="aspectFit" />
|
||||
</View>
|
||||
<Text className="pay-status-title">
|
||||
{needsWechatAuth ? '需完成微信授权' : '待支付'}
|
||||
|
||||
@@ -135,7 +135,7 @@ export default function PickupReceivePage() {
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text className="order-product-name">{name}</Text>
|
||||
{spec ? <Text className="u-muted">{spec}</Text> : null}
|
||||
<Text className="u-muted">×{order.quantity ?? 1}</Text>
|
||||
<Text className="u-muted">×{order.quantity ?? 1}瓶</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import '../../styles/product-detail.css';
|
||||
import '../../styles/benefit-promo.css';
|
||||
import Taro, {
|
||||
useDidShow,
|
||||
usePageScroll,
|
||||
@@ -13,7 +14,7 @@ import PageShell from '../../components/PageShell';
|
||||
import PageNavBar from '../../components/PageNavBar';
|
||||
import ProductCarousel from '../../components/ProductCarousel';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import BenefitFigure from '../../components/BenefitFigure';
|
||||
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { ensurePayReady } from '../../lib/pay-ready';
|
||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||
@@ -156,9 +157,6 @@ export default function ProductDetailPage() {
|
||||
}, [product, specEnabled, skus, selected, attrs]);
|
||||
|
||||
const displayPrice = activeSku ? Number(activeSku.price) : Number(product?.price ?? 0);
|
||||
const displayBenefit = activeSku
|
||||
? Number(activeSku.benefitAmount)
|
||||
: Number(product?.benefitDisplay ?? product?.benefitAmount ?? product?.price ?? 0);
|
||||
const fulfillment = activeSku
|
||||
? {
|
||||
allowOnlinePurchase: activeSku.allowOnlinePurchase,
|
||||
@@ -276,6 +274,8 @@ export default function ProductDetailPage() {
|
||||
</View>
|
||||
|
||||
<View className="product-detail-info">
|
||||
<BenefitIntroCard showLink className="product-detail-benefit-intro" />
|
||||
|
||||
<View className="product-detail-price">
|
||||
<Text className="product-detail-price-symbol">¥</Text>
|
||||
<Text className="product-detail-price-value">{displayPrice.toFixed(2)}</Text>
|
||||
@@ -317,22 +317,6 @@ export default function ProductDetailPage() {
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="product-detail-promo">
|
||||
<View className="product-detail-promo-glow" />
|
||||
<View className="product-detail-promo-head">
|
||||
<View className="product-detail-promo-icon">
|
||||
<Text className="product-detail-promo-icon-text">惠</Text>
|
||||
</View>
|
||||
<View className="product-detail-promo-title">
|
||||
<Text>买杜康美酒 · 享全城好客礼遇</Text>
|
||||
<BenefitFigure value={String(displayBenefit)} size="sm" className="product-detail-promo-amount" />
|
||||
</View>
|
||||
</View>
|
||||
<Text className="product-detail-promo-desc">
|
||||
购酒即享本城专属好客权益,为您安排一场地道饭局。权益金可直接用于本地签约门店消费,到店享用,醇香美酒配佳肴。
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="product-detail-content">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '核销成功',
|
||||
navigationBarTitleText: '评价门店',
|
||||
});
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { View, Text, Image, Textarea } from '@tarojs/components';
|
||||
import '../../styles/redeem.css';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import Taro, { useLoad, useRouter } from '@tarojs/taro';
|
||||
import {
|
||||
STORE_RATING_MAX_COMMENT,
|
||||
STORE_RATING_MAX_IMAGES,
|
||||
STORE_RATING_QUICK_TAGS,
|
||||
type StoreRatingDto,
|
||||
} from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { formatShanghaiDateTime } from '../../lib/datetime';
|
||||
import { formatMoney, toMoneyNumber } from '../../lib/money';
|
||||
import BenefitFigure from '../../components/BenefitFigure';
|
||||
import { toMoneyNumber } from '../../lib/money';
|
||||
import { chooseAndUploadRatingImages } from '../../lib/upload-rating-image';
|
||||
|
||||
const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
|
||||
|
||||
const SCORE_LABELS = ['', '较差', '一般', '还行', '很好', '非常好'] as const;
|
||||
|
||||
type RedeemRecord = {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
@@ -18,59 +26,106 @@ type RedeemRecord = {
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
createdAt: string;
|
||||
rating?: StoreRatingDto | null;
|
||||
};
|
||||
|
||||
/** 与权益「历史记录」一致:2026-08-03 13:53:03(Asia/Shanghai) */
|
||||
function formatChinaDateTime(input?: string | null) {
|
||||
return formatShanghaiDateTime(input ?? new Date());
|
||||
function formatAmountYuan(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 StarRating({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (score: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<View className="redeem-rating-row">
|
||||
<Text className="redeem-rating-label">{label}</Text>
|
||||
<View className="redeem-star-row">
|
||||
{[1, 2, 3, 4, 5].map((score) => (
|
||||
<Text
|
||||
key={score}
|
||||
className={`redeem-star-btn${score <= value ? ' redeem-star-btn--active' : ''}`}
|
||||
onClick={() => onChange(score)}
|
||||
>
|
||||
★
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
function formatVisitLine(createdAt?: string | null, amount?: unknown) {
|
||||
const full = formatShanghaiDateTime(createdAt ?? new Date());
|
||||
if (full === '—') return `核销用餐权益 ${formatAmountYuan(amount)}`;
|
||||
const datePart = full.slice(0, 10);
|
||||
const time = full.slice(11, 16);
|
||||
const today = formatShanghaiDateTime(new Date()).slice(0, 10);
|
||||
const prefix = datePart === today ? '今日' : datePart.slice(5);
|
||||
return `${prefix} ${time} · 核销用餐权益 ${formatAmountYuan(amount)}`;
|
||||
}
|
||||
|
||||
function readCachedRecord(): RedeemRecord | null {
|
||||
try {
|
||||
const cached = Taro.getStorageSync(LAST_REDEEM_RESULT_KEY);
|
||||
return cached ? (JSON.parse(String(cached)) as RedeemRecord) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function RedeemSuccessPage() {
|
||||
const router = useRouter();
|
||||
const [serviceScore, setServiceScore] = useState(5);
|
||||
const [envScore, setEnvScore] = useState(5);
|
||||
const [fromHistory, setFromHistory] = useState(false);
|
||||
const [record, setRecord] = useState<RedeemRecord | null>(null);
|
||||
const [score, setScore] = useState(5);
|
||||
const [tags, setTags] = useState<string[]>(['菜品好', '环境佳', '服务周到']);
|
||||
const [comment, setComment] = useState('');
|
||||
const [imageUrls, setImageUrls] = useState<string[]>([]);
|
||||
const [coverUrl, setCoverUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const rated = Boolean(record?.rating);
|
||||
|
||||
const record = useMemo<RedeemRecord | null>(() => {
|
||||
try {
|
||||
const cached = Taro.getStorageSync(LAST_REDEEM_RESULT_KEY);
|
||||
return cached ? (JSON.parse(String(cached)) as RedeemRecord) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const applyRating = useCallback((rating: StoreRatingDto) => {
|
||||
const nextScore = Number(rating.serviceScore || rating.envScore || 5);
|
||||
setScore(Number.isFinite(nextScore) && nextScore > 0 ? Math.min(5, Math.round(nextScore)) : 5);
|
||||
setTags(Array.isArray(rating.tags) ? rating.tags : []);
|
||||
setComment(String(rating.comment || ''));
|
||||
setImageUrls(Array.isArray(rating.imageUrls) ? rating.imageUrls : []);
|
||||
}, []);
|
||||
|
||||
const loadRecord = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
let data: RedeemRecord | null = null;
|
||||
try {
|
||||
data = await request<RedeemRecord>(`/redeem/records/${id}`);
|
||||
} catch {
|
||||
const records = await request<{ list?: RedeemRecord[] } | RedeemRecord[]>(
|
||||
'/redeem/records?page=1&pageSize=50',
|
||||
);
|
||||
const list = Array.isArray(records) ? records : records?.list ?? [];
|
||||
data = list.find((item) => String(item.id) === id) ?? null;
|
||||
}
|
||||
if (!data) {
|
||||
toast('核销记录不存在');
|
||||
return;
|
||||
}
|
||||
setRecord(data);
|
||||
if (data.rating) applyRating(data.rating);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
}
|
||||
},
|
||||
[applyRating],
|
||||
);
|
||||
|
||||
useLoad((options) => {
|
||||
const id = String(options?.id || router.params.id || '').trim();
|
||||
const from = String(options?.from || router.params.from || '');
|
||||
setFromHistory(from === 'history');
|
||||
if (id) {
|
||||
void loadRecord(id);
|
||||
return;
|
||||
}
|
||||
const cached = readCachedRecord();
|
||||
if (cached) setRecord(cached);
|
||||
});
|
||||
|
||||
const amount = toMoneyNumber(record?.amount ?? router.params.amount);
|
||||
const storeName = record?.storeName || '门店';
|
||||
const redeemNo = record?.redeemNo || '—';
|
||||
const redeemedAt = formatChinaDateTime(record?.createdAt);
|
||||
const visitLine = formatVisitLine(record?.createdAt, amount);
|
||||
|
||||
useEffect(() => {
|
||||
if (!record?.storeId) return;
|
||||
void request<{ coverUrl?: string | null }>(`/stores/${record.storeId}`)
|
||||
.then((store) => {
|
||||
const url = String(store?.coverUrl || '').trim();
|
||||
if (url) setCoverUrl(url);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [record?.storeId]);
|
||||
|
||||
function clearCache() {
|
||||
try {
|
||||
@@ -80,81 +135,207 @@ export default function RedeemSuccessPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function goBenefit() {
|
||||
function leave() {
|
||||
clearCache();
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (fromHistory && pages.length > 1) {
|
||||
Taro.navigateBack();
|
||||
return;
|
||||
}
|
||||
Taro.switchTab({ url: '/pages/benefit/index' });
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
clearCache();
|
||||
Taro.switchTab({ url: '/pages/home/index' });
|
||||
function toggleTag(tag: string) {
|
||||
if (rated) return;
|
||||
setTags((prev) => (prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]));
|
||||
}
|
||||
|
||||
async function addPhotos() {
|
||||
if (rated || uploading) return;
|
||||
if (imageUrls.length >= STORE_RATING_MAX_IMAGES) {
|
||||
toast(`最多上传${STORE_RATING_MAX_IMAGES}张图片`);
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const urls = await chooseAndUploadRatingImages(imageUrls.length);
|
||||
if (urls.length) setImageUrls((prev) => [...prev, ...urls].slice(0, STORE_RATING_MAX_IMAGES));
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function removePhoto(url: string) {
|
||||
if (rated) return;
|
||||
setImageUrls((prev) => prev.filter((item) => item !== url));
|
||||
}
|
||||
|
||||
async function submitRatingAndFinish() {
|
||||
if (!record?.id) {
|
||||
toast('找不到核销记录');
|
||||
return;
|
||||
}
|
||||
if (rated) {
|
||||
leave();
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
if (record?.id) {
|
||||
await request('/redeem/ratings', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
redeemRecordId: record.id,
|
||||
serviceScore,
|
||||
envScore,
|
||||
},
|
||||
});
|
||||
toast('评价已提交', 'success');
|
||||
}
|
||||
} catch {
|
||||
/* 评价失败不阻塞返回 */
|
||||
await request('/redeem/ratings', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
redeemRecordId: record.id,
|
||||
serviceScore: score,
|
||||
envScore: score,
|
||||
comment: comment.trim(),
|
||||
tags,
|
||||
imageUrls,
|
||||
},
|
||||
});
|
||||
toast('评价已提交', 'success');
|
||||
setRecord((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
rating: { serviceScore: score, envScore: score, comment, tags, imageUrls },
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
setTimeout(() => leave(), 400);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '评价失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
goBenefit();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="redeem-success-page">
|
||||
<SubPageHeader title="核销成功" onBack={goBenefit} />
|
||||
<View className="sub-page-body">
|
||||
<View className="redeem-success-icon">
|
||||
<Text>✓</Text>
|
||||
</View>
|
||||
<Text className="redeem-success-title">核销成功</Text>
|
||||
<BenefitFigure value={formatMoney(amount)} size="lg" className="redeem-success-amount" />
|
||||
<Text className="redeem-success-desc">已在 {storeName} 完成核销</Text>
|
||||
|
||||
<View className="redeem-success-details">
|
||||
<View className="redeem-success-detail-row">
|
||||
<Text>核销门店</Text>
|
||||
<Text>{storeName}</Text>
|
||||
</View>
|
||||
<View className="redeem-success-detail-row">
|
||||
<Text>核销时间</Text>
|
||||
<Text>{redeemedAt}</Text>
|
||||
</View>
|
||||
<View className="redeem-success-detail-row">
|
||||
<Text>核销单号</Text>
|
||||
<Text className="redeem-success-mono">{redeemNo}</Text>
|
||||
<SubPageHeader title={rated ? '查看评价' : '评价门店'} onBack={leave} />
|
||||
<View className="sub-page-body review-page-body">
|
||||
<View className="review-store-card">
|
||||
{coverUrl ? (
|
||||
<Image className="review-store-cover" src={coverUrl} mode="aspectFill" />
|
||||
) : (
|
||||
<View className="review-store-cover review-store-cover--empty" />
|
||||
)}
|
||||
<View className="review-store-meta">
|
||||
<Text className="review-store-name">{storeName}</Text>
|
||||
<Text className="review-store-visit">{visitLine}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="redeem-success-rating">
|
||||
<Text className="redeem-success-rating-title">为门店服务评分</Text>
|
||||
<StarRating label="服务态度" value={serviceScore} onChange={setServiceScore} />
|
||||
<StarRating label="用餐环境" value={envScore} onChange={setEnvScore} />
|
||||
<View className="review-card">
|
||||
<Text className="review-card-title">本次到店体验</Text>
|
||||
<View className="review-star-row">
|
||||
{[1, 2, 3, 4, 5].map((value) => (
|
||||
<Text
|
||||
key={value}
|
||||
className={`review-star${value <= score ? ' review-star--active' : ''}`}
|
||||
onClick={() => {
|
||||
if (!rated) setScore(value);
|
||||
}}
|
||||
>
|
||||
★
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<Text className="review-score-label">{SCORE_LABELS[score]}</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`redeem-submit${loading ? ' redeem-submit--disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (!loading) void submitRatingAndFinish();
|
||||
}}
|
||||
>
|
||||
<Text>{loading ? '提交中…' : '提交评价并返回'}</Text>
|
||||
<View className="review-card">
|
||||
<View className="review-section-head">
|
||||
<View className="review-section-bar" />
|
||||
<Text className="review-section-title">快捷标签</Text>
|
||||
</View>
|
||||
<View className="review-tag-list">
|
||||
{STORE_RATING_QUICK_TAGS.map((tag) => (
|
||||
<Text
|
||||
key={tag}
|
||||
className={`review-tag${tags.includes(tag) ? ' review-tag--active' : ''}`}
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<View className="redeem-cancel-btn" onClick={goHome}>
|
||||
<Text>回到首页</Text>
|
||||
|
||||
<View className="review-card">
|
||||
<View className="review-section-head">
|
||||
<View className="review-section-bar" />
|
||||
<Text className="review-section-title">补充说明</Text>
|
||||
</View>
|
||||
{process.env.TARO_ENV === 'h5' ? (
|
||||
<textarea
|
||||
className="review-comment review-comment--native"
|
||||
placeholder="口味、环境、服务都可以写,选填"
|
||||
rows={4}
|
||||
maxLength={STORE_RATING_MAX_COMMENT}
|
||||
value={comment}
|
||||
disabled={rated}
|
||||
onChange={(e) => setComment(e.currentTarget.value)}
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
className="review-comment"
|
||||
placeholder="口味、环境、服务都可以写,选填"
|
||||
maxlength={STORE_RATING_MAX_COMMENT}
|
||||
value={comment}
|
||||
disabled={rated}
|
||||
onInput={(e) => setComment(e.detail.value)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="review-card">
|
||||
<View className="review-section-head">
|
||||
<View className="review-section-bar" />
|
||||
<Text className="review-section-title">上传图片</Text>
|
||||
</View>
|
||||
<View className="review-photos">
|
||||
{imageUrls.map((url) => (
|
||||
<View key={url} className="review-photo">
|
||||
<Image
|
||||
className="review-photo-img"
|
||||
src={url}
|
||||
mode="aspectFill"
|
||||
onClick={() => Taro.previewImage({ current: url, urls: imageUrls })}
|
||||
/>
|
||||
{!rated ? (
|
||||
<Text className="review-photo-remove" onClick={() => removePhoto(url)}>
|
||||
×
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
{!rated && imageUrls.length < STORE_RATING_MAX_IMAGES ? (
|
||||
<View className="review-photo-add" onClick={() => void addPhotos()}>
|
||||
<Text className="review-photo-add-plus">{uploading ? '…' : '+'}</Text>
|
||||
<Text className="review-photo-add-text">{uploading ? '上传中' : '添加'}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{!rated ? (
|
||||
<View
|
||||
className={`review-submit${loading || uploading ? ' review-submit--disabled' : ''}`}
|
||||
onClick={() => {
|
||||
if (!loading && !uploading) void submitRatingAndFinish();
|
||||
}}
|
||||
>
|
||||
<Text>{loading ? '提交中…' : '提交评价'}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{!rated && !fromHistory ? (
|
||||
<Text className="review-skip" onClick={leave}>
|
||||
暂不评价
|
||||
</Text>
|
||||
) : null}
|
||||
<Text className="review-disclaimer">评价用于帮助其他到店客人选择门店,不赠送用餐权益。</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -100,11 +100,11 @@ export default function RedeemPage() {
|
||||
async function submit() {
|
||||
const value = Math.round(Number(amount) * 100) / 100;
|
||||
if (!Number.isFinite(value) || value < MIN_REDEEM_AMOUNT) {
|
||||
toast(`核销金额不能低于 ${MIN_REDEEM_AMOUNT.toFixed(2)} 元`);
|
||||
toast(`核销权益不能低于 ${MIN_REDEEM_AMOUNT.toFixed(2)} `);
|
||||
return;
|
||||
}
|
||||
if (value > redeemableMax) {
|
||||
toast(couponId ? '核销金额不能超过该权益可用余额' : '核销金额不能超过可用余额');
|
||||
toast(couponId ? '核销权益不能超过该权益可用核销权益' : '核销权益不能超过可用核销权益');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function RedeemPage() {
|
||||
<View className="sub-page-body">
|
||||
<View className="redeem-hero">
|
||||
<Text className="redeem-hero-label">
|
||||
{couponId ? '当前权益可用余额' : '可用余额'}
|
||||
{couponId ? '当前可用核销权益' : '可用核销权益'}
|
||||
</Text>
|
||||
<BenefitFigure
|
||||
value={redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
|
||||
@@ -149,7 +149,7 @@ export default function RedeemPage() {
|
||||
key={inputKey}
|
||||
className="redeem-input"
|
||||
type="digit"
|
||||
placeholder="输入核销金额"
|
||||
placeholder="输入核销权益"
|
||||
placeholderClass="redeem-input-placeholder"
|
||||
value={amount}
|
||||
maxlength={12}
|
||||
@@ -160,7 +160,7 @@ export default function RedeemPage() {
|
||||
</View>
|
||||
<View className="redeem-amount-foot">
|
||||
<View className="redeem-amount-hint">
|
||||
<Text>最高可核销</Text>
|
||||
<Text>最高可核销权益</Text>
|
||||
<BenefitFigure value={formatMoney(redeemableMax)} size="sm" />
|
||||
</View>
|
||||
<Text className="redeem-fill-max" onClick={fillMaxAmount}>
|
||||
@@ -169,7 +169,7 @@ export default function RedeemPage() {
|
||||
</View>
|
||||
<View className="redeem-tips">
|
||||
<Text className="redeem-tips-text">
|
||||
核销金额最低 0.01 元,小数最多两位。不超过可用权益余额。核销码有效期 3 分钟,请到店出示给收银员扫码。
|
||||
核销权益最低 0.01,小数最多两位。不超过可用核销权益。核销码有效期 3 分钟,请到店出示给收银员扫码。
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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,
|
||||
@@ -12,13 +13,18 @@ import Taro, {
|
||||
import PageShell from '../../components/PageShell';
|
||||
import PageNavBar from '../../components/PageNavBar';
|
||||
import ProductCarousel from '../../components/ProductCarousel';
|
||||
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
|
||||
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 { formatShanghaiDateTime } from '../../lib/datetime';
|
||||
import {
|
||||
storeCategoryTags,
|
||||
storeStarCount,
|
||||
type StoreCategoryTreeNode,
|
||||
} from '../../lib/store-display';
|
||||
import {
|
||||
buildSceneSharePayload,
|
||||
toWeappShareMessage,
|
||||
@@ -63,7 +69,16 @@ type Store = {
|
||||
avgPrice?: number | null;
|
||||
latitude?: number | string | null;
|
||||
longitude?: number | string | null;
|
||||
category?: { name: 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;
|
||||
};
|
||||
|
||||
type RecentRedeem = {
|
||||
@@ -104,11 +119,6 @@ function pickStoreId(raw?: string | null) {
|
||||
.replace(/[^\d]/g, '');
|
||||
}
|
||||
|
||||
/** 与历史记录一致:2026-08-03 15:14:30(Asia/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';
|
||||
@@ -122,20 +132,29 @@ function formatRedeemAmountYuan(amount: unknown) {
|
||||
return n.toFixed(2).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
function formatRecentRedeemLine(row: RecentRedeem) {
|
||||
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 {
|
||||
// 优先用服务端拼好的 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 userLabel = String(row.userLabel || '用户***').trim() || '用户***';
|
||||
const amount = formatRedeemAmountYuan(row.amount);
|
||||
return `${label} ${time} 核销${amount}元`;
|
||||
if (!amount) return null;
|
||||
return { userLabel, amount };
|
||||
} catch {
|
||||
return '';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +179,7 @@ export default function StoreDetailPage() {
|
||||
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);
|
||||
@@ -236,6 +256,12 @@ export default function StoreDetailPage() {
|
||||
}
|
||||
}, [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);
|
||||
@@ -254,8 +280,8 @@ export default function StoreDetailPage() {
|
||||
});
|
||||
}, [store, storeId]);
|
||||
|
||||
const marqueeLines = useMemo(
|
||||
() => recentRedeems.map(formatRecentRedeemLine).filter(Boolean),
|
||||
const marqueeItems = useMemo(
|
||||
() => recentRedeems.map(toMarqueeItem).filter((row): row is StoreRedeemMarqueeItem => !!row),
|
||||
[recentRedeems],
|
||||
);
|
||||
|
||||
@@ -369,7 +395,38 @@ export default function StoreDetailPage() {
|
||||
</View>
|
||||
|
||||
<View className="store-detail-info-card">
|
||||
<Text className="store-detail-name">{store.name}</Text>
|
||||
{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>
|
||||
{storeCategoryTags(store, categoryTree).map((tag) => (
|
||||
<Text key={tag} className="store-detail-tag">
|
||||
{tag}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<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">
|
||||
@@ -404,23 +461,20 @@ export default function StoreDetailPage() {
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{store.category?.name ? (
|
||||
<View className="store-detail-tags">
|
||||
<Text className="store-detail-tag">{store.category.name}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{marqueeLines.length > 0 ? (
|
||||
<View className="store-detail-marquee-wrap">
|
||||
<StoreRedeemMarquee key={marqueeLines.join('|')} lines={marqueeLines} />
|
||||
<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">
|
||||
<Text className="store-detail-section-title">门店套餐</Text>
|
||||
<SectionTitle>门店套餐</SectionTitle>
|
||||
<View className="store-detail-package-list">
|
||||
{packages.map((pkg, index) => (
|
||||
<View
|
||||
@@ -440,16 +494,9 @@ export default function StoreDetailPage() {
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{benefitRule ? (
|
||||
<View className="store-detail-section">
|
||||
<Text className="store-detail-section-title store-detail-section-title--rule">使用规则</Text>
|
||||
<Text className="store-detail-intro">{benefitRule}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{intro ? (
|
||||
<View className="store-detail-section">
|
||||
<Text className="store-detail-section-title">门店详情</Text>
|
||||
<SectionTitle>门店详情</SectionTitle>
|
||||
<ScrollView className="store-detail-intro-scroll" scrollY showScrollbar>
|
||||
<Text className="store-detail-intro">{intro}</Text>
|
||||
</ScrollView>
|
||||
@@ -458,7 +505,7 @@ export default function StoreDetailPage() {
|
||||
|
||||
{envPhotos.length > 0 ? (
|
||||
<View className="store-detail-section">
|
||||
<Text className="store-detail-section-title">店内环境</Text>
|
||||
<SectionTitle>店内环境</SectionTitle>
|
||||
<View className="store-detail-env-grid">
|
||||
{envPhotos.map((url, index) => (
|
||||
<View
|
||||
|
||||
@@ -34,12 +34,15 @@ import {
|
||||
markStoresSessionBootstrapped,
|
||||
patchStoresFilterCache,
|
||||
setStoresListCache,
|
||||
type StoreSortKey,
|
||||
} from '../../lib/stores-session';
|
||||
import {
|
||||
buildSceneSharePayload,
|
||||
toWeappShareMessage,
|
||||
toWeappShareTimeline,
|
||||
} from '../../lib/wechat-share';
|
||||
import BenefitSloganBar from '../../components/BenefitSloganBar';
|
||||
import { storeCategoryTags, storeStarCount } from '../../lib/store-display';
|
||||
import openBadgeImg from '../../assets/icons/store-open-badge.png';
|
||||
|
||||
type Store = {
|
||||
@@ -57,12 +60,26 @@ type Store = {
|
||||
avgPrice?: number | null;
|
||||
status?: string;
|
||||
categoryId?: string | null;
|
||||
category?: { id?: string; name?: string; parentId?: string | null } | null;
|
||||
category?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
parentId?: string | null;
|
||||
parent?: { name?: string } | null;
|
||||
} | null;
|
||||
tags?: unknown;
|
||||
rating?: number | string | null;
|
||||
latitude?: number | string | null;
|
||||
longitude?: number | string | null;
|
||||
distanceMeters?: number | null;
|
||||
redeemCount?: number | null;
|
||||
};
|
||||
|
||||
const STORE_SORT_OPTIONS: { key: StoreSortKey; label: string }[] = [
|
||||
{ key: 'nearby', label: '附近优先' },
|
||||
{ key: 'rating', label: '好评优先' },
|
||||
{ key: 'redeem', label: '核销次数' },
|
||||
];
|
||||
|
||||
function makeCityKey(region: Pick<RegionSelection, 'province' | 'city'>): string {
|
||||
return `${region.province}|${region.city}`;
|
||||
}
|
||||
@@ -98,13 +115,15 @@ export default function StoresPage() {
|
||||
);
|
||||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||
const [locating, setLocating] = useState(false);
|
||||
const [sort, setSort] = useState<StoreSortKey>(() => cached?.sort ?? 'nearby');
|
||||
const [sortOpen, setSortOpen] = useState(false);
|
||||
const fetchCityKeyRef = useRef<string | null>(cached?.cityKey ?? null);
|
||||
const fetchSeqRef = useRef(0);
|
||||
const regionRef = useRef(region);
|
||||
regionRef.current = region;
|
||||
const regionLabel = formatRegionLabel(region);
|
||||
const categoryLabel = formatCategoryLabel(category);
|
||||
const sortLabel = STORE_SORT_OPTIONS.find((o) => o.key === sort)?.label ?? '附近优先';
|
||||
const showBootLoading = loading && stores.length === 0;
|
||||
|
||||
const childIdsByParent = useMemo(() => {
|
||||
@@ -151,6 +170,7 @@ export default function StoresPage() {
|
||||
keyword: prev?.keyword ?? keyword,
|
||||
keywordInput: prev?.keywordInput ?? keywordInput,
|
||||
category: prev?.category ?? category,
|
||||
sort: prev?.sort ?? sort,
|
||||
});
|
||||
} catch (e) {
|
||||
if (seq !== fetchSeqRef.current) return;
|
||||
@@ -278,13 +298,29 @@ export default function StoresPage() {
|
||||
return siblings.includes(storeCatId);
|
||||
}
|
||||
|
||||
const filtered = stores.filter((s) => {
|
||||
if (!matchesRegionFilter(s, region)) return false;
|
||||
if (!matchesCategory(s)) return false;
|
||||
if (!keyword.trim()) return true;
|
||||
const q = keyword.trim();
|
||||
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
|
||||
});
|
||||
const filtered = useMemo(() => {
|
||||
const list = stores.filter((s) => {
|
||||
if (!matchesRegionFilter(s, region)) return false;
|
||||
if (!matchesCategory(s)) return false;
|
||||
if (!keyword.trim()) return true;
|
||||
const q = keyword.trim();
|
||||
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
|
||||
});
|
||||
const next = [...list];
|
||||
next.sort((a, b) => {
|
||||
if (sort === 'rating') {
|
||||
const diff = storeStarCount(b.rating) - storeStarCount(a.rating);
|
||||
if (diff !== 0) return diff;
|
||||
} else if (sort === 'redeem') {
|
||||
const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0);
|
||||
if (diff !== 0) return diff;
|
||||
}
|
||||
const da = a.distanceMeters ?? Number.POSITIVE_INFINITY;
|
||||
const db = b.distanceMeters ?? Number.POSITIVE_INFINITY;
|
||||
return da - db;
|
||||
});
|
||||
return next;
|
||||
}, [stores, region, category, keyword, sort, childIdsByParent]);
|
||||
|
||||
function applySearch() {
|
||||
const next = keywordInput.trim();
|
||||
@@ -296,52 +332,18 @@ export default function StoresPage() {
|
||||
setKeywordInput('');
|
||||
setKeyword('');
|
||||
setCategory(EMPTY_CATEGORY);
|
||||
setSort('nearby');
|
||||
setRegion(DEFAULT_REGION);
|
||||
regionRef.current = DEFAULT_REGION;
|
||||
patchStoresFilterCache({
|
||||
keyword: '',
|
||||
keywordInput: '',
|
||||
category: EMPTY_CATEGORY,
|
||||
sort: 'nearby',
|
||||
filterRegion: DEFAULT_REGION,
|
||||
});
|
||||
}
|
||||
|
||||
async function locateToUserRegion() {
|
||||
if (locating) return;
|
||||
const { confirm } = await Taro.showModal({
|
||||
title: '获取当前位置',
|
||||
content: '是否允许获取当前位置,并将筛选定位到您所在的城市与区县?',
|
||||
confirmText: '允许',
|
||||
cancelText: '暂不',
|
||||
}).catch(() => ({ confirm: false, cancel: true }));
|
||||
if (!confirm) return;
|
||||
|
||||
setLocating(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
const resolved = await resolveUserCity(true);
|
||||
// 筛选器用真实省市+区县;拉数仍按开城 cityCode(未开城则郑州)
|
||||
const filterRegion = resolved.region;
|
||||
const { cityCode, region: listRegion } = regionForCatalogFetch(resolved);
|
||||
const nextCityKey = makeCityKey(listRegion);
|
||||
setRegion(filterRegion);
|
||||
regionRef.current = filterRegion;
|
||||
await fetchStores(
|
||||
cityCode,
|
||||
readCachedUserCoords(),
|
||||
nextCityKey,
|
||||
listRegion,
|
||||
filterRegion,
|
||||
);
|
||||
toast(`已定位到${formatRegionLabel(filterRegion)}`, 'success');
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '定位失败');
|
||||
setLoading(false);
|
||||
} finally {
|
||||
setLocating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function hoursText(store: Store): string {
|
||||
const parts: string[] = [];
|
||||
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
|
||||
@@ -366,30 +368,59 @@ export default function StoresPage() {
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<TabMainHeader title="门店" />
|
||||
|
||||
<View className="store-slogan-wrap">
|
||||
<BenefitSloganBar />
|
||||
</View>
|
||||
|
||||
<View className="store-filter">
|
||||
<View className="store-search-row">
|
||||
<Input
|
||||
className="store-search-input"
|
||||
placeholder="搜索门店名称/地址"
|
||||
placeholder="搜索门店名称或地址"
|
||||
value={keywordInput}
|
||||
confirmType="search"
|
||||
onInput={(e) => setKeywordInput(e.detail.value)}
|
||||
onConfirm={applySearch}
|
||||
/>
|
||||
<View className="store-search-btn" onClick={applySearch} aria-label="搜索">
|
||||
<View className="store-search-icon" />
|
||||
<View className="store-search-btn" onClick={applySearch}>
|
||||
<Text className="store-search-btn-text">搜索</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="store-filter-row">
|
||||
<View className="store-filter-chip" onClick={() => setRegionOpen(true)}>
|
||||
<View
|
||||
className="store-filter-chip"
|
||||
onClick={() => {
|
||||
setCategoryOpen(false);
|
||||
setSortOpen(false);
|
||||
setRegionOpen(true);
|
||||
}}
|
||||
>
|
||||
<Text className="store-filter-chip-text">{regionLabel}</Text>
|
||||
<Text className="store-filter-chip-arrow">▾</Text>
|
||||
</View>
|
||||
<View className="store-filter-chip" onClick={() => setCategoryOpen(true)}>
|
||||
<View
|
||||
className="store-filter-chip"
|
||||
onClick={() => {
|
||||
setRegionOpen(false);
|
||||
setSortOpen(false);
|
||||
setCategoryOpen(true);
|
||||
}}
|
||||
>
|
||||
<Text className="store-filter-chip-text">{categoryLabel}</Text>
|
||||
<Text className="store-filter-chip-arrow">▾</Text>
|
||||
</View>
|
||||
<View
|
||||
className="store-filter-chip"
|
||||
onClick={() => {
|
||||
setRegionOpen(false);
|
||||
setCategoryOpen(false);
|
||||
setSortOpen(true);
|
||||
}}
|
||||
>
|
||||
<Text className="store-filter-chip-text">{sortLabel}</Text>
|
||||
<Text className="store-filter-chip-arrow">▾</Text>
|
||||
</View>
|
||||
<View
|
||||
className="store-filter-icon-btn"
|
||||
onClick={resetFilters}
|
||||
@@ -398,15 +429,6 @@ export default function StoresPage() {
|
||||
{/* 小程序 View 伪元素不稳定,用 Text 保证真机可见 */}
|
||||
<Text className="store-filter-icon-glyph">↺</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`store-filter-icon-btn${locating ? ' store-filter-icon-btn--busy' : ''}`}
|
||||
onClick={() => {
|
||||
void locateToUserRegion();
|
||||
}}
|
||||
aria-label="获取当前位置"
|
||||
>
|
||||
<Text className="store-filter-icon-glyph">⌖</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -435,11 +457,34 @@ export default function StoresPage() {
|
||||
/>
|
||||
</View>
|
||||
<View className="store-card-body">
|
||||
{/* 第1行:标题(截断无省略号,顶到最右) */}
|
||||
<View className="store-card-row store-card-row--head">
|
||||
<Text className="store-card-name">{s.name}</Text>
|
||||
</View>
|
||||
{/* 第2行:地址(最多两行)+ 距离 */}
|
||||
{(() => {
|
||||
const tags = storeCategoryTags(s, categoryTree);
|
||||
return tags.length ? (
|
||||
<View className="store-card-tags">
|
||||
{tags.map((tag) => (
|
||||
<Text key={tag} className="store-card-tag">
|
||||
{tag}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
) : null;
|
||||
})()}
|
||||
<View className="store-card-row store-card-row--rating">
|
||||
<View className="store-card-stars">
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<Text
|
||||
key={n}
|
||||
className={`store-card-star${n <= storeStarCount(s.rating) ? ' store-card-star--on' : ''}`}
|
||||
>
|
||||
★
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<Text className="store-card-redeem">核销{s.redeemCount ?? 0}次</Text>
|
||||
</View>
|
||||
<View className="store-card-row store-card-row--mid">
|
||||
<Text className="store-card-address" numberOfLines={2}>
|
||||
{s.address || (s.district ? `${s.district}` : '地址待完善')}
|
||||
@@ -448,12 +493,10 @@ export default function StoresPage() {
|
||||
{formatDistanceMeters(s.distanceMeters)}
|
||||
</Text>
|
||||
</View>
|
||||
{/* 第3行:营业时间(同行) */}
|
||||
<View className="store-card-row store-card-row--hours">
|
||||
<Text className="store-card-hours">{hoursText(s)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="store-card-arrow">›</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
@@ -480,6 +523,35 @@ export default function StoresPage() {
|
||||
patchStoresFilterCache({ category: next });
|
||||
}}
|
||||
/>
|
||||
{sortOpen ? (
|
||||
<View className="region-picker-overlay" onClick={() => setSortOpen(false)}>
|
||||
<View className="region-picker-sheet store-sort-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<View className="region-picker-toolbar">
|
||||
<View className="region-picker-tabs">
|
||||
<Text className="region-picker-tab active">排序规则</Text>
|
||||
</View>
|
||||
<Text className="region-picker-confirm ready" onClick={() => setSortOpen(false)}>
|
||||
关闭
|
||||
</Text>
|
||||
</View>
|
||||
<View className="region-picker-list">
|
||||
{STORE_SORT_OPTIONS.map((opt) => (
|
||||
<View
|
||||
key={opt.key}
|
||||
className={`region-picker-option${sort === opt.key ? ' selected' : ''}`}
|
||||
onClick={() => {
|
||||
setSort(opt.key);
|
||||
patchStoresFilterCache({ sort: opt.key });
|
||||
setSortOpen(false);
|
||||
}}
|
||||
>
|
||||
<Text>{opt.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user