feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代

This commit is contained in:
2026-08-04 21:32:13 +08:00
parent c8ea5a3119
commit 9d96c73246
1341 changed files with 0 additions and 195605 deletions
-72
View File
@@ -1,72 +0,0 @@
export default defineAppConfig({
pages: [
'pages/home/index',
'pages/stores/index',
'pages/benefit/index',
'pages/mine/index',
'pages/product-detail/index',
'pages/store-detail/index',
'pages/order-confirm/index',
'pages/order-confirm-pickup/index',
'pages/pay/index',
'pages/orders/index',
'pages/order-detail/index',
'pages/pickup-receive/index',
'pages/addresses/index',
'pages/address-edit/index',
'pages/customer-service/index',
'pages/benefit-detail/index',
'pages/redeem/index',
'pages/redeem-code/index',
'pages/redeem-success/index',
'pages/login/index',
'pages/user-agreement/index',
'pages/privacy-policy/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#FAF9F7',
navigationBarTitleText: '杜康好客',
navigationBarTextStyle: 'black',
backgroundColor: '#FAF9F7',
},
permission: {
'scope.userLocation': {
desc: '用于展示您所在城市的商品与门店',
},
},
requiredPrivateInfos: ['getLocation'],
tabBar: {
custom: false,
color: '#999999',
selectedColor: '#A61D24',
backgroundColor: '#FFFFFF',
borderStyle: 'black',
list: [
{
pagePath: 'pages/home/index',
text: '首页',
iconPath: 'assets/tabbar/home.png',
selectedIconPath: 'assets/tabbar/home-active.png',
},
{
pagePath: 'pages/stores/index',
text: '门店',
iconPath: 'assets/tabbar/store.png',
selectedIconPath: 'assets/tabbar/store-active.png',
},
{
pagePath: 'pages/benefit/index',
text: '好客权益',
iconPath: 'assets/tabbar/benefit.png',
selectedIconPath: 'assets/tabbar/benefit-active.png',
},
{
pagePath: 'pages/mine/index',
text: '我的',
iconPath: 'assets/tabbar/mine.png',
selectedIconPath: 'assets/tabbar/mine-active.png',
},
],
},
});
-62
View File
@@ -1,62 +0,0 @@
@import './styles/tokens.css';
@import './styles/base.css';
@import './styles/nav-bar.css';
@import './styles/home.css';
@import './styles/stores.css';
@import './styles/benefit.css';
@import './styles/mine.css';
@import './styles/product-detail.css';
@import './styles/store-detail.css';
@import './styles/login.css';
@import './styles/legal.css';
@import './styles/order.css';
@import './styles/address.css';
@import './styles/redeem.css';
page,
body {
background: var(--color-background);
color: var(--color-on-surface);
font-family: var(--font-body);
margin: 0;
}
/* 全局隐藏滚动条(H5 + 小程序均可滚动,仅隐藏轨道) */
html,
page,
body,
#app,
.taro_page,
.taro_router,
.taro-tabbar__panel {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* legacy Edge */
}
html::-webkit-scrollbar,
page::-webkit-scrollbar,
body::-webkit-scrollbar,
#app::-webkit-scrollbar,
.taro_page::-webkit-scrollbar,
.taro_router::-webkit-scrollbar,
.taro-tabbar__panel::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
background: transparent;
}
/* H5:页面内已渲染 UserTabBar,隐藏 Taro 自带底栏,避免双层 Tab */
.taro-tabbar__tabbar,
.taro-tabbar__border {
display: none !important;
height: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
.taro-tabbar__container,
.taro-tabbar__panel {
padding-bottom: 0 !important;
bottom: 0 !important;
}
-75
View File
@@ -1,75 +0,0 @@
import './lib/intl-polyfill';
import './lib/text-encoding-polyfill';
import { PropsWithChildren, useRef } from 'react';
import Taro, { useDidShow } from '@tarojs/taro';
import WechatShareBootstrap from './components/WechatShareBootstrap';
import { patchTaroH5Hooks } from './lib/patch-taro-h5-hooks';
import { handleWechatOrderConfirmShow } from './lib/wechat-order-confirm';
import { installClientErrorReporting } from './lib/client-error';
import './app.css';
// H5:在首屏 page hooks 执行前,把 Taro.useDidShow 等绑到与 createReactApp 同一份 runtime
patchTaroH5Hooks();
installClientErrorReporting();
function App({ children }: PropsWithChildren) {
const handlingRef = useRef(false);
useDidShow((options?: {
referrerInfo?: {
appId?: string;
extraData?: { status?: string; errormsg?: string; req_extradata?: Record<string, string> };
};
}) => {
if (process.env.TARO_ENV !== 'weapp') return;
if (handlingRef.current) return;
const referrerInfo =
options?.referrerInfo ||
(typeof Taro.getEnterOptionsSync === 'function'
? (
Taro.getEnterOptionsSync() as {
referrerInfo?: {
appId?: string;
extraData?: {
status?: string;
errormsg?: string;
req_extradata?: Record<string, string>;
};
};
}
).referrerInfo
: undefined);
if (!referrerInfo?.appId) return;
handlingRef.current = true;
void handleWechatOrderConfirmShow({ referrerInfo })
.then((result) => {
if (result.redirectUrl) {
Taro.redirectTo({ url: result.redirectUrl }).catch(() => {
Taro.reLaunch({ url: result.redirectUrl! });
});
return;
}
if (!result.handled || !result.orderId) return;
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as { route?: string } | undefined;
const route = cur?.route || '';
if (route.includes('pickup-receive')) {
Taro.redirectTo({ url: '/pages/orders/index?tab=all' }).catch(() => {});
}
})
.finally(() => {
handlingRef.current = false;
});
});
return (
<>
<WechatShareBootstrap />
{children}
</>
);
}
export default App;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 241 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 B

@@ -1,159 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
export type StoreCategoryNode = {
id: string;
name: string;
children?: StoreCategoryNode[];
};
export type CategorySelection = {
parentId: string;
parentName: string;
childId: string;
childName: string;
};
export const EMPTY_CATEGORY: CategorySelection = {
parentId: '',
parentName: '',
childId: '',
childName: '',
};
export function formatCategoryLabel(sel: CategorySelection): string {
if (sel.childName) return sel.childName;
if (sel.parentName) return sel.parentName;
return '全部分类';
}
type CategoryPickerProps = {
open: boolean;
tree: StoreCategoryNode[];
value: CategorySelection;
onClose: () => void;
onConfirm: (next: CategorySelection) => void;
};
type TabKey = 'parent' | 'child';
export default function CategoryPicker({
open,
tree,
value,
onClose,
onConfirm,
}: CategoryPickerProps) {
const [draft, setDraft] = useState<CategorySelection>(value);
const [activeTab, setActiveTab] = useState<TabKey>('parent');
useEffect(() => {
if (!open) return;
setDraft(value);
setActiveTab(value.parentId ? 'child' : 'parent');
}, [open, value]);
const children = useMemo(() => {
const parent = tree.find((n) => n.id === draft.parentId);
return parent?.children ?? [];
}, [tree, draft.parentId]);
if (!open) return null;
function selectParent(node: StoreCategoryNode | null) {
if (!node) {
setDraft(EMPTY_CATEGORY);
return;
}
setDraft({
parentId: node.id,
parentName: node.name,
childId: '',
childName: '',
});
setActiveTab('child');
}
function selectChild(node: StoreCategoryNode | null) {
if (!node) {
setDraft((prev) => ({ ...prev, childId: '', childName: '' }));
return;
}
setDraft((prev) => ({
...prev,
childId: node.id,
childName: node.name,
}));
}
function handleConfirm() {
onConfirm(draft);
onClose();
}
return (
<View className="region-picker-overlay" onClick={onClose}>
<View className="region-picker-sheet" onClick={(e) => e.stopPropagation()}>
<View className="region-picker-toolbar">
<View className="region-picker-tabs">
<Text
className={`region-picker-tab${activeTab === 'parent' ? ' active' : ''}`}
onClick={() => setActiveTab('parent')}
>
{draft.parentName || '大类'}
</Text>
<Text
className={`region-picker-tab${activeTab === 'child' ? ' active' : ''}${!draft.parentId ? ' disabled' : ''}`}
onClick={() => draft.parentId && setActiveTab('child')}
>
{draft.childName || '细类'}
</Text>
</View>
<Text className="region-picker-confirm ready" onClick={handleConfirm}>
</Text>
</View>
<ScrollView className="region-picker-list" scrollY showScrollbar={false}>
{activeTab === 'parent' ? (
<>
<View
className={`region-picker-option${!draft.parentId ? ' selected' : ''} region-picker-option--all`}
onClick={() => selectParent(null)}
>
<Text></Text>
</View>
{tree.map((item) => (
<View
key={item.id}
className={`region-picker-option${draft.parentId === item.id ? ' selected' : ''}`}
onClick={() => selectParent(item)}
>
<Text>{item.name}</Text>
</View>
))}
</>
) : (
<>
<View
className={`region-picker-option${!draft.childId ? ' selected' : ''} region-picker-option--all`}
onClick={() => selectChild(null)}
>
<Text></Text>
</View>
{children.map((item) => (
<View
key={item.id}
className={`region-picker-option${draft.childId === item.id ? ' selected' : ''}`}
onClick={() => selectChild(item)}
>
<Text>{item.name}</Text>
</View>
))}
</>
)}
</ScrollView>
</View>
</View>
);
}
@@ -1,50 +0,0 @@
import { Button, Text } from '@tarojs/components';
import type { ReactNode } from 'react';
export type ContactCsSessionContext = {
orderId?: string;
orderNo?: string;
from?: string;
};
type ContactCsButtonProps = {
className?: string;
children?: ReactNode;
/** 客服会话来源上下文,便于客服后台识别 */
session?: ContactCsSessionContext;
};
const isWeapp = process.env.TARO_ENV === 'weapp';
/** 组装 session-from(微信限制约 1000 字符) */
export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
if (!session) return 'dukang|from=mini-user';
const parts = ['dukang'];
if (session.from) parts.push(`from=${session.from}`);
if (session.orderNo) parts.push(`orderNo=${session.orderNo}`);
if (session.orderId) parts.push(`orderId=${session.orderId}`);
return parts.join('|');
}
/**
* 微信小程序客服入口(open-type=contact)。
* 非 weapp 环境不渲染,由调用方走电话等兜底。
*/
export default function ContactCsButton({
className = '',
children = '联系在线客服',
session,
}: ContactCsButtonProps) {
if (!isWeapp) return null;
return (
<Button
className={className}
openType="contact"
sessionFrom={buildCsSessionFrom(session)}
hoverClass="none"
>
{typeof children === 'string' ? <Text>{children}</Text> : children}
</Button>
);
}
@@ -1,17 +0,0 @@
import { Text } from '@tarojs/components';
type CouponBadgeProps = {
amount: number | string;
label?: string;
};
/** Taro 友好版权益角标(对齐 shared-ui CouponBadge */
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
const n = Number(amount);
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
return (
<Text className="coupon-badge">
¥{display} {label}
</Text>
);
}
@@ -1,56 +0,0 @@
import type { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import { subPageNavBarStyle, subPageNavContentStyle, useNavBarMetrics } from '../lib/nav-bar';
type PageNavBarProps = {
title: string;
solid?: boolean;
titleVisible?: boolean;
onBack?: () => void;
right?: ReactNode;
};
const isH5 = process.env.TARO_ENV === 'h5';
/** 内页自定义导航栏(返回 + 标题 + 右侧操作),适配刘海屏 */
export default function PageNavBar({
title,
solid = false,
titleVisible = true,
onBack,
right,
}: PageNavBarProps) {
const metrics = useNavBarMetrics();
const showTitle = !isH5 && titleVisible;
return (
<View
className={`page-nav-bar${solid ? ' page-nav-bar--solid' : ''}`}
style={subPageNavBarStyle(metrics)}
aria-label={title}
>
<Text
className={`page-nav-bar__title${showTitle ? ' page-nav-bar__title--visible' : ''}`}
>
{showTitle ? title : ''}
</Text>
<View
className="page-nav-bar__content"
style={subPageNavContentStyle(metrics)}
>
{onBack ? (
<View className="page-nav-bar__btn page-nav-bar__btn--back" onClick={onBack}>
<Text className="page-nav-bar__icon"></Text>
</View>
) : (
<View className="page-nav-bar__btn page-nav-bar__btn--back page-nav-bar__btn--placeholder" />
)}
{right ? (
<View className="page-nav-bar__right-slot">{right}</View>
) : (
<View className="page-nav-bar__btn page-nav-bar__btn--right page-nav-bar__btn--placeholder" />
)}
</View>
</View>
);
}
@@ -1,40 +0,0 @@
import type { PropsWithChildren, ReactNode } from 'react';
import { View } from '@tarojs/components';
import { pageShellCssVars, useNavBarMetrics } from '../lib/nav-bar';
type PageShellVariant = 'tab' | 'scroll' | 'sub' | 'plain';
type PageShellProps = PropsWithChildren<{
variant?: PageShellVariant;
className?: string;
/** 栈页固定底栏时额外底部留白 */
hasFixedFooter?: boolean;
}>;
/**
* 页面壳:唯一注入刘海屏 CSS 变量;统一 min-height / 底栏安全区。
* 顶栏组件自行调用 navBarStyle,不要在根节点再套 navBarStyle。
*/
export default function PageShell({
variant = 'tab',
className = '',
hasFixedFooter = false,
children,
}: PageShellProps) {
const metrics = useNavBarMetrics();
const classes = [
'page-shell',
`page-shell--${variant}`,
hasFixedFooter ? 'page-shell--fixed-footer' : '',
variant === 'tab' && process.env.TARO_ENV === 'weapp' ? 'page-shell--native-tabbar' : '',
className,
]
.filter(Boolean)
.join(' ');
return (
<View className={classes} style={pageShellCssVars(metrics)}>
{children as ReactNode}
</View>
);
}
@@ -1,63 +0,0 @@
import { Button, Text, View } from '@tarojs/components';
type PhoneQuickLoginButtonProps = {
loading?: boolean;
disabled?: boolean;
/** 须已主动勾选协议后才挂载 getPhoneNumber,避免未同意即拉起授权 */
agreed: boolean;
onRequireAgree: () => void;
onGetPhoneNumber: (phoneCode: string) => void;
onFail?: (message: string) => void;
};
/**
* 小程序手机号快捷登录(open-type=getPhoneNumber)。
* 文案不得使用「微信」字样或仿官方图标,以符合审核要求。
*/
export default function PhoneQuickLoginButton({
loading = false,
disabled = false,
agreed,
onRequireAgree,
onGetPhoneNumber,
onFail,
}: PhoneQuickLoginButtonProps) {
const inactive = loading || disabled;
const className = `login-phone-quick-btn${inactive ? ' login-phone-quick-btn--disabled' : ''}`;
const label = loading ? '登录中...' : '手机号快捷登录';
if (!agreed) {
return (
<View className={className} onClick={inactive ? undefined : onRequireAgree}>
<Text className="login-phone-quick-btn__text">{label}</Text>
</View>
);
}
return (
<Button
className={className}
openType={inactive ? undefined : 'getPhoneNumber'}
hoverClass="none"
onGetPhoneNumber={(e) => {
if (inactive) return;
const detail = e.detail as {
errMsg?: string;
code?: string;
errno?: number;
};
if (!detail?.code) {
const denied =
detail?.errMsg?.includes('deny') ||
detail?.errMsg?.includes('cancel') ||
detail?.errno === 103;
onFail?.(denied ? '已取消手机号授权' : detail?.errMsg || '获取手机号失败');
return;
}
onGetPhoneNumber(detail.code);
}}
>
<Text className="login-phone-quick-btn__text">{label}</Text>
</Button>
);
}
@@ -1,46 +0,0 @@
import { useState } from 'react';
import { View, Image, Swiper, SwiperItem } from '@tarojs/components';
type ProductCarouselProps = {
images: string[];
alt: string;
variant?: 'home' | 'detail' | 'store';
};
/** 商品/门店轮播(对齐 h5 ProductCarousel 三变体) */
export default function ProductCarousel({ images, alt, variant = 'detail' }: ProductCarouselProps) {
const slides = images.length > 0 ? images : [''];
const [activeIndex, setActiveIndex] = useState(0);
const prefix =
variant === 'store' ? 'store-detail-carousel' : variant === 'home' ? 'home-carousel' : 'detail-carousel';
return (
<View className={`${prefix}-wrap`}>
<Swiper
className={prefix}
circular={slides.length > 1}
onChange={(e) => setActiveIndex(e.detail.current)}
>
{slides.map((src, index) => (
<SwiperItem key={`${src}-${index}`} className={`${prefix}-item`}>
{src ? (
<Image className={`${prefix}-image`} src={src} mode="aspectFill" alt={alt} />
) : (
<View className={`${prefix}-placeholder`} />
)}
</SwiperItem>
))}
</Swiper>
{slides.length > 1 ? (
<View className={`${prefix}-dots`}>
{slides.map((_, index) => (
<View
key={index}
className={`${prefix}-dot${index === activeIndex ? ` ${prefix}-dot--active` : ''}`}
/>
))}
</View>
) : null}
</View>
);
}
@@ -1,75 +0,0 @@
import { useEffect, useState } from 'react';
import { View, Canvas } from '@tarojs/components';
import Taro from '@tarojs/taro';
import {
REDEEM_QR_DISPLAY_SIZE,
buildRedeemQrDataUrl,
drawRedeemQrOnCanvas,
} from '../lib/redeem-qr';
const CANVAS_ID = 'redeem-qr-canvas';
type RedeemQrCodeProps = {
token: string;
};
function drawOnWeappCanvas(token: string) {
const page = Taro.getCurrentInstance().page;
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
query
.select(`#${CANVAS_ID}`)
.fields({ node: true, size: true })
.exec((res) => {
const item = res[0] as { node?: WechatMiniprogram.Canvas; width?: number; height?: number } | undefined;
const canvas = item?.node;
if (!canvas) return;
const layoutW = item.width || REDEEM_QR_DISPLAY_SIZE;
const layoutH = item.height || REDEEM_QR_DISPLAY_SIZE;
const drawSize = Math.min(layoutW, layoutH);
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D;
const dpr = Taro.getSystemInfoSync().pixelRatio || 2;
canvas.width = layoutW * dpr;
canvas.height = layoutH * dpr;
ctx.scale(dpr, dpr);
drawRedeemQrOnCanvas(ctx, token, drawSize);
});
}
export default function RedeemQrCode({ token }: RedeemQrCodeProps) {
const [imgSrc, setImgSrc] = useState('');
const isWeapp = process.env.TARO_ENV === 'weapp';
useEffect(() => {
if (!token) {
setImgSrc('');
return;
}
if (isWeapp) {
const timer = setTimeout(() => drawOnWeappCanvas(token), 120);
return () => clearTimeout(timer);
}
let cancelled = false;
void buildRedeemQrDataUrl(token).then((url) => {
if (!cancelled) setImgSrc(url);
});
return () => {
cancelled = true;
};
}, [token, isWeapp]);
return (
<View className="redeem-qr-box">
<View className="redeem-qr-placeholder" />
{isWeapp ? (
<Canvas type="2d" id={CANVAS_ID} canvasId={CANVAS_ID} className="redeem-qr-canvas" />
) : imgSrc ? (
<View className="redeem-qr-img" style={{ backgroundImage: `url(${imgSrc})` }} />
) : null}
<View className="redeem-qr-scanline" />
</View>
);
}
@@ -1,186 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import {
REGION_ALL,
getCities,
getCitiesForPicker,
getDistricts,
getDistrictsForPicker,
getProvincesForPicker,
normalizeRegionSelection,
toCityLevelRegion,
type RegionSelection,
} from '../lib/region-data';
type RegionPickerProps = {
open: boolean;
value: RegionSelection;
onClose: () => void;
onConfirm: (region: RegionSelection) => void;
levels?: 2 | 3;
};
type PickerLevel = 'province' | 'city' | 'district';
const ALL_TABS: Array<{ key: PickerLevel; label: string }> = [
{ key: 'province', label: '省份' },
{ key: 'city', label: '城市' },
{ key: 'district', label: '区县' },
];
function initialTab(value: RegionSelection, levels: 2 | 3): PickerLevel {
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
if (levels === 2) {
return normalized.province && normalized.province !== REGION_ALL ? 'city' : 'province';
}
if (normalized.district && normalized.district !== REGION_ALL) return 'district';
if (normalized.city && normalized.city !== REGION_ALL) return 'city';
return 'province';
}
function tabLabel(tab: PickerLevel, draft: RegionSelection, fallback: string) {
if (tab === 'province') {
return draft.province && draft.province !== REGION_ALL ? draft.province : fallback;
}
if (tab === 'city') {
return draft.city && draft.city !== REGION_ALL ? draft.city : fallback;
}
return draft.district && draft.district !== REGION_ALL ? draft.district : fallback;
}
export default function RegionPicker({
open,
value,
onClose,
onConfirm,
levels = 3,
}: RegionPickerProps) {
const [draft, setDraft] = useState<RegionSelection>(value);
const [activeTab, setActiveTab] = useState<PickerLevel>('province');
const tabs = levels === 2 ? ALL_TABS.slice(0, 2) : ALL_TABS;
useEffect(() => {
if (!open) return;
const normalized = levels === 2 ? toCityLevelRegion(value) : normalizeRegionSelection(value);
setDraft(normalized);
setActiveTab(initialTab(value, levels));
}, [open, value, levels]);
const listItems = useMemo(() => {
if (activeTab === 'province') return getProvincesForPicker();
if (activeTab === 'city') return getCitiesForPicker(draft.province);
return getDistrictsForPicker(draft.province, draft.city);
}, [activeTab, draft.province, draft.city]);
const selectedValue =
activeTab === 'province' ? draft.province : activeTab === 'city' ? draft.city : draft.district;
const canConfirm =
levels === 2
? Boolean(draft.province && draft.city)
: Boolean(draft.province && draft.city && draft.district);
if (!open) return null;
function selectProvince(province: string) {
if (province === REGION_ALL) {
setDraft({ province: REGION_ALL, city: REGION_ALL, district: REGION_ALL });
setActiveTab('city');
return;
}
const nextCities = getCities(province);
const city = nextCities[0] ?? '';
if (levels === 2) {
setDraft({ province, city, district: REGION_ALL });
setActiveTab('city');
return;
}
const nextDistricts = getDistricts(province, city);
setDraft({ province, city, district: nextDistricts[0] ?? '' });
setActiveTab('city');
}
function selectCity(city: string) {
if (city === REGION_ALL) {
setDraft({ ...draft, city: REGION_ALL, district: REGION_ALL });
if (levels === 3) setActiveTab('district');
return;
}
if (levels === 2) {
setDraft({ ...draft, city, district: REGION_ALL });
return;
}
const nextDistricts = getDistricts(draft.province, city);
setDraft({ ...draft, city, district: nextDistricts[0] ?? '' });
setActiveTab('district');
}
function selectDistrict(district: string) {
setDraft({ ...draft, district });
}
function onSelectItem(item: string) {
if (activeTab === 'province') selectProvince(item);
else if (activeTab === 'city') selectCity(item);
else selectDistrict(item);
}
function onTabClick(tab: PickerLevel) {
if (tab === 'city' && !draft.province) return;
if (tab === 'district' && (!draft.province || !draft.city)) return;
setActiveTab(tab);
}
function handleConfirm() {
if (!canConfirm) return;
const next = levels === 2 ? toCityLevelRegion(draft) : normalizeRegionSelection(draft);
onConfirm(next);
onClose();
}
return (
<View className="region-picker-overlay" onClick={onClose}>
<View className="region-picker-sheet" onClick={(e) => e.stopPropagation()}>
<View className="region-picker-toolbar">
<View className="region-picker-tabs">
{tabs.map((tab) => {
const disabled =
(tab.key === 'city' && !draft.province) ||
(tab.key === 'district' && (!draft.province || !draft.city));
return (
<Text
key={tab.key}
className={`region-picker-tab${activeTab === tab.key ? ' active' : ''}${disabled ? ' disabled' : ''}`}
onClick={() => !disabled && onTabClick(tab.key)}
>
{tabLabel(tab.key, draft, tab.label)}
</Text>
);
})}
</View>
<Text
className={`region-picker-confirm${canConfirm ? ' ready' : ''}`}
onClick={() => canConfirm && handleConfirm()}
>
</Text>
</View>
<ScrollView className="region-picker-list" scrollY showScrollbar={false}>
{listItems.map((item) => (
<View
key={item}
className={`region-picker-option${selectedValue === item ? ' selected' : ''}${
item === REGION_ALL ? ' region-picker-option--all' : ''
}`}
onClick={() => onSelectItem(item)}
>
<Text>{item}</Text>
</View>
))}
</ScrollView>
</View>
</View>
);
}
@@ -1,56 +0,0 @@
.share-guide {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
align-items: flex-end;
padding: 12px 16px;
box-sizing: border-box;
}
.share-guide__arrow {
width: 0;
height: 0;
margin-right: 18px;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 14px solid #fff;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.12));
}
.share-guide__card {
margin-top: 0;
margin-right: 8px;
max-width: 260px;
padding: 16px 18px;
border-radius: 12px;
background: #fff;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
text-align: left;
}
.share-guide__title {
display: block;
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 8px;
}
.share-guide__desc {
display: block;
font-size: 13px;
line-height: 1.5;
color: #666;
}
.share-guide__ok {
display: block;
margin-top: 14px;
text-align: right;
font-size: 14px;
font-weight: 600;
color: #a61d24;
}
@@ -1,50 +0,0 @@
import { useState } from 'react';
import { Button, Text, View } from '@tarojs/components';
import { handleShareButtonClick, type PageSharePayload } from '../lib/wechat-share';
import './ShareGuide.css';
type ShareNavButtonProps = {
payload?: PageSharePayload;
};
/**
* 顶栏分享:
* - 小程序:open-type=share 弹出微信分享面板
* - H5 微信:JSSDK share / 右上角引导蒙层
*/
export default function ShareNavButton({ payload }: ShareNavButtonProps) {
const [guideVisible, setGuideVisible] = useState(false);
if (process.env.TARO_ENV === 'weapp') {
return (
<Button className="page-nav-bar__btn page-nav-bar__share-btn" openType="share" hoverClass="none">
<Text className="page-nav-bar__icon page-nav-bar__icon--share"></Text>
</Button>
);
}
return (
<>
<View
className="page-nav-bar__btn"
onClick={() => {
void handleShareButtonClick(payload).then((res) => {
if (res.showGuide) setGuideVisible(true);
});
}}
>
<Text className="page-nav-bar__icon page-nav-bar__icon--share"></Text>
</View>
{guideVisible ? (
<View className="share-guide" onClick={() => setGuideVisible(false)}>
<View className="share-guide__arrow" />
<View className="share-guide__card">
<Text className="share-guide__title"></Text>
<Text className="share-guide__desc"> ··· </Text>
<Text className="share-guide__ok"></Text>
</View>
</View>
) : null}
</>
);
}
@@ -1,215 +0,0 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
type StoreRedeemMarqueeProps = {
lines: string[];
};
const FLY_SPEED = 56;
const MIN_FLY_MS = 2400;
const PAUSE_MIN_MS = 1000;
const PAUSE_MAX_MS = 5000;
const TICK_MS = 16;
/** 全文滚出视口后,再向左多走 10px */
const EXTRA_AFTER_EXIT_PX = 10;
function estimateTextWidth(text: string): number {
let w = 0;
for (const ch of text) {
w += /[^\x00-\xff]/.test(ch) ? 12 : 7;
}
return Math.max(Math.ceil(w), 80);
}
function randomPauseMs() {
return PAUSE_MIN_MS + Math.floor(Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS + 1));
}
/** 容器宽兜底(不依赖 DOM 测量,小程序首帧即可用) */
function getBoxWidthFallback(): number {
try {
const sys = Taro.getSystemInfoSync();
const screenW = Number(sys.windowWidth || sys.screenWidth || 375);
// 与 section 同宽:左右 var(--space-page)
return Math.max(220, Math.floor(screenW - 32));
} catch {
return 300;
}
}
function measureBoxWidth(selector: string, fallback: number): Promise<number> {
return new Promise((resolve) => {
Taro.nextTick(() => {
try {
const page = Taro.getCurrentInstance().page;
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
query
.select(selector)
.boundingClientRect()
.exec((res) => {
const w = Number(res?.[0]?.width || 0);
resolve(w > 8 ? Math.ceil(w) : fallback);
});
} catch {
resolve(fallback);
}
});
});
}
function measureTextWidth(selector: string, text: string): Promise<number> {
const fallback = estimateTextWidth(text);
return new Promise((resolve) => {
Taro.nextTick(() => {
try {
const page = Taro.getCurrentInstance().page;
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
query
.select(selector)
.boundingClientRect()
.exec((res) => {
const w = Number(res?.[0]?.width || 0);
if (w > 8 && w < fallback * 3) resolve(Math.ceil(w));
else resolve(fallback);
});
} catch {
resolve(fallback);
}
});
});
}
/**
* 核销走马灯:单条从右向左位移飞出,间隔 1~5 秒随机再播下一条。
*
* 小程序注意:
* - 不用 useReady(子组件内不触发 → opacity 永远 0)
* - 不用 Text + transform(支持差),改用 View + left
* - 字宽用估算,避免屏外元素测宽失败
*/
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
const items = useMemo(
() =>
lines
.map((s) => String(s || '').trim())
.filter(Boolean),
[lines],
);
const rootIdRef = useRef(`smr${Math.random().toString(36).slice(2, 10)}`);
const textIdRef = useRef(`smt${Math.random().toString(36).slice(2, 10)}`);
const indexRef = useRef(0);
const boxWidthRef = useRef(getBoxWidthFallback());
const itemsKey = items.join('\n');
const [displayIndex, setDisplayIndex] = useState(0);
const [leftPx, setLeftPx] = useState(() => boxWidthRef.current);
useEffect(() => {
if (!items.length) return;
let cancelled = false;
const waiters = new Set<ReturnType<typeof setTimeout>>();
let tickTimer: ReturnType<typeof setInterval> | undefined;
const sleep = (ms: number) =>
new Promise<void>((resolve) => {
const id = setTimeout(() => {
waiters.delete(id);
resolve();
}, ms);
waiters.add(id);
});
const clearTick = () => {
if (tickTimer) {
clearInterval(tickTimer);
tickTimer = undefined;
}
};
const fly = (from: number, to: number, durationMs: number) =>
new Promise<void>((resolve) => {
const began = Date.now();
setLeftPx(from);
clearTick();
tickTimer = setInterval(() => {
if (cancelled) {
clearTick();
resolve();
return;
}
const t = Math.min(1, (Date.now() - began) / durationMs);
setLeftPx(from + (to - from) * t);
if (t >= 1) {
clearTick();
resolve();
}
}, TICK_MS);
});
const loop = async () => {
indexRef.current = 0;
setDisplayIndex(0);
const measured = await measureBoxWidth(`#${rootIdRef.current}`, boxWidthRef.current);
boxWidthRef.current = measured;
if (cancelled) return;
while (!cancelled && items.length) {
const idx = indexRef.current % items.length;
const text = items[idx];
const box = boxWidthRef.current;
setDisplayIndex(idx);
const from = box;
setLeftPx(from);
await sleep(48);
if (cancelled) break;
const textW = await measureTextWidth(`#${textIdRef.current}`, text);
// 全文 left 边缘移出容器左边界后再走 10px
const to = -(textW + EXTRA_AFTER_EXIT_PX);
const distance = from - to;
const durationMs = Math.max(MIN_FLY_MS, Math.round((distance / FLY_SPEED) * 1000));
await sleep(32);
if (cancelled) break;
await fly(from, to, durationMs);
if (cancelled) break;
await sleep(randomPauseMs());
if (cancelled) break;
indexRef.current = (idx + 1) % items.length;
}
};
void loop();
return () => {
cancelled = true;
clearTick();
waiters.forEach(clearTimeout);
waiters.clear();
};
}, [itemsKey, items]);
if (!items.length) return null;
const current = items[displayIndex] || items[0];
const innerStyle: CSSProperties = { left: `${leftPx}px` };
return (
<View id={rootIdRef.current} className="store-detail-marquee">
<View className="store-detail-marquee-inner" style={innerStyle}>
<Text id={textIdRef.current} className="store-detail-marquee-text">
{current}
</Text>
</View>
</View>
);
}
@@ -1,45 +0,0 @@
import type { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { subPageNavBarStyle, subPageNavContentStyle, useNavBarMetrics } from '../lib/nav-bar';
type SubPageHeaderProps = {
title: string;
onBack?: () => void;
right?: ReactNode;
};
const isH5 = process.env.TARO_ENV === 'h5';
/** 子页面固定实底顶栏(确认订单 / 地址 / 支付 / 订单列表等) */
export default function SubPageHeader({ title, onBack, right }: SubPageHeaderProps) {
const metrics = useNavBarMetrics();
function handleBack() {
if (onBack) {
onBack();
return;
}
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack();
return;
}
Taro.switchTab({ url: '/pages/home/index' });
}
return (
<View className="sub-page-header" style={subPageNavBarStyle(metrics)} aria-label={title}>
{!isH5 ? <Text className="sub-page-header__title">{title}</Text> : null}
<View
className="sub-page-header__content"
style={subPageNavContentStyle(metrics)}
>
<View className="sub-page-header__back" onClick={handleBack}>
<Text className="sub-page-header__back-icon"></Text>
</View>
{right ? <View className="sub-page-header__right">{right}</View> : null}
</View>
</View>
);
}
@@ -1,31 +0,0 @@
import type { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import { navBarStyle, tabNavContentStyle, useNavBarMetrics } from '../lib/nav-bar';
type TabMainHeaderProps = {
title: string;
extra?: ReactNode;
};
/**
* Tab 页顶栏:仅在有右侧扩展内容时渲染。
* 小程序 / H5 标题走系统导航栏,避免自定义顶栏造成顶部留白。
*/
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
const metrics = useNavBarMetrics();
if (!extra) {
return null;
}
return (
<View className="tab-main-header" style={navBarStyle(metrics)} aria-label={title}>
{process.env.TARO_ENV !== 'h5' ? (
<Text className="tab-main-header__title">{title}</Text>
) : null}
<View className="tab-main-header__content" style={tabNavContentStyle(metrics)}>
<View className="tab-main-header__extra">{extra}</View>
</View>
</View>
);
}
@@ -1,44 +0,0 @@
.u-tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
justify-content: space-around;
align-items: center;
padding: 8px 12px calc(8px + env(safe-area-inset-bottom, 0px));
background: var(--color-card, #fff);
border-top: 1px solid rgba(226, 190, 188, 0.3);
box-shadow: var(--shadow-tabbar, 0 -4px 20px rgba(0, 0, 0, 0.06));
box-sizing: border-box;
}
.u-tabbar__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 4px;
border-radius: 8px;
color: var(--color-subtle-gray, #999);
}
.u-tabbar__item--active {
color: var(--color-heritage-red, #a61d24);
}
.u-tabbar__icon {
width: 24px;
height: 24px;
display: block;
}
.u-tabbar__label {
font-size: 11px;
font-weight: 500;
line-height: 1.2;
letter-spacing: 0.02em;
}
@@ -1,86 +0,0 @@
import { View, Text, Image } from '@tarojs/components';
import Taro from '@tarojs/taro';
import './UserTabBar.css';
import iconHome from '../assets/tabbar/home.png';
import iconHomeActive from '../assets/tabbar/home-active.png';
import iconStore from '../assets/tabbar/store.png';
import iconStoreActive from '../assets/tabbar/store-active.png';
import iconBenefit from '../assets/tabbar/benefit.png';
import iconBenefitActive from '../assets/tabbar/benefit-active.png';
import iconMine from '../assets/tabbar/mine.png';
import iconMineActive from '../assets/tabbar/mine-active.png';
export const USER_TABS = [
{
pagePath: '/pages/home/index',
text: '首页',
icon: iconHome,
iconActive: iconHomeActive,
},
{
pagePath: '/pages/stores/index',
text: '门店',
icon: iconStore,
iconActive: iconStoreActive,
},
{
pagePath: '/pages/benefit/index',
text: '好客权益',
icon: iconBenefit,
iconActive: iconBenefitActive,
},
{
pagePath: '/pages/mine/index',
text: '我的',
icon: iconMine,
iconActive: iconMineActive,
},
] as const;
type UserTabBarProps = {
selected: number;
};
/** C 端底栏:本地 PNG 图标(weapp 无法加载 Material 字体) */
export default function UserTabBar({ selected }: UserTabBarProps) {
return (
<View className="u-tabbar">
{USER_TABS.map((tab, index) => {
const active = selected === index;
return (
<View
key={tab.pagePath}
className={`u-tabbar__item${active ? ' u-tabbar__item--active' : ''}`}
onClick={() => {
if (!active) Taro.switchTab({ url: tab.pagePath });
}}
>
<Image
className="u-tabbar__icon"
src={active ? tab.iconActive : tab.icon}
mode="aspectFit"
/>
<Text className="u-tabbar__label">{tab.text}</Text>
</View>
);
})}
</View>
);
}
/** weapp custom-tab-bar 选中态同步;H5 无 getTabBar 时忽略 */
export function syncTabBarSelected(index: number) {
try {
const page = Taro.getCurrentInstance().page as
| { getTabBar?: () => { setSelected?: (i: number) => void } }
| undefined;
page?.getTabBar?.()?.setSelected?.(index);
} catch {
/* ignore */
}
}
export function shouldRenderPageTabBar(): boolean {
return process.env.TARO_ENV === 'h5';
}
@@ -1,28 +0,0 @@
import { View, Text } from '@tarojs/components';
type WechatLoginButtonProps = {
loading?: boolean;
disabled?: boolean;
/** 默认「授权登录」,避免使用「微信」字样与官方风格图标 */
label?: string;
onClick: () => void;
};
/** 授权登录按钮(无微信品牌元素,满足小程序审核) */
export default function WechatLoginButton({
loading = false,
disabled = false,
label = '授权登录',
onClick,
}: WechatLoginButtonProps) {
const inactive = loading || disabled;
return (
<View
className={`login-wechat-btn${inactive ? ' login-wechat-btn--disabled' : ''}`}
onClick={inactive ? undefined : onClick}
>
<Text className="login-wechat-btn__text">{loading ? '授权中...' : label}</Text>
</View>
);
}
@@ -1,143 +0,0 @@
import Taro from '@tarojs/taro';
import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
import { useEffect, useRef } from 'react';
import { finishLoginNavigate, forceReloadAfterAccountMerge, goLogin } from '../lib/auth-nav';
import { toast } from '../lib/api';
import { capturePromoSceneAndTouchScan } from '../lib/promo';
import { saveWechatLoginResult } from '../lib/pay-wechat';
import { applyWechatShare } from '../lib/wechat-share';
import { handleWechatAuthCallback } from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
function currentPagePathWithQuery(): string {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as
| { route?: string; options?: Record<string, string | undefined> }
| undefined;
if (!cur?.route) {
if (typeof window !== 'undefined') {
return `${window.location.pathname}${window.location.search}`;
}
return '';
}
const path = cur.route.startsWith('/') ? cur.route : `/${cur.route}`;
const opts = cur.options ?? {};
const qs = Object.entries(opts)
.filter(([k, v]) => v != null && v !== '' && k !== 'code' && k !== 'state')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&');
return qs ? `${path}?${qs}` : path;
}
/**
* H5 App 根节点:iOS 签名 URL + 默认分享 + OAuth code 回调。
* 小程序:冷启动时捕获推广码 scene 并回传扫码埋点。
*/
export default function WechatShareBootstrap() {
const handlingCode = useRef(false);
useEffect(() => {
void capturePromoSceneAndTouchScan();
}, []);
useEffect(() => {
if (process.env.TARO_ENV !== 'h5') return;
if (typeof window === 'undefined') return;
captureIosJssdkEntryUrl();
function refreshShare() {
void applyWechatShare().catch(() => {});
}
function tryHandleOAuth() {
if (!isWechatEnv()) return;
const params = new URLSearchParams(window.location.search);
if (!params.get('code')) return;
if (handlingCode.current) return;
handlingCode.current = true;
const returnFromLogin = (() => {
const path = currentPagePathWithQuery();
if (path.includes('/pages/login/')) {
try {
return decodeURIComponent(params.get('return') || '') || undefined;
} catch {
return undefined;
}
}
return undefined;
})();
handleWechatAuthCallback()
.then((result) => {
if (!result) return;
if (saveWechatLoginResult(result)) {
toast('微信授权成功', 'success');
const ret = returnFromLogin || params.get('return') || undefined;
if (result.accountMerged) {
forceReloadAfterAccountMerge(ret);
return;
}
if (
returnFromLogin !== undefined ||
currentPagePathWithQuery().includes('/pages/login/')
) {
finishLoginNavigate(ret);
}
return;
}
// 兼容旧接口:仅 needBindPhone 时引导可选绑定,不阻塞浏览
if (result.needBindPhone && result.wxSessionKey) {
goLogin(returnFromLogin, {
bindMode: '1',
wxSessionKey: result.wxSessionKey,
});
}
})
.catch((e) => {
toast(e instanceof Error ? e.message : '微信授权失败');
})
.finally(() => {
handlingCode.current = false;
});
}
refreshShare();
tryHandleOAuth();
const onVisible = () => {
if (document.visibilityState === 'visible') refreshShare();
};
const onLocation = () => {
refreshShare();
tryHandleOAuth();
};
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('popstate', onLocation);
const { pushState, replaceState } = window.history;
window.history.pushState = function (...args) {
const ret = pushState.apply(this, args);
window.dispatchEvent(new Event('dukang-h5-route'));
return ret;
};
window.history.replaceState = function (...args) {
const ret = replaceState.apply(this, args);
window.dispatchEvent(new Event('dukang-h5-route'));
return ret;
};
window.addEventListener('dukang-h5-route', onLocation);
return () => {
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('popstate', onLocation);
window.removeEventListener('dukang-h5-route', onLocation);
window.history.pushState = pushState;
window.history.replaceState = replaceState;
};
}, []);
return null;
}
@@ -1,46 +0,0 @@
import { useEffect } from 'react';
import Taro, { useDidShow } from '@tarojs/taro';
import { captureIosJssdkEntryUrl } from '@dukang/weixin-sdk';
import { applyWechatShare, type PageSharePayload } from '../lib/wechat-share';
/**
* H5:进入页面时刷新微信分享卡片;
* 小程序:开启右上角分享菜单。
*/
export default function WechatShareReady({ payload }: { payload?: PageSharePayload }) {
useEffect(() => {
if (process.env.TARO_ENV === 'h5') {
captureIosJssdkEntryUrl();
}
}, []);
useDidShow(() => {
if (process.env.TARO_ENV === 'weapp') {
void Taro.showShareMenu({
withShareTicket: true,
showShareItems: ['shareAppMessage', 'shareTimeline'],
}).catch(() => {
void Taro.showShareMenu({ withShareTicket: true }).catch(() => {});
});
return;
}
void applyWechatShare({
title: payload?.title,
desc: payload?.desc,
imgUrl: payload?.imgUrl,
link: payload?.link,
}).catch(() => {});
});
useEffect(() => {
if (process.env.TARO_ENV !== 'h5') return;
void applyWechatShare({
title: payload?.title,
desc: payload?.desc,
imgUrl: payload?.imgUrl,
link: payload?.link,
}).catch(() => {});
}, [payload?.title, payload?.desc, payload?.imgUrl, payload?.link]);
return null;
}
-16
View File
@@ -1,16 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<title>杜康好客</title>
</head>
<body>
<div id="app"></div>
<script><%= htmlWebpackPlugin.options.script %></script>
</body>
</html>
-37
View File
@@ -1,37 +0,0 @@
import Taro from '@tarojs/taro';
import { createUserTracker, getSessionId } from '@dukang/client-logging';
import { API_BASE, CLIENT_APP, getToken } from './api';
function currentPagePath(): string | undefined {
try {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as { route?: string; $taroPath?: string } | undefined;
return cur?.$taroPath || cur?.route || undefined;
} catch {
return undefined;
}
}
const tracker = createUserTracker({
apiBase: API_BASE,
clientApp: CLIENT_APP,
getToken,
getPagePath: currentPagePath,
postJson: ({ url, headers, body }) => {
void Taro.request({ url, method: 'POST', header: headers, data: body }).catch(() => {});
},
});
export { getSessionId };
export function track(eventName: string, params?: Record<string, unknown>) {
tracker.track(eventName, params);
}
export function trackPageView(eventName: string, params?: Record<string, unknown>) {
tracker.trackPageView(eventName, params);
}
export function initUserAnalytics() {
tracker.trackSessionStart();
}
-139
View File
@@ -1,139 +0,0 @@
import Taro from '@tarojs/taro';
import { ClientApp } from '@dukang/shared-types';
import { forceReloadAfterAccountMerge } from './auth-nav';
import { resetStoresSessionBootstrap } from './stores-session';
import { resetHomeCatalogBootstrap } from './home-catalog-session';
function resolveApiBase(): string {
const origin =
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
? TARO_APP_API_ORIGIN
: process.env.TARO_ENV === 'h5'
? 'http://localhost:3000'
: '';
if (origin) {
return `${origin.replace(/\/$/, '')}/api/v1`;
}
return '/api/v1';
}
export const API_BASE = resolveApiBase();
const TOKEN_KEY = 'user_access_token';
const REFRESH_KEY = 'user_refresh_token';
/** H5 产物走公众号体系(USER_H5);小程序原生走 USER_MINI。勿混用,否则 JSAPI 会出现 appid 与 openid 不匹配 */
export const CLIENT_APP =
process.env.TARO_ENV === 'h5' ? ClientApp.USER_H5 : ClientApp.USER_MINI;
export function getToken(): string {
try {
return Taro.getStorageSync(TOKEN_KEY) || '';
} catch {
return '';
}
}
export function getRefreshToken(): string {
try {
return Taro.getStorageSync(REFRESH_KEY) || '';
} catch {
return '';
}
}
export function saveAuth(data: { accessToken: string; refreshToken?: string }) {
Taro.setStorageSync(TOKEN_KEY, data.accessToken);
if (data.refreshToken) {
Taro.setStorageSync(REFRESH_KEY, data.refreshToken);
}
}
export function clearAuth() {
Taro.removeStorageSync(TOKEN_KEY);
Taro.removeStorageSync(REFRESH_KEY);
}
export function isLoggedIn(): boolean {
return !!getToken();
}
export function logout() {
clearAuth();
// 主动退出才重置门店/首页「当次登录」会话;401 清 token 不要打断筛选
resetStoresSessionBootstrap();
resetHomeCatalogBootstrap();
Taro.reLaunch({ url: '/pages/home/index' });
}
type ReqOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
data?: Record<string, unknown> | unknown;
auth?: boolean;
};
function parseBody(data: unknown): { code?: number; message?: string } {
if (data && typeof data === 'object') {
return data as { code?: number; message?: string };
}
return {};
}
/** 统一请求:注入 CLIENT_APP + Bearer,解包 { code, message, data } */
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
const header: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': CLIENT_APP,
};
const token = getToken();
if (token) header.Authorization = `Bearer ${token}`;
const res = await Taro.request({
url: `${API_BASE}${path}`,
method: options.method ?? 'GET',
data: options.data as Record<string, unknown>,
header,
});
const status = res.statusCode;
const body = parseBody(res.data);
if (status === 401 || body?.code === 401) {
// 仅清掉「发起本请求时」仍在使用的 token,避免登录页旧 /auth/me 竞态清掉刚写入的新 token
const stillCurrent = !!token && getToken() === token;
if (stillCurrent) {
clearAuth();
const mergedMsg = body?.message || '';
if (/账号已合并/.test(mergedMsg)) {
// 合并后旧 JWT 失效:强制刷新,不引导「重新登录」
forceReloadAfterAccountMerge();
}
}
throw new Error(body?.message || '登录已过期,请重新登录');
}
if (status === 404 && body.code === undefined) {
throw new Error('接口不可达,请确认 API 服务已启动');
}
if (status >= 400 || body.code !== 0) {
throw new Error(body?.message || `请求失败(${status})`);
}
return (res.data as { data: T }).data;
}
export function toast(title: string, icon: 'success' | 'error' | 'none' = 'none') {
Taro.showToast({ title, icon, duration: 1800 });
}
export type SessionPayload = {
accessToken: string;
refreshToken?: string;
phoneVerified?: boolean;
accountMerged?: boolean;
};
export type UserProfile = {
id: string;
phone?: string | null;
nickname?: string | null;
avatarUrl?: string | null;
phoneVerified?: boolean;
hasWechat?: boolean;
};
-130
View File
@@ -1,130 +0,0 @@
import Taro from '@tarojs/taro';
const TAB_PAGES = new Set([
'/pages/home/index',
'/pages/stores/index',
'/pages/benefit/index',
'/pages/mine/index',
]);
let loginNavigationPending = false;
function isLoginPageActive(): boolean {
const pages = Taro.getCurrentPages();
const current = pages[pages.length - 1] as { route?: string } | undefined;
return !!current?.route?.includes('pages/login/');
}
function currentPagePath(): string {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as
| { route?: string; options?: Record<string, string | undefined> }
| undefined;
if (!cur?.route) return '';
const path = cur.route.startsWith('/') ? cur.route : `/${cur.route}`;
if (path.includes('/pages/login/')) return '';
const opts = cur.options ?? {};
const qs = Object.entries(opts)
.filter(([, v]) => v != null && v !== '')
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&');
return qs ? `${path}?${qs}` : path;
}
/** 跳转登录页;默认带回当前页作为 return */
export function goLogin(returnPath?: string, extras?: Record<string, string>) {
if (loginNavigationPending || isLoginPageActive()) return;
const returnTo = returnPath ?? currentPagePath();
const parts: string[] = [];
if (returnTo) parts.push(`return=${encodeURIComponent(returnTo)}`);
if (extras) {
for (const [key, value] of Object.entries(extras)) {
if (value) parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
const url = parts.length ? `/pages/login/index?${parts.join('&')}` : '/pages/login/index';
loginNavigationPending = true;
void Taro.navigateTo({ url })
.catch(() => Taro.redirectTo({ url }))
.finally(() => {
// 等路由栈稳定后再释放,拦截同一轮请求触发的重复登录跳转。
setTimeout(() => {
loginNavigationPending = false;
}, 500);
});
}
/** 登录成功后回到 return 页,或回退 / 首页 */
export function finishLoginNavigate(returnTo?: string) {
const raw = (returnTo || '').trim();
let target = '';
try {
target = raw ? decodeURIComponent(raw) : '';
} catch {
target = raw;
}
// 防止 return 仍指向登录页造成死循环
const pathOnly = target.split('?')[0];
if (!pathOnly || pathOnly.includes('/pages/login')) {
Taro.reLaunch({ url: '/pages/home/index' });
return;
}
if (TAB_PAGES.has(pathOnly)) {
Taro.switchTab({ url: pathOnly }).catch(() => {
Taro.reLaunch({ url: pathOnly });
});
return;
}
if (pathOnly.startsWith('/pages/')) {
Taro.redirectTo({ url: target }).catch(() => {
Taro.reLaunch({ url: pathOnly });
});
return;
}
Taro.reLaunch({ url: '/pages/home/index' });
}
/**
* 账号合并后强制整页刷新,避免旧会话栈 / 旧用户缓存继续提示绑定手机号。
* H5:按当前路径推算出落地 URL 后 location.replace;小程序:reLaunch。
*/
export function forceReloadAfterAccountMerge(returnTo?: string) {
const raw = (returnTo || '').trim();
let target = '';
try {
target = raw ? decodeURIComponent(raw) : '';
} catch {
target = raw;
}
const pathOnly = target.split('?')[0];
const safePath =
pathOnly && pathOnly.startsWith('/pages/') && !pathOnly.includes('/pages/login')
? target
: '/pages/home/index';
const launchPath = safePath.split('?')[0];
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
const { origin, pathname, search } = window.location;
const marker = '/pages/';
const idx = pathname.indexOf(marker);
let href: string;
if (idx >= 0) {
href = `${origin}${pathname.slice(0, idx)}${safePath}`;
} else if (window.location.hash.includes('/pages/')) {
href = `${origin}${pathname}${search}#${safePath}`;
} else {
const base = pathname.replace(/\/$/, '') || '';
href = `${origin}${base}${safePath.startsWith('/') ? safePath : `/${safePath}`}`;
}
window.location.replace(href);
return;
}
if (TAB_PAGES.has(launchPath)) {
Taro.reLaunch({ url: launchPath });
return;
}
Taro.reLaunch({ url: safePath.startsWith('/') ? safePath : `/${safePath}` });
}
-59
View File
@@ -1,59 +0,0 @@
export type CheckoutContext = {
productId?: string;
qty?: string;
addressId?: string;
cross?: boolean;
select?: boolean;
};
export function buildQuery(ctx: CheckoutContext): string {
const parts: string[] = [];
if (ctx.productId) parts.push(`productId=${encodeURIComponent(ctx.productId)}`);
if (ctx.qty) parts.push(`qty=${encodeURIComponent(ctx.qty)}`);
if (ctx.addressId) parts.push(`addressId=${encodeURIComponent(ctx.addressId)}`);
if (ctx.cross) parts.push('cross=1');
if (ctx.select) parts.push('select=1');
return parts.join('&');
}
export function buildOrderConfirmUrl(ctx: CheckoutContext): string {
const qs = buildQuery(ctx);
return qs ? `/pages/order-confirm/index?${qs}` : '/pages/order-confirm/index';
}
export function buildAddressListUrl(ctx: CheckoutContext): string {
const qs = buildQuery({ ...ctx, select: true });
return qs ? `/pages/addresses/index?${qs}` : '/pages/addresses/index';
}
export function buildAddressEditUrl(id: string | undefined, ctx: CheckoutContext): string {
const base = id ? `/pages/address-edit/index?id=${encodeURIComponent(id)}` : '/pages/address-edit/index';
const extra = buildQuery(ctx);
if (!extra) return base;
return `${base}${base.includes('?') ? '&' : '?'}${extra}`;
}
export function buildPayUrl(params: {
orderId: string;
productId?: string;
qty?: string;
addressId?: string;
cross?: boolean;
}): string {
const parts = [`orderId=${encodeURIComponent(params.orderId)}`];
if (params.productId) parts.push(`productId=${encodeURIComponent(params.productId)}`);
if (params.qty) parts.push(`qty=${encodeURIComponent(params.qty)}`);
if (params.addressId) parts.push(`addressId=${encodeURIComponent(params.addressId)}`);
if (params.cross) parts.push('cross=1');
return `/pages/pay/index?${parts.join('&')}`;
}
export function readCheckoutContext(params: Record<string, string | undefined>): CheckoutContext {
return {
productId: params.productId,
qty: params.qty,
addressId: params.addressId,
cross: params.cross === '1',
select: params.select === '1',
};
}
-125
View File
@@ -1,125 +0,0 @@
import Taro from '@tarojs/taro';
import { CLIENT_APP, getToken, API_BASE } from './api';
export type ClientErrorLevel = 'fatal' | 'error' | 'warn';
export type ClientErrorCategory =
| 'js_error'
| 'unhandled_rejection'
| 'api_error'
| 'network'
| 'render'
| 'bridge'
| 'other';
export type ClientErrorPayload = {
level: ClientErrorLevel;
category: ClientErrorCategory;
message: string;
stack?: string;
pagePath?: string;
extra?: Record<string, unknown>;
};
function currentPagePath(): string | undefined {
try {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as { route?: string; $taroPath?: string } | undefined;
return cur?.$taroPath || cur?.route || undefined;
} catch {
return undefined;
}
}
/** 上报客户端错误(失败静默,避免递归) */
export function reportClientError(payload: ClientErrorPayload): void {
const body = {
level: payload.level,
category: payload.category,
message: String(payload.message || 'unknown').slice(0, 1000),
stack: payload.stack ? String(payload.stack).slice(0, 4000) : undefined,
pagePath: payload.pagePath || currentPagePath(),
clientApp: CLIENT_APP,
extra: payload.extra,
};
const header: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': CLIENT_APP,
};
const token = getToken();
if (token) header.Authorization = `Bearer ${token}`;
void Taro.request({
url: `${API_BASE}/common/client-errors`,
method: 'POST',
data: body,
header,
}).catch(() => {});
}
let installed = false;
/** 安装小程序/H5 全局未捕获错误钩子(幂等) */
export function installClientErrorReporting(): void {
if (installed) return;
installed = true;
try {
Taro.onError?.((msg) => {
reportClientError({
level: 'fatal',
category: 'js_error',
message: typeof msg === 'string' ? msg : String(msg),
});
});
} catch {
/* ignore */
}
try {
Taro.onUnhandledRejection?.((res) => {
const reason = (res as { reason?: unknown })?.reason;
const message =
reason instanceof Error
? reason.message
: typeof reason === 'string'
? reason
: JSON.stringify(reason);
const stack = reason instanceof Error ? reason.stack : undefined;
reportClientError({
level: 'error',
category: 'unhandled_rejection',
message: message || 'UnhandledRejection',
stack,
});
});
} catch {
/* ignore */
}
if (typeof window !== 'undefined') {
window.addEventListener('error', (ev) => {
reportClientError({
level: 'fatal',
category: 'js_error',
message: ev.message || 'window.error',
stack: ev.error instanceof Error ? ev.error.stack : undefined,
extra: { filename: ev.filename, lineno: ev.lineno, colno: ev.colno },
});
});
window.addEventListener('unhandledrejection', (ev) => {
const reason = ev.reason;
reportClientError({
level: 'error',
category: 'unhandled_rejection',
message:
reason instanceof Error
? reason.message
: typeof reason === 'string'
? reason
: 'unhandledrejection',
stack: reason instanceof Error ? reason.stack : undefined,
});
});
}
}
-36
View File
@@ -1,36 +0,0 @@
import { weixinSdk } from './weixin';
export type ClientGpsLocation = {
province?: string;
city?: string;
district?: string;
latitude: number;
longitude: number;
address?: string;
};
/** 尝试获取客户端 GPS(微信 JSSDK / 浏览器 Geolocation),失败返回 null 不阻塞下单 */
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
if (process.env.TARO_ENV === 'weapp') {
try {
const Taro = (await import('@tarojs/taro')).default;
const loc = await new Promise<{ latitude: number; longitude: number }>((resolve, reject) => {
Taro.getLocation({
type: 'gcj02',
success: resolve,
fail: reject,
});
});
return { latitude: loc.latitude, longitude: loc.longitude };
} catch {
return null;
}
}
const loc = await weixinSdk.getLocation();
if (!loc) return null;
return {
latitude: loc.latitude,
longitude: loc.longitude,
};
}
-16
View File
@@ -1,16 +0,0 @@
/** 格式化为 Asia/Shanghai2026-08-03 15:14:30(不依赖 Intl,兼容微信小程序) */
export function formatShanghaiDateTime(input?: string | Date | null): string {
if (input == null || input === '') return '—';
if (typeof input === 'string') {
const s = input.trim();
if (/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}/.test(s)) {
return s.slice(0, 19).replace('T', ' ');
}
}
const d = input instanceof Date ? input : new Date(input);
if (Number.isNaN(d.getTime())) return '—';
// 用 UTC 读数 + 固定东八区偏移,避免依赖 Intl / 设备时区 API 差异
const sh = new Date(d.getTime() + 8 * 60 * 60 * 1000);
const p = (n: number) => String(n).padStart(2, '0');
return `${sh.getUTCFullYear()}-${p(sh.getUTCMonth() + 1)}-${p(sh.getUTCDate())} ${p(sh.getUTCHours())}:${p(sh.getUTCMinutes())}:${p(sh.getUTCSeconds())}`;
}
-23
View File
@@ -1,23 +0,0 @@
/** 球面距离(米) */
export function haversineMeters(
lat1: number,
lng1: number,
lat2: number,
lng2: number,
): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const R = 6371000;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
}
export function formatDistanceMeters(meters: number | null | undefined): string {
if (meters == null || !Number.isFinite(meters) || meters < 0) return '—';
if (meters < 1000) return `${Math.max(1, Math.round(meters))}m`;
const km = meters / 1000;
return `${km < 10 ? km.toFixed(1) : Math.round(km)}km`;
}
@@ -1,75 +0,0 @@
/**
* 首页商品列表「当次登录」会话 —— 切 tab 不重复拉商品;
* 城市 / 登录态变化或下拉刷新时再请求。logout 时 clear。
*/
import Taro from '@tarojs/taro';
export type HomeCatalogCache = {
cityCode: string;
authKey: string;
products: unknown[];
};
type HomeSession = {
bootstrapped: boolean;
cache: HomeCatalogCache | null;
};
const STORAGE_KEY = 'dukang_home_catalog_session_v1';
let memory: HomeSession | null = null;
function emptySession(): HomeSession {
return { bootstrapped: false, cache: null };
}
function readSession(): HomeSession {
if (memory) return memory;
try {
const raw = Taro.getStorageSync(STORAGE_KEY);
if (!raw) {
memory = emptySession();
return memory;
}
const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Partial<HomeSession>;
memory = {
bootstrapped: !!parsed.bootstrapped,
cache: (parsed.cache as HomeCatalogCache | null) ?? null,
};
return memory;
} catch {
memory = emptySession();
return memory;
}
}
function writeSession(next: HomeSession) {
memory = next;
try {
Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
export function isHomeCatalogBootstrapped(): boolean {
return readSession().bootstrapped;
}
export function getHomeCatalogCache(): HomeCatalogCache | null {
return readSession().cache;
}
export function setHomeCatalogCache(cache: HomeCatalogCache): void {
writeSession({ bootstrapped: true, cache });
}
export function resetHomeCatalogBootstrap(): void {
memory = emptySession();
try {
Taro.removeStorageSync(STORAGE_KEY);
} catch {
/* ignore */
}
}
-72
View File
@@ -1,72 +0,0 @@
/**
* 微信小程序基础库无 Intl。业务代码已避免依赖,此处仅作兜底,
* 防止旧包 / 依赖偶发 `new Intl.DateTimeFormat` 直接白屏。
*/
function pad(n: number) {
return String(n).padStart(2, '0');
}
function shanghaiParts(date: Date) {
const sh = new Date(date.getTime() + 8 * 60 * 60 * 1000);
return {
year: String(sh.getUTCFullYear()),
month: pad(sh.getUTCMonth() + 1),
day: pad(sh.getUTCDate()),
hour: pad(sh.getUTCHours()),
minute: pad(sh.getUTCMinutes()),
second: pad(sh.getUTCSeconds()),
};
}
function installIntlStub() {
const root = (typeof globalThis !== 'undefined'
? globalThis
: typeof global !== 'undefined'
? global
: typeof wx !== 'undefined'
? wx
: {}) as typeof globalThis & { Intl?: typeof Intl };
if (typeof root.Intl !== 'undefined' && typeof root.Intl.DateTimeFormat === 'function') {
return;
}
class MiniDateTimeFormat {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
constructor(_locales?: string | string[], _options?: Record<string, unknown>) {}
format(date?: Date | number) {
const d = date instanceof Date ? date : new Date(date ?? Date.now());
if (Number.isNaN(d.getTime())) return '';
const p = shanghaiParts(d);
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`;
}
formatToParts(date?: Date | number) {
const d = date instanceof Date ? date : new Date(date ?? Date.now());
if (Number.isNaN(d.getTime())) return [];
const p = shanghaiParts(d);
return [
{ type: 'year', value: p.year },
{ type: 'literal', value: '-' },
{ type: 'month', value: p.month },
{ type: 'literal', value: '-' },
{ type: 'day', value: p.day },
{ type: 'literal', value: ' ' },
{ type: 'hour', value: p.hour },
{ type: 'literal', value: ':' },
{ type: 'minute', value: p.minute },
{ type: 'literal', value: ':' },
{ type: 'second', value: p.second },
];
}
}
root.Intl = {
DateTimeFormat: MiniDateTimeFormat,
} as unknown as typeof Intl;
}
installIntlStub();
export {};
@@ -1,157 +0,0 @@
import Taro from '@tarojs/taro';
import type { UserProfile } from './api';
export type MiniWechatProfile = {
nickname?: string;
avatarUrl?: string;
};
export type MiniWechatProfileUpdate = MiniWechatProfile & {
avatarResourceId?: string;
};
export type UploadedAvatarResource = {
resourceId: string;
url: string;
bucket: string;
ossKey: string;
};
const WX_PROFILE_CACHE_KEY = 'wx_mini_profile_cache';
export function cacheWxProfile(info: MiniWechatProfile) {
if (!info.nickname && !info.avatarUrl) return;
try {
Taro.setStorageSync(WX_PROFILE_CACHE_KEY, JSON.stringify(info));
} catch {
/* ignore */
}
}
export function getCachedWxProfile(): MiniWechatProfile | null {
try {
const raw = Taro.getStorageSync(WX_PROFILE_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(String(raw)) as MiniWechatProfile;
if (!parsed?.nickname && !parsed?.avatarUrl) return null;
return parsed;
} catch {
return null;
}
}
export function isDefaultMiniNickname(nickname?: string | null): boolean {
if (!nickname || nickname === '访客' || nickname === '微信用户' || nickname === '用户') return true;
return /^用户\d{4}$/.test(nickname);
}
/** 是否缺少可展示的微信头像/昵称(需走 chooseAvatar + nickname 填写) */
export function needsWxProfileFill(profile: UserProfile | null | undefined): boolean {
if (!profile) return true;
return !profile.avatarUrl || isDefaultMiniNickname(profile.nickname);
}
export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
const cached = getCachedWxProfile();
if (!cached && !profile.hasWechat) return profile;
const nickname =
(!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) ||
cached?.nickname ||
profile.nickname ||
'微信用户';
return {
...profile,
nickname,
avatarUrl: profile.avatarUrl || cached?.avatarUrl || null,
};
}
/** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */
export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
const { API_BASE, CLIENT_APP, getToken } = await import('./api');
const token = getToken();
if (!token) throw new Error('请先登录');
const res = await Taro.uploadFile({
url: `${API_BASE}/common/resources/upload`,
filePath: tempFilePath,
name: 'file',
formData: {
bizType: 'AVATAR',
mediaType: 'IMAGE',
},
header: {
Authorization: `Bearer ${token}`,
'X-Client-App': CLIENT_APP,
},
});
let body: {
code?: number;
message?: string;
data?: { resourceId?: string; url?: string; bucket?: string; ossKey?: string };
} = {};
try {
body = JSON.parse(String(res.data || '{}')) as typeof body;
} catch {
throw new Error('头像上传响应异常');
}
if (res.statusCode === 401 || body.code === 401) {
throw new Error(body.message || '登录已过期,请重新登录');
}
if (
res.statusCode >= 400 ||
body.code !== 0 ||
!body.data?.resourceId ||
!body.data.url ||
!body.data.bucket ||
!body.data.ossKey
) {
throw new Error(body.message || '头像上传失败');
}
return {
resourceId: body.data.resourceId,
url: body.data.url,
bucket: body.data.bucket,
ossKey: body.data.ossKey,
};
}
export async function uploadMiniWechatProfile(info: MiniWechatProfileUpdate): Promise<UserProfile | null> {
if (!info.nickname && !info.avatarUrl && !info.avatarResourceId) return null;
const { request } = await import('./api');
const updated = await request<UserProfile>('/auth/wechat/mini-profile', {
method: 'POST',
data: info,
});
cacheWxProfile({
nickname: updated?.nickname ?? info.nickname,
avatarUrl: updated?.avatarUrl ?? info.avatarUrl,
});
return updated;
}
/**
* 兼容旧调用:getUserProfile 已无法拿到真实头像昵称。
* 始终导出为函数,避免循环依赖/旧包出现 “is not a function”。
*/
export async function fetchMiniWechatUserInfo(): Promise<MiniWechatProfile> {
const cached = getCachedWxProfile();
if (cached?.nickname || cached?.avatarUrl) {
return cached;
}
// 不再弹 getUserProfile;引导走「我的」页 chooseAvatar / nickname
throw new Error('请在「我的」页点击头像完善微信头像和昵称');
}
/** 绑定后上报微信资料(优先使用已拉取的信息) */
export async function syncMiniWechatProfile(
prefetched?: MiniWechatProfile | null,
): Promise<MiniWechatProfile | null> {
if (process.env.TARO_ENV !== 'weapp') return null;
const info = prefetched?.nickname || prefetched?.avatarUrl ? prefetched : getCachedWxProfile();
if (!info?.nickname && !info?.avatarUrl) return null;
// 缓存头像 URL 可能来自历史微信资料,未经过当前 OSS 上传登记;这里只同步昵称。
if (info.nickname) await uploadMiniWechatProfile({ nickname: info.nickname });
return info;
}
-9
View File
@@ -1,9 +0,0 @@
/** 金额展示(不依赖 Intl / toLocaleString,兼容微信小程序) */
export function formatMoney(amount: number | string): string {
const n = typeof amount === 'number' ? amount : Number(amount);
if (!Number.isFinite(n)) return '0.00';
const fixed = n.toFixed(2);
const [intPart, dec] = fixed.split('.');
const withComma = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `${withComma}.${dec}`;
}
-130
View File
@@ -1,130 +0,0 @@
import { useMemo } from 'react';
import Taro from '@tarojs/taro';
export type NavBarMetrics = {
/** 状态栏高度(刘海/灵动岛上方) */
statusBarHeight: number;
/** 导航栏总高度 = 状态栏 + 内容区 */
navBarHeight: number;
/** 标题栏内容区高度(与胶囊对齐) */
navContentHeight: number;
/** 右侧留白,避免与微信胶囊按钮重叠 */
navBarPaddingRight: number;
/** 左侧留白,与右侧对称以实现标题视觉居中 */
navBarPaddingLeft: number;
};
const H5_FALLBACK: NavBarMetrics = {
statusBarHeight: 0,
navBarHeight: 56,
navContentHeight: 56,
/** 避开微信内置浏览器右上角 ··· / 设置入口(约一颗胶囊宽) */
navBarPaddingRight: 96,
navBarPaddingLeft: 16,
};
const WEAPP_FALLBACK: NavBarMetrics = {
statusBarHeight: 20,
navBarHeight: 64,
navContentHeight: 44,
navBarPaddingRight: 96,
navBarPaddingLeft: 96,
};
/** 计算小程序自定义导航栏尺寸(对齐微信胶囊按钮) */
export function getNavBarMetrics(): NavBarMetrics {
if (process.env.TARO_ENV === 'h5') {
// H5:在微信浏览器内额外避让右上角菜单;非微信保持较小右侧留白
const inWechat =
typeof navigator !== 'undefined' && /MicroMessenger/i.test(navigator.userAgent || '');
return {
...H5_FALLBACK,
navBarPaddingRight: inWechat ? 96 : 16,
};
}
try {
const win = Taro.getWindowInfo?.() ?? Taro.getSystemInfoSync();
const menu = Taro.getMenuButtonBoundingClientRect();
const statusBarHeight = win.statusBarHeight ?? WEAPP_FALLBACK.statusBarHeight;
const navContentHeight =
menu.height > 0
? (menu.top - statusBarHeight) * 2 + menu.height
: WEAPP_FALLBACK.navContentHeight;
const navBarHeight = statusBarHeight + navContentHeight;
const navBarPaddingRight =
menu.width > 0
? Math.max(win.windowWidth - menu.left + 8, 16)
: WEAPP_FALLBACK.navBarPaddingRight;
return {
statusBarHeight,
navBarHeight,
navContentHeight,
navBarPaddingRight,
navBarPaddingLeft: navBarPaddingRight,
};
} catch {
return WEAPP_FALLBACK;
}
}
export function useNavBarMetrics(): NavBarMetrics {
return useMemo(() => getNavBarMetrics(), []);
}
/** 仅注入 CSS 变量,供 PageShell / sticky 子元素使用(不加 height/padding */
export function pageShellCssVars(metrics: NavBarMetrics): Record<string, string> {
return {
'--nav-bar-height': `${metrics.navBarHeight}px`,
'--nav-content-height': `${metrics.navContentHeight}px`,
'--nav-status-bar-height': `${metrics.statusBarHeight}px`,
'--nav-padding-right': `${metrics.navBarPaddingRight}px`,
'--nav-padding-left': `${metrics.navBarPaddingLeft}px`,
};
}
/** 顶栏自身样式:statusBar padding + 总高 + CSS 变量 */
export function navBarStyle(metrics: NavBarMetrics): Record<string, string | number> {
return {
paddingTop: `${metrics.statusBarHeight}px`,
height: `${metrics.navBarHeight}px`,
...pageShellCssVars(metrics),
};
}
/** Tab 顶栏内容行:左右留白(标题单独全屏居中) */
export function tabNavContentStyle(metrics: NavBarMetrics): Record<string, string | number> {
return {
height: `${metrics.navContentHeight}px`,
paddingLeft: '20px',
paddingRight: `${metrics.navBarPaddingRight}px`,
boxSizing: 'border-box',
};
}
/** 子页/内页顶栏:标题全屏居中,内容区单独留白 */
export function subPageNavBarStyle(metrics: NavBarMetrics): Record<string, string | number> {
const pagePad = 20;
// 标题两侧取「右侧避让胶囊」宽度,保证相对屏幕视觉居中
const titlePad = Math.max(metrics.navBarPaddingRight, 72);
return {
paddingTop: `${metrics.statusBarHeight}px`,
height: `${metrics.navBarHeight}px`,
'--nav-bar-height': `${metrics.navBarHeight}px`,
'--nav-content-height': `${metrics.navContentHeight}px`,
'--nav-status-bar-height': `${metrics.statusBarHeight}px`,
'--nav-padding-left': `${pagePad}px`,
'--nav-padding-right': `${titlePad}px`,
};
}
/** 子页顶栏内容行:左侧页边距 + 右侧避让胶囊 */
export function subPageNavContentStyle(metrics: NavBarMetrics): Record<string, string | number> {
return {
height: `${metrics.navContentHeight}px`,
paddingLeft: '20px',
paddingRight: `${metrics.navBarPaddingRight}px`,
boxSizing: 'border-box',
};
}
@@ -1,69 +0,0 @@
/**
* H5Taro Vite 偶发把 @tarojs/plugin-framework-react/dist/runtime 打成两份,
* 导致 createReactApp 初始化了 B 份 reactMeta,而 Taro.useDidShow 仍绑定 A 份(R={}),
* 页面一进就报 n.useContext is not a function。
*
* 在 App 渲染前,用与 createReactApp 同一份 runtime 的 hooks 覆盖到 Taro 上。
*/
import Taro from '@tarojs/taro';
import {
useAddToFavorites,
useDidHide,
useDidShow,
useError,
useKeyboardHeight,
useLaunch,
useLoad,
useOptionMenuClick,
usePageNotFound,
usePageScroll,
usePullDownRefresh,
usePullIntercept,
useReachBottom,
useReady,
useResize,
useRouter,
useSaveExitState,
useScope,
useShareAppMessage,
useShareTimeline,
useTabItemTap,
useTitleClick,
useUnhandledRejection,
useUnload,
} from '@tarojs/plugin-framework-react/dist/runtime';
const HOOKS = {
useAddToFavorites,
useDidHide,
useDidShow,
useError,
useKeyboardHeight,
useLaunch,
useLoad,
useOptionMenuClick,
usePageNotFound,
usePageScroll,
usePullDownRefresh,
usePullIntercept,
useReachBottom,
useReady,
useResize,
useRouter,
useSaveExitState,
useScope,
useShareAppMessage,
useShareTimeline,
useTabItemTap,
useTitleClick,
useUnhandledRejection,
useUnload,
} as const;
export function patchTaroH5Hooks() {
if (process.env.TARO_ENV !== 'h5') return;
const target = Taro as unknown as Record<string, unknown>;
for (const [key, fn] of Object.entries(HOOKS)) {
target[key] = fn;
}
}
-46
View File
@@ -1,46 +0,0 @@
import { goLogin } from './auth-nav';
import { isLoggedIn } from './api';
import { ensureWechatAuthForPay } from './wechat-auth';
import { fetchClientConfig, fetchUserProfile, needsWechatAuthForPay } from './pay-wechat';
/**
* 支付前门禁:
* - 未登录 → 跳转登录页(微信授权即可,不强制手机号)
* - H5 微信内缺 openId → 尝试 OAuth(可能跳转微信授权页)
* - 小程序缺绑定 → 跳转登录页 needWechat
*/
export async function ensurePayReady(returnPath: string): Promise<boolean> {
if (!isLoggedIn()) {
goLogin(returnPath);
return false;
}
try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
if (!needsWechatAuthForPay(config, profile)) {
return true;
}
if (process.env.TARO_ENV === 'h5') {
const auth = await ensureWechatAuthForPay();
if (auth.ok) return true;
// 旧版 needBindPhone 已不再返回;缺 openId 时走登录补微信绑定
if ('needBindPhone' in auth && auth.needBindPhone) {
goLogin(returnPath, { needWechat: '1' });
return false;
}
// redirecting:正在跳转微信 OAuth
return false;
}
goLogin(returnPath, { needWechat: '1' });
return false;
} catch (e) {
// 仅会话失效时踢回登录;网络/业务错误不误清登录态
const msg = e instanceof Error ? e.message : '';
if (/登录已过期|重新登录|401/.test(msg) || !isLoggedIn()) {
goLogin(returnPath);
}
return false;
}
}
-94
View File
@@ -1,94 +0,0 @@
import type {
ClientRuntimeConfig,
WechatJsapiPrepayParams,
WechatLoginResult,
WechatPayOrderResult,
} from '@dukang/shared-types';
import { WECHAT_AUTH_REQUIRED, isWxAuthorizeEnabled } from '@dukang/shared-types';
import { invokeWechatPay } from '@dukang/weixin-sdk';
import { request, saveAuth, type UserProfile } from './api';
import { isWechatEnv, weixinSdk } from './weixin';
export function isMiniWechatEnv(): boolean {
return process.env.TARO_ENV === 'weapp';
}
export function isWechatAuthRequiredError(err: unknown): boolean {
return err instanceof Error && err.message === WECHAT_AUTH_REQUIRED;
}
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
return request<ClientRuntimeConfig>('/common/client-config');
}
export async function fetchUserProfile(): Promise<UserProfile> {
return request<UserProfile>('/auth/me');
}
/** 真实微信支付且未绑定微信时需要授权(小程序 / H5 微信内) */
export function needsWechatAuthForPay(
config: ClientRuntimeConfig,
profile: UserProfile | null,
): boolean {
if (!isWxAuthorizeEnabled(config)) return false;
return !config.mockPay && config.wechatPayEnabled && isWechatEnv() && !profile?.hasWechat;
}
export function saveWechatLoginResult(result: WechatLoginResult): boolean {
if (!result.accessToken) return false;
saveAuth({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
return true;
}
/** H5:发起公众号 OAuth(可能直接跳转);小程序请用 bindWechatForUser */
export async function authorizeWechatForPay(): Promise<WechatLoginResult | void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return;
if (!isWechatEnv()) {
throw new Error('请在微信内打开以完成授权');
}
if (process.env.TARO_ENV === 'h5') {
return weixinSdk.login();
}
throw new Error('请使用小程序微信授权');
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function waitOrderPaid(orderId: string, maxAttempts = 15): Promise<boolean> {
for (let i = 0; i < maxAttempts; i += 1) {
const order = await request<{ payStatus?: string }>(`/trade/orders/${orderId}`);
if (order.payStatus === 'PAID') return true;
await sleep(2000);
}
return false;
}
export async function payOrder(orderId: string): Promise<'paid' | 'pending'> {
const result = await request<WechatPayOrderResult>(`/trade/orders/${orderId}/pay`, {
method: 'POST',
});
if (result.mode === 'jsapi' && result.prepay) {
const prepay = result.prepay as WechatJsapiPrepayParams;
if (process.env.TARO_ENV === 'h5') {
await weixinSdk.pay(prepay);
} else {
await invokeWechatPay(prepay, { platform: 'mini' });
}
const paid = await waitOrderPaid(orderId);
return paid ? 'paid' : 'pending';
}
return 'paid';
}
export type WechatBindResult =
| { ok: true; profile?: UserProfile }
| { ok: false; needBindPhone: true; wxSessionKey: string }
| { ok: false; redirecting: true };
-23
View File
@@ -1,23 +0,0 @@
const MOBILE_PHONE_RE = /^1[3-9]\d{9}$/;
export function normalizePhoneInput(value: string): string {
return value.replace(/\D/g, '').slice(0, 11);
}
export function validateMobilePhone(phone: string): { ok: boolean; message?: string } {
const trimmed = phone.trim();
if (!trimmed) {
return { ok: false, message: '请输入手机号码' };
}
if (trimmed.length !== 11) {
return { ok: false, message: '手机号码须为 11 位' };
}
if (!MOBILE_PHONE_RE.test(trimmed)) {
return { ok: false, message: '请输入正确的手机号码' };
}
return { ok: true };
}
export function maskPhone(phone: string) {
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
}
@@ -1,45 +0,0 @@
/** 商品履约能力(与 HQ / 交易硬闸一致) */
export type FulfillmentFlags = {
allowOnlinePurchase?: boolean | null;
allowOnSitePickup?: boolean | null;
allowCrossCityDelivery?: boolean | null;
};
/** 未返回时默认允许线上(存量商品) */
export function canBuyOnline(p: FulfillmentFlags): boolean {
return p.allowOnlinePurchase !== false;
}
/** 仅显式开启才展示现场取货 */
export function canPickupOnSite(p: FulfillmentFlags): boolean {
return p.allowOnSitePickup === true;
}
export function canCrossCity(p: FulfillmentFlags): boolean {
return p.allowCrossCityDelivery !== false;
}
export function normalizeFulfillmentFlags<T extends FulfillmentFlags>(p: T): T {
return {
...p,
allowOnlinePurchase: canBuyOnline(p),
allowOnSitePickup: canPickupOnSite(p),
allowCrossCityDelivery: canCrossCity(p),
};
}
/** 与交易侧一致:收货市 ≠ 开城市且 ≠ 郑州 → 跨城 */
export function isCrossCityAddress(
addressCity: string | null | undefined,
openCityName: string | null | undefined,
): boolean {
const addr = (addressCity || '').trim();
const open = (openCityName || '').trim();
if (!addr) return false;
if (addr === '郑州市') return false;
if (open && addr === open) return false;
// 尚无开城信息时,非郑州地址先按可能跨城处理(由预览接口最终裁定)
if (!open) return addr !== '郑州市';
return true;
}
-45
View File
@@ -1,45 +0,0 @@
/** 商品无图时的占位(小程序端无本地兜底图时用空串由 UI 显示灰底) */
export const PRODUCT_IMAGE_FALLBACK = '';
export type ProductImageSource = {
mainImageUrl?: string | null;
carouselUrls?: string[] | null;
detailImageUrls?: string[] | null;
};
function uniqueUrls(urls: Array<string | null | undefined>) {
const seen = new Set<string>();
const result: string[] = [];
for (const url of urls) {
if (!url || seen.has(url)) continue;
seen.add(url);
result.push(url);
}
return result;
}
export function getProductMainImage(source?: ProductImageSource | null): string {
return source?.mainImageUrl ?? source?.carouselUrls?.[0] ?? PRODUCT_IMAGE_FALLBACK;
}
export function getProductImages(source?: ProductImageSource | null): string[] {
const carousel = uniqueUrls(source?.carouselUrls ?? []);
if (carousel.length > 0) return carousel;
const main = source?.mainImageUrl;
if (main) return [main];
return PRODUCT_IMAGE_FALLBACK ? [PRODUCT_IMAGE_FALLBACK] : [];
}
/** 详情页顶部轮播 */
export function getProductCarouselImages(source?: ProductImageSource | null): string[] {
const carousel = uniqueUrls(source?.carouselUrls ?? []);
if (carousel.length > 0) return carousel;
return getProductImages(source);
}
/** 详情页图文长图 */
export function getProductDetailImages(source?: ProductImageSource | null): string[] {
return uniqueUrls(source?.detailImageUrls ?? []);
}
export const FALLBACK_CITY_CODE = '410100';
-129
View File
@@ -1,129 +0,0 @@
import Taro from '@tarojs/taro';
import { request } from './api';
const PROMO_ID_KEY = 'dukang_promo_id';
/** 同一次进入只 touch 一次扫码计数,避免首页反复 onShow 刷量 */
let lastScanTouchKey = '';
function safeDecode(raw: string): string {
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
function normalizePromoId(raw: unknown): string | null {
if (raw == null || raw === '') return null;
const s = safeDecode(String(raw)).trim();
// 小程序码 scene 写入的是推广活动数字 ID
if (!/^\d+$/.test(s)) return null;
return s;
}
type EnterOptionsLike = {
scene?: string | number;
query?: Record<string, string | undefined>;
path?: string;
};
/** 从启动/进入参数解析推广活动 ID(优先 query.scene,与 getwxacodeunlimit 一致) */
export function extractPromoIdFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
if (!opts) return null;
const q = opts.query ?? {};
return (
normalizePromoId(q.scene) ||
normalizePromoId(q.promoId) ||
normalizePromoId(q.pid) ||
null
);
}
export function getStoredPromoId(): string | null {
try {
const v = Taro.getStorageSync(PROMO_ID_KEY);
return normalizePromoId(v);
} catch {
return null;
}
}
export function setStoredPromoId(promoId: string) {
const id = normalizePromoId(promoId);
if (!id) return;
try {
Taro.setStorageSync(PROMO_ID_KEY, id);
} catch {
/* ignore */
}
}
function readEnterOptions(): EnterOptionsLike | null {
try {
if (typeof Taro.getEnterOptionsSync === 'function') {
return Taro.getEnterOptionsSync() as EnterOptionsLike;
}
} catch {
/* ignore */
}
try {
if (typeof Taro.getLaunchOptionsSync === 'function') {
return Taro.getLaunchOptionsSync() as EnterOptionsLike;
}
} catch {
/* ignore */
}
// H5:从 URL query 读取
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
return {
query: {
scene: params.get('scene') || undefined,
promoId: params.get('promoId') || undefined,
pid: params.get('pid') || undefined,
},
};
}
return null;
}
/**
* 主页面进入时:取出 scene(活动 ID)本地缓存,并回传 /promo/touch 累加扫码次数。
* 同一进入会话只计一次扫码。
*/
export async function capturePromoSceneAndTouchScan(): Promise<void> {
const opts = readEnterOptions();
const fromEnter = extractPromoIdFromEnterOptions(opts);
if (fromEnter) {
setStoredPromoId(fromEnter);
const touchKey = `${fromEnter}|${opts?.path || ''}|${JSON.stringify(opts?.query || {})}|${String(opts?.scene ?? '')}`;
if (touchKey === lastScanTouchKey) return;
lastScanTouchKey = touchKey;
await touchPromo({ promoId: fromEnter, countScan: true });
return;
}
// 无新 scene 时不重复扫码计数
}
/** 登录成功后:用已缓存的活动 ID 做归因(不重复加扫码次数) */
export async function touchStoredPromoAfterLogin(): Promise<void> {
const promoId = getStoredPromoId();
if (!promoId) return;
await touchPromo({ promoId, countScan: false });
}
async function touchPromo(input: { promoId: string; countScan: boolean }): Promise<void> {
try {
await request('/promo/touch', {
method: 'POST',
data: {
promoId: input.promoId,
countScan: input.countScan,
},
});
} catch {
/* 静默失败,不阻断浏览 */
}
}
-42
View File
@@ -1,42 +0,0 @@
import './text-encoding-polyfill';
import QRCode from 'qrcode';
const QR_SIZE = 240;
const QR_OPTIONS = {
width: QR_SIZE * 2,
margin: 0,
color: { dark: '#1f1a17', light: '#ffffff' },
} as const;
/** 在 2d Canvas 上绘制二维码(小程序 / H5 通用) */
export function drawRedeemQrOnCanvas(
ctx: CanvasRenderingContext2D,
token: string,
sizePx = QR_SIZE,
) {
const qr = QRCode.create(token, { errorCorrectionLevel: 'M' });
const count = qr.modules.size;
const cell = sizePx / count;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, sizePx, sizePx);
ctx.fillStyle = '#1f1a17';
for (let row = 0; row < count; row++) {
for (let col = 0; col < count; col++) {
if (qr.modules.get(row, col)) {
ctx.fillRect(col * cell, row * cell, cell, cell);
}
}
}
}
/** H5Data URL;失败时回退到与 h5-user 相同的外部 QR 服务 */
export async function buildRedeemQrDataUrl(token: string): Promise<string> {
try {
return await QRCode.toDataURL(token, QR_OPTIONS);
} catch {
return `https://api.qrserver.com/v1/create-qr-code/?size=${QR_SIZE * 2}x${QR_SIZE * 2}&data=${encodeURIComponent(token)}`;
}
}
export const REDEEM_QR_DISPLAY_SIZE = QR_SIZE;
-166
View File
@@ -1,166 +0,0 @@
import { regionData } from 'element-china-area-data';
export type RegionTree = Record<string, Record<string, string[]>>;
function buildRegionTree(): RegionTree {
const tree: RegionTree = {};
for (const province of regionData) {
const cities: Record<string, string[]> = {};
for (const city of province.children ?? []) {
cities[city.label] = (city.children ?? []).map((district) => district.label);
}
tree[province.label] = cities;
}
return tree;
}
export const REGION_TREE: RegionTree = buildRegionTree();
export const PROVINCES = Object.keys(REGION_TREE);
export const REGION_ALL = '全市';
export function getCities(province: string): string[] {
if (province === REGION_ALL) return [];
return Object.keys(REGION_TREE[province] ?? {});
}
export function getDistricts(province: string, city: string): string[] {
if (province === REGION_ALL || city === REGION_ALL) return [];
return REGION_TREE[province]?.[city] ?? [];
}
export function getProvincesForPicker(): string[] {
return [...PROVINCES];
}
export function getCitiesForPicker(province: string): string[] {
if (province === REGION_ALL) return [REGION_ALL];
return [...getCities(province)];
}
export function getDistrictsForPicker(province: string, city: string): string[] {
if (province === REGION_ALL || city === REGION_ALL) return [REGION_ALL];
return [REGION_ALL, ...getDistricts(province, city)];
}
export function formatRegion(province: string, city: string, district: string): string {
if (!province) return '';
if (province === REGION_ALL) return REGION_ALL;
if (city === REGION_ALL) return `${province} ${REGION_ALL}`;
if (district === REGION_ALL) return `${province} ${city} ${REGION_ALL}`;
if (!city || !district) return '';
return `${province} ${city} ${district}`;
}
export function formatRegionCity(province: string, city: string): string {
if (!province) return '';
if (province === REGION_ALL) return REGION_ALL;
if (city === REGION_ALL) return `${province} ${REGION_ALL}`;
if (!city) return province;
return `${province} ${city}`;
}
export function toCityLevelRegion(selection: RegionSelection): RegionSelection {
const normalized = normalizeRegionSelection(selection);
return {
province: normalized.province,
city: normalized.city,
district: REGION_ALL,
};
}
export type RegionSelection = {
province: string;
city: string;
district: string;
};
export function normalizeRegionSelection(selection: RegionSelection): RegionSelection {
if (selection.province === REGION_ALL) {
return { province: REGION_ALL, city: REGION_ALL, district: REGION_ALL };
}
const province = PROVINCES.includes(selection.province)
? selection.province
: DEFAULT_REGION.province;
if (selection.city === REGION_ALL) {
return { province, city: REGION_ALL, district: REGION_ALL };
}
const cities = getCities(province);
const city = cities.includes(selection.city) ? selection.city : (cities[0] ?? DEFAULT_REGION.city);
if (selection.district === REGION_ALL) {
return { province, city, district: REGION_ALL };
}
const districts = getDistricts(province, city);
const district = districts.includes(selection.district)
? selection.district
: (districts[0] ?? DEFAULT_REGION.district);
return { province, city, district };
}
export const DEFAULT_REGION: RegionSelection = {
province: '河南省',
city: '郑州市',
district: REGION_ALL,
};
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
const cityName = city.endsWith('市') ? city : `${city}`;
const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province;
const cities = getCities(provinceInTree);
const matchedCity = cities.includes(cityName)
? cityName
: cities.find((c) => c.replace(/市$/, '') === city.replace(/市$/, '')) ?? cityName;
const districts = getDistricts(provinceInTree, matchedCity);
const districtName =
district && districts.includes(district) ? district : REGION_ALL;
return normalizeRegionSelection({
province: provinceInTree,
city: cities.includes(matchedCity) ? matchedCity : matchedCity,
district: districtName,
});
}
function normalizeCityName(name: string) {
return name.replace(/市$/, '').trim();
}
type RegionFilterStore = {
province?: string;
cityName?: string;
district?: string;
};
/** 门店列表按省市区筛选(支持 REGION_ALL */
export function matchesRegionFilter(store: RegionFilterStore, region: RegionSelection): boolean {
const normalized = normalizeRegionSelection(region);
if (normalized.province !== REGION_ALL) {
if ((store.province ?? '') !== normalized.province) return false;
}
if (normalized.city !== REGION_ALL) {
const storeCity = store.cityName ?? '';
const cityNorm = normalizeCityName(normalized.city);
if (
storeCity !== normalized.city &&
normalizeCityName(storeCity) !== cityNorm
) {
return false;
}
}
if (normalized.district !== REGION_ALL) {
if ((store.district ?? '') !== normalized.district) return false;
}
return true;
}
export function formatRegionLabel(region: RegionSelection): string {
const normalized = normalizeRegionSelection(region);
if (normalized.province === REGION_ALL) return REGION_ALL;
if (normalized.city === REGION_ALL) return normalized.province;
if (normalized.district === REGION_ALL) return `${normalized.city}`;
return normalized.district;
}
-137
View File
@@ -1,137 +0,0 @@
/**
* 门店列表「当次登录」会话 —— 用 Taro Storage 持久化,
* 避免模块多实例 / globalThis 不可靠导致切 tab 后当成首次进入。
* 仅 logout 时 clear。
*/
import Taro from '@tarojs/taro';
export type StoresSessionRegion = {
province: string;
city: string;
district: string;
};
export type StoresSessionCategory = {
parentId: string;
parentName: string;
childId: string;
childName: string;
};
export type StoresListCache = {
cityKey: string;
cityCode: string;
/** 登录态指纹:token 变化时需重新拉取(白名单) */
authKey: string;
listRegion: StoresSessionRegion;
items: unknown[];
filterRegion: StoresSessionRegion;
keyword: string;
keywordInput: string;
category: StoresSessionCategory;
};
type StoresSession = {
bootstrapped: boolean;
cache: StoresListCache | null;
};
const STORAGE_KEY = 'dukang_stores_session_v1';
let memory: StoresSession | null = null;
function emptySession(): StoresSession {
return { bootstrapped: false, cache: null };
}
function readSession(): StoresSession {
if (memory) return memory;
try {
const raw = Taro.getStorageSync(STORAGE_KEY);
if (!raw) {
memory = emptySession();
return memory;
}
const parsed = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Partial<StoresSession>;
memory = {
bootstrapped: !!parsed.bootstrapped,
cache: (parsed.cache as StoresListCache | null) ?? null,
};
return memory;
} catch {
memory = emptySession();
return memory;
}
}
function writeSession(next: StoresSession) {
memory = next;
try {
Taro.setStorageSync(STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore quota */
}
}
export function isStoresSessionBootstrapped(): boolean {
return readSession().bootstrapped;
}
export function markStoresSessionBootstrapped(): void {
const cur = readSession();
writeSession({ ...cur, bootstrapped: true });
}
export function getStoresListCache(): StoresListCache | null {
return readSession().cache;
}
export function setStoresListCache(cache: StoresListCache | null): void {
const cur = readSession();
writeSession({ ...cur, bootstrapped: true, cache });
}
export function patchStoresFilterCache(
patch: Partial<
Pick<StoresListCache, 'filterRegion' | 'keyword' | 'keywordInput' | 'category'>
>,
): void {
const cur = readSession();
if (!cur.cache) {
// 列表尚未写入时也要记下用户筛选,避免切回丢失
writeSession({
bootstrapped: true,
cache: {
cityKey: '',
cityCode: '',
listRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
items: [],
filterRegion: patch.filterRegion ?? { province: '', city: '', district: '' },
keyword: patch.keyword ?? '',
keywordInput: patch.keywordInput ?? '',
category: patch.category ?? {
parentId: '',
parentName: '',
childId: '',
childName: '',
},
},
});
return;
}
writeSession({
...cur,
bootstrapped: true,
cache: { ...cur.cache, ...patch },
});
}
export function resetStoresSessionBootstrap(): void {
memory = emptySession();
try {
Taro.removeStorageSync(STORAGE_KEY);
} catch {
/* ignore */
}
}
@@ -1,52 +0,0 @@
/** 微信小程序基础库未内置 TextEncoderqrcode 库依赖它编码 payload */
function installTextEncodingPolyfill() {
const root = (typeof globalThis !== 'undefined'
? globalThis
: typeof global !== 'undefined'
? global
: typeof wx !== 'undefined'
? wx
: {}) as typeof globalThis & { TextEncoder?: typeof TextEncoder };
if (typeof root.TextEncoder !== 'undefined') return;
class MiniTextEncoder implements TextEncoder {
readonly encoding = 'utf-8';
encode(input?: string): Uint8Array {
const str = input ?? '';
const bytes: number[] = [];
for (let i = 0; i < str.length; i++) {
let code = str.charCodeAt(i);
if (code < 0x80) {
bytes.push(code);
} else if (code < 0x800) {
bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
} else if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
const next = str.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
i += 1;
code = 0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00);
bytes.push(
0xf0 | (code >> 18),
0x80 | ((code >> 12) & 0x3f),
0x80 | ((code >> 6) & 0x3f),
0x80 | (code & 0x3f),
);
} else {
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
}
} else {
bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
}
}
return new Uint8Array(bytes);
}
}
root.TextEncoder = MiniTextEncoder as unknown as typeof TextEncoder;
}
installTextEncodingPolyfill();
export {};
-11
View File
@@ -1,11 +0,0 @@
import { useEffect, useRef } from 'react';
import { trackPageView } from './analytics';
export function usePageView(eventName: string, params?: Record<string, unknown>) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
trackPageView(eventName, params);
}, [eventName, params]);
}
-284
View File
@@ -1,284 +0,0 @@
import Taro from '@tarojs/taro';
import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
import { API_BASE, CLIENT_APP, getToken, request } from './api';
import { DEFAULT_REGION, REGION_ALL, regionFromGeo, type RegionSelection } from './region-data';
import { FALLBACK_CITY_CODE } from './product-images';
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
const USER_COORDS_KEY = 'dukang_user_coords';
/** 用户拒绝定位后持久化,避免首页/门店每次 useDidShow 再弹授权 */
const LOCATION_DENIED_KEY = 'dukang_location_denied';
export type ResolvedUserCity = {
province: string;
city: string;
district: string;
cityCode?: string;
cityName?: string;
openCity: boolean;
region: RegionSelection;
displayCity: string;
};
export type UserCoords = { latitude: number; longitude: number };
type GpsCityCache = ResolvedUserCity & { timestamp: number };
const FALLBACK_CITY: ResolvedUserCity = {
province: DEFAULT_REGION.province,
city: DEFAULT_REGION.city,
district: REGION_ALL,
cityCode: FALLBACK_CITY_CODE,
cityName: '郑州市',
openCity: true,
region: DEFAULT_REGION,
displayCity: '郑州市',
};
function isLocationDenied(): boolean {
try {
return Taro.getStorageSync(LOCATION_DENIED_KEY) === '1';
} catch {
return false;
}
}
function markLocationDenied() {
try {
Taro.setStorageSync(LOCATION_DENIED_KEY, '1');
} catch {
/* ignore */
}
}
function clearLocationDenied() {
try {
Taro.removeStorageSync(LOCATION_DENIED_KEY);
} catch {
/* ignore */
}
}
function isDenyMessage(errMsg?: string): boolean {
return /auth deny|authorize|permission|denied|拒绝|用户拒绝|getLocation:fail/i.test(
errMsg || '',
);
}
function readCache(): GpsCityCache | null {
try {
const raw = Taro.getStorageSync(GPS_CITY_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(String(raw)) as GpsCityCache;
if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
return parsed;
} catch {
return null;
}
}
function writeCache(data: ResolvedUserCity) {
try {
Taro.setStorageSync(
GPS_CITY_STORAGE_KEY,
JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache),
);
} catch {
/* ignore */
}
}
export function writeUserCoords(latitude: number, longitude: number) {
try {
Taro.setStorageSync(
USER_COORDS_KEY,
JSON.stringify({ latitude, longitude, timestamp: Date.now() }),
);
} catch {
/* ignore */
}
}
export function readCachedUserCoords(): UserCoords | null {
try {
const raw = Taro.getStorageSync(USER_COORDS_KEY);
if (!raw) return null;
const parsed = JSON.parse(String(raw)) as UserCoords & { timestamp?: number };
if (parsed.timestamp && Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
if (!Number.isFinite(parsed.latitude) || !Number.isFinite(parsed.longitude)) return null;
return { latitude: parsed.latitude, longitude: parsed.longitude };
} catch {
return null;
}
}
/** 门店列表默认用市级全市筛选 */
export function toCityWideRegion(region: RegionSelection): RegionSelection {
return {
province: region.province,
city: region.city,
district: REGION_ALL,
};
}
/** 拒绝或失败后写入兜底城市,避免短时间内反复调起定位 */
function cacheFallbackAndMaybeDeny(denied: boolean) {
if (denied) markLocationDenied();
writeCache(FALLBACK_CITY);
}
async function reportLocationToServer(payload: {
latitude?: number;
longitude?: number;
sdk: 'jssdk' | 'geolocation';
status: 'success' | 'fail';
errMsg?: string;
}) {
return request<{
province?: string;
city?: string;
district?: string;
cityCode?: string;
cityName?: string;
openCity?: boolean;
}>('/common/wechat/location', {
method: 'POST',
data: payload,
});
}
function toResolved(data: {
province?: string;
city?: string;
district?: string;
cityCode?: string;
cityName?: string;
openCity?: boolean;
}): ResolvedUserCity | null {
if (!data.province || !data.city) return null;
const region = regionFromGeo(data.province, data.city, data.district);
const displayCity = data.cityName ?? (data.city.endsWith('市') ? data.city : `${data.city}`);
return {
province: data.province,
city: data.city,
district: data.district ?? '',
cityCode: data.cityCode,
cityName: data.cityName,
openCity: !!data.openCity,
region,
displayCity,
};
}
async function promptLocationAuthOnce() {
await Taro.showModal({
title: '位置授权',
content: '需要获取您的位置以展示所在城市的商品与门店。拒绝后将默认使用郑州市,不会再次弹窗。',
confirmText: '知道了',
showCancel: false,
}).catch(() => {});
}
function getMiniLocation(): Promise<Taro.getLocation.SuccessCallbackResult> {
return new Promise((resolve, reject) => {
Taro.getLocation({
type: 'gcj02',
success: resolve,
fail: reject,
});
});
}
async function resolveViaH5Jssdk(): Promise<ResolvedUserCity | null> {
const outcome = await getWechatLocationDetailed({
apiBase: API_BASE,
clientApp: CLIENT_APP,
getAccessToken: () => getToken() || null,
});
if (!outcome.location) {
const denied = isDenyMessage(outcome.errMsg);
await reportLocationToServer({
sdk: outcome.sdk,
status: 'fail',
errMsg: outcome.errMsg,
}).catch(() => {});
cacheFallbackAndMaybeDeny(denied);
return null;
}
try {
writeUserCoords(outcome.location.latitude, outcome.location.longitude);
const data = await reportLocationToServer({
latitude: outcome.location.latitude,
longitude: outcome.location.longitude,
sdk: outcome.sdk,
status: 'success',
});
const resolved = toResolved(data);
if (resolved) {
clearLocationDenied();
writeCache(resolved);
}
return resolved;
} catch {
cacheFallbackAndMaybeDeny(false);
return null;
}
}
/** 获取并解析用户当前城市;失败返回郑州市兜底 */
export async function resolveUserCity(force = false): Promise<ResolvedUserCity> {
if (!force) {
if (isLocationDenied()) {
const cached = readCache();
return cached ?? FALLBACK_CITY;
}
const cached = readCache();
if (cached) return cached;
}
if (process.env.TARO_ENV === 'h5') {
const fromJssdk = await resolveViaH5Jssdk();
return fromJssdk ?? FALLBACK_CITY;
}
if (process.env.TARO_ENV !== 'weapp') {
return FALLBACK_CITY;
}
try {
const loc = await getMiniLocation();
writeUserCoords(loc.latitude, loc.longitude);
const data = await reportLocationToServer({
latitude: loc.latitude,
longitude: loc.longitude,
sdk: 'jssdk',
status: 'success',
});
const resolved = toResolved(data);
if (resolved) {
clearLocationDenied();
writeCache(resolved);
return resolved;
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const denied = isDenyMessage(errMsg);
if (denied && !isLocationDenied()) {
await promptLocationAuthOnce();
}
await reportLocationToServer({
sdk: 'jssdk',
status: 'fail',
errMsg: errMsg.slice(0, 200),
}).catch(() => {});
cacheFallbackAndMaybeDeny(denied);
}
return FALLBACK_CITY;
}
export function getCityCodeForCatalog(resolved: ResolvedUserCity): string {
return resolved.openCity && resolved.cityCode ? resolved.cityCode : FALLBACK_CITY_CODE;
}
-38
View File
@@ -1,38 +0,0 @@
import Taro from '@tarojs/taro';
import { normalizePhoneInput } from './phone';
const USER_PHONE_KEY = 'user_phone';
function isValidMobile(phone: string) {
return /^1[3-9]\d{9}$/.test(phone);
}
export function saveUserPhone(phone: string) {
const normalized = normalizePhoneInput(phone);
if (!isValidMobile(normalized)) return;
try {
Taro.setStorageSync(USER_PHONE_KEY, normalized);
} catch {
/* ignore */
}
}
export function getStoredUserPhone(): string {
try {
const value = Taro.getStorageSync(USER_PHONE_KEY);
return typeof value === 'string' ? value : '';
} catch {
return '';
}
}
/** 新增地址等场景:优先本地缓存,其次资料里的已验证手机号 */
export function resolveDefaultUserPhone(profile?: { phone?: string | null; phoneVerified?: boolean } | null) {
const stored = getStoredUserPhone();
if (isValidMobile(stored)) return stored;
if (!profile?.phoneVerified || !profile.phone) return '';
const fromProfile = normalizePhoneInput(profile.phone);
if (!isValidMobile(fromProfile)) return '';
saveUserPhone(fromProfile);
return fromProfile;
}
-149
View File
@@ -1,149 +0,0 @@
import type { WechatLoginResult } from '@dukang/shared-types';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import Taro from '@tarojs/taro';
import { saveAuth } from './api';
import {
fetchMiniWechatUserInfo,
mergeWxDisplayProfile,
needsWxProfileFill,
syncMiniWechatProfile,
} from './mini-wechat-profile';
/**
* 兼容旧分包对资料 helper 的引用,避免 tree-shake 后出现 is not a function
*(开发者工具热更新时常见旧页 + 新 common 混用)
*/
export { fetchMiniWechatUserInfo, mergeWxDisplayProfile, needsWxProfileFill };
/** 强制保留导出绑定,防止打包器删掉未引用的 re-export */
const _wxProfileCompat = {
fetchMiniWechatUserInfo,
mergeWxDisplayProfile,
needsWxProfileFill,
};
if (
typeof _wxProfileCompat.needsWxProfileFill !== 'function' ||
typeof _wxProfileCompat.fetchMiniWechatUserInfo !== 'function' ||
typeof _wxProfileCompat.mergeWxDisplayProfile !== 'function'
) {
throw new Error('mini-wechat-profile helpers missing');
}
import {
authorizeWechatForPay,
fetchClientConfig,
fetchUserProfile,
needsWechatAuthForPay,
saveWechatLoginResult,
type WechatBindResult,
} from './pay-wechat';
import { isWechatEnv, weixinSdk } from './weixin';
export type WechatAuthEnsureResult =
| { ok: true }
| { ok: false; redirecting: true }
| { ok: false; needBindPhone: true; wxSessionKey: string };
/** 小程序:Taro.login → /auth/login/wechatH5:公众号 OAuth */
export async function loginWithWechat(): Promise<WechatLoginResult | void> {
if (process.env.TARO_ENV === 'h5') {
return loginWithWechatSdk();
}
const res = await Taro.login();
if (!res.code) {
throw new Error(res.errMsg || '微信登录失败,未获取到 code');
}
const { request } = await import('./api');
return request<WechatLoginResult>('/auth/login/wechat', {
method: 'POST',
data: { code: res.code, platform: 'mini' },
});
}
export async function checkNeedsWechatAuth(): Promise<boolean> {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
return needsWechatAuthForPay(config, profile);
}
/** 真实微信支付前确保已绑定微信;OAuth 跳转时返回 redirecting */
export async function ensureWechatAuthForPay(): Promise<WechatAuthEnsureResult> {
if (!isWechatEnv()) return { ok: true };
if (!(await checkNeedsWechatAuth())) return { ok: true };
const result = await authorizeWechatForPay();
if (!result) return { ok: false, redirecting: true };
if (result.needBindPhone && result.wxSessionKey) {
return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey };
}
if (saveWechatLoginResult(result)) {
return { ok: true };
}
return { ok: false, redirecting: true };
}
/** 处理 URL 中 OAuth ?code= 回调(H5 公众号) */
export async function handleWechatAuthCallback(): Promise<WechatLoginResult | null> {
if (process.env.TARO_ENV !== 'h5') return null;
if (!isWechatEnv()) return null;
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return null;
const result = await weixinSdk.handleOAuthCallback();
if (result) stripOAuthParamsFromLocation();
return result;
}
export async function loginWithWechatSdk(): Promise<WechatLoginResult | void> {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) return;
if (!isWechatEnv()) {
throw new Error('请在微信内打开以使用微信一键授权');
}
return weixinSdk.login();
}
export function applyWechatLoginResult(result: WechatLoginResult): boolean {
return saveWechatLoginResult(result);
}
/** 已登录用户绑定微信:小程序 code;H5 走公众号 OAuth(带 JWT 时服务端会 attach */
export async function bindWechatForUser(
prefetchedWxProfile?: { nickname?: string; avatarUrl?: string } | null,
): Promise<WechatBindResult> {
if (process.env.TARO_ENV === 'h5') {
const result = await authorizeWechatForPay();
if (!result) {
return { ok: false, redirecting: true };
}
if (result.needBindPhone && result.wxSessionKey) {
return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey };
}
if (result.accessToken) {
saveAuth({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
}
const profile = await fetchUserProfile();
return { ok: true, profile };
}
const res = await Taro.login();
if (!res.code) {
throw new Error(res.errMsg || '微信授权失败');
}
const { request } = await import('./api');
const data = await request<WechatLoginResult>('/auth/wechat/bind', {
method: 'POST',
data: { code: res.code, platform: 'mini' },
});
if (data.needBindPhone && data.wxSessionKey) {
return { ok: false, needBindPhone: true, wxSessionKey: data.wxSessionKey };
}
await syncMiniWechatProfile(prefetchedWxProfile);
const profile = await fetchUserProfile();
return { ok: true, profile };
}
export type { WechatBindResult };
@@ -1,299 +0,0 @@
import Taro from '@tarojs/taro';
import { request, toast } from './api';
import { fetchClientConfig } from './pay-wechat';
/** 微信确认收货组件来源 AppId(官方固定) */
export const WECHAT_ORDER_CONFIRM_APPID = 'wx1183b055aeec94d1';
const PENDING_KEY = 'pending_wechat_order_confirm';
export type WechatConfirmPayload = {
merchantId?: string | null;
merchantTradeNo?: string | null;
transactionId?: string | null;
};
type PendingConfirm = {
orderId: string;
redirectUrl?: string;
};
type OpenBusinessViewOptions = {
businessType: string;
extraData: Record<string, string>;
success?: () => void;
fail?: (err: { errMsg?: string }) => void;
complete?: () => void;
};
type MiniWx = {
openBusinessView?: (opts: OpenBusinessViewOptions) => void;
};
/**
* 取小程序原生 wx.openBusinessView。
* 官方兼容写法:`if (wx.openBusinessView) { ... }`(不要用 canIUse 挡业务组件)。
* Taro 未封装该 API;模块作用域下可能读不到全局 wx,需多重回退。
*/
function getOpenBusinessView(): ((opts: OpenBusinessViewOptions) => void) | null {
if (process.env.TARO_ENV !== 'weapp') return null;
const candidates: Array<MiniWx | null | undefined> = [];
try {
// eslint-disable-next-line no-undef
if (typeof wx !== 'undefined') candidates.push(wx as MiniWx);
} catch {
/* ignore */
}
const g = globalThis as typeof globalThis & { wx?: MiniWx };
candidates.push(g.wx);
try {
// 跳出 bundler 模块作用域,读微信运行时全局
const fromRuntime = new Function(
'return typeof wx !== "undefined" ? wx : null',
)() as MiniWx | null;
candidates.push(fromRuntime);
} catch {
/* ignore */
}
for (const api of candidates) {
if (api && typeof api.openBusinessView === 'function') {
return api.openBusinessView.bind(api);
}
}
return null;
}
export function savePendingWechatOrderConfirm(pending: PendingConfirm) {
Taro.setStorageSync(PENDING_KEY, JSON.stringify(pending));
}
export function takePendingWechatOrderConfirm(): PendingConfirm | null {
try {
const raw = Taro.getStorageSync(PENDING_KEY);
if (!raw) return null;
Taro.removeStorageSync(PENDING_KEY);
return typeof raw === 'string' ? (JSON.parse(raw) as PendingConfirm) : (raw as PendingConfirm);
} catch {
Taro.removeStorageSync(PENDING_KEY);
return null;
}
}
function normalizePayload(payload?: WechatConfirmPayload | null): WechatConfirmPayload {
return {
merchantId: payload?.merchantId?.trim() || undefined,
merchantTradeNo: payload?.merchantTradeNo?.trim() || undefined,
transactionId: payload?.transactionId?.trim() || undefined,
};
}
async function resolveConfirmPayload(
orderId: string,
hint?: WechatConfirmPayload | null,
): Promise<WechatConfirmPayload> {
const fromHint = normalizePayload(hint);
if (fromHint.transactionId || (fromHint.merchantId && fromHint.merchantTradeNo)) {
return fromHint;
}
const order = await request<{
orderNo?: string;
payExternalNo?: string | null;
payment?: { externalNo?: string | null } | null;
wechatConfirm?: WechatConfirmPayload | null;
}>(`/trade/orders/${orderId}`);
const fromApi = normalizePayload(order.wechatConfirm);
if (fromApi.transactionId || (fromApi.merchantId && fromApi.merchantTradeNo)) {
return fromApi;
}
const transactionId =
order.payExternalNo?.trim() || order.payment?.externalNo?.trim() || undefined;
return normalizePayload({
transactionId,
merchantTradeNo: order.orderNo,
merchantId: fromApi.merchantId,
});
}
/**
* 拉起微信「确认收货」半屏组件。
* @see https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping-half.html
*/
export function openWechatOrderConfirm(opts: {
orderId: string;
payload: WechatConfirmPayload;
redirectUrl?: string;
}): Promise<'opened' | 'unsupported' | 'missing_pay_ref'> {
const open = getOpenBusinessView();
if (!open) {
console.warn('[wechat-order-confirm] openBusinessView unavailable', {
taroEnv: process.env.TARO_ENV,
});
return Promise.resolve('unsupported');
}
const payload = normalizePayload(opts.payload);
const transactionId = payload.transactionId;
const merchantId = payload.merchantId;
const merchantTradeNo = payload.merchantTradeNo;
if (!transactionId && !(merchantId && merchantTradeNo)) {
console.warn('[wechat-order-confirm] missing pay ref', payload);
return Promise.resolve('missing_pay_ref');
}
const extraData: Record<string, string> = {};
if (transactionId) extraData.transaction_id = transactionId;
if (merchantId) extraData.merchant_id = merchantId;
if (merchantTradeNo) extraData.merchant_trade_no = merchantTradeNo;
savePendingWechatOrderConfirm({
orderId: opts.orderId,
redirectUrl: opts.redirectUrl,
});
return new Promise((resolve) => {
let settled = false;
const done = (mode: 'opened' | 'unsupported' | 'missing_pay_ref') => {
if (settled) return;
settled = true;
resolve(mode);
};
try {
open({
businessType: 'weappOrderConfirm',
extraData,
success: () => done('opened'),
fail: (err) => {
Taro.removeStorageSync(PENDING_KEY);
console.error('[wechat-order-confirm] openBusinessView fail', err, extraData);
toast(err?.errMsg || '无法打开微信确认收货,请稍后重试');
done('unsupported');
},
});
} catch (err) {
Taro.removeStorageSync(PENDING_KEY);
console.error('[wechat-order-confirm] openBusinessView throw', err);
toast('无法打开微信确认收货组件');
done('unsupported');
}
});
}
type ReferrerExtra = {
status?: string;
errormsg?: string;
req_extradata?: Record<string, string>;
};
/**
* 处理确认收货组件回跳(App/页面 onShow)。
* 成功则调用后端同步订单,并返回是否已处理。
*/
export async function handleWechatOrderConfirmShow(options?: {
referrerInfo?: { appId?: string; extraData?: ReferrerExtra };
}): Promise<{ handled: boolean; orderId?: string; redirectUrl?: string }> {
const info = options?.referrerInfo;
if (!info?.appId || info.appId !== WECHAT_ORDER_CONFIRM_APPID) {
return { handled: false };
}
const pending = takePendingWechatOrderConfirm();
const status = info.extraData?.status;
if (status === 'cancel') {
toast('已取消确认收货');
return { handled: true, orderId: pending?.orderId };
}
if (status === 'fail') {
toast(info.extraData?.errormsg || '微信确认收货失败');
return { handled: true, orderId: pending?.orderId };
}
if (status !== 'success' || !pending?.orderId) {
return { handled: true };
}
try {
await request(`/trade/orders/${pending.orderId}/confirm-receive`, {
method: 'POST',
data: { source: 'WECHAT_COMPONENT' },
});
toast('确认收货成功', 'success');
return {
handled: true,
orderId: pending.orderId,
redirectUrl: pending.redirectUrl,
};
} catch (e) {
toast(e instanceof Error ? e.message : '同步订单失败');
return { handled: true, orderId: pending.orderId };
}
}
async function confirmLocally(opts: {
orderId: string;
onSitePickup?: boolean;
onLocalSuccess?: () => void | Promise<void>;
}): Promise<'local'> {
await request(`/trade/orders/${opts.orderId}/confirm-receive`, {
method: 'POST',
data: {
onSitePickup: !!opts.onSitePickup,
source: 'USER',
},
});
await opts.onLocalSuccess?.();
return 'local';
}
/**
* 统一入口:
* - 小程序 + 真实支付:必须拉起 weappOrderConfirm,禁止静默降级
* - Mock / H5:本地确认
*/
export async function confirmOrderReceive(opts: {
orderId: string;
wechatConfirm?: WechatConfirmPayload | null;
onSitePickup?: boolean;
redirectUrl?: string;
onLocalSuccess?: () => void | Promise<void>;
}): Promise<'wechat' | 'local'> {
const isWeapp = process.env.TARO_ENV === 'weapp';
if (!isWeapp) {
return confirmLocally(opts);
}
let mockPay = false;
try {
const cfg = await fetchClientConfig();
mockPay = !!cfg.mockPay;
} catch {
mockPay = false;
}
if (mockPay) {
return confirmLocally(opts);
}
const payload = await resolveConfirmPayload(opts.orderId, opts.wechatConfirm);
const mode = await openWechatOrderConfirm({
orderId: opts.orderId,
payload,
redirectUrl: opts.redirectUrl,
});
if (mode === 'opened') return 'wechat';
if (mode === 'missing_pay_ref') {
throw new Error('缺少微信支付单号,无法打开确认收货组件');
}
throw new Error('当前环境无法打开微信确认收货组件,请用微信最新版打开小程序后重试');
}
-107
View File
@@ -1,107 +0,0 @@
import Taro from '@tarojs/taro';
import { BRAND_LOGO_URL } from '@dukang/shared-types';
import type { WechatShareData } from '@dukang/weixin-sdk';
import { getWechatShareLink, isWechatBrowser, isMiniProgram } from '@dukang/weixin-sdk';
import { toast } from './api';
import { isWechatEnv, weixinSdk } from './weixin';
export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单';
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友';
export function getDefaultShareImageUrl(): string {
return BRAND_LOGO_URL;
}
export function buildDefaultShareData(
overrides?: Partial<WechatShareData>,
): WechatShareData {
let link = overrides?.link;
if (!link) {
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
link = getWechatShareLink();
} else {
link = '';
}
}
return {
title: overrides?.title ?? DEFAULT_SHARE_TITLE,
desc: overrides?.desc ?? DEFAULT_SHARE_DESC,
link,
imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(),
};
}
/** 配置 H5 微信内右上角分享卡片 */
export async function applyWechatShare(
overrides?: Partial<WechatShareData>,
): Promise<void> {
if (process.env.TARO_ENV !== 'h5') return;
if (!isWechatBrowser()) return;
await weixinSdk.setShare(buildDefaultShareData(overrides));
}
export type PageSharePayload = {
title?: string;
desc?: string;
/** 小程序分享 path,如 /pages/product-detail/index?id=1 */
path?: string;
imgUrl?: string;
/** H5 自定义分享 link,默认当前页 */
link?: string;
};
/**
* 点击「分享」按钮。
* H5:走微信 JSSDK 配置分享卡片,并尝试 invoke 调起面板;否则返回需展示引导蒙层。
*/
export async function handleShareButtonClick(
payload?: PageSharePayload,
): Promise<{ showGuide: boolean }> {
if (process.env.TARO_ENV === 'weapp' || isMiniProgram()) {
try {
await Taro.showShareMenu({ withShareTicket: true, showShareItems: ['shareAppMessage', 'shareTimeline'] });
} catch {
try {
await Taro.showShareMenu({ withShareTicket: true });
} catch {
/* ignore */
}
}
toast(WECHAT_SHARE_HINT);
return { showGuide: false };
}
if (!isWechatEnv()) {
toast('请在微信内打开后分享');
return { showGuide: false };
}
const data = buildDefaultShareData({
title: payload?.title,
desc: payload?.desc,
imgUrl: payload?.imgUrl,
link: payload?.link,
});
try {
const result = await weixinSdk.share(data);
if (result.invoked) {
return { showGuide: false };
}
toast(WECHAT_SHARE_HINT);
return { showGuide: true };
} catch (e) {
toast(e instanceof Error ? e.message : '分享配置失败,请刷新后重试');
return { showGuide: true };
}
}
/** 供 useShareAppMessage 使用的标题/路径/图 */
export function toWeappShareMessage(payload?: PageSharePayload) {
return {
title: payload?.title || DEFAULT_SHARE_TITLE,
path: payload?.path || '/pages/home/index',
imageUrl: payload?.imgUrl || getDefaultShareImageUrl(),
};
}
-16
View File
@@ -1,16 +0,0 @@
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
import { API_BASE, CLIENT_APP, getToken } from './api';
/**
* H5 与业务 API 共用 CLIENT_APP
* - H5 → USER_H5(公众号 OAuth openId + 公众号支付 appId
* - 小程序 → USER_MINI
* 须与 JWT 签发一致,才能同时避免「反复授权」与「appid/openid 不匹配」。
*/
export const weixinSdk = createWeixinSdk({
apiBase: API_BASE,
clientApp: CLIENT_APP,
getAccessToken: () => getToken() || null,
});
export { isWechatEnv };
@@ -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,
});
-251
View File
@@ -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,
});
-401
View File
@@ -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: '登录',
});
-555
View File
@@ -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,
});
-591
View File
@@ -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',
});
-186
View File
@@ -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: '收银台',
});
-245
View File
@@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More