feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '编辑地址',
|
||||
});
|
||||
@@ -1,253 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '地址管理',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '权益明细',
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '好客权益',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
enableShareAppMessage: true,
|
||||
enableShareTimeline: true,
|
||||
});
|
||||
@@ -1,251 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '联系客服',
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '杜康好客',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
enableShareAppMessage: true,
|
||||
enableShareTimeline: true,
|
||||
});
|
||||
@@ -1,401 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '登录',
|
||||
});
|
||||
@@ -1,555 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '我的',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
enableShareAppMessage: true,
|
||||
enableShareTimeline: true,
|
||||
});
|
||||
@@ -1,591 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '现场取货确认',
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '确认订单',
|
||||
});
|
||||
@@ -1,428 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '订单详情',
|
||||
enableShareAppMessage: true,
|
||||
enableShareTimeline: true,
|
||||
});
|
||||
@@ -1,313 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '我的订单',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
@@ -1,186 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '收银台',
|
||||
});
|
||||
@@ -1,245 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '确认收货',
|
||||
});
|
||||
@@ -1,167 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '隐私政策',
|
||||
navigationStyle: 'custom',
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '商品详情',
|
||||
enableShareAppMessage: true,
|
||||
enableShareTimeline: true,
|
||||
});
|
||||
@@ -1,272 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '核销码',
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '核销成功',
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
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:03(Asia/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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '权益核销',
|
||||
});
|
||||
@@ -1,176 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '门店详情',
|
||||
enableShareAppMessage: true,
|
||||
enableShareTimeline: true,
|
||||
});
|
||||
@@ -1,463 +0,0 @@
|
||||
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:30(Asia/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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '门店',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
enableShareAppMessage: true,
|
||||
enableShareTimeline: true,
|
||||
});
|
||||
@@ -1,485 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '用户服务协议',
|
||||
navigationStyle: 'custom',
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user