feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '编辑地址',
});
@@ -0,0 +1,253 @@
import { useEffect, useState } from 'react';
import { View, Text, Input, Textarea, Switch } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import RegionPicker from '../../components/RegionPicker';
import { buildAddressListUrl, readCheckoutContext } from '../../lib/checkout-nav';
import {
DEFAULT_REGION,
REGION_ALL,
formatRegion,
type RegionSelection,
} from '../../lib/region-data';
import { normalizePhoneInput, validateMobilePhone } from '../../lib/phone';
import { getStoredUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
import { resolveUserCity } from '../../lib/user-location';
import { request, toast, type UserProfile } from '../../lib/api';
type AddressForm = {
receiverName: string;
phone: string;
province: string;
city: string;
district: string;
detail: string;
isDefault: boolean;
};
export default function AddressEditPage() {
const router = useRouter();
const id = router.params.id;
const isEdit = !!id;
const checkoutCtx = readCheckoutContext(router.params);
const [pickerOpen, setPickerOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<AddressForm>(() => ({
receiverName: '',
phone: id ? '' : getStoredUserPhone(),
province: DEFAULT_REGION.province,
city: DEFAULT_REGION.city,
district: DEFAULT_REGION.district,
detail: '',
isDefault: true,
}));
useEffect(() => {
if (id) return;
request<UserProfile>('/auth/me')
.then((me) => {
const phone = resolveDefaultUserPhone(me);
if (!phone) return;
setForm((prev) => (prev.phone ? prev : { ...prev, phone }));
})
.catch(() => {});
}, [id]);
useEffect(() => {
if (id) return;
let cancelled = false;
setLocating(true);
void resolveUserCity(true)
.then((resolved) => {
if (cancelled) return;
const district =
resolved.region.district && resolved.region.district !== REGION_ALL
? resolved.region.district
: resolved.district && resolved.district !== REGION_ALL
? resolved.district
: DEFAULT_REGION.district;
setForm((prev) => ({
...prev,
province: resolved.region.province || prev.province,
city: resolved.region.city || prev.city,
district: district || prev.district,
}));
})
.catch(() => {})
.finally(() => {
if (!cancelled) setLocating(false);
});
return () => {
cancelled = true;
};
}, [id]);
useEffect(() => {
if (!id) return;
request<Array<Record<string, unknown>>>('/user/addresses').then((list) => {
const found = list.find((a) => String(a.id) === id);
if (found) {
setForm({
receiverName: String(found.receiverName ?? ''),
phone: String(found.phone ?? ''),
province: String(found.province ?? DEFAULT_REGION.province),
city: String(found.city ?? DEFAULT_REGION.city),
district: String(found.district ?? DEFAULT_REGION.district),
detail: String(found.detail ?? ''),
isDefault: found.isDefault === 1 || found.isDefault === true,
});
}
}).catch(() => {});
}, [id]);
const regionText = formatRegion(form.province, form.city, form.district);
function validateForm(): string | null {
if (!form.receiverName.trim()) return '请输入收货人姓名';
const phoneCheck = validateMobilePhone(form.phone);
if (!phoneCheck.ok) return phoneCheck.message ?? '请输入正确的手机号码';
if (!form.province || !form.city || !form.district) return '请选择所在地区';
if (!form.detail.trim()) return '请输入详细地址';
return null;
}
async function save() {
const validationError = validateForm();
if (validationError) {
setError(validationError);
toast(validationError);
return;
}
setSaving(true);
setError('');
try {
const payload = {
receiverName: form.receiverName.trim(),
phone: form.phone.trim(),
province: form.province,
city: form.city,
district: form.district,
detail: form.detail.trim(),
isDefault: form.isDefault,
};
if (isEdit && id) {
await request(`/user/addresses/${id}`, { method: 'PUT', data: payload });
toast('地址已更新', 'success');
} else {
await request('/user/addresses', { method: 'POST', data: payload });
toast('地址已新增', 'success');
}
setTimeout(() => {
Taro.redirectTo({ url: buildAddressListUrl(checkoutCtx) }).catch(() => {
Taro.navigateBack();
});
}, 400);
} catch (e) {
const msg = e instanceof Error ? e.message : '保存失败';
setError(msg);
toast(msg);
} finally {
setSaving(false);
}
}
function onRegionConfirm(region: RegionSelection) {
setForm((prev) => ({
...prev,
province: region.province,
city: region.city,
district: region.district,
}));
}
return (
<PageShell variant="sub" className="address-page" hasFixedFooter>
<SubPageHeader title={isEdit ? '编辑地址' : '新增地址'} />
<View className="sub-page-body" style={{ paddingBottom: 80 }}>
<View className="address-form-field">
<Text className="address-form-label"></Text>
<Input
className="address-form-input"
placeholder="请输入姓名"
value={form.receiverName}
onInput={(e) => setForm((prev) => ({ ...prev, receiverName: e.detail.value }))}
/>
</View>
<View className="address-form-field">
<Text className="address-form-label"></Text>
<Input
className="address-form-input"
type="number"
maxlength={11}
placeholder="请输入手机号"
value={form.phone}
onInput={(e) =>
setForm((prev) => ({ ...prev, phone: normalizePhoneInput(e.detail.value) }))
}
/>
</View>
<View className="address-form-field">
<Text className="address-form-label"></Text>
<View
className="address-form-input"
style={{ display: 'flex', alignItems: 'center' }}
onClick={() => setPickerOpen(true)}
>
<Text>
{locating && !isEdit
? '定位中…'
: regionText || '请选择省市区'}
</Text>
</View>
</View>
<View className="address-form-field">
<Text className="address-form-label"></Text>
{process.env.TARO_ENV === 'h5' ? (
<textarea
className="address-form-textarea address-form-textarea--native"
placeholder="街道门牌号等"
rows={3}
value={form.detail}
onChange={(e) => {
const value = e.currentTarget.value;
setForm((prev) => ({ ...prev, detail: value }));
}}
/>
) : (
<Textarea
className="address-form-textarea"
placeholder="街道门牌号等"
value={form.detail}
maxlength={200}
onInput={(e) => setForm((prev) => ({ ...prev, detail: e.detail.value }))}
/>
)}
</View>
<View className="address-form-row">
<Text></Text>
<Switch
checked={form.isDefault}
color="#A61D24"
onChange={(e) => setForm((prev) => ({ ...prev, isDefault: e.detail.value }))}
/>
</View>
{error ? <Text className="address-form-error">{error}</Text> : null}
</View>
<View className="address-fab" onClick={() => !saving && void save()}>
<Text>{saving ? '保存中…' : '保存'}</Text>
</View>
<RegionPicker
open={pickerOpen}
value={{ province: form.province, city: form.city, district: form.district }}
onClose={() => setPickerOpen(false)}
onConfirm={onRegionConfirm}
levels={3}
/>
</PageShell>
);
}
@@ -0,0 +1,6 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '地址管理',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
});
@@ -0,0 +1,139 @@
import { useCallback, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import {
buildAddressEditUrl,
buildOrderConfirmUrl,
readCheckoutContext,
} from '../../lib/checkout-nav';
import { request, toast } from '../../lib/api';
type Address = {
id: string;
receiverName: string;
phone: string;
province: string;
city: string;
district: string;
detail: string;
isDefault?: number | boolean;
};
function formatAddress(a: Address) {
return `${a.province}${a.city}${a.district}${a.detail}`;
}
export default function AddressesPage() {
const router = useRouter();
const checkoutCtx = readCheckoutContext(router.params);
const selectMode = checkoutCtx.select === true;
const [list, setList] = useState<Address[]>([]);
const [loading, setLoading] = useState(true);
const loadList = useCallback(() => {
setLoading(true);
request<Address[]>('/user/addresses')
.then((data) => setList(Array.isArray(data) ? data : []))
.catch(() => setList([]))
.finally(() => setLoading(false));
}, []);
useDidShow(() => {
loadList();
});
usePullDownRefresh(() => {
void Promise.resolve(loadList()).finally(() => Taro.stopPullDownRefresh());
});
function selectAddress(addr: Address) {
if (!selectMode) return;
Taro.redirectTo({
url: buildOrderConfirmUrl({
productId: checkoutCtx.productId,
qty: checkoutCtx.qty,
addressId: addr.id,
cross: checkoutCtx.cross,
}),
});
}
async function removeAddress(id: string) {
const res = await Taro.showModal({
title: '删除地址',
content: '确定删除该收货地址吗?',
});
if (!res.confirm) return;
try {
await request(`/user/addresses/${id}`, { method: 'DELETE' });
toast('已删除', 'success');
loadList();
} catch (e) {
toast(e instanceof Error ? e.message : '删除失败');
}
}
return (
<PageShell variant="sub" className="address-page" hasFixedFooter>
<SubPageHeader title={selectMode ? '选择收货地址' : '地址管理'} />
<View className="sub-page-body" style={{ paddingBottom: 80 }}>
{loading ? <View className="u-empty"></View> : null}
{!loading && list.length === 0 ? (
<View className="u-empty"></View>
) : null}
{list.map((a) => (
<View
key={a.id}
className="address-item"
onClick={() => selectAddress(a)}
>
<View className="address-item-head">
<Text className="address-item-name">{a.receiverName}</Text>
<Text className="address-item-phone">{a.phone}</Text>
{a.isDefault === 1 || a.isDefault === true ? (
<Text className="address-default-tag"></Text>
) : null}
</View>
<Text className="address-item-detail">{formatAddress(a)}</Text>
{!selectMode ? (
<View className="address-item-actions">
<Text
className="address-action"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: buildAddressEditUrl(a.id, checkoutCtx) }).catch((err) => {
toast(err instanceof Error ? err.message : '无法打开编辑页');
});
}}
>
</Text>
<Text
className="address-action"
onClick={(e) => {
e.stopPropagation();
void removeAddress(a.id);
}}
>
</Text>
</View>
) : null}
</View>
))}
</View>
<View
className="address-fab"
onClick={() => {
Taro.navigateTo({ url: buildAddressEditUrl(undefined, checkoutCtx) }).catch((e) => {
toast(e instanceof Error ? e.message : '无法打开新增地址页');
});
}}
>
<Text></Text>
</View>
</PageShell>
);
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '权益明细',
});
@@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request } from '../../lib/api';
type LedgerItem = {
id: string;
title?: string;
amount: number;
createdAt?: string;
type?: string;
};
export default function BenefitDetailPage() {
const [items, setItems] = useState<LedgerItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
request<LedgerItem[]>('/benefit/ledger')
.then((data) => setItems(Array.isArray(data) ? data : []))
.catch(() => {
// UI shell fallback demo rows when API missing
setItems([]);
})
.finally(() => setLoading(false));
}, []);
return (
<PageShell variant="sub" className="benefit-detail-page">
<SubPageHeader
title="权益明细"
onBack={() => {
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack();
return;
}
Taro.switchTab({ url: '/pages/mine/index' });
}}
/>
<View className="sub-page-body">
<View className="order-card">
{loading ? <View className="u-empty"></View> : null}
{!loading && items.length === 0 ? (
<View className="u-empty"></View>
) : null}
{items.map((item) => {
const isOut = Number(item.amount) < 0 || item.type === 'REDEEM';
return (
<View key={item.id} className="ledger-item">
<View>
<Text className="ledger-title">{item.title || (isOut ? '门店核销' : '购酒入账')}</Text>
<Text className="ledger-time">
{item.createdAt ? String(item.createdAt).slice(0, 19).replace('T', ' ') : '-'}
</Text>
</View>
<Text className={`ledger-amount ${isOut ? 'ledger-amount--out' : 'ledger-amount--in'}`}>
{isOut ? '' : '+'}
{Number(item.amount).toFixed(2)}
</Text>
</View>
);
})}
</View>
</View>
</PageShell>
);
}
@@ -0,0 +1,8 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '好客权益',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
enableShareAppMessage: true,
enableShareTimeline: true,
});
+251
View File
@@ -0,0 +1,251 @@
import { useCallback, useMemo, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { goLogin } from '../../lib/auth-nav';
import { isLoggedIn, request, toast } from '../../lib/api';
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../../lib/nav-bar';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
import { formatMoney } from '../../lib/money';
import iconBenefit from '../../assets/tabbar/benefit-active.png';
type BenefitSummary = {
totalBalance: number;
maxRedeemAmount: number;
activeCouponCount: number;
};
type CouponItem = {
id: string;
couponNo: string;
totalAmount: number;
usedAmount: number;
balance: number;
status: string;
sourceProduct: string;
};
type RedeemHistoryItem = {
id: string;
redeemNo: string;
amount: number;
storeName: string;
createdAt: string;
};
function usagePercent(coupon: CouponItem) {
const total = Number(coupon.totalAmount);
if (total <= 0) return 0;
return Math.min(100, Math.round((Number(coupon.usedAmount) / total) * 100));
}
export default function BenefitPage() {
const metrics = useNavBarMetrics();
const [loggedIn, setLoggedIn] = useState(() => isLoggedIn());
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 resetGuestState = useCallback(() => {
setSummary(null);
setCoupons([]);
setRedeemHistory([]);
}, []);
const loadBenefit = useCallback(() => {
if (!isLoggedIn()) return Promise.resolve();
return Promise.all([
request<BenefitSummary>('/benefit/summary'),
request<CouponItem[]>('/benefit/coupons'),
request<{ list?: RedeemHistoryItem[] } | RedeemHistoryItem[]>('/redeem/records?page=1&pageSize=50'),
])
.then(([s, list, records]) => {
setSummary(s);
setCoupons(Array.isArray(list) ? list : []);
const hist = Array.isArray(records)
? records
: Array.isArray(records?.list)
? records.list
: [];
setRedeemHistory(hist);
})
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, []);
useDidShow(() => {
syncTabBarSelected(2);
const loggedInNow = isLoggedIn();
setLoggedIn(loggedInNow);
if (loggedInNow) {
void loadBenefit();
} else {
resetGuestState();
}
});
usePullDownRefresh(() => {
const loggedInNow = isLoggedIn();
setLoggedIn(loggedInNow);
if (!loggedInNow) {
resetGuestState();
Taro.stopPullDownRefresh();
return;
}
void loadBenefit().finally(() => Taro.stopPullDownRefresh());
});
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
const sharePayload = useMemo(
() => ({
title: '好客权益 · 杜康好客',
desc: DEFAULT_SHARE_DESC,
path: '/pages/benefit/index',
}),
[],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
imageUrl: sharePayload.imgUrl,
}));
return (
<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>
</View>
{!loggedIn ? (
<View className="benefit-login-gate">
<View className="u-empty"></View>
<View
className="u-btn u-btn--block"
style={{ maxWidth: 240, margin: '0 auto' }}
onClick={() => goLogin('/pages/benefit/index')}
>
<Text></Text>
</View>
</View>
) : (
<View className="benefit-main">
<View className="benefit-hero">
<View className="benefit-hero-top">
<View>
<Text className="benefit-hero-label"></Text>
<View className="benefit-hero-amount">
<Text className="benefit-hero-symbol">¥</Text>
<Text className="benefit-hero-value">
{summary ? formatMoney(summary.totalBalance) : '--'}
</Text>
</View>
</View>
<View className="benefit-hero-logo">
<Image className="benefit-hero-logo-img" src={iconBenefit} mode="aspectFit" />
</View>
</View>
<View
className="benefit-hero-cta"
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
>
<Text>使</Text>
</View>
</View>
<View className="benefit-tabs">
<Text
className={`benefit-tab${tab === 'available' ? ' benefit-tab--active' : ''}`}
onClick={() => setTab('available')}
>
</Text>
<Text
className={`benefit-tab${tab === 'history' ? ' benefit-tab--active' : ''}`}
onClick={() => setTab('history')}
>
</Text>
</View>
{tab === 'available' ? (
available.length === 0 ? (
<View className="u-empty"></View>
) : (
available.map((c) => (
<View key={c.id} className="benefit-coupon">
<View className="benefit-coupon-notch" />
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
<View className="benefit-coupon-head">
<Text className="benefit-coupon-name">{c.sourceProduct || '好客权益'}</Text>
<Text className="benefit-coupon-balance">¥{formatMoney(c.balance)}</Text>
</View>
<Text className="benefit-coupon-no">NO. {c.couponNo}</Text>
<View className="benefit-progress">
<View className="benefit-progress-bar" style={{ width: `${usagePercent(c)}%` }} />
</View>
<View className="benefit-coupon-footer">
<Text className="benefit-coupon-meta">
¥{formatMoney(c.usedAmount)} / ¥{formatMoney(c.totalAmount)}
</Text>
<Text
className="benefit-coupon-btn"
onClick={() =>
Taro.navigateTo({
url: `/pages/redeem/index?couponId=${c.id}&amount=${c.balance}`,
})
}
>
</Text>
</View>
</View>
))
)
) : redeemHistory.length === 0 ? (
<View className="u-empty"></View>
) : (
redeemHistory.map((r) => (
<View key={r.id} className="benefit-coupon">
<View className="benefit-coupon-notch" />
<View className="benefit-coupon-notch benefit-coupon-notch--right" />
<View className="benefit-coupon-head">
<Text className="benefit-coupon-name">{r.storeName || '门店核销'}</Text>
<Text className="benefit-coupon-balance">-¥{formatMoney(Number(r.amount))}</Text>
</View>
<Text className="benefit-coupon-no">NO. {r.redeemNo}</Text>
<View className="benefit-coupon-footer">
<Text className="benefit-coupon-meta">
{r.createdAt ? String(r.createdAt).slice(0, 19).replace('T', ' ') : '-'}
</Text>
</View>
</View>
))
)}
</View>
)}
{shouldRenderPageTabBar() ? <UserTabBar selected={2} /> : null}
</PageShell>
);
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '联系客服',
});
@@ -0,0 +1,45 @@
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import ContactCsButton from '../../components/ContactCsButton';
import { toast } from '../../lib/api';
const isWeapp = process.env.TARO_ENV === 'weapp';
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
function dialPhone() {
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => toast('无法拨打电话'));
}
export default function CustomerServicePage() {
return (
<PageShell variant="sub" className="cs-page">
<SubPageHeader title="联系客服" />
<View className="sub-page-body inset-page cs-body">
<Text className="cs-brand"></Text>
<Text className="cs-hint">
{isWeapp
? '点击下方按钮,进入小程序在线客服会话'
: '请在微信小程序内打开以使用在线客服,或拨打客服电话'}
</Text>
<Text className="cs-hours">9:00 - 21:00</Text>
{isWeapp ? (
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
) : null}
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={dialPhone}>
<Text>
{isWeapp ? `或拨打客服电话 ${CUSTOMER_SERVICE_PHONE}` : '拨打客服电话'}
</Text>
</View>
{!isWeapp ? (
<Text className="cs-phone-display">{CUSTOMER_SERVICE_PHONE}</Text>
) : null}
</View>
</PageShell>
);
}
@@ -0,0 +1,7 @@
export default definePageConfig({
navigationBarTitleText: '杜康好客',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
enableShareAppMessage: true,
enableShareTimeline: true,
});
+401
View File
@@ -0,0 +1,401 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
import Taro, {
useDidShow,
usePageScroll,
usePullDownRefresh,
useShareAppMessage,
useShareTimeline,
} from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import CouponBadge from '../../components/CouponBadge';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { goLogin } from '../../lib/auth-nav';
import { getToken, isLoggedIn, request, toast } from '../../lib/api';
import {
getHomeCatalogCache,
isHomeCatalogBootstrapped,
setHomeCatalogCache,
} from '../../lib/home-catalog-session';
import { ensurePayReady } from '../../lib/pay-ready';
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
import { getProductMainImage } from '../../lib/product-images';
import {
canBuyOnline,
canPickupOnSite,
normalizeFulfillmentFlags,
} from '../../lib/product-fulfillment';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
import { trackPageView } from '../../lib/analytics';
type Product = {
id: string;
name: string;
subtitle?: string;
spec?: string;
price: number;
benefitDisplay?: number;
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
aromaType: string;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
};
type MiniHomeConfig = {
banners: string[];
footerUrl: string | null;
};
const AROMA_TABS = [
{ key: 'QINGXIANG', label: '清香型' },
{ key: 'JIANGXIANG', label: '酱香型' },
{ key: 'NONGXIANG', label: '浓香型' },
] as const;
type AromaKey = (typeof AROMA_TABS)[number]['key'];
/** sticky 香型导航高度(与 CSS 大致一致),锚点滚动时预留 */
const AROMA_NAV_OFFSET_PX = 44;
function aromaSectionId(key: AromaKey) {
return `aroma-section-${key}`;
}
export default function HomePage() {
const [activeAroma, setActiveAroma] = useState<AromaKey>('QINGXIANG');
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [displayCity, setDisplayCity] = useState('郑州市');
const [cityCode, setCityCode] = useState('410100');
const [miniHome, setMiniHome] = useState<MiniHomeConfig>({ banners: [], footerUrl: null });
const scrollingToRef = useRef<AromaKey | null>(null);
const scrollLockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastScrollSyncAtRef = useRef(0);
useEffect(() => {
trackPageView('home_view', { pagePath: '/pages/home/index', cityCode });
}, [cityCode]);
const loadMiniHome = useCallback(() => {
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config')
.then((cfg) => {
const banners = Array.isArray(cfg.miniHome?.banners)
? cfg.miniHome!.banners.filter((u) => typeof u === 'string' && !!u.trim())
: [];
const footerUrl =
typeof cfg.miniHome?.footerUrl === 'string' && cfg.miniHome.footerUrl.trim()
? cfg.miniHome.footerUrl.trim()
: null;
setMiniHome({ banners, footerUrl });
})
.catch(() => {
/* 首页装饰图失败不阻断商品列表 */
});
}, []);
const applyProductList = useCallback((list: Product[], nextCode: string, authKey: string) => {
const normalized = Array.isArray(list) ? list.map((p) => normalizeFulfillmentFlags(p)) : [];
setProducts(normalized);
setHomeCatalogCache({ cityCode: nextCode, authKey, products: normalized });
}, []);
const fetchProducts = useCallback(
(nextCode: string, authKey: string) => {
setLoading(true);
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`)
.then((list) => applyProductList(list, nextCode, authKey))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
},
[applyProductList],
);
/**
* 首次进入 / 城市或登录态变化:拉商品。
* 同次再切 tab:只同步选中态,不重复请求(对齐门店页)。
*/
useDidShow(() => {
syncTabBarSelected(0);
void capturePromoSceneAndTouchScan();
void loadMiniHome();
const authKey = getToken() || '';
void (async () => {
const resolved = await resolveUserCity();
const nextCode = getCityCodeForCatalog(resolved);
setDisplayCity(resolved.displayCity);
setCityCode(nextCode);
const cache = getHomeCatalogCache();
if (
isHomeCatalogBootstrapped() &&
cache &&
cache.cityCode === nextCode &&
cache.authKey === authKey &&
Array.isArray(cache.products)
) {
setProducts(cache.products as Product[]);
setLoading(false);
return;
}
await fetchProducts(nextCode, authKey);
})();
});
usePullDownRefresh(() => {
void (async () => {
try {
const authKey = getToken() || '';
const resolved = await resolveUserCity();
setDisplayCity(resolved.displayCity);
const nextCode = getCityCodeForCatalog(resolved);
setCityCode(nextCode);
setLoading(true);
const [list] = await Promise.all([
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`),
loadMiniHome(),
]);
applyProductList(
Array.isArray(list) ? list : [],
nextCode,
authKey,
);
} catch (e) {
toast(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
Taro.stopPullDownRefresh();
}
})();
});
function openProductDetail(id: string) {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
}
async function goOnSitePickup(productId: string) {
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
if (!isLoggedIn()) {
goLogin(returnPath);
return;
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath });
}
const productsByAroma = useMemo(() => {
const map: Record<AromaKey, Product[]> = {
QINGXIANG: [],
JIANGXIANG: [],
NONGXIANG: [],
};
for (const p of products) {
const key = p.aromaType as AromaKey;
if (key in map) map[key].push(p);
}
return map;
}, [products]);
const visibleAromaTabs = useMemo(
() => AROMA_TABS.filter((t) => productsByAroma[t.key].length > 0),
[productsByAroma],
);
useEffect(() => {
if (loading || visibleAromaTabs.length === 0) return;
if (!visibleAromaTabs.some((t) => t.key === activeAroma)) {
setActiveAroma(visibleAromaTabs[0].key);
}
}, [loading, visibleAromaTabs, activeAroma]);
const banners = miniHome.banners;
const footerUrl = miniHome.footerUrl;
const sharePayload = useMemo(
() => ({
title: DEFAULT_SHARE_TITLE,
desc: DEFAULT_SHARE_DESC,
path: '/pages/home/index',
imgUrl: banners[0] || undefined,
}),
[banners],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
imageUrl: sharePayload.imgUrl,
}));
function scrollToAroma(key: AromaKey) {
setActiveAroma(key);
scrollingToRef.current = key;
if (scrollLockTimerRef.current) clearTimeout(scrollLockTimerRef.current);
scrollLockTimerRef.current = setTimeout(() => {
scrollingToRef.current = null;
}, 450);
const query = Taro.createSelectorQuery();
query.select(`#${aromaSectionId(key)}`).boundingClientRect();
query.selectViewport().scrollOffset();
query.exec((res) => {
const rect = res?.[0] as { top?: number } | undefined;
const viewport = res?.[1] as { scrollTop?: number } | undefined;
if (rect?.top == null || viewport?.scrollTop == null) return;
const scrollTop = Math.max(0, viewport.scrollTop + rect.top - AROMA_NAV_OFFSET_PX);
void Taro.pageScrollTo({ scrollTop, duration: 280 });
});
}
usePageScroll(() => {
if (scrollingToRef.current) return;
const now = Date.now();
if (now - lastScrollSyncAtRef.current < 80) return;
lastScrollSyncAtRef.current = now;
const query = Taro.createSelectorQuery();
visibleAromaTabs.forEach((t) => {
query.select(`#${aromaSectionId(t.key)}`).boundingClientRect();
});
query.exec((rects) => {
if (!Array.isArray(rects) || rects.length === 0) return;
let next: AromaKey = visibleAromaTabs[0]?.key ?? AROMA_TABS[0].key;
for (let i = 0; i < visibleAromaTabs.length; i++) {
const rect = rects[i] as { top?: number } | null;
if (!rect || rect.top == null) continue;
// 区块顶进入导航下方一带时视为当前香型
if (rect.top <= AROMA_NAV_OFFSET_PX + 24) {
next = visibleAromaTabs[i].key;
}
}
setActiveAroma((prev) => (prev === next ? prev : next));
});
});
function renderProductCard(p: Product) {
const thumb = getProductMainImage(p);
const spec = p.subtitle || p.spec || '';
return (
<View key={p.id} className="home-product-card">
<View className="home-product-card-inner" onClick={() => openProductDetail(p.id)}>
<View className="home-product-thumb-wrap">
{thumb ? (
<Image className="home-product-thumb" src={thumb} mode="aspectFill" />
) : (
<View className="home-product-thumb home-product-thumb--empty" />
)}
</View>
<View className="home-product-main">
<View className="home-product-row">
<Text className="home-product-name">{p.name}</Text>
<Text className="home-product-price">¥{Number(p.price).toFixed(0)}</Text>
</View>
{spec ? <Text className="home-product-sub">{spec}</Text> : null}
<View className="home-product-footer">
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
</View>
<View className="home-product-actions">
{canPickupOnSite(p) ? (
<Text
className="home-pickup-btn"
onClick={(e) => {
e.stopPropagation?.();
void goOnSitePickup(p.id);
}}
>
</Text>
) : null}
{canBuyOnline(p) ? (
<Text
className="home-buy-btn"
onClick={(e) => {
e.stopPropagation?.();
openProductDetail(p.id);
}}
>
</Text>
) : null}
</View>
</View>
</View>
</View>
);
}
return (
<PageShell variant="tab" className="home-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="杜康好客" />
{banners.length > 0 ? (
<View className="home-promo-banner">
<Swiper
className="home-promo-banner-swiper"
indicatorDots={banners.length > 1}
autoplay={banners.length > 1}
circular={banners.length > 1}
interval={2500}
>
{banners.map((url) => (
<SwiperItem key={url}>
<Image className="home-promo-banner-img" src={url} mode="aspectFill" />
</SwiperItem>
))}
</Swiper>
</View>
) : null}
<View className="home-aroma-nav">
<View className="home-aroma-tabs">
{visibleAromaTabs.map((t) => (
<Text
key={t.key}
className={`home-aroma-tab${activeAroma === t.key ? ' home-aroma-tab--active' : ''}`}
onClick={() => scrollToAroma(t.key)}
>
{t.label}
</Text>
))}
</View>
<Text className="home-aroma-city">{displayCity}</Text>
</View>
<View className="home-product-list">
{loading ? <View className="home-empty"></View> : null}
{!loading && products.length === 0 ? (
<View className="home-empty"></View>
) : null}
{!loading &&
products.length > 0 &&
visibleAromaTabs.map((t) => {
const list = productsByAroma[t.key];
return (
<View key={t.key} id={aromaSectionId(t.key)} className="home-aroma-section">
<Text className="home-aroma-section-title">{t.label}</Text>
{list.map((p) => renderProductCard(p))}
</View>
);
})}
</View>
{footerUrl ? (
<View className="home-promo-footer">
<Image className="home-promo-footer-img" src={footerUrl} mode="aspectFill" />
</View>
) : null}
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
</PageShell>
);
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '登录',
});
+555
View File
@@ -0,0 +1,555 @@
import { useEffect, useState } from 'react';
import { View, Text, Input, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import {
SmsScene,
isWxAuthorizeEnabled,
type ClientRuntimeConfig,
type WechatLoginResult,
} from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import WechatLoginButton from '../../components/WechatLoginButton';
import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton';
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
import {
bindWechatForUser,
loginWithWechat,
} from '../../lib/wechat-auth';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
import {
getCachedWxProfile,
syncMiniWechatProfile,
type MiniWechatProfile,
} from '../../lib/mini-wechat-profile';
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
import { touchStoredPromoAfterLogin } from '../../lib/promo';
const IS_WEAPP = process.env.TARO_ENV === 'weapp';
function normalizePhone(value: string) {
return value.replace(/\D/g, '').slice(0, 11);
}
function isValidPhone(phone: string) {
return /^1[3-9]\d{9}$/.test(phone);
}
function AgreementRow({
agreed,
onToggle,
}: {
agreed: boolean;
onToggle: () => void;
}) {
return (
<View className="login-agreement" onClick={onToggle}>
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
{agreed ? <Text></Text> : null}
</View>
<Text className="login-agreement-text">
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/user-agreement/index' });
}}
>
</Text>
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
}}
>
</Text>
</Text>
</View>
);
}
export default function LoginPage() {
const router = useRouter();
const returnTo = router.params.return || '';
const needPhone = router.params.needPhone === '1';
const needWechat = router.params.needWechat === '1';
const initialBindMode = router.params.bindMode === '1';
const initialWxSessionKey = router.params.wxSessionKey || null;
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [phoneQuickLoading, setPhoneQuickLoading] = useState(false);
const [sending, setSending] = useState(false);
const [cooldown, setCooldown] = useState(0);
/** 须用户主动勾选,禁止默认同意 */
const [agreed, setAgreed] = useState(false);
const [msg, setMsg] = useState('');
const [sentHint, setSentHint] = useState('');
const [bindMode, setBindMode] = useState(initialBindMode);
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
const [wxAuthorize, setWxAuthorize] = useState(true);
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
useEffect(() => {
request<ClientRuntimeConfig>('/common/client-config')
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(true));
}, []);
useEffect(() => {
if (!isLoggedIn()) {
setCompleteMode(null);
return;
}
if (!needPhone && !needWechat) {
setCompleteMode(null);
return;
}
let cancelled = false;
fetchUserProfile()
.then((me) => {
if (cancelled) return;
if (needPhone && !me.phoneVerified) {
setCompleteMode('phone');
setShowSmsForm(true);
return;
}
if (needWechat && !me.hasWechat) {
setCompleteMode('wechat');
return;
}
finishLoginNavigate(returnTo);
})
.catch(() => {
if (!cancelled) setCompleteMode(null);
});
return () => {
cancelled = true;
};
}, [needPhone, needWechat, returnTo]);
useEffect(() => {
if (cooldown <= 0) return;
const timer = setTimeout(() => setCooldown((c) => Math.max(0, c - 1)), 1000);
return () => clearTimeout(timer);
}, [cooldown]);
function ensureAgreed() {
if (!agreed) {
setMsg('请先阅读并勾选同意《用户服务协议》和《隐私政策》');
return false;
}
return true;
}
function cancelLogin() {
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack().catch(() => {
Taro.switchTab({ url: '/pages/home/index' });
});
return;
}
Taro.switchTab({ url: '/pages/home/index' }).catch(() => {
Taro.reLaunch({ url: '/pages/home/index' });
});
}
function applySessionAndLeave(
data: SessionPayload | WechatLoginResult,
phoneValue?: string,
wxInfo?: MiniWechatProfile | null,
successToast = '登录成功',
) {
if (!data.accessToken) return;
if (phoneValue) saveUserPhone(phoneValue);
saveAuth({
accessToken: data.accessToken,
refreshToken: data.refreshToken,
});
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
void touchStoredPromoAfterLogin();
if (!phoneValue) {
void fetchUserProfile()
.then((me) => resolveDefaultUserPhone(me))
.catch(() => {});
}
toast(successToast, 'success');
if (data.accountMerged) {
forceReloadAfterAccountMerge(returnTo);
return;
}
finishLoginNavigate(returnTo);
}
function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) {
if (result.accessToken) {
applySessionAndLeave(result, undefined, wxInfo);
return;
}
if (result.needBindPhone && result.wxSessionKey) {
setBindMode(true);
setWxSessionKey(result.wxSessionKey);
setShowSmsForm(true);
setMsg('授权成功,可绑定手机号(也可稍后在下单时再绑定)');
setSentHint('');
return;
}
setMsg('登录未完成,请重试或使用手机号登录');
}
async function onPhoneQuickLogin(phoneCode: string) {
if (!ensureAgreed()) return;
setPhoneQuickLoading(true);
setMsg('');
setSentHint('');
try {
let loginCode: string | undefined;
try {
const loginRes = await Taro.login();
loginCode = loginRes.code || undefined;
} catch {
/* openId 绑定失败不阻断手机号登录 */
}
const data = await request<WechatLoginResult>('/auth/login/wechat-phone', {
method: 'POST',
data: {
phoneCode,
...(loginCode ? { loginCode } : {}),
platform: 'mini',
},
});
if (!data?.accessToken) {
setMsg('登录成功但未返回令牌,请重试');
return;
}
const profilePhone =
typeof data.user === 'object' && data.user && 'phone' in data.user
? String((data.user as { phone?: string }).phone || '')
: '';
applySessionAndLeave(data, profilePhone || undefined);
} catch (e) {
setMsg(e instanceof Error ? e.message : '手机号快捷登录失败');
} finally {
setPhoneQuickLoading(false);
}
}
async function onSendCode() {
if (!ensureAgreed()) return;
if (cooldown > 0 || sending) return;
const normalized = phone.trim();
if (!isValidPhone(normalized)) {
setMsg('请输入正确的手机号');
return;
}
setMsg('');
setSentHint('');
setSending(true);
try {
const scene =
bindMode || completeMode === 'phone' ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN;
await request('/auth/sms/send', {
method: 'POST',
data: { phone: normalized, scene },
});
setCooldown(60);
setSentHint('验证码已发送');
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
} finally {
setSending(false);
}
}
async function login() {
if (!ensureAgreed()) return;
const normalized = phone.trim();
if (!isValidPhone(normalized)) {
setMsg('请输入正确的手机号');
return;
}
if (!code.trim()) {
setMsg('请输入验证码');
return;
}
setLoading(true);
setMsg('');
setSentHint('');
try {
if (bindMode && wxSessionKey) {
const data = await request<WechatLoginResult>('/auth/wechat/bind-phone', {
method: 'POST',
data: { wxSessionKey, phone: normalized, code: code.trim() },
});
handleWechatLoginResult(data);
saveUserPhone(normalized);
return;
}
if (completeMode === 'phone' && isLoggedIn()) {
const data = await request<SessionPayload>('/auth/phone/bind', {
method: 'POST',
data: { phone: normalized, code: code.trim() },
});
if (!data?.accessToken) {
setMsg('手机号验证成功但会话未返回,请重新登录');
return;
}
applySessionAndLeave(data, normalized, null, '手机号验证成功');
return;
}
const data = await request<SessionPayload>('/auth/login/sms', {
method: 'POST',
data: { phone: normalized, code: code.trim() },
});
if (!data?.accessToken) {
setMsg('登录成功但未返回令牌,请重试');
return;
}
applySessionAndLeave(data, normalized);
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
}
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
setSentHint('');
setWxLoading(true);
try {
const wxInfo = getCachedWxProfile();
if (completeMode === 'wechat' && isLoggedIn()) {
const result = await bindWechatForUser(wxInfo);
if (!result.ok && 'redirecting' in result && result.redirecting) {
return;
}
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
setBindMode(true);
setWxSessionKey(result.wxSessionKey);
setCompleteMode('phone');
setShowSmsForm(true);
setMsg('请绑定手机号完成认证');
return;
}
if (result.ok) {
if (wxInfo) await syncMiniWechatProfile(wxInfo);
toast('授权成功', 'success');
finishLoginNavigate(returnTo);
return;
}
return;
}
const result = await loginWithWechat();
if (result) handleWechatLoginResult(result, wxInfo);
} catch (e) {
const raw = e instanceof Error ? e.message : '授权登录失败';
const hint = /invalid code/i.test(raw)
? process.env.TARO_ENV === 'weapp'
? '授权失败:请确认后端小程序 AppID 配置正确'
: '授权失败:请确认公众号网页授权域名配置正确'
: raw;
setMsg(hint);
} finally {
setWxLoading(false);
}
}
const displayMsg = msg || sentHint;
const codeDisabled = cooldown > 0 || sending;
const showAuthLogin =
(completeMode === 'wechat' || (!IS_WEAPP && !bindMode && !completeMode)) &&
(IS_WEAPP || wxAuthorize);
const showPhoneQuick =
IS_WEAPP && completeMode !== 'wechat' && !bindMode && completeMode !== 'phone';
const cardTitle =
completeMode === 'phone'
? '验证手机号'
: bindMode
? '绑定手机号'
: completeMode === 'wechat'
? '授权登录'
: '手机号快捷登录';
return (
<PageShell variant="plain" className="login-page">
<View className="login-nav">
<View className="login-nav-back" onClick={cancelLogin}>
<Text className="login-nav-back-icon"></Text>
<Text></Text>
</View>
</View>
<View className="login-header">
<View className="login-logo-wrap">
<View className="login-logo">
<Image className="login-logo-img" src={BRAND_LOGO_WIDE_URL} mode="aspectFit" />
</View>
<Text className="login-logo-badge"></Text>
</View>
<View className="login-welcome">
<Text className="login-welcome-title">
{completeMode === 'phone'
? '建议绑定手机号'
: completeMode === 'wechat'
? '完成授权登录'
: '欢迎来到杜康好客'}
</Text>
<Text className="login-welcome-sub">
{completeMode === 'phone'
? '便于订单通知与售后,也可稍后绑定'
: completeMode === 'wechat'
? '完成后将返回继续支付'
: '买美酒,享好礼'}
</Text>
</View>
</View>
<View className="login-main">
{completeMode === 'wechat' ? (
<View className="login-card">
<Text className="login-card-title"></Text>
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
使
</Text>
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
{displayMsg ? (
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
{displayMsg}
</Text>
) : null}
{showAuthLogin ? (
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
) : null}
</View>
) : (
<View className="login-card">
<Text className="login-card-title">{cardTitle}</Text>
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
{showPhoneQuick ? (
<PhoneQuickLoginButton
loading={phoneQuickLoading}
agreed={agreed}
onRequireAgree={() => ensureAgreed()}
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
onFail={(message) => setMsg(message)}
/>
) : null}
{showPhoneQuick ? (
<View className="login-divider" style={{ marginTop: 20 }}>
<View className="login-divider-line" />
<Text
className="login-divider-text"
onClick={() => setShowSmsForm((v) => !v)}
>
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
</Text>
<View className="login-divider-line" />
</View>
) : null}
{(showSmsForm || !showPhoneQuick) && (
<>
<View className="login-field" style={showPhoneQuick ? { marginTop: 8 } : undefined}>
<Text className="login-field-prefix">+86</Text>
<Input
className="login-field-input"
type="number"
maxlength={11}
placeholder="请输入手机号"
value={phone}
onInput={(e) => {
setPhone(normalizePhone(e.detail.value));
setMsg('');
setSentHint('');
}}
/>
</View>
<View className="login-field">
<Input
className="login-field-input"
type="number"
maxlength={6}
placeholder="请输入验证码"
value={code}
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
/>
<Text
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
onClick={() => void onSendCode()}
>
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
</Text>
</View>
<View
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
onClick={loading ? undefined : () => void login()}
>
<Text className="login-sms-btn__text">
{loading
? '处理中...'
: completeMode === 'phone'
? '完成验证'
: bindMode
? '绑定并登录'
: '验证码登录'}
</Text>
</View>
</>
)}
{displayMsg ? (
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
{displayMsg}
</Text>
) : null}
{completeMode === 'phone' ? (
<View
className="login-skip-bind"
onClick={() => finishLoginNavigate(returnTo)}
style={{ marginTop: 12, textAlign: 'center' }}
>
<Text className="u-muted" style={{ fontSize: 14 }}>
</Text>
</View>
) : null}
</View>
)}
{showAuthLogin && completeMode !== 'wechat' ? (
<>
<View className="login-divider">
<View className="login-divider-line" />
<Text className="login-divider-text"></Text>
<View className="login-divider-line" />
</View>
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
</>
) : null}
<View className="login-cancel-btn" onClick={cancelLogin}>
<Text></Text>
</View>
<Text className="login-cancel-hint"></Text>
</View>
</PageShell>
);
}
@@ -0,0 +1,7 @@
export default definePageConfig({
navigationBarTitleText: '我的',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
enableShareAppMessage: true,
enableShareTimeline: true,
});
+591
View File
@@ -0,0 +1,591 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Text, Image, Button, Input, ScrollView } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import {
BRAND_LOGO_MARK_URL,
QUALIFICATION_DISCLOSURE_URL,
isWxAuthorizeEnabled,
type ClientRuntimeConfig,
} from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import WechatShareReady from '../../components/WechatShareReady';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { goLogin } from '../../lib/auth-nav';
import { bindWechatForUser } from '../../lib/wechat-auth';
import {
fetchMiniWechatUserInfo,
isDefaultMiniNickname,
mergeWxDisplayProfile,
needsWxProfileFill,
uploadAvatarTempFile,
uploadMiniWechatProfile,
} from '../../lib/mini-wechat-profile';
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
import { isWechatEnv } from '../../lib/weixin';
import { maskPhone } from '../../lib/phone';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
import iconPendingPay from '../../assets/icons/待付款.png';
import iconPaid from '../../assets/icons/已付款.png';
import iconCompleted from '../../assets/icons/已完成.png';
import iconAddress from '../../assets/icons/地址管理.png';
import iconStores from '../../assets/icons/可用门店.png';
import iconCs from '../../assets/icons/联系客服.png';
import iconQualification from '../../assets/icons/资质公示.png';
import iconAbout from '../../assets/icons/关于我们.png';
import { formatMoney } from '../../lib/money';
const ORDER_SHORTCUTS = [
{ tab: 'pending_pay', icon: iconPendingPay, label: '待付款' },
{ tab: 'paid', icon: iconPaid, label: '已付款' },
{ tab: 'completed', icon: iconCompleted, label: '已完成' },
] as const;
const SERVICES = [
{ icon: iconAddress, label: '地址管理', url: '/pages/addresses/index' },
{ 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 },
] as const;
const isWeapp = process.env.TARO_ENV === 'weapp';
export default function MinePage() {
const [authed, setAuthed] = useState(() => isLoggedIn());
const [profile, setProfile] = useState<UserProfile | null>(null);
const [benefitBalance, setBenefitBalance] = useState(0);
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
const [wxAuthorize, setWxAuthorize] = useState(true);
const [bindingWx, setBindingWx] = useState(false);
const [profileSheetOpen, setProfileSheetOpen] = useState(false);
const [draftAvatarTemp, setDraftAvatarTemp] = useState('');
const [draftAvatarUrl, setDraftAvatarUrl] = useState('');
const [draftNickname, setDraftNickname] = useState('');
const [savingProfile, setSavingProfile] = useState(false);
const [profileLoadError, setProfileLoadError] = useState('');
const [qualificationOpen, setQualificationOpen] = useState(false);
function resetGuestState() {
setProfile(null);
setBenefitBalance(0);
setOrderCounts({});
setProfileLoadError('');
}
function applyProfile(me: UserProfile) {
setProfile(mergeWxDisplayProfile(me));
}
function loadProfile() {
if (!isLoggedIn()) return Promise.resolve();
setProfileLoadError('');
return Promise.all([
request<UserProfile>('/auth/me'),
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
...ORDER_SHORTCUTS.map((s) =>
request<{ total: number }>(`/trade/orders?tab=${s.tab}&pageSize=1`).catch(() => ({ total: 0 })),
),
])
.then(([me, coupons, ...totals]) => {
applyProfile(me);
const balance = (coupons as Array<Record<string, unknown>>).reduce((sum, c) => {
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
return sum;
}, 0);
setBenefitBalance(balance);
const counts: Record<string, number> = {};
ORDER_SHORTCUTS.forEach((s, i) => {
counts[s.tab] = (totals[i] as { total: number })?.total ?? 0;
});
setOrderCounts(counts);
})
.catch((error) => {
if (!isLoggedIn()) {
setAuthed(false);
resetGuestState();
return;
}
const message = error instanceof Error ? error.message : '个人资料加载失败';
setProfileLoadError(message);
toast('个人资料加载失败,请点击重试');
});
}
useDidShow(() => {
syncTabBarSelected(3);
const loggedInNow = isLoggedIn();
setAuthed(loggedInNow);
if (loggedInNow) {
loadProfile();
} else {
resetGuestState();
}
});
usePullDownRefresh(() => {
const loggedInNow = isLoggedIn();
setAuthed(loggedInNow);
if (!loggedInNow) {
resetGuestState();
Taro.stopPullDownRefresh();
return;
}
void loadProfile().finally(() => Taro.stopPullDownRefresh());
});
useEffect(() => {
request<ClientRuntimeConfig>('/common/client-config')
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(true));
}, []);
const sharePayload = useMemo(
() => ({
title: '杜康好客 · 我的',
desc: DEFAULT_SHARE_DESC,
path: '/pages/mine/index',
}),
[],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
}));
async function ensureWechatBound(): Promise<boolean> {
if (profile?.hasWechat) return true;
if (!wxAuthorize) {
toast('当前环境未开启微信授权');
return false;
}
setBindingWx(true);
try {
if (!isWeapp) {
if (!isWechatEnv()) {
toast('请在微信内打开后授权');
return false;
}
const result = await bindWechatForUser();
if (!result.ok && 'redirecting' in result && result.redirecting) return false;
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
goLogin('/pages/mine/index', {
bindMode: '1',
wxSessionKey: result.wxSessionKey,
});
return false;
}
if (result.ok && result.profile) {
applyProfile({ ...result.profile, hasWechat: true });
return true;
}
return false;
}
const result = await bindWechatForUser(null);
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey });
return false;
}
if (result.ok && result.profile) {
applyProfile({ ...result.profile, hasWechat: true });
return true;
}
return false;
} catch (e) {
toast(e instanceof Error ? e.message : '授权失败');
return false;
} finally {
setBindingWx(false);
}
}
function openProfileSheet(me?: UserProfile | null) {
const base = mergeWxDisplayProfile(
me || profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false },
);
setDraftAvatarTemp('');
setDraftAvatarUrl(base.avatarUrl || '');
setDraftNickname(isDefaultMiniNickname(base.nickname) ? '' : base.nickname || '');
setProfileSheetOpen(true);
}
/** 点击头像:绑定账号后用 chooseAvatar + nickname 获取头像昵称 */
async function handleAvatarTap() {
if (bindingWx || savingProfile) {
toast(savingProfile ? '资料保存中…' : '请稍候…');
return;
}
if (!isLoggedIn()) {
goLogin('/pages/mine/index');
return;
}
if (isWeapp) {
openProfileSheet();
return;
}
if (!profile?.hasWechat) {
const ok = await ensureWechatBound();
if (ok) loadProfile();
}
return;
}
async function onChooseAvatar(e: { detail?: { avatarUrl?: string } }) {
const tempPath = e.detail?.avatarUrl?.trim();
if (!tempPath) {
toast('未获取到头像,请重试');
return;
}
setDraftAvatarTemp(tempPath);
setDraftAvatarUrl(tempPath);
}
async function saveWxProfile() {
const nickname = draftNickname.trim();
if (!nickname) {
toast('请填写昵称');
return;
}
if (!draftAvatarTemp && !draftAvatarUrl) {
toast('请选择头像');
return;
}
setSavingProfile(true);
try {
let avatarUrl = draftAvatarUrl;
let avatarResourceId: string | undefined;
if (draftAvatarTemp) {
const uploaded = await uploadAvatarTempFile(draftAvatarTemp);
avatarUrl = uploaded.url;
avatarResourceId = uploaded.resourceId;
}
const updated = await uploadMiniWechatProfile({
nickname,
...(avatarResourceId ? { avatarUrl, avatarResourceId } : {}),
});
if (updated) applyProfile(updated);
setProfileSheetOpen(false);
toast('头像昵称已更新', 'success');
loadProfile();
} catch (err) {
toast(err instanceof Error ? err.message : '保存失败');
} finally {
setSavingProfile(false);
}
}
function handleService(item: (typeof SERVICES)[number]) {
if ('url' in item && item.url) {
Taro.navigateTo({ url: item.url });
return;
}
if ('tab' in item && item.tab) {
Taro.switchTab({ url: item.tab });
return;
}
if ('action' in item && item.action === 'qualification') {
setQualificationOpen(true);
return;
}
if ('action' in item && item.action === 'about') {
toast('杜康好客 · 传承千年酒文化');
}
}
function renderAvatarContent(displayAvatarUrl: string | null) {
if (displayAvatarUrl) {
return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />;
}
return <Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />;
}
if (!authed) {
return (
<PageShell variant="tab" className="mine-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="我的" />
<View className="mine-header">
<View className="mine-header-texture" />
<View className="mine-profile">
<View className="mine-avatar-wrap mine-avatar-wrap--action" onClick={() => goLogin('/pages/mine/index')}>
<View className="mine-avatar mine-avatar--wx-pending">
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
</View>
</View>
<View>
<Text className="mine-profile-name"></Text>
<Text className="mine-member-tag"></Text>
</View>
</View>
</View>
<View className="mine-login-gate">
<View className="mine-login-gate-hint">
</View>
<View className="mine-login-btn" onClick={() => goLogin('/pages/mine/index')}>
<Text></Text>
</View>
</View>
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
</PageShell>
);
}
const hasWechat = !!profile?.hasWechat;
const canWxAuth = wxAuthorize && (isWeapp || isWechatEnv());
const display = mergeWxDisplayProfile(
profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false },
);
// 强制保留 common 导出,避免开发者工具「旧页 + 新 common」混用时报 is not a function
if (typeof needsWxProfileFill !== 'function' || typeof fetchMiniWechatUserInfo !== 'function') {
throw new Error('wx profile helpers missing');
}
const nickname = display.nickname || '用户';
const needProfileFill = isWeapp && needsWxProfileFill(display);
const avatarProfileReady = isWeapp ? !needProfileFill : hasWechat;
const maskedPhone = profile?.phone ? maskPhone(String(profile.phone)) : '';
// 昵称下优先展示脱敏手机号;无手机号时再提示完善资料/授权
const memberLabel =
maskedPhone ||
(needProfileFill
? '点击头像完善资料'
: !isWeapp && !hasWechat && canWxAuth
? '点击头像授权'
: '未绑定手机');
const avatarClickable = isWeapp || (!hasWechat && canWxAuth);
const previewAvatar = draftAvatarUrl || display.avatarUrl;
return (
<PageShell variant="tab" className="mine-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="我的" />
<View className="mine-header">
<View className="mine-header-texture" />
<View className="mine-profile">
<View
className={`mine-avatar-wrap${avatarClickable ? ' mine-avatar-wrap--action' : ''}`}
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
>
<View
className={`mine-avatar${
avatarProfileReady ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending'
}`}
>
{renderAvatarContent(display.avatarUrl)}
</View>
{avatarClickable ? (
<View
className={`mine-avatar-status${
avatarProfileReady ? ' mine-avatar-status--ok' : ' mine-avatar-status--pending'
}`}
>
<Text>
{bindingWx ? '授权中' : avatarProfileReady ? '更换' : '去完善'}
</Text>
</View>
) : null}
</View>
<View
className="mine-profile-meta"
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
>
<Text className="mine-profile-name">{nickname}</Text>
<Text className={`mine-member-tag${avatarProfileReady ? ' mine-member-tag--wechat' : ''}`}>
{memberLabel}
</Text>
{profileLoadError ? (
<Text
className="mine-profile-retry"
onClick={(event) => {
event.stopPropagation();
loadProfile();
}}
>
</Text>
) : null}
</View>
</View>
</View>
<View className="mine-main">
<View className="mine-card">
<View className="mine-card-head">
<Text className="mine-card-title"></Text>
<Text
className="mine-card-link"
onClick={() => Taro.navigateTo({ url: '/pages/benefit-detail/index' })}
>
</Text>
</View>
<View className="mine-asset-panel">
<View>
<Text className="mine-asset-label"></Text>
<View className="mine-asset-amount">
<Text className="mine-asset-currency">¥</Text>
<Text className="mine-asset-value">{formatMoney(benefitBalance)}</Text>
</View>
</View>
<Text
className="mine-asset-cta"
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
>
使
</Text>
</View>
</View>
<View className="mine-card">
<View className="mine-card-head">
<Text className="mine-card-title"></Text>
<Text
className="mine-card-link"
onClick={() => Taro.navigateTo({ url: '/pages/orders/index' })}
>
</Text>
</View>
<View className="mine-order-grid">
{ORDER_SHORTCUTS.map((item) => {
const count = orderCounts[item.tab] ?? 0;
return (
<View
key={item.tab}
className="mine-order-item"
onClick={() =>
Taro.navigateTo({ url: `/pages/orders/index?tab=${item.tab}` })
}
>
<View className="mine-order-icon">
<Image className="mine-order-icon-img" src={item.icon} mode="aspectFit" />
</View>
{count > 0 ? (
<Text className="mine-order-badge">{count > 99 ? '99+' : count}</Text>
) : null}
<Text className="mine-order-label">{item.label}</Text>
</View>
);
})}
</View>
</View>
<View className="mine-card">
<View className="mine-card-head">
<Text className="mine-card-title"></Text>
</View>
<View className="mine-service-grid">
{SERVICES.map((item) => (
<View
key={item.label}
className="mine-service-item"
onClick={() => handleService(item)}
>
<View className="mine-service-icon">
<Image className="mine-service-icon-img" src={item.icon} mode="aspectFit" />
</View>
<Text className="mine-service-label">{item.label}</Text>
</View>
))}
</View>
</View>
<View className="mine-footer">
<Text className="mine-version"></Text>
<Text className="mine-logout" onClick={() => logout()}>
退
</Text>
</View>
</View>
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
{profileSheetOpen ? (
<View className="mine-profile-sheet-mask" onClick={() => !savingProfile && setProfileSheetOpen(false)}>
<View className="mine-profile-sheet" onClick={(e) => e.stopPropagation()}>
<Text className="mine-profile-sheet-title"></Text>
<Text className="mine-profile-sheet-hint">
</Text>
<Button
className="mine-profile-avatar-btn"
openType="chooseAvatar"
hoverClass="none"
onChooseAvatar={onChooseAvatar}
>
<View className="mine-profile-avatar-preview">
{previewAvatar ? (
<Image className="mine-avatar-img" src={previewAvatar} mode="aspectFill" />
) : (
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
)}
</View>
<Text className="mine-profile-avatar-tip"></Text>
</Button>
<View className="mine-profile-nickname-wrap">
<Text className="mine-profile-nickname-label"></Text>
<Input
className="mine-profile-nickname-input"
type="nickname"
maxlength={32}
placeholder="点击填写昵称"
value={draftNickname}
onInput={(e) => setDraftNickname(e.detail.value)}
onBlur={(e) => setDraftNickname(e.detail.value.trim())}
/>
</View>
<View className="mine-profile-sheet-actions">
<View
className="mine-profile-sheet-cancel"
onClick={() => !savingProfile && setProfileSheetOpen(false)}
>
<Text></Text>
</View>
<View
className={`mine-profile-sheet-save${savingProfile ? ' is-disabled' : ''}`}
onClick={() => {
if (!savingProfile) void saveWxProfile();
}}
>
<Text>{savingProfile ? '保存中…' : '保存'}</Text>
</View>
</View>
</View>
</View>
) : null}
{qualificationOpen ? (
<View
className="mine-qualification-mask"
onClick={() => setQualificationOpen(false)}
>
<ScrollView
scrollY
enableFlex
className="mine-qualification-scroll"
style={{ height: '100%' }}
enhanced
showScrollbar
>
<View className="mine-qualification-body">
<Image
className="mine-qualification-img"
src={QUALIFICATION_DISCLOSURE_URL}
mode="widthFix"
/>
</View>
</ScrollView>
<Text className="mine-qualification-hint"></Text>
</View>
) : null}
</PageShell>
);
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '现场取货确认',
});
@@ -0,0 +1,262 @@
import { useEffect, useRef, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { goLogin } from '../../lib/auth-nav';
import { buildPayUrl } from '../../lib/checkout-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images';
type PreviewProduct = {
id: string;
name: string;
spec?: string;
subtitle?: string;
price: number;
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
};
type OrderPreview = {
product: PreviewProduct;
quantity: number;
deliveryType: string;
productAmount: number;
payAmount: number;
benefitAmount: number;
quantityOk?: boolean;
quantityMessage?: string | null;
minQty?: number;
};
export default function OrderConfirmPickupPage() {
const router = useRouter();
const productId = router.params.productId ?? '';
const [quantity, setQuantity] = useState(Math.max(2, Number(router.params.qty || 2)));
const [preview, setPreview] = useState<OrderPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
const phonePromptSkipped = useRef(false);
useEffect(() => {
if (!productId) return;
let cancelled = false;
setPreviewLoading(true);
request<OrderPreview>('/trade/orders/preview', {
method: 'POST',
data: { productId, quantity, onSitePickup: true },
})
.then((data) => {
if (!cancelled) {
setPreview(data);
setMsg(data.quantityOk === false ? data.quantityMessage || '' : '');
}
})
.catch((e) => {
if (!cancelled) {
setPreview(null);
setMsg(e instanceof Error ? e.message : '加载失败');
}
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
return () => {
cancelled = true;
};
}, [productId, quantity]);
const minQty = preview?.minQty ?? 2;
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
const canSubmit = !!preview && quantityOk && !loading && !previewLoading;
function updateQuantity(next: number) {
if (next < minQty) {
const tip = `现场提货至少购买 ${minQty}`;
toast(tip);
setMsg(tip);
if (next < 1) return;
setQuantity(next);
return;
}
setQuantity(next);
}
async function doSubmit() {
const order = await request<{ id: string }>('/trade/orders', {
method: 'POST',
data: { productId, quantity, onSitePickup: true },
});
Taro.redirectTo({
url: buildPayUrl({
orderId: order.id,
productId,
qty: String(quantity),
}),
});
}
async function submit() {
if (!canSubmit) {
if (!quantityOk) {
const tip = `现场提货至少购买 ${minQty}`;
setMsg(tip);
toast(tip);
return;
}
}
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=${quantity}`;
if (!phonePromptSkipped.current) {
try {
const profile = await fetchUserProfile();
const phoneBound =
!!profile.phoneVerified ||
(!!profile.phone && /^1[3-9]\d{9}$/.test(String(profile.phone)));
if (!phoneBound) {
const { confirm, cancel } = await Taro.showModal({
title: '建议绑定手机号',
content: '绑定后便于订单通知与售后联系;也可跳过,不绑定也能继续下单。',
confirmText: '去绑定',
cancelText: '暂不绑定',
});
if (confirm) {
goLogin(returnPath, { needPhone: '1' });
return;
}
if (cancel) {
phonePromptSkipped.current = true;
}
}
} catch {
/* 拉取档案失败不阻塞下单 */
}
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
const submitLabel = loading
? '提交中…'
: !quantityOk
? `至少购买 ${minQty}`
: '提交订单';
return (
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
<SubPageHeader title="现场取货确认" />
<View className="sub-page-body">
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="u-muted"> · · </Text>
</View>
{preview ? (
<>
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-product-row">
<View className="order-product-thumb">
{productImage ? (
<Image
className="order-product-thumb-img"
src={productImage}
mode="aspectFill"
/>
) : null}
</View>
<View style={{ flex: 1 }}>
<Text className="order-product-name">{preview.product.name}</Text>
{preview.product.spec ? (
<Text className="u-muted">{preview.product.spec}</Text>
) : null}
<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>
{!quantityOk ? (
<Text className="order-qty-hint">
{`现场提货至少购买 ${minQty} 瓶,请调整数量`}
</Text>
) : null}
</View>
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">¥{Number(preview.productAmount).toFixed(2)}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value--price">¥{Number(preview.benefitAmount).toFixed(2)}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value"></Text>
</View>
</View>
</>
) : previewLoading ? (
<View className="u-empty"></View>
) : null}
{msg ? (
<Text className="u-muted" style={{ display: 'block', marginTop: 8, textAlign: 'center' }}>
{msg}
</Text>
) : null}
</View>
<View className="order-confirm-bar">
<View className="order-confirm-total">
<Text className="order-confirm-total-label"></Text>
<Text className="order-confirm-total-value">
¥{preview ? Number(preview.payAmount).toFixed(2) : '—'}
</Text>
</View>
<View
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
onClick={() => {
if (!canSubmit) return;
void submit();
}}
>
<Text>{submitLabel}</Text>
</View>
</View>
</PageShell>
);
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '确认订单',
});
@@ -0,0 +1,428 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { goLogin } from '../../lib/auth-nav';
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
import { tryGetClientGpsLocation } from '../../lib/client-location';
import { maskPhone } from '../../lib/phone';
import { ensurePayReady } from '../../lib/pay-ready';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api';
import { canCrossCity, isCrossCityAddress } from '../../lib/product-fulfillment';
import { getProductMainImage } from '../../lib/product-images';
type Address = {
id: string;
receiverName: string;
phone: string;
province: string;
city: string;
district: string;
detail: string;
isDefault?: number | boolean;
};
type PreviewProduct = {
id: string;
name: string;
spec?: string;
subtitle?: string;
price: number;
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
allowCrossCityDelivery?: boolean;
allowOnlinePurchase?: boolean;
};
type OrderPreview = {
product: PreviewProduct;
quantity: number;
deliveryType: 'LOCAL' | 'CROSS_CITY';
productAmount: number;
freightPayType: 'COD' | null;
payAmount: number;
benefitAmount: number;
city?: { name?: string; localMinQty: number; crossMinQty: number };
quantityOk?: boolean;
quantityMessage?: string | null;
addressOk?: boolean;
addressMessage?: string | null;
minQty?: number;
allowCrossCityDelivery?: boolean;
allowOnlinePurchase?: boolean;
};
const CROSS_CITY_BLOCK_MSG = '该商品不支持跨城配送,请更换为开城城市内的收货地址';
function formatAddress(a: Address) {
return `${a.province}${a.city}${a.district}${a.detail}`;
}
export default function OrderConfirmPage() {
const router = useRouter();
const checkoutCtx = readCheckoutContext(router.params);
const productId = checkoutCtx.productId ?? '';
const forceCross = checkoutCtx.cross === true;
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
const [addresses, setAddresses] = useState<Address[]>([]);
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
const [preview, setPreview] = useState<OrderPreview | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
const phonePromptSkipped = useRef(false);
const toastedAddressBlockRef = useRef('');
useEffect(() => {
request<Address[]>('/user/addresses')
.then((list) => {
setAddresses(list);
const fromUrl = checkoutCtx.addressId;
if (fromUrl && list.some((a) => String(a.id) === fromUrl)) {
setAddressId(fromUrl);
return;
}
const def = list.find((a) => a.isDefault === 1 || a.isDefault === true) || list[0];
if (def) setAddressId(String(def.id));
})
.catch(() => setAddresses([]));
}, [checkoutCtx.addressId]);
useEffect(() => {
if (!productId) return;
let cancelled = false;
setPreviewLoading(true);
const body: { productId: string; quantity: number; addressId?: string } = {
productId,
quantity,
};
if (addressId) body.addressId = addressId;
request<OrderPreview>('/trade/orders/preview', { method: 'POST', data: body })
.then((data) => {
if (!cancelled) {
setPreview(data);
const nextMsg =
data.addressOk === false
? data.addressMessage || CROSS_CITY_BLOCK_MSG
: data.quantityOk === false
? data.quantityMessage || ''
: '';
setMsg(nextMsg);
if (
data.addressOk === false &&
addressId &&
toastedAddressBlockRef.current !== addressId
) {
toastedAddressBlockRef.current = addressId;
toast(data.addressMessage || CROSS_CITY_BLOCK_MSG);
}
if (data.addressOk !== false) {
toastedAddressBlockRef.current = '';
}
}
})
.catch((e) => {
if (!cancelled) {
setPreview(null);
setMsg(e instanceof Error ? e.message : '加载失败');
}
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
return () => {
cancelled = true;
};
}, [productId, quantity, addressId]);
const selectedAddress = useMemo(
() => addresses.find((a) => String(a.id) === addressId),
[addresses, addressId],
);
const allowCross =
preview?.allowCrossCityDelivery !== undefined
? canCrossCity({ allowCrossCityDelivery: preview.allowCrossCityDelivery })
: canCrossCity(preview?.product ?? {});
const localCross =
!!selectedAddress &&
isCrossCityAddress(selectedAddress.city, preview?.city?.name);
const isCross =
forceCross || preview?.deliveryType === 'CROSS_CITY' || localCross;
const crossBlocked = isCross && !allowCross;
const addressOk = preview ? preview.addressOk !== false && !crossBlocked : !crossBlocked;
const minQty =
preview?.minQty ??
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
const canSubmit =
!!addressId && !!preview && quantityOk && addressOk && !loading && !previewLoading;
const addressHint = !addressOk
? preview?.addressMessage || CROSS_CITY_BLOCK_MSG
: '';
function updateQuantity(next: number) {
if (next < minQty) {
const tip = isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty}`;
toast(tip);
setMsg(tip);
if (next < 1) return;
setQuantity(next);
return;
}
setQuantity(next);
}
async function doSubmit() {
let clientLocation = null;
try {
clientLocation = await tryGetClientGpsLocation();
} catch {
/* GPS 获取失败不阻塞下单 */
}
const order = await request<{ id: string }>('/trade/orders', {
method: 'POST',
data: {
productId,
quantity,
addressId,
...(clientLocation ? { clientLocation } : {}),
},
});
Taro.redirectTo({
url: buildPayUrl({
orderId: order.id,
productId,
qty: String(quantity),
addressId,
cross: forceCross,
}),
});
}
async function submit() {
if (!canSubmit) {
if (!addressId) {
setMsg('请选择收货地址');
return;
}
if (!addressOk) {
const tip = addressHint || CROSS_CITY_BLOCK_MSG;
setMsg(tip);
toast(tip);
return;
}
if (!quantityOk) {
const tip = isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty}`;
setMsg(tip);
toast(tip);
}
return;
}
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`;
if (!phonePromptSkipped.current) {
try {
const profile = await fetchUserProfile();
const phoneBound =
!!profile.phoneVerified ||
(!!profile.phone && /^1[3-9]\d{9}$/.test(String(profile.phone)));
if (!phoneBound) {
const { confirm, cancel } = await Taro.showModal({
title: '建议绑定手机号',
content: '绑定后便于订单通知与售后联系;也可跳过,不绑定也能继续下单。',
confirmText: '去绑定',
cancelText: '暂不绑定',
});
if (confirm) {
goLogin(returnPath, { needPhone: '1' });
return;
}
if (cancel) {
phonePromptSkipped.current = true;
}
}
} catch {
/* 拉取档案失败不阻塞下单 */
}
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
setLoading(true);
setMsg('');
try {
await doSubmit();
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setLoading(false);
}
}
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
const submitLabel = loading
? '提交中…'
: !addressId
? '请选择地址'
: !addressOk
? '请更换地址'
: !quantityOk
? `至少购买 ${minQty}`
: '提交订单';
const displayMsg = msg || addressHint;
return (
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
<SubPageHeader title="确认订单" />
<View className="sub-page-body">
<View
className="order-card"
onClick={() =>
Taro.navigateTo({
url: buildAddressListUrl({
productId,
qty: String(quantity),
addressId,
cross: forceCross,
}),
})
}
>
<Text className="order-card-title"></Text>
{selectedAddress ? (
<View>
<View style={{ display: 'flex', gap: '8px', marginBottom: 4 }}>
<Text className="order-card-title" style={{ fontSize: 15 }}>{selectedAddress.receiverName}</Text>
<Text className="u-muted">{maskPhone(selectedAddress.phone)}</Text>
</View>
<Text className="u-muted">{formatAddress(selectedAddress)}</Text>
</View>
) : (
<Text className="u-muted"></Text>
)}
</View>
{!addressOk && addressId ? (
<View className="order-card order-card--warn">
<Text className="order-warn-text">{addressHint || CROSS_CITY_BLOCK_MSG}</Text>
</View>
) : null}
{isCross && addressOk ? (
<View className="order-card">
<Text className="u-muted">
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}
</Text>
</View>
) : null}
{preview ? (
<>
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-product-row">
<View className="order-product-thumb">
{productImage ? (
<Image
className="order-product-thumb-img"
src={productImage}
mode="aspectFill"
/>
) : null}
</View>
<View style={{ flex: 1 }}>
<Text className="order-product-name">{preview.product.name}</Text>
<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>
{!quantityOk ? (
<Text className="order-qty-hint">
{isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
: `同城配送至少购买 ${minQty} 瓶,请调整数量`}
</Text>
) : null}
</View>
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">¥{preview.productAmount.toFixed(2)}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value--price">¥{preview.benefitAmount.toFixed(2)}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">{isCross ? '到付' : '免运费'}</Text>
</View>
</View>
</>
) : null}
{previewLoading && !preview && productId ? (
<View className="u-empty"></View>
) : null}
{!previewLoading && !preview && productId ? (
<View className="u-empty"></View>
) : null}
{displayMsg ? (
<Text className="order-warn-text" style={{ display: 'block', marginTop: 8, textAlign: 'center' }}>
{displayMsg}
</Text>
) : null}
</View>
<View className="order-confirm-bar">
<View className="order-confirm-total">
<Text className="order-confirm-total-label"></Text>
<Text className="order-confirm-total-value">
¥{preview ? preview.payAmount.toFixed(2) : '—'}
</Text>
</View>
<View
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
onClick={() => {
if (!canSubmit) return;
void submit();
}}
>
<Text>{submitLabel}</Text>
</View>
</View>
</PageShell>
);
}
@@ -0,0 +1,6 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '订单详情',
enableShareAppMessage: true,
enableShareTimeline: true,
});
@@ -0,0 +1,313 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady';
import ContactCsButton from '../../components/ContactCsButton';
import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
import { maskPhone } from '../../lib/phone';
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
import { usePageView } from '../../lib/usePageView';
const isWeapp = process.env.TARO_ENV === 'weapp';
type OrderItem = {
productName?: string;
productSpec?: string;
quantity?: number;
};
type OrderDetail = {
id: string;
orderNo?: string;
status?: string;
payAmount?: number;
productName?: string;
quantity?: number;
qty?: number;
receiverName?: string;
receiverPhone?: string;
receiverProvince?: string;
receiverCity?: string;
receiverDistrict?: string;
receiverAddress?: string;
createdAt?: string;
originOrderId?: string | null;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
items?: OrderItem[];
wechatConfirm?: WechatConfirmPayload | null;
};
const STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '出库中',
SHIPPING: '配送中',
SHIPPED: '配送中',
PENDING_RECEIVE: '待签收',
DELIVERED: '待签收',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
function fullReceiverAddress(order: OrderDetail) {
const detail = (order.receiverAddress || '').trim();
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
.filter(Boolean)
.join('');
if (!region && !detail) return '';
if (region && detail.startsWith(region)) return detail;
return `${region}${detail}`;
}
export default function OrderDetailPage() {
const router = useRouter();
const orderId = router.params.id ?? '';
usePageView('order_detail_view', orderId ? { orderId } : undefined);
const [order, setOrder] = useState<OrderDetail | null>(null);
const [confirming, setConfirming] = useState(false);
useEffect(() => {
if (!orderId) return;
request<OrderDetail>(`/trade/orders/${orderId}`)
.then(setOrder)
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [orderId]);
useDidShow(() => {
if (!orderId) return;
// 从微信确认收货组件返回后刷新
request<OrderDetail>(`/trade/orders/${orderId}`)
.then(setOrder)
.catch(() => {});
});
const isReship = !!order?.originOrderId;
const isProxy = !!order && (order.isProxyOrder || order.orderType === 'PROXY');
const canPay = !!order && order.status === 'PENDING_PAY' && !isReship;
const canConfirmReceive =
!!order && !isReship && !isProxy && ['PENDING_RECEIVE', 'DELIVERED'].includes(order.status || '');
const item = order?.items?.[0];
const productName = item?.productName || order?.productName || '杜康商品';
const quantity = item?.quantity ?? order?.quantity ?? order?.qty ?? 1;
const addressText = order ? fullReceiverAddress(order) : '';
const receiverLine = order
? [order.receiverName, order.receiverPhone ? maskPhone(String(order.receiverPhone)) : '']
.filter(Boolean)
.join(' ')
: '';
const sharePayload = useMemo(
() => ({
title: productName !== '杜康商品' ? `我买了${productName} · 杜康好客` : DEFAULT_SHARE_TITLE,
desc: DEFAULT_SHARE_DESC,
path: orderId ? `/pages/order-detail/index?id=${orderId}` : '/pages/home/index',
}),
[productName, orderId],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: orderId ? `id=${orderId}` : '',
}));
function goPay() {
if (!order) return;
Taro.navigateTo({ url: buildPayUrl({ orderId: order.id }) });
}
function goCustomerService() {
Taro.navigateTo({ url: '/pages/customer-service/index' });
}
async function confirmReceive() {
if (!order || !canConfirmReceive || confirming) return;
const { confirm } = await Taro.showModal({
title: '确认收货?',
content: isWeapp
? '将打开微信确认收货,完成后订单即完结,无需再点服务通知。'
: '请确认已收到商品。确认后订单将完成,好客权益可正常使用。',
confirmText: '确认收货',
cancelText: '再想想',
});
if (!confirm) return;
setConfirming(true);
try {
const mode = await confirmOrderReceive({
orderId: order.id,
wechatConfirm: order.wechatConfirm,
onLocalSuccess: async () => {
const updated = await request<OrderDetail>(`/trade/orders/${order.id}`);
setOrder(updated);
toast('已确认收货');
},
});
if (mode === 'wechat') {
// 回跳后由 App.onShow / 本页 useDidShow 处理
return;
}
} catch (e) {
toast(e instanceof Error ? e.message : '确认收货失败');
} finally {
setConfirming(false);
}
}
const pageClass = [
'order-detail-page',
order ? 'order-detail-page--with-actions' : '',
canPay || canConfirmReceive ? 'order-detail-page--with-pay' : '',
]
.filter(Boolean)
.join(' ');
return (
<PageShell variant="sub" className={pageClass}>
<WechatShareReady payload={sharePayload} />
<SubPageHeader
title="订单详情"
onBack={() => {
// 支付完成后 reLaunch 进详情:栈仅一页时 navigateBack 会退出小程序,统一回首页
const fromPay = String(router.params.from || '') === 'pay';
if (fromPay || Taro.getCurrentPages().length <= 1) {
Taro.switchTab({ url: '/pages/home/index' }).catch(() => {
Taro.reLaunch({ url: '/pages/home/index' });
});
return;
}
Taro.navigateBack();
}}
right={<ShareNavButton payload={sharePayload} />}
/>
<View className="sub-page-body">
{!order ? (
<View className="u-empty"></View>
) : (
<>
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-status-row">
<Text className="order-list-status">
{STATUS_LABELS[order.status || ''] || order.status || '处理中'}
</Text>
{isProxy ? <Text className="order-proxy-badge"></Text> : null}
</View>
{isProxy && order.proxyPartnerName ? (
<Text className="order-proxy-hint"> {order.proxyPartnerName} </Text>
) : null}
</View>
<View className="order-card">
<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>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value--price">
¥{Number(order.payAmount ?? 0).toFixed(2)}
</Text>
</View>
</View>
<View className="order-card">
<Text className="order-card-title"></Text>
{receiverLine || addressText ? (
<>
{receiverLine ? (
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">{receiverLine}</Text>
</View>
) : null}
{addressText ? (
<View className="order-row order-row--address">
<Text className="order-row-label"></Text>
<Text className="order-row-value order-row-value--wrap">{addressText}</Text>
</View>
) : null}
</>
) : (
<Text className="u-muted"></Text>
)}
</View>
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">{order.orderNo || order.id}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">
{order.createdAt ? String(order.createdAt).slice(0, 19).replace('T', ' ') : '-'}
</Text>
</View>
</View>
</>
)}
</View>
{order ? (
<View
className={`order-detail-actionbar${
canPay || canConfirmReceive ? ' order-detail-actionbar--with-pay' : ''
}`}
>
{isWeapp ? (
<ContactCsButton
className="order-detail-cs-btn"
session={{
from: 'order-detail',
orderId: order.id,
orderNo: order.orderNo,
}}
>
</ContactCsButton>
) : (
<View className="order-detail-cs-btn" onClick={goCustomerService}>
<Text></Text>
</View>
)}
{canPay ? (
<>
<View className="order-confirm-total">
<Text className="order-confirm-total-label"></Text>
<Text className="order-confirm-total-value">
¥{Number(order.payAmount ?? 0).toFixed(2)}
</Text>
</View>
<View className="order-confirm-submit" onClick={goPay}>
</View>
</>
) : null}
{canConfirmReceive ? (
<View
className={`order-confirm-submit${confirming ? ' order-confirm-submit--disabled' : ''}`}
onClick={confirming ? undefined : () => void confirmReceive()}
>
{confirming ? '提交中…' : '确认收货'}
</View>
) : null}
</View>
) : null}
</PageShell>
);
}
@@ -0,0 +1,6 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '我的订单',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
});
+186
View File
@@ -0,0 +1,186 @@
import { useCallback, useEffect, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
import { usePageView } from '../../lib/usePageView';
import { ORDER_STATUS_LABELS } from '@dukang/shared-types';
const TABS = [
{ key: 'all', label: '全部订单' },
{ key: 'pending_pay', label: '待付款' },
{ key: 'paid', label: '已付款' },
{ key: 'completed', label: '已完成' },
] as const;
/** 兼容历史链接 tab=done */
function normalizeOrdersTab(raw?: string): string {
if (!raw) return 'all';
if (raw === 'done') return 'completed';
return TABS.some((t) => t.key === raw) ? raw : 'all';
}
function orderStatusLabel(tab: string, status?: string): string {
const tabLabel = TABS.find((t) => t.key === tab)?.label;
if (tab !== 'all' && tabLabel) return tabLabel;
if (!status) return '';
return ORDER_STATUS_LABELS[status] || status;
}
type OrderItem = {
productName?: string;
productImage?: string;
unitPrice?: number;
quantity?: number;
};
type OrderRow = {
id: string;
orderNo?: string;
status?: string;
payAmount?: number;
productName?: string;
qty?: number;
quantity?: number;
originOrderId?: string | null;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
items?: OrderItem[];
};
export default function OrdersPage() {
const router = useRouter();
const [tab, setTab] = useState(() => normalizeOrdersTab(router.params.tab as string));
usePageView('order_list_view', { tab });
const [orders, setOrders] = useState<OrderRow[]>([]);
const [loading, setLoading] = useState(true);
const loadOrders = useCallback(() => {
setLoading(true);
return request<{ list?: OrderRow[]; items?: OrderRow[]; total?: number } | OrderRow[]>(
`/trade/orders?tab=${encodeURIComponent(tab)}&pageSize=20`,
)
.then((data) => {
if (Array.isArray(data)) setOrders(data);
else setOrders(Array.isArray(data?.list) ? data.list : Array.isArray(data?.items) ? data.items : []);
})
.catch((e) => {
toast(e instanceof Error ? e.message : '加载失败');
setOrders([]);
})
.finally(() => setLoading(false));
}, [tab]);
useEffect(() => {
void loadOrders();
}, [loadOrders]);
useDidShow(() => {
const next = normalizeOrdersTab(router.params.tab as string);
if (next !== tab) setTab(next);
else void loadOrders();
});
usePullDownRefresh(() => {
void loadOrders().finally(() => Taro.stopPullDownRefresh());
});
function goPay(orderId: string) {
Taro.navigateTo({ url: buildPayUrl({ orderId }) });
}
return (
<PageShell variant="sub" className="orders-page">
<SubPageHeader
title="我的订单"
onBack={() => {
Taro.switchTab({ url: '/pages/home/index' }).catch(() => {
Taro.reLaunch({ url: '/pages/home/index' });
});
}}
/>
<View className="order-tabs">
{TABS.map((t) => (
<Text
key={t.key}
className={`order-tab${tab === t.key ? ' order-tab--active' : ''}`}
onClick={() => setTab(t.key)}
>
{t.label}
</Text>
))}
</View>
{loading ? <View className="u-empty"></View> : null}
{!loading && orders.length === 0 ? <View className="u-empty"></View> : null}
{!loading &&
orders.map((o) => {
const item = o.items?.[0];
const productName = item?.productName || o.productName || '杜康商品';
const productImage = (item?.productImage || '').trim();
const qty = item?.quantity ?? o.quantity ?? o.qty ?? 1;
const unitPrice = Number(item?.unitPrice ?? 0);
const canPay = o.status === 'PENDING_PAY' && !o.originOrderId;
const isProxy = o.isProxyOrder || o.orderType === 'PROXY';
return (
<View
key={o.id}
className="order-list-item"
onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })}
>
<View className="order-list-head">
<View className="order-list-head-left">
<Text className="order-list-no">{o.orderNo || o.id}</Text>
{isProxy ? <Text className="order-proxy-badge"></Text> : null}
</View>
<Text className="order-list-status">
{orderStatusLabel(tab, o.status)}
</Text>
</View>
{isProxy && o.proxyPartnerName ? (
<Text className="order-proxy-hint"> {o.proxyPartnerName} </Text>
) : null}
<View className="order-list-body">
<View className="order-list-thumb">
{productImage ? (
<Image className="order-list-thumb-img" src={productImage} mode="aspectFill" />
) : null}
</View>
<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"> ¥{unitPrice.toFixed(2)}</Text>
</View>
</View>
</View>
<View className="order-list-footer">
<View className="order-list-pay-amount">
<Text className="order-list-meta"></Text>
<Text className="order-product-price">
¥{Number(o.payAmount ?? 0).toFixed(2)}
</Text>
</View>
{canPay ? (
<View
className="order-list-pay-btn"
onClick={(e) => {
e.stopPropagation();
goPay(o.id);
}}
>
</View>
) : null}
</View>
</View>
);
})}
</PageShell>
);
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '收银台',
});
+245
View File
@@ -0,0 +1,245 @@
import { useCallback, useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import WechatLoginButton from '../../components/WechatLoginButton';
import { ensurePayReady } from '../../lib/pay-ready';
import {
authorizeWechatForPay,
fetchClientConfig,
fetchUserProfile,
isWechatAuthRequiredError,
needsWechatAuthForPay,
payOrder,
saveWechatLoginResult,
} from '../../lib/pay-wechat';
import { applyWechatLoginResult } from '../../lib/wechat-auth';
import { isWechatEnv } from '../../lib/weixin';
import { goLogin } from '../../lib/auth-nav';
import { request, toast } from '../../lib/api';
export default function PayPage() {
const router = useRouter();
const orderId = router.params.orderId ?? '';
const [loading, setLoading] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [mockMode, setMockMode] = useState(true);
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
const [msg, setMsg] = useState('');
const [orderNo, setOrderNo] = useState('');
const [payAmount, setPayAmount] = useState('—');
const [deliveryType, setDeliveryType] = useState('');
const returnPath = orderId
? `/pages/pay/index?orderId=${orderId}`
: '/pages/pay/index';
const refreshPayReadiness = useCallback(async () => {
try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
setMockMode(config.mockPay);
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
return profile;
} catch {
return null;
}
}, []);
useDidShow(() => {
void refreshPayReadiness();
});
useEffect(() => {
if (!orderId) return;
if (process.env.TARO_ENV === 'weapp') {
void ensurePayReady(returnPath);
}
}, [orderId, returnPath]);
useEffect(() => {
if (!orderId) {
setOrderNo('');
setPayAmount('—');
return;
}
request<{
orderNo?: string;
payAmount?: number | string;
totalAmount?: number | string;
deliveryType?: string;
}>(`/trade/orders/${orderId}`)
.then((order) => {
setOrderNo(order.orderNo || '');
setDeliveryType(order.deliveryType || '');
const amount = Number(order.payAmount ?? order.totalAmount ?? 0);
if (Number.isFinite(amount) && amount > 0) {
setPayAmount(amount.toFixed(2));
}
})
.catch((e) => {
setOrderNo('');
setDeliveryType('');
toast(e instanceof Error ? e.message : '加载订单失败');
});
}, [orderId]);
async function wechatAuthorize() {
setAuthLoading(true);
setMsg('');
try {
if (!isWechatEnv()) {
setMsg('请在微信内打开以授权微信支付');
return;
}
if (process.env.TARO_ENV === 'weapp') {
const ready = await ensurePayReady(returnPath);
if (ready) await refreshPayReadiness();
return;
}
const result = await authorizeWechatForPay();
if (result) {
if (result.needBindPhone && result.wxSessionKey) {
goLogin(returnPath, { bindMode: '1', wxSessionKey: result.wxSessionKey });
return;
}
if (saveWechatLoginResult(result) || applyWechatLoginResult(result)) {
setMsg('');
await refreshPayReadiness();
toast('微信授权成功', 'success');
}
}
} catch (e) {
setMsg(e instanceof Error ? e.message : '微信授权失败');
} finally {
setAuthLoading(false);
}
}
async function pay() {
if (!orderId) {
toast('订单不存在');
return;
}
if (needsWechatAuth) {
setMsg('请先完成微信授权后再支付');
if (process.env.TARO_ENV === 'h5') {
await wechatAuthorize();
} else {
await ensurePayReady(returnPath);
}
return;
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
setLoading(true);
setMsg('');
try {
const status = await payOrder(orderId);
if (status === 'pending') {
toast('支付结果确认中,请稍后在订单列表查看');
} else {
toast('支付成功', 'success');
}
if (deliveryType === 'ON_SITE_PICKUP') {
// 现场提货支付即完成 → 订单详情;from=pay 返回强制回首页,避免 navigateBack 退出小程序
Taro.reLaunch({ url: `/pages/order-detail/index?id=${orderId}&from=pay` });
} else {
Taro.reLaunch({ url: '/pages/orders/index?tab=paid&from=pay' });
}
} catch (e) {
if (isWechatAuthRequiredError(e)) {
setNeedsWechatAuth(true);
setMsg('微信支付需要先完成微信授权');
return;
}
const message = e instanceof Error ? e.message : '支付失败';
if (message.includes('取消')) {
setMsg('已取消支付');
return;
}
setMsg(message);
toast(message);
} finally {
setLoading(false);
}
}
return (
<PageShell variant="sub" className="pay-page" hasFixedFooter>
<SubPageHeader title="收银台" />
<View className="sub-page-body">
<View className="pay-status">
<View className="pay-status-icon">
<Text>¥</Text>
</View>
<Text className="pay-status-title">
{needsWechatAuth ? '需完成微信授权' : '待支付'}
</Text>
<Text className="pay-status-amount">¥{payAmount}</Text>
</View>
{needsWechatAuth ? (
<View className="pay-wechat-auth-card">
<Text className="pay-wechat-auth-title"></Text>
<Text className="pay-wechat-auth-desc">
</Text>
<WechatLoginButton
loading={authLoading}
onClick={() => void wechatAuthorize()}
/>
</View>
) : null}
<View className="order-card">
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">{orderNo || '—'}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value"></Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">
{mockMode ? 'Mock 模式由服务端直接标记已付款' : '将调起微信收银台'}
</Text>
</View>
</View>
{msg ? (
<Text className="pay-wechat-auth-msg" style={{ marginTop: 12 }}>
{msg}
</Text>
) : null}
</View>
<View className="pay-bar">
<View
className="order-confirm-submit"
style={{ flex: 1, opacity: loading || needsWechatAuth ? 0.7 : 1 }}
onClick={() => {
if (loading) return;
if (needsWechatAuth) {
void wechatAuthorize();
return;
}
void pay();
}}
>
<Text>
{loading
? '支付中…'
: needsWechatAuth
? authLoading
? '授权中…'
: '微信一键授权'
: '立即支付'}
</Text>
</View>
</View>
</PageShell>
);
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '确认收货',
});
@@ -0,0 +1,167 @@
import { useCallback, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images';
import { confirmOrderReceive, type WechatConfirmPayload } from '../../lib/wechat-order-confirm';
const isWeapp = process.env.TARO_ENV === 'weapp';
type OrderDetail = {
id: string;
orderNo?: string;
status?: string;
payAmount?: number | string;
productName?: string;
productSpec?: string;
quantity?: number;
product?: {
name?: string;
spec?: string;
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
};
items?: Array<{
productName?: string;
productSpec?: string;
productImage?: string;
quantity?: number;
}>;
imageUrl?: string | null;
mainImageUrl?: string | null;
wechatConfirm?: WechatConfirmPayload | null;
};
const ORDERS_ALL_URL = '/pages/orders/index?tab=all';
export default function PickupReceivePage() {
const router = useRouter();
const orderId = router.params.id ?? router.params.orderId ?? '';
const [order, setOrder] = useState<OrderDetail | null>(null);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const load = useCallback(() => {
if (!orderId) return;
setLoading(true);
request<OrderDetail>(`/trade/orders/${orderId}`)
.then((data) => setOrder(data))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, [orderId]);
useDidShow(() => {
load();
});
async function confirmReceive() {
if (!orderId || !order || submitting) return;
if (isWeapp) {
const { confirm } = await Taro.showModal({
title: '确认收货?',
content: '将打开微信确认收货,完成后订单即完结,无需再点服务通知。',
confirmText: '确认收货',
cancelText: '再想想',
});
if (!confirm) return;
}
setSubmitting(true);
try {
const mode = await confirmOrderReceive({
orderId,
wechatConfirm: order.wechatConfirm,
onSitePickup: true,
redirectUrl: ORDERS_ALL_URL,
onLocalSuccess: async () => {
toast('确认收货成功', 'success');
setTimeout(() => {
Taro.redirectTo({ url: ORDERS_ALL_URL });
}, 500);
},
});
if (mode === 'wechat') return;
} catch (e) {
toast(e instanceof Error ? e.message : '确认失败');
} finally {
setSubmitting(false);
}
}
const item = order?.items?.[0];
const name = item?.productName || order?.productName || order?.product?.name || '商品';
const spec = item?.productSpec || order?.productSpec || order?.product?.spec;
const image =
(item?.productImage || '').trim() ||
order?.mainImageUrl ||
order?.imageUrl ||
(order?.product ? getProductMainImage(order.product) : '') ||
'';
const amount = Number(order?.payAmount ?? 0);
const canConfirm = order?.status === 'PENDING_RECEIVE' || order?.status === 'DELIVERED';
return (
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
<SubPageHeader title="确认收货" />
<View className="sub-page-body">
<View className="order-card">
<Text className="order-card-title"></Text>
<Text className="u-muted"></Text>
</View>
{loading && !order ? (
<View className="order-card">
<Text className="u-muted"></Text>
</View>
) : null}
{order ? (
<>
<View className="order-card">
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">{order.orderNo || '—'}</Text>
</View>
<View className="order-product-row" style={{ marginTop: 12 }}>
<View className="order-product-thumb">
{image ? (
<Image className="order-product-thumb-img" src={image} mode="aspectFill" />
) : null}
</View>
<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>
</View>
</View>
</View>
<View className="order-card">
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value order-pay-amount">
¥{Number.isFinite(amount) ? amount.toFixed(2) : '—'}
</Text>
</View>
</View>
</>
) : null}
</View>
<View className="pay-bar">
<View
className="order-confirm-submit"
style={{ flex: 1, opacity: canConfirm && !submitting ? 1 : 0.6 }}
onClick={() => {
if (!canConfirm || submitting) return;
void confirmReceive();
}}
>
<Text>{submitting ? '提交中…' : canConfirm ? '确认收货' : '订单状态不可确认'}</Text>
</View>
</View>
</PageShell>
);
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '隐私政策',
navigationStyle: 'custom',
});
@@ -0,0 +1,33 @@
import { View, Text } from '@tarojs/components';
import { getLegalDocument } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
export default function PrivacyPolicyPage() {
const doc = getLegalDocument('privacy-policy');
return (
<PageShell variant="sub" className="legal-page">
<SubPageHeader title={doc.title} />
<View className="sub-page-body inset-page legal-body">
<Text className="legal-updated" selectable>
{doc.updatedAt}
</Text>
<Text className="legal-intro" selectable>
{doc.intro}
</Text>
{doc.sections.map((section) => (
<View key={section.heading} className="legal-section">
<Text className="legal-heading" selectable>
{section.heading}
</Text>
{section.paragraphs.map((p, i) => (
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable>
{p}
</Text>
))}
</View>
))}
</View>
</PageShell>
);
}
@@ -0,0 +1,6 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '商品详情',
enableShareAppMessage: true,
enableShareTimeline: true,
});
@@ -0,0 +1,272 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, {
useDidShow,
usePageScroll,
useRouter,
useShareAppMessage,
useShareTimeline,
} from '@tarojs/taro';
import type { ProductDetailContentDto } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady';
import { goLogin } from '../../lib/auth-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { isLoggedIn, request, toast } from '../../lib/api';
import {
getProductCarouselImages,
getProductDetailImages,
getProductMainImage,
type ProductImageSource,
} from '../../lib/product-images';
import {
canBuyOnline,
canPickupOnSite,
normalizeFulfillmentFlags,
} from '../../lib/product-fulfillment';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
import iconHome from '../../assets/tabbar/home.png';
import { usePageView } from '../../lib/usePageView';
type Product = ProductImageSource & {
id: string;
name: string;
subtitle?: string | null;
price: number;
benefitAmount?: number;
benefitDisplay?: number;
detailContent?: ProductDetailContentDto | null;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
};
export default function ProductDetailPage() {
const router = useRouter();
const productId = router.params.id ?? '';
usePageView(
'product_detail_view',
productId ? { refType: 'PRODUCT', refId: productId, productId } : undefined,
);
const [product, setProduct] = useState<Product | null>(null);
const [headerSolid, setHeaderSolid] = useState(false);
usePageScroll(({ scrollTop }) => {
setHeaderSolid(scrollTop > 100);
});
const loadProduct = useCallback(() => {
if (!productId) return;
request<Product | null>(`/catalog/products/${productId}`)
.then((p) => {
if (!p) {
setProduct(null);
toast('商品不存在或暂未开放');
return;
}
setProduct(normalizeFulfillmentFlags(p));
})
.catch((e) => {
setProduct(null);
toast(e instanceof Error ? e.message : '加载失败');
});
}, [productId]);
useEffect(() => {
loadProduct();
}, [loadProduct]);
// 登录后返回详情须带 token 重拉,否则白名单商品会一直空白
useDidShow(() => {
loadProduct();
});
const sharePayload = useMemo(
() => ({
title: product?.name || DEFAULT_SHARE_TITLE,
desc: product?.subtitle || DEFAULT_SHARE_DESC,
path: `/pages/product-detail/index?id=${productId}`,
imgUrl: (product ? getProductMainImage(product) : '') || undefined,
}),
[product, productId],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: productId ? `id=${productId}` : '',
imageUrl: sharePayload.imgUrl,
}));
function goBack() {
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack();
return;
}
Taro.switchTab({ url: '/pages/home/index' });
}
function goHome() {
Taro.switchTab({ url: '/pages/home/index' });
}
async function goBuy() {
if (!productId) return;
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=2`;
if (!isLoggedIn()) {
goLogin(returnPath);
return;
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath });
}
async function goOnSitePickup() {
if (!productId) return;
const returnPath = `/pages/order-confirm-pickup/index?productId=${productId}&qty=1`;
if (!isLoggedIn()) {
goLogin(returnPath);
return;
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath });
}
if (!product) {
return (
<PageShell variant="scroll" className="product-detail-page">
<PageNavBar title="商品详情" solid onBack={goBack} />
<View className="page-with-nav-bar u-empty"></View>
</PageShell>
);
}
const allowOnline = canBuyOnline(product);
const allowOnSite = canPickupOnSite(product);
const benefit = Number(product.benefitDisplay ?? product.benefitAmount ?? product.price);
const carouselImages = getProductCarouselImages(product);
const detailImages = getProductDetailImages(product);
const detail = product.detailContent ?? {};
const features = detail.features ?? [];
return (
<PageShell variant="scroll" className="product-detail-page" hasFixedFooter>
<WechatShareReady payload={sharePayload} />
<PageNavBar
title={product.name}
solid={headerSolid}
titleVisible={headerSolid}
onBack={goBack}
right={<ShareNavButton payload={sharePayload} />}
/>
<View className="product-detail-main">
<View className="product-detail-hero full-bleed">
<ProductCarousel images={carouselImages} alt={product.name} variant="detail" />
</View>
<View className="product-detail-info">
<View className="product-detail-price">
<Text className="product-detail-price-symbol">¥</Text>
<Text className="product-detail-price-value">{Number(product.price).toFixed(2)}</Text>
</View>
<Text className="product-detail-name">{product.name}</Text>
{product.subtitle ? (
<Text className="product-detail-subtitle">{product.subtitle}</Text>
) : 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>
<Text className="product-detail-promo-title">
·
<Text className="product-detail-promo-amount"> ¥{benefit}</Text>
</Text>
</View>
<Text className="product-detail-promo-desc">
</Text>
</View>
</View>
<View className="product-detail-content">
<View className="product-detail-section-head">
<View className="product-detail-section-bar" />
<Text className="product-detail-section-title"></Text>
</View>
{detailImages.map((src, index) => (
<Image
key={`${src}-${index}`}
className="product-detail-banner full-bleed"
src={src}
mode="widthFix"
/>
))}
{(detail.storyTitle || detail.storyText || features.length > 0) ? (
<View className="product-detail-copy">
{(detail.storyTitle || detail.storyText) ? (
<View className="product-detail-story">
{detail.storyTitle ? (
<Text className="product-detail-story-title">{detail.storyTitle}</Text>
) : null}
{detail.storyText ? (
<Text className="product-detail-story-text">{detail.storyText}</Text>
) : null}
</View>
) : null}
{features.length > 0 ? (
<View className="product-detail-features">
{features.map((f) => (
<View key={`${f.title}-${f.icon}`} className="product-detail-feature">
<Text className="product-detail-feature-icon"></Text>
<Text className="product-detail-feature-title">{f.title}</Text>
<Text className="product-detail-feature-desc">{f.desc}</Text>
</View>
))}
</View>
) : null}
</View>
) : null}
</View>
</View>
<View className="product-detail-bar">
<View className="product-detail-bar-home" onClick={goHome}>
<Image className="product-detail-bar-home-icon" src={iconHome} mode="aspectFit" />
<Text className="product-detail-bar-home-label"></Text>
</View>
{allowOnSite ? (
<View className="product-detail-pickup-btn" onClick={() => void goOnSitePickup()}>
<Text className="product-detail-pickup-btn-text"></Text>
</View>
) : null}
{allowOnline ? (
<View className="product-detail-buy-btn" onClick={() => void goBuy()}>
<Text className="product-detail-buy-btn-text"></Text>
</View>
) : null}
{!allowOnline && !allowOnSite ? (
<View className="product-detail-buy-btn product-detail-buy-btn--disabled">
<Text className="product-detail-buy-btn-text"></Text>
</View>
) : null}
</View>
</PageShell>
);
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '核销码',
});
@@ -0,0 +1,171 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import { REDEEM_TOKEN_TTL_SECONDS } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import RedeemQrCode from '../../components/RedeemQrCode';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { formatMoney } from '../../lib/money';
const POLL_INTERVAL_MS = 2500;
const LAST_REDEEM_RECORD_KEY = 'lastRedeemRecordId';
const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
type RedeemTokenStatus =
| { status: 'PENDING'; expireInSeconds: number; amount: number }
| {
status: 'CONSUMED';
record: {
id: string;
redeemNo: string;
amount: number;
storeId: string;
storeName: string;
createdAt: string;
};
}
| { status: 'EXPIRED' };
function formatTimer(seconds: number) {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
}
export default function RedeemCodePage() {
const router = useRouter();
const token = decodeURIComponent(router.params.token ?? '');
const amount = Number(router.params.amount ?? 0);
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const successHandled = useRef(false);
const lastTokenTapAt = useRef(0);
function onTokenTap() {
if (!token) return;
const now = Date.now();
if (now - lastTokenTapAt.current < 350) {
lastTokenTapAt.current = 0;
void Taro.setClipboardData({ data: token })
.then(() => toast('核销码编号已复制', 'success'))
.catch(() => toast('复制失败'));
return;
}
lastTokenTapAt.current = now;
}
const stopTimer = useCallback(() => {
if (timerRef.current != null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const handleRedeemExpired = useCallback(() => {
if (successHandled.current) return;
successHandled.current = true;
stopTimer();
toast('核销码已失效,请重新生成');
setTimeout(() => {
Taro.navigateBack();
}, 1500);
}, [stopTimer]);
const handleRedeemSuccess = useCallback(
(record: NonNullable<Extract<RedeemTokenStatus, { status: 'CONSUMED' }>['record']>) => {
if (successHandled.current) return;
successHandled.current = true;
stopTimer();
Taro.setStorageSync(LAST_REDEEM_RECORD_KEY, record.id);
Taro.setStorageSync(LAST_REDEEM_RESULT_KEY, JSON.stringify(record));
Taro.redirectTo({
url: `/pages/redeem-success/index?amount=${record.amount}`,
});
},
[stopTimer],
);
useEffect(() => {
if (!token) {
toast('核销码无效,请重新生成');
Taro.navigateBack();
return undefined;
}
successHandled.current = false;
setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
timerRef.current = setInterval(() => {
setTimerSec((prev) => {
if (prev <= 1) {
stopTimer();
setTimeout(() => handleRedeemExpired(), 0);
return 0;
}
return prev - 1;
});
}, 1000);
return () => stopTimer();
}, [token, stopTimer, handleRedeemExpired]);
useEffect(() => {
if (!token) return undefined;
let cancelled = false;
async function pollStatus() {
try {
const status = await request<RedeemTokenStatus>(`/redeem/tokens/${token}/status`);
if (cancelled || successHandled.current) return;
if (status.status === 'CONSUMED' && status.record) {
handleRedeemSuccess(status.record);
} else if (status.status === 'EXPIRED') {
handleRedeemExpired();
} else if (status.status === 'PENDING' && status.expireInSeconds > 0) {
setTimerSec((prev) => Math.min(prev, status.expireInSeconds));
}
} catch {
/* 轮询失败忽略,下次重试 */
}
}
void pollStatus();
const pollId = setInterval(() => void pollStatus(), POLL_INTERVAL_MS);
return () => {
cancelled = true;
clearInterval(pollId);
};
}, [token, handleRedeemSuccess, handleRedeemExpired]);
return (
<PageShell variant="sub" className="redeem-code-page">
<SubPageHeader title="核销码" />
<View className="sub-page-body">
<View className="redeem-code-panel">
<Text className="redeem-code-head"></Text>
<View className="redeem-qr-wrap">
<RedeemQrCode token={token} />
</View>
<View className={`redeem-timer${timerSec > 0 && timerSec < REDEEM_TOKEN_TTL_SECONDS ? ' redeem-timer--active' : ''}`}>
<Text className="redeem-timer-value">{formatTimer(timerSec)}</Text>
<Text className="redeem-timer-label"></Text>
</View>
<Text className="u-muted"></Text>
<Text className="redeem-code-amount">¥ {formatMoney(amount)}</Text>
{token ? (
<View className="redeem-code-token-wrap" onClick={onTokenTap}>
<Text className="redeem-code-token-label"></Text>
<Text className="redeem-code-token">{token}</Text>
<Text className="redeem-code-token-hint"></Text>
</View>
) : null}
</View>
<View className="redeem-cancel-btn" onClick={() => Taro.navigateBack()}>
<Text></Text>
</View>
</View>
</PageShell>
);
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '核销成功',
});
@@ -0,0 +1,159 @@
import { useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { formatShanghaiDateTime } from '../../lib/datetime';
import { formatMoney } from '../../lib/money';
const LAST_REDEEM_RESULT_KEY = 'lastRedeemResult';
type RedeemRecord = {
id: string;
redeemNo: string;
amount: number;
storeId: string;
storeName: string;
createdAt: string;
};
/** 与权益「历史记录」一致:2026-08-03 13:53:03Asia/Shanghai */
function formatChinaDateTime(input?: string | null) {
return formatShanghaiDateTime(input ?? new Date());
}
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>
);
}
export default function RedeemSuccessPage() {
const router = useRouter();
const [serviceScore, setServiceScore] = useState(5);
const [envScore, setEnvScore] = useState(5);
const [loading, setLoading] = useState(false);
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 amount = Number(record?.amount ?? router.params.amount ?? 0);
const storeName = record?.storeName || '门店';
const redeemNo = record?.redeemNo || '—';
const redeemedAt = formatChinaDateTime(record?.createdAt);
function clearCache() {
try {
Taro.removeStorageSync(LAST_REDEEM_RESULT_KEY);
} catch {
/* ignore */
}
}
function goBenefit() {
clearCache();
Taro.switchTab({ url: '/pages/benefit/index' });
}
function goHome() {
clearCache();
Taro.switchTab({ url: '/pages/home/index' });
}
async function submitRatingAndFinish() {
setLoading(true);
try {
if (record?.id) {
await request('/redeem/ratings', {
method: 'POST',
data: {
redeemRecordId: record.id,
serviceScore,
envScore,
},
});
toast('评价已提交', 'success');
}
} catch {
/* 评价失败不阻塞返回 */
} 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>
<Text className="redeem-success-amount">¥ {formatMoney(amount)}</Text>
<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>
</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>
<View
className={`redeem-submit${loading ? ' redeem-submit--disabled' : ''}`}
onClick={() => {
if (!loading) void submitRatingAndFinish();
}}
>
<Text>{loading ? '提交中…' : '提交评价并返回'}</Text>
</View>
<View className="redeem-cancel-btn" onClick={goHome}>
<Text></Text>
</View>
</View>
</PageShell>
);
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '权益核销',
});
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react';
import { View, Text, Input } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { goLogin } from '../../lib/auth-nav';
import { isLoggedIn, request, toast } from '../../lib/api';
import { formatMoney } from '../../lib/money';
type BenefitSummary = {
totalBalance: number;
maxRedeemAmount: number;
};
const MIN_REDEEM_AMOUNT = 0.01;
/**
* 核销金额输入清洗:
* - 只保留数字与一个小数点
* - 小数最多 2 位(再输入会被截断)
* - 去掉多余前导 0
*/
function sanitizeRedeemAmountInput(raw: string): string {
let next = String(raw ?? '').replace(/[^\d.]/g, '');
if (!next) return '';
const firstDot = next.indexOf('.');
if (firstDot >= 0) {
const intRaw = next.slice(0, firstDot).replace(/\D/g, '');
const decRaw = next
.slice(firstDot + 1)
.replace(/\D/g, '')
.slice(0, 2);
const intPart = intRaw.replace(/^0+(?=\d)/, '') || '0';
if (next.endsWith('.') && decRaw.length === 0) {
return `${intPart}.`;
}
if (decRaw.length > 0) {
return `${intPart}.${decRaw}`;
}
return intPart;
}
return next.replace(/^0+(?=\d)/, '');
}
export default function RedeemPage() {
const router = useRouter();
const couponId = router.params.couponId;
const initialAmount = router.params.amount ?? '';
const [balance, setBalance] = useState(0);
const [couponBalance, setCouponBalance] = useState<number | null>(null);
const [amount, setAmount] = useState(() => sanitizeRedeemAmountInput(initialAmount));
/** 原生 input 在截断小数后偶发不同步,强制 remount 对齐受控值 */
const [inputKey, setInputKey] = useState(0);
const [loading, setLoading] = useState(false);
const redeemableMax = couponId ? (couponBalance ?? 0) : balance;
useEffect(() => {
if (!isLoggedIn()) {
goLogin('/pages/redeem/index');
return;
}
request<BenefitSummary>('/benefit/summary')
.then((s) => setBalance(Number(s.maxRedeemAmount ?? s.totalBalance ?? 0)))
.catch(() => {});
}, []);
useEffect(() => {
if (!couponId) {
setCouponBalance(null);
return;
}
request<Array<{ id: string; balance: number }>>('/benefit/coupons')
.then((list) => {
const found = list.find((c) => String(c.id) === couponId);
if (found) setCouponBalance(Number(found.balance));
})
.catch(() => {});
}, [couponId]);
function fillMaxAmount() {
if (redeemableMax < MIN_REDEEM_AMOUNT) return;
setAmount(sanitizeRedeemAmountInput(redeemableMax.toFixed(2)));
setInputKey((k) => k + 1);
}
function onAmountChange(raw: string) {
const next = sanitizeRedeemAmountInput(raw);
setAmount(next);
// 用户试图输入超过两位小数 / 非法字符时,强制刷新原生框显示
if (next !== raw) {
setInputKey((k) => k + 1);
}
}
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)}`);
return;
}
if (value > redeemableMax) {
toast(couponId ? '核销金额不能超过该权益可用余额' : '超出可用余额');
return;
}
setLoading(true);
try {
const body: { amount: number; couponId?: string } = { amount: value };
if (couponId) body.couponId = couponId;
const data = await request<{ token: string; amount: number }>('/redeem/tokens', {
method: 'POST',
data: body,
});
Taro.navigateTo({
url: `/pages/redeem-code/index?token=${encodeURIComponent(data.token)}&amount=${data.amount}`,
});
} catch (e) {
toast(e instanceof Error ? e.message : '生成失败');
} finally {
setLoading(false);
}
}
return (
<PageShell variant="sub" className="redeem-page">
<SubPageHeader title="权益核销" />
<View className="sub-page-body">
<View className="redeem-hero">
<Text className="redeem-hero-label">
{couponId ? '当前权益可用余额' : '可用余额'}
</Text>
<Text className="redeem-hero-amount">
¥{redeemableMax > 0 ? formatMoney(redeemableMax) : '--'}
</Text>
</View>
<View className="redeem-input-wrap">
<Input
key={inputKey}
className="redeem-input"
type="digit"
placeholder="输入核销金额"
placeholderClass="redeem-input-placeholder"
value={amount}
maxlength={12}
onInput={(e) => onAmountChange(e.detail.value)}
onBlur={(e) => onAmountChange(e.detail.value)}
style={{ textAlign: 'center' }}
/>
</View>
<View className="redeem-amount-foot">
<Text className="redeem-amount-hint">
¥{formatMoney(redeemableMax)}
</Text>
<Text className="redeem-fill-max" onClick={fillMaxAmount}>
</Text>
</View>
<View className="redeem-tips">
<Text className="redeem-tips-text">
0.01 3
</Text>
</View>
<View
className={`redeem-submit${loading || redeemableMax < MIN_REDEEM_AMOUNT ? ' redeem-submit--disabled' : ''}`}
onClick={loading || redeemableMax < MIN_REDEEM_AMOUNT ? undefined : submit}
>
<Text>{loading ? '生成中...' : '生成核销码'}</Text>
</View>
</View>
</PageShell>
);
}
@@ -0,0 +1,6 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '门店详情',
enableShareAppMessage: true,
enableShareTimeline: true,
});
@@ -0,0 +1,463 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, {
useDidShow,
useLoad,
usePageScroll,
useRouter,
useShareAppMessage,
useShareTimeline,
} from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
import ShareNavButton from '../../components/ShareNavButton';
import StoreRedeemMarquee from '../../components/StoreRedeemMarquee';
import WechatShareReady from '../../components/WechatShareReady';
import { request, toast } from '../../lib/api';
import { formatShanghaiDateTime } from '../../lib/datetime';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
type StoreMedia = {
url?: string | null;
bizType?: string | null;
mediaType?: string | null;
};
type StorePackage = {
name: string;
price: string | number;
dishes: string;
usableTime?: string | null;
otherNotes?: string | null;
sortOrder?: number;
};
type Store = {
id: string;
name: string;
address?: string;
province?: string;
cityName?: string;
city?: string;
district?: string;
phone?: string;
intro?: string | null;
benefitUsageRule?: string | null;
coverUrl?: string | null;
carouselUrls?: string[] | null;
media?: StoreMedia[] | null;
packages?: StorePackage[] | null;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
latitude?: number | string | null;
longitude?: number | string | null;
category?: { name: string } | null;
};
type RecentRedeem = {
userLabel: string;
amount: number | string;
createdAt: string;
text?: string;
};
function uniqueUrls(urls: Array<string | null | undefined>) {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of urls) {
const url = String(raw || '').trim();
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(url);
}
return out;
}
function envPhotoUrls(store: Store) {
return uniqueUrls(
(store.media || [])
.filter((m) => !m.bizType || m.bizType === 'ENV')
.map((m) => m.url),
);
}
function fullAddress(store: Store) {
const city = store.cityName || store.city || '';
return `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
}
function pickStoreId(raw?: string | null) {
return String(raw || '')
.trim()
.replace(/[^\d]/g, '');
}
/** 与历史记录一致:2026-08-03 15:14:30Asia/Shanghai */
function formatRedeemTime(input?: string | null) {
return formatShanghaiDateTime(input);
}
function formatRedeemAmountYuan(amount: number | string) {
const n = typeof amount === 'number' ? amount : Number(amount);
if (!Number.isFinite(n)) return '0';
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return n.toFixed(2).replace(/\.?0+$/, '');
}
function formatRecentRedeemLine(row: RecentRedeem) {
try {
// 优先用服务端拼好的 text,避免客户端时区 / Intl 差异
if (row.text?.trim()) return row.text.trim();
const label = String(row.userLabel || '用户***').trim() || '用户***';
const rawTime = String(row.createdAt || '').trim();
const time =
/^\d{4}-\d{2}-\d{2}/.test(rawTime)
? rawTime.slice(0, 19).replace('T', ' ')
: formatRedeemTime(row.createdAt);
const amount = formatRedeemAmountYuan(row.amount);
return `${label} ${time} 核销${amount}`;
} catch {
return '';
}
}
function normalizeRecentRedeems(payload: unknown): RecentRedeem[] {
try {
if (Array.isArray(payload)) return payload as RecentRedeem[];
if (payload && typeof payload === 'object') {
const list =
(payload as { list?: unknown; items?: unknown; data?: unknown }).list ??
(payload as { items?: unknown }).items ??
(payload as { data?: unknown }).data;
if (Array.isArray(list)) return list as RecentRedeem[];
}
} catch {
/* ignore */
}
return [];
}
export default function StoreDetailPage() {
const router = useRouter();
const [storeId, setStoreId] = useState(() => pickStoreId(router.params.id));
const [store, setStore] = useState<Store | null>(null);
const [recentRedeems, setRecentRedeems] = useState<RecentRedeem[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [headerSolid, setHeaderSolid] = useState(false);
const storeRef = useRef<Store | null>(null);
storeRef.current = store;
usePageScroll(({ scrollTop }) => {
setHeaderSolid(scrollTop > 100);
});
const loadStore = useCallback(async (id: string) => {
if (!id) {
setLoading(false);
setLoadError('缺少门店参数');
return;
}
setLoadError('');
if (!storeRef.current) setLoading(true);
try {
const data = await request<Store>(`/stores/${id}`);
if (!data || !data.id) {
setStore(null);
setLoadError('门店不存在或暂不可见');
toast('门店不存在或暂不可见');
return;
}
setStore(data);
} catch (e) {
const msg = e instanceof Error ? e.message : '加载失败';
setLoadError(msg);
if (!storeRef.current) toast(msg);
} finally {
setLoading(false);
}
}, []);
const loadRecentRedeems = useCallback(async (id: string) => {
if (!id) {
setRecentRedeems([]);
return;
}
try {
const list = await request<unknown>(`/stores/${id}/recent-redeems?limit=20`);
setRecentRedeems(normalizeRecentRedeems(list));
} catch {
setRecentRedeems([]);
}
}, []);
const bootstrap = useCallback(
(id: string) => {
const nextId = pickStoreId(id);
if (!nextId) {
setLoading(false);
setLoadError('缺少门店参数');
return;
}
setStoreId(nextId);
void loadStore(nextId);
void loadRecentRedeems(nextId);
},
[loadStore, loadRecentRedeems],
);
// 首屏:useLoad 带 options.id,比仅用 useDidShow 更稳(H5/小程序都覆盖)
useLoad((options) => {
bootstrap(options?.id || router.params.id || '');
});
useEffect(() => {
const fromRouter = pickStoreId(router.params.id);
if (fromRouter && fromRouter !== storeId) {
bootstrap(fromRouter);
}
}, [router.params.id, storeId, bootstrap]);
// 登录态变化后回到本页:重拉详情与走马灯
useDidShow(() => {
const id = pickStoreId(storeId || router.params.id);
if (!id) return;
void loadStore(id);
void loadRecentRedeems(id);
});
const sharePayload = useMemo(
() => {
const envFirst = store ? envPhotoUrls(store)[0] : undefined;
return {
title: store?.name || DEFAULT_SHARE_TITLE,
desc: store?.intro?.trim() || store?.address || DEFAULT_SHARE_DESC,
path: `/pages/store-detail/index?id=${storeId}`,
imgUrl: store?.coverUrl || envFirst || store?.carouselUrls?.[0] || undefined,
};
},
[store, storeId],
);
const marqueeLines = useMemo(
() => recentRedeems.map(formatRecentRedeemLine).filter(Boolean),
[recentRedeems],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: storeId ? `id=${storeId}` : '',
imageUrl: sharePayload.imgUrl,
}));
function goBack() {
const pages = Taro.getCurrentPages();
if (pages.length > 1) Taro.navigateBack();
else Taro.switchTab({ url: '/pages/stores/index' });
}
function callStore() {
if (!store?.phone) {
toast('暂无门店电话');
return;
}
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打电话'));
}
function openMap() {
if (!store) return;
const lat = store.latitude != null ? Number(store.latitude) : NaN;
const lng = store.longitude != null ? Number(store.longitude) : NaN;
const address = fullAddress(store) || store.address || store.name;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
Taro.openLocation({
latitude: lat,
longitude: lng,
name: store.name,
address,
scale: 16,
}).catch(() => {
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
window.location.href = `https://uri.amap.com/marker?position=${lng},${lat}&name=${encodeURIComponent(store.name)}&address=${encodeURIComponent(address)}`;
return;
}
toast('无法打开地图导航');
});
return;
}
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' && address) {
window.location.href = `https://uri.amap.com/search?keyword=${encodeURIComponent(address)}&src=dukang`;
return;
}
toast('门店位置待完善,暂无法导航');
}
if (!store) {
return (
<PageShell variant="scroll" className="store-detail-page">
<PageNavBar title="门店详情" solid onBack={goBack} />
<View className="page-with-nav-bar u-empty">
{loading ? '加载中…' : loadError || '门店不存在或暂不可见'}
</View>
</PageShell>
);
}
const envPhotos = envPhotoUrls(store);
const images = uniqueUrls([
store.coverUrl,
...(store.carouselUrls || []),
...envPhotos,
]);
const intro = store.intro?.trim() || '';
const benefitRuleRaw = store.benefitUsageRule?.trim() || '';
const benefitRule =
benefitRuleRaw && !/^null$/i.test(benefitRuleRaw) ? benefitRuleRaw : '';
function previewEnv(index: number) {
if (!envPhotos.length) return;
Taro.previewImage({
current: envPhotos[index],
urls: envPhotos,
}).catch(() => toast('无法预览图片'));
}
return (
<PageShell variant="scroll" className="store-detail-page" hasFixedFooter>
<WechatShareReady payload={sharePayload} />
<PageNavBar
title={store.name}
solid={headerSolid}
titleVisible={headerSolid}
onBack={goBack}
right={<ShareNavButton payload={sharePayload} />}
/>
<View className="store-detail-hero full-bleed">
<ProductCarousel images={images} alt={store.name} variant="store" />
</View>
<View className="store-detail-info-card">
<Text className="store-detail-name">{store.name}</Text>
<View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex">
{store.district ? `${store.district} · ` : ''}
{store.address || '地址待完善'}
</Text>
<Text className="store-detail-action" onClick={openMap}>
</Text>
</View>
<Text className="store-detail-meta">
:{' '}
{(() => {
const parts: string[] = [];
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
return parts.length ? parts.join('') : '10:00-22:00';
})()}
</Text>
{store.avgPrice != null && Number(store.avgPrice) > 0 ? (
<Text className="store-detail-meta"> ¥{Number(store.avgPrice).toFixed(0)}</Text>
) : null}
{store.phone ? (
<View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex">: {store.phone}</Text>
<Text className="store-detail-action" onClick={callStore}>
</Text>
</View>
) : null}
<View className="store-detail-tags">
{store.category?.name ? (
<Text className="store-detail-tag">{store.category.name}</Text>
) : null}
<Text className="store-detail-tag"></Text>
<Text className="store-detail-tag"></Text>
</View>
</View>
{marqueeLines.length > 0 ? (
<View className="store-detail-marquee-wrap">
<StoreRedeemMarquee key={marqueeLines.join('|')} lines={marqueeLines} />
</View>
) : null}
{store.packages && store.packages.length > 0 ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
{store.packages.map((pkg, index) => (
<View key={`${pkg.name}-${index}`} className="store-detail-package-card">
<Text className="store-detail-package-name">{pkg.name}</Text>
<Text className="store-detail-package-body">
{formatRedeemAmountYuan(pkg.price)} · {pkg.dishes}
</Text>
{pkg.usableTime ? (
<Text className="store-detail-package-meta">使{pkg.usableTime}</Text>
) : null}
{pkg.otherNotes ? (
<Text className="store-detail-package-meta">{pkg.otherNotes}</Text>
) : null}
</View>
))}
</View>
) : null}
{intro ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
<Text className="store-detail-intro">{intro}</Text>
</View>
) : null}
{benefitRule ? (
<View className="store-detail-section">
<Text className="store-detail-section-title">使</Text>
<Text className="store-detail-intro">{benefitRule}</Text>
</View>
) : null}
{envPhotos.length > 0 ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
<View className="store-detail-env-grid">
{envPhotos.map((url, index) => (
<View
key={`${url}-${index}`}
className="store-detail-env-item"
onClick={() => previewEnv(index)}
>
<Image className="store-detail-env-img" src={url} mode="aspectFill" />
</View>
))}
</View>
</View>
) : null}
<View className="store-detail-bar">
<View
className="u-btn u-btn--block"
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
>
<Text></Text>
</View>
</View>
</PageShell>
);
}
@@ -0,0 +1,7 @@
export default definePageConfig({
navigationBarTitleText: '门店',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
enableShareAppMessage: true,
enableShareTimeline: true,
});
+485
View File
@@ -0,0 +1,485 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image, Input } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useShareAppMessage, useShareTimeline } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import WechatShareReady from '../../components/WechatShareReady';
import RegionPicker from '../../components/RegionPicker';
import CategoryPicker, {
EMPTY_CATEGORY,
formatCategoryLabel,
type CategorySelection,
type StoreCategoryNode,
} from '../../components/CategoryPicker';
import {
DEFAULT_REGION,
formatRegionLabel,
matchesRegionFilter,
type RegionSelection,
} from '../../lib/region-data';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import {
getCityCodeForCatalog,
readCachedUserCoords,
resolveUserCity,
toCityWideRegion,
type UserCoords,
} from '../../lib/user-location';
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
import { formatDistanceMeters } from '../../lib/geo';
import { getToken, request, toast } from '../../lib/api';
import {
getStoresListCache,
isStoresSessionBootstrapped,
markStoresSessionBootstrapped,
patchStoresFilterCache,
setStoresListCache,
} from '../../lib/stores-session';
import {
DEFAULT_SHARE_DESC,
DEFAULT_SHARE_TITLE,
toWeappShareMessage,
} from '../../lib/wechat-share';
type Store = {
id: string;
name: string;
address?: string;
province?: string;
cityName?: string;
district?: string;
coverUrl?: string | null;
openTime?: string | null;
closeTime?: string | null;
openTime2?: string | null;
closeTime2?: string | null;
avgPrice?: number | null;
status?: string;
categoryId?: string | null;
category?: { id?: string; name?: string; parentId?: string | null } | null;
latitude?: number | string | null;
longitude?: number | string | null;
distanceMeters?: number | null;
};
function makeCityKey(region: Pick<RegionSelection, 'province' | 'city'>): string {
return `${region.province}|${region.city}`;
}
/**
* 定位城市若未开城,接口会 fallback 到郑州 cityCode
* 筛选器必须与真实拉取城市一致,否则列表被客户端滤空。
*/
function regionForCatalogFetch(resolved: {
openCity: boolean;
cityCode?: string;
region: RegionSelection;
}): { cityCode: string; region: RegionSelection } {
const cityCode = getCityCodeForCatalog(resolved);
if (resolved.openCity && resolved.cityCode) {
return { cityCode, region: toCityWideRegion(resolved.region) };
}
return { cityCode: FALLBACK_CITY_CODE, region: toCityWideRegion(DEFAULT_REGION) };
}
export default function StoresPage() {
const cached = getStoresListCache();
const [stores, setStores] = useState<Store[]>(() => (cached?.items as Store[] | undefined) ?? []);
const [loading, setLoading] = useState(() => !cached && !isStoresSessionBootstrapped());
const [keywordInput, setKeywordInput] = useState(() => cached?.keywordInput ?? '');
const [keyword, setKeyword] = useState(() => cached?.keyword ?? '');
const [region, setRegion] = useState<RegionSelection>(
() => cached?.filterRegion ?? cached?.listRegion ?? DEFAULT_REGION,
);
const [regionOpen, setRegionOpen] = useState(false);
const [category, setCategory] = useState<CategorySelection>(
() => cached?.category ?? EMPTY_CATEGORY,
);
const [categoryOpen, setCategoryOpen] = useState(false);
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [locating, setLocating] = 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 showBootLoading = loading && stores.length === 0;
const childIdsByParent = useMemo(() => {
const map = new Map<string, string[]>();
for (const root of categoryTree) {
map.set(
root.id,
(root.children ?? []).map((c) => c.id),
);
}
return map;
}, [categoryTree]);
async function fetchStores(
nextCode: string,
coords: UserCoords | null,
cityKey: string,
listRegion: RegionSelection,
/** 写入会话的筛选器;默认保留用户当前选择 */
filterRegion: RegionSelection = regionRef.current,
) {
const seq = ++fetchSeqRef.current;
const qs = new URLSearchParams();
if (nextCode) qs.set('cityCode', nextCode);
if (coords) {
qs.set('lat', String(coords.latitude));
qs.set('lng', String(coords.longitude));
}
const path = qs.toString() ? `/stores?${qs}` : '/stores';
try {
const list = await request<Store[]>(path);
if (seq !== fetchSeqRef.current) return;
const items = Array.isArray(list) ? list : [];
setStores(items);
fetchCityKeyRef.current = cityKey;
const prev = getStoresListCache();
setStoresListCache({
cityKey,
cityCode: nextCode,
authKey: getToken() || '',
listRegion: toCityWideRegion(listRegion),
items,
filterRegion,
keyword: prev?.keyword ?? keyword,
keywordInput: prev?.keywordInput ?? keywordInput,
category: prev?.category ?? category,
});
} catch (e) {
if (seq !== fetchSeqRef.current) return;
toast(e instanceof Error ? e.message : '加载失败');
} finally {
if (seq === fetchSeqRef.current) setLoading(false);
}
}
/**
* 首次进入:弹窗 + 定位 + 拉列表。
* 同次再切回:只同步 tab 选中态(登录态未变)。
* 登录/退出后 token 变化:按缓存失效重新拉列表(白名单)。
*/
useDidShow(() => {
syncTabBarSelected(1);
const authKey = getToken() || '';
const cache = getStoresListCache();
if (isStoresSessionBootstrapped()) {
if (cache && (cache.authKey ?? '') === authKey) {
return;
}
// 登录态变了:保留筛选,重新拉列表
void (async () => {
setLoading(true);
const nextCode = cache?.cityCode || FALLBACK_CITY_CODE;
const listRegion = cache?.listRegion
? {
province: cache.listRegion.province,
city: cache.listRegion.city,
district: cache.listRegion.district || '全部',
}
: regionRef.current;
const nextCityKey = cache?.cityKey || makeCityKey(listRegion);
await fetchStores(
nextCode,
readCachedUserCoords(),
nextCityKey,
listRegion,
regionRef.current,
);
})();
return;
}
markStoresSessionBootstrapped();
void (async () => {
const { confirm } = await Taro.showModal({
title: '获取当前位置',
content: '是否允许获取当前位置来搜索附近门店?拒绝后将按默认城市展示,可下拉刷新重新定位。',
confirmText: '允许',
cancelText: '暂不',
}).catch(() => ({ confirm: false, cancel: true }));
if (confirm) {
setLoading(true);
const resolved = await resolveUserCity(true);
const { cityCode, region: nextRegion } = regionForCatalogFetch(resolved);
const nextCityKey = makeCityKey(nextRegion);
setRegion(nextRegion);
regionRef.current = nextRegion;
await fetchStores(
cityCode,
readCachedUserCoords(),
nextCityKey,
nextRegion,
nextRegion,
);
return;
}
const nextRegion = toCityWideRegion(DEFAULT_REGION);
const nextCityKey = makeCityKey(nextRegion);
setRegion(nextRegion);
regionRef.current = nextRegion;
setLoading(true);
await fetchStores(FALLBACK_CITY_CODE, null, nextCityKey, nextRegion, nextRegion);
})();
});
useEffect(() => {
void request<StoreCategoryNode[]>('/store-categories')
.then((tree) => setCategoryTree(Array.isArray(tree) ? tree : []))
.catch(() => setCategoryTree([]));
}, []);
usePullDownRefresh(() => {
void (async () => {
try {
const resolved = await resolveUserCity(true);
const { cityCode, region: nextRegion } = regionForCatalogFetch(resolved);
const nextCityKey = makeCityKey(nextRegion);
setRegion(nextRegion);
regionRef.current = nextRegion;
if (fetchCityKeyRef.current !== nextCityKey) {
setStores([]);
setLoading(true);
}
await fetchStores(
cityCode,
readCachedUserCoords(),
nextCityKey,
nextRegion,
nextRegion,
);
} catch (e) {
toast(e instanceof Error ? e.message : '加载失败');
setLoading(false);
} finally {
Taro.stopPullDownRefresh();
}
})();
});
function matchesCategory(store: Store): boolean {
if (!category.parentId) return true;
const storeCatId = String(store.categoryId || store.category?.id || '');
const storeParentId = String(store.category?.parentId || '');
if (category.childId) {
return storeCatId === category.childId;
}
if (storeParentId && storeParentId === category.parentId) return true;
const siblings = childIdsByParent.get(category.parentId) ?? [];
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);
});
function applySearch() {
const next = keywordInput.trim();
setKeyword(next);
patchStoresFilterCache({ keyword: next, keywordInput });
}
function resetFilters() {
setKeywordInput('');
setKeyword('');
setCategory(EMPTY_CATEGORY);
setRegion(DEFAULT_REGION);
regionRef.current = DEFAULT_REGION;
patchStoresFilterCache({
keyword: '',
keywordInput: '',
category: EMPTY_CATEGORY,
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 formatHours(store: Store) {
const parts: string[] = [];
if (store.openTime && store.closeTime) parts.push(`${store.openTime}-${store.closeTime}`);
if (store.openTime2 && store.closeTime2) parts.push(`${store.openTime2}-${store.closeTime2}`);
return parts.length ? `营业时间: ${parts.join('')}` : '营业时间: 10:00-22:00';
}
const sharePayload = useMemo(
() => ({
title: '杜康好客门店',
desc: DEFAULT_SHARE_DESC,
path: '/pages/stores/index',
}),
[],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
useShareTimeline(() => ({
title: sharePayload.title || DEFAULT_SHARE_TITLE,
query: '',
}));
return (
<PageShell variant="tab" className="store-page no-tab-header">
<WechatShareReady payload={sharePayload} />
<TabMainHeader title="门店" />
<View className="store-filter">
<View className="store-search-row">
<Input
className="store-search-input"
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>
</View>
<View className="store-filter-row">
<View className="store-filter-chip" onClick={() => 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)}>
<Text className="store-filter-chip-text">{categoryLabel}</Text>
<Text className="store-filter-chip-arrow"></Text>
</View>
<View
className="store-filter-icon-btn"
onClick={resetFilters}
aria-label="重置筛选"
>
{/* 小程序 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>
<View className="store-list">
{showBootLoading ? <View className="u-empty"></View> : null}
{!showBootLoading && filtered.length === 0 ? (
<View className="u-empty"></View>
) : null}
{!showBootLoading &&
filtered.map((s) => (
<View
key={s.id}
className="store-card"
onClick={() => Taro.navigateTo({ url: `/pages/store-detail/index?id=${s.id}` })}
>
{s.coverUrl ? (
<Image className="store-card-cover" src={s.coverUrl} mode="aspectFill" />
) : (
<View className="store-card-cover--empty" />
)}
<View className="store-card-body">
<Text className="store-card-name">{s.name}</Text>
<Text className="store-card-meta">
{s.district ? `${s.district} · ` : ''}
{s.address || '地址待完善'}
</Text>
<Text className="store-card-meta">{formatHours(s)}</Text>
{s.avgPrice != null && Number(s.avgPrice) > 0 ? (
<Text className="store-card-meta">¥{Number(s.avgPrice).toFixed(0)}</Text>
) : null}
<View className="store-card-footer">
<Text className="store-card-distance">
{formatDistanceMeters(s.distanceMeters)}
</Text>
<Text
className="store-card-cta"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/redeem/index' });
}}
>
</Text>
</View>
</View>
</View>
))}
</View>
{shouldRenderPageTabBar() ? <UserTabBar selected={1} /> : null}
<RegionPicker
open={regionOpen}
value={region}
levels={3}
onClose={() => setRegionOpen(false)}
onConfirm={(next) => {
setRegion(next);
regionRef.current = next;
patchStoresFilterCache({ filterRegion: next });
}}
/>
<CategoryPicker
open={categoryOpen}
tree={categoryTree}
value={category}
onClose={() => setCategoryOpen(false)}
onConfirm={(next) => {
setCategory(next);
patchStoresFilterCache({ category: next });
}}
/>
</PageShell>
);
}
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '用户服务协议',
navigationStyle: 'custom',
});
@@ -0,0 +1,33 @@
import { View, Text } from '@tarojs/components';
import { getLegalDocument } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
export default function UserAgreementPage() {
const doc = getLegalDocument('user-agreement');
return (
<PageShell variant="sub" className="legal-page">
<SubPageHeader title={doc.title} />
<View className="sub-page-body inset-page legal-body">
<Text className="legal-updated" selectable>
{doc.updatedAt}
</Text>
<Text className="legal-intro" selectable>
{doc.intro}
</Text>
{doc.sections.map((section) => (
<View key={section.heading} className="legal-section">
<Text className="legal-heading" selectable>
{section.heading}
</Text>
{section.paragraphs.map((p, i) => (
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable>
{p}
</Text>
))}
</View>
))}
</View>
</PageShell>
);
}