feat(assoc): v4.0.1 合伙人关联码、分佣账单与 H5 用户管理

订单佣金只认关联用户;合伙人备注写入独立表;H5 增加用户管理与首页统计。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 14:35:39 +08:00
parent 3b669f7e38
commit 9c8d5f2cad
125 changed files with 6355 additions and 1436 deletions
@@ -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>
);
}
+68 -36
View File
@@ -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>
))
+38 -3
View File
@@ -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>
);
}
+5 -6
View File
@@ -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>
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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: '评价门店',
});
+277 -96
View File
@@ -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:03Asia/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>
);
+6 -6
View File
@@ -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
+88 -41
View File
@@ -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: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';
@@ -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
+135 -63
View File
@@ -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>
);
}