小程序修改

This commit is contained in:
2026-07-12 19:44:36 +08:00
parent 3b4833b51a
commit e2dfb08de3
45 changed files with 2028 additions and 347 deletions
+1
View File
@@ -15,6 +15,7 @@
"@dukang/shared-types": "workspace:*",
"@dukang/shared-ui": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
"element-china-area-data": "^6.1.0",
"@tarojs/components": "4.2.0",
"@tarojs/helper": "4.2.0",
"@tarojs/plugin-framework-react": "4.2.0",
+6
View File
@@ -26,6 +26,12 @@ export default defineAppConfig({
navigationBarTextStyle: 'black',
backgroundColor: '#FAF9F7',
},
permission: {
'scope.userLocation': {
desc: '用于展示您所在城市的商品与门店',
},
},
requiredPrivateInfos: ['getLocation'],
tabBar: {
custom: false,
color: '#999999',
+2 -2
View File
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import { navBarStyle, useNavBarMetrics } from '../lib/nav-bar';
import { subPageNavBarStyle, useNavBarMetrics } from '../lib/nav-bar';
type PageNavBarProps = {
title: string;
@@ -23,7 +23,7 @@ export default function PageNavBar({
return (
<View
className={`page-nav-bar${solid ? ' page-nav-bar--solid' : ''}`}
style={navBarStyle(metrics)}
style={subPageNavBarStyle(metrics)}
>
<View
className="page-nav-bar__content"
+162 -25
View File
@@ -1,48 +1,185 @@
import { View, Text } from '@tarojs/components';
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;
valueLabel?: string;
value: RegionSelection;
onClose: () => void;
onConfirm?: (label: string) => 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,
valueLabel = '郑州市',
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-mask" onClick={onClose}>
<View className="region-picker-overlay" onClick={onClose}>
<View className="region-picker-sheet" onClick={(e) => e.stopPropagation()}>
<View className="region-picker-head">
<Text className="region-picker-cancel" onClick={onClose}>
</Text>
<Text className="region-picker-title"></Text>
<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-ok"
onClick={() => {
onConfirm?.(valueLabel);
onClose();
}}
className={`region-picker-confirm${canConfirm ? ' ready' : ''}`}
onClick={() => canConfirm && handleConfirm()}
>
</Text>
</View>
<View className="region-picker-body">
<Text className="region-picker-item region-picker-item--active">{valueLabel}</Text>
<Text className="u-muted" style={{ display: 'block', marginTop: 12, textAlign: 'center' }}>
</Text>
</View>
<ScrollView className="region-picker-list" scrollY>
{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,7 +1,7 @@
import type { ReactNode } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { navBarStyle, useNavBarMetrics } from '../lib/nav-bar';
import { subPageNavBarStyle, useNavBarMetrics } from '../lib/nav-bar';
type SubPageHeaderProps = {
title: string;
@@ -27,7 +27,7 @@ export default function SubPageHeader({ title, onBack, right }: SubPageHeaderPro
}
return (
<View className="sub-page-header" style={navBarStyle(metrics)}>
<View className="sub-page-header" style={subPageNavBarStyle(metrics)}>
<View
className="sub-page-header__content"
style={{ height: `${metrics.navContentHeight}px` }}
+2
View File
@@ -121,4 +121,6 @@ export type UserProfile = {
phone?: string | null;
nickname?: string | null;
avatarUrl?: string | null;
phoneVerified?: boolean;
hasWechat?: boolean;
};
+9 -4
View File
@@ -24,11 +24,16 @@ function currentPagePath(): string {
}
/** 跳转登录页;默认带回当前页作为 return */
export function goLogin(returnPath?: string) {
export function goLogin(returnPath?: string, extras?: Record<string, string>) {
const returnTo = returnPath ?? currentPagePath();
const url = returnTo
? `/pages/login/index?return=${encodeURIComponent(returnTo)}`
: '/pages/login/index';
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';
Taro.navigateTo({ url }).catch(() => {
Taro.redirectTo({ url });
});
+58
View File
@@ -0,0 +1,58 @@
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);
return extra ? `${base}&${extra}` : base;
}
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',
};
}
@@ -0,0 +1,34 @@
import Taro from '@tarojs/taro';
import { request } from './api';
type MiniProfilePayload = {
nickname?: string;
avatarUrl?: string;
};
/** 小程序授权后拉取微信昵称/头像并上报服务端 */
export async function syncMiniWechatProfile(): Promise<void> {
if (process.env.TARO_ENV !== 'weapp') return;
let profile: MiniProfilePayload | null = null;
try {
const res = await Taro.getUserProfile({ desc: '用于完善会员资料' });
profile = {
nickname: res.userInfo?.nickName,
avatarUrl: res.userInfo?.avatarUrl,
};
} catch {
return;
}
if (!profile.nickname && !profile.avatarUrl) return;
try {
await request('/auth/wechat/mini-profile', {
method: 'POST',
data: profile,
});
} catch {
/* 用户拒绝或上报失败时不阻断主流程 */
}
}
+24 -1
View File
@@ -10,6 +10,8 @@ export type NavBarMetrics = {
navContentHeight: number;
/** 右侧留白,避免与微信胶囊按钮重叠 */
navBarPaddingRight: number;
/** 左侧留白,与右侧对称以实现标题视觉居中 */
navBarPaddingLeft: number;
};
const H5_FALLBACK: NavBarMetrics = {
@@ -17,6 +19,7 @@ const H5_FALLBACK: NavBarMetrics = {
navBarHeight: 56,
navContentHeight: 56,
navBarPaddingRight: 16,
navBarPaddingLeft: 16,
};
const WEAPP_FALLBACK: NavBarMetrics = {
@@ -24,6 +27,7 @@ const WEAPP_FALLBACK: NavBarMetrics = {
navBarHeight: 64,
navContentHeight: 44,
navBarPaddingRight: 96,
navBarPaddingLeft: 96,
};
/** 计算小程序自定义导航栏尺寸(对齐微信胶囊按钮) */
@@ -51,6 +55,7 @@ export function getNavBarMetrics(): NavBarMetrics {
navBarHeight,
navContentHeight,
navBarPaddingRight,
navBarPaddingLeft: navBarPaddingRight,
};
} catch {
return WEAPP_FALLBACK;
@@ -68,15 +73,33 @@ export function pageShellCssVars(metrics: NavBarMetrics): Record<string, string>
'--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 + 总高 + 右侧胶囊避让 */
/** 顶栏自身样式:statusBar padding + 总高 + 左右对称胶囊避让 */
export function navBarStyle(metrics: NavBarMetrics): Record<string, string | number> {
return {
paddingTop: `${metrics.statusBarHeight}px`,
height: `${metrics.navBarHeight}px`,
paddingLeft: `${metrics.navBarPaddingLeft}px`,
paddingRight: `${metrics.navBarPaddingRight}px`,
...pageShellCssVars(metrics),
};
}
/** 子页顶栏:左侧为返回按钮预留与右侧胶囊等宽空间 */
export function subPageNavBarStyle(metrics: NavBarMetrics): Record<string, string | number> {
const paddingSide = Math.max(48, metrics.navBarPaddingRight);
return {
paddingTop: `${metrics.statusBarHeight}px`,
height: `${metrics.navBarHeight}px`,
paddingLeft: `${paddingSide}px`,
paddingRight: `${paddingSide}px`,
'--nav-bar-height': `${metrics.navBarHeight}px`,
'--nav-content-height': `${metrics.navContentHeight}px`,
'--nav-status-bar-height': `${metrics.statusBarHeight}px`,
'--nav-padding-right': `${paddingSide}px`,
'--nav-padding-left': `${paddingSide}px`,
};
}
+27
View File
@@ -0,0 +1,27 @@
import { goLogin } from './auth-nav';
import { isLoggedIn } from './api';
import { fetchClientConfig, fetchUserProfile, needsWechatAuthForPay } from './pay-wechat';
/** 支付前门禁:未登录/未验手机/未绑微信时跳转登录页 */
export async function ensurePayReady(returnPath: string): Promise<boolean> {
if (!isLoggedIn()) {
goLogin(returnPath);
return false;
}
try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
if (!profile.phoneVerified) {
goLogin(returnPath, { needPhone: '1' });
return false;
}
if (needsWechatAuthForPay(config, profile)) {
goLogin(returnPath, { needWechat: '1' });
return false;
}
return true;
} catch {
goLogin(returnPath);
return false;
}
}
+79
View File
@@ -0,0 +1,79 @@
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 Taro from '@tarojs/taro';
import { request, type UserProfile } from './api';
import { syncMiniWechatProfile } from './mini-wechat-profile';
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');
}
/** 真实微信支付且未绑定微信时需要授权 */
export function needsWechatAuthForPay(
config: ClientRuntimeConfig,
profile: UserProfile | null,
): boolean {
if (!isWxAuthorizeEnabled(config)) return false;
return !config.mockPay && config.wechatPayEnabled && isMiniWechatEnv() && !profile?.hasWechat;
}
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) {
await invokeWechatPay(result.prepay as WechatJsapiPrepayParams);
const paid = await waitOrderPaid(orderId);
return paid ? 'paid' : 'pending';
}
return 'paid';
}
export type WechatBindResult =
| { ok: true; profile?: UserProfile }
| { ok: false; needBindPhone: true; wxSessionKey: string };
export async function bindWechatForUser(): Promise<WechatBindResult> {
const res = await Taro.login();
if (!res.code) {
throw new Error(res.errMsg || '微信授权失败');
}
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();
const profile = await fetchUserProfile();
return { ok: true, profile };
}
+23
View File
@@ -0,0 +1,23 @@
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');
}
+166
View File
@@ -0,0 +1,166 @@
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: '金水区',
};
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;
}
+163
View File
@@ -0,0 +1,163 @@
import Taro from '@tarojs/taro';
import { request } from './api';
import { DEFAULT_REGION, regionFromGeo, type RegionSelection } from './region-data';
import { FALLBACK_CITY_CODE } from './product-images';
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
export type ResolvedUserCity = {
province: string;
city: string;
district: string;
cityCode?: string;
cityName?: string;
openCity: boolean;
region: RegionSelection;
displayCity: string;
};
type GpsCityCache = ResolvedUserCity & { timestamp: number };
const FALLBACK_CITY: ResolvedUserCity = {
province: DEFAULT_REGION.province,
city: DEFAULT_REGION.city,
district: DEFAULT_REGION.district,
cityCode: FALLBACK_CITY_CODE,
cityName: '郑州市',
openCity: true,
region: DEFAULT_REGION,
displayCity: '郑州市',
};
let locationPrompted = false;
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 */
}
}
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 promptLocationAuth() {
if (locationPrompted) return;
locationPrompted = true;
await Taro.showModal({
title: '位置授权',
content: '需要获取您的位置以展示所在城市的商品与门店',
confirmText: '去授权',
showCancel: true,
}).catch(() => {});
}
function getLocation(): Promise<Taro.getLocation.SuccessCallbackResult> {
return new Promise((resolve, reject) => {
Taro.getLocation({
type: 'gcj02',
success: resolve,
fail: reject,
});
});
}
/** 获取并解析用户当前城市;失败返回郑州市兜底 */
export async function resolveUserCity(force = false): Promise<ResolvedUserCity> {
if (!force) {
const cached = readCache();
if (cached) return cached;
}
if (process.env.TARO_ENV !== 'weapp') {
return FALLBACK_CITY;
}
try {
const loc = await getLocation();
const data = await reportLocationToServer({
latitude: loc.latitude,
longitude: loc.longitude,
sdk: 'jssdk',
status: 'success',
});
const resolved = toResolved(data);
if (resolved) {
writeCache(resolved);
return resolved;
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
const denied = /auth deny|authorize|permission|拒绝/i.test(errMsg);
if (denied) {
await promptLocationAuth();
}
await reportLocationToServer({
sdk: 'jssdk',
status: 'fail',
errMsg: errMsg.slice(0, 200),
}).catch(() => {});
}
return FALLBACK_CITY;
}
export function getCityCodeForCatalog(resolved: ResolvedUserCity): string {
return resolved.openCity && resolved.cityCode ? resolved.cityCode : FALLBACK_CITY_CODE;
}
+22
View File
@@ -0,0 +1,22 @@
import Taro from '@tarojs/taro';
const USER_PHONE_KEY = 'user_phone';
export function saveUserPhone(phone: string) {
const normalized = phone.replace(/\D/g, '').slice(0, 11);
if (!/^1[3-9]\d{9}$/.test(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 '';
}
}
+8 -1
View File
@@ -1,6 +1,7 @@
import type { WechatLoginResult } from '@dukang/shared-types';
import Taro from '@tarojs/taro';
import { request } from './api';
import { syncMiniWechatProfile } from './mini-wechat-profile';
/** 小程序微信授权登录:Taro.login → /auth/login/wechat */
export async function loginWithWechat(): Promise<WechatLoginResult> {
@@ -8,8 +9,14 @@ export async function loginWithWechat(): Promise<WechatLoginResult> {
if (!res.code) {
throw new Error(res.errMsg || '微信登录失败,未获取到 code');
}
return request<WechatLoginResult>('/auth/login/wechat', {
const result = await request<WechatLoginResult>('/auth/login/wechat', {
method: 'POST',
data: { code: res.code, platform: 'mini' },
});
if (result.accessToken) {
await syncMiniWechatProfile();
}
return result;
}
export { bindWechatForUser } from './pay-wechat';
+146 -23
View File
@@ -1,25 +1,129 @@
import { useState } from 'react';
import { View, Text, Input, Textarea } from '@tarojs/components';
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 { toast } from '../../lib/api';
import RegionPicker from '../../components/RegionPicker';
import { buildAddressListUrl, readCheckoutContext } from '../../lib/checkout-nav';
import { DEFAULT_REGION, formatRegion, type RegionSelection } from '../../lib/region-data';
import { normalizePhoneInput, validateMobilePhone } from '../../lib/phone';
import { getStoredUserPhone } from '../../lib/user-phone';
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 isEdit = !!router.params.id;
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [region, setRegion] = useState('河南省 郑州市');
const [detail, setDetail] = useState('');
const id = router.params.id;
const isEdit = !!id;
const checkoutCtx = readCheckoutContext(router.params);
const [pickerOpen, setPickerOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [form, setForm] = useState<AddressForm>({
receiverName: '',
phone: '',
province: DEFAULT_REGION.province,
city: DEFAULT_REGION.city,
district: DEFAULT_REGION.district,
detail: '',
isDefault: true,
});
function save() {
if (!name.trim() || !phone.trim() || !detail.trim()) {
toast('请完善地址信息');
useEffect(() => {
if (id) return;
request<UserProfile>('/auth/me')
.then((me) => {
if (!me.phoneVerified) return;
const stored = getStoredUserPhone();
if (!stored) return;
setForm((prev) => (prev.phone ? prev : { ...prev, phone: stored }));
})
.catch(() => {});
}, [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);
return;
}
toast(isEdit ? '地址已更新(UI 壳)' : '地址已新增(UI 壳)', 'success');
setTimeout(() => Taro.navigateBack(), 600);
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) {
setError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
function onRegionConfirm(region: RegionSelection) {
setForm((prev) => ({
...prev,
province: region.province,
city: region.city,
district: region.district,
}));
}
return (
@@ -31,8 +135,8 @@ export default function AddressEditPage() {
<Input
className="address-form-input"
placeholder="请输入姓名"
value={name}
onInput={(e) => setName(e.detail.value)}
value={form.receiverName}
onInput={(e) => setForm((prev) => ({ ...prev, receiverName: e.detail.value }))}
/>
</View>
<View className="address-form-field">
@@ -42,8 +146,10 @@ export default function AddressEditPage() {
type="number"
maxlength={11}
placeholder="请输入手机号"
value={phone}
onInput={(e) => setPhone(e.detail.value)}
value={form.phone}
onInput={(e) =>
setForm((prev) => ({ ...prev, phone: normalizePhoneInput(e.detail.value) }))
}
/>
</View>
<View className="address-form-field">
@@ -51,9 +157,9 @@ export default function AddressEditPage() {
<View
className="address-form-input"
style={{ display: 'flex', alignItems: 'center' }}
onClick={() => toast('区域选择器后续接入')}
onClick={() => setPickerOpen(true)}
>
<Text>{region || '请选择省市区'}</Text>
<Text>{regionText || '请选择省市区'}</Text>
</View>
</View>
<View className="address-form-field">
@@ -61,14 +167,31 @@ export default function AddressEditPage() {
<Textarea
className="address-form-textarea"
placeholder="街道门牌号等"
value={detail}
onInput={(e) => setDetail(e.detail.value)}
value={form.detail}
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={save}>
<Text></Text>
<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>
);
}
+84 -30
View File
@@ -1,72 +1,126 @@
import { useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import Taro, { useDidShow, 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;
province: string;
city: string;
district: string;
detail: string;
isDefault?: boolean;
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);
useEffect(() => {
const loadList = useCallback(() => {
setLoading(true);
request<Address[]>('/user/addresses')
.then((data) => setList(Array.isArray(data) ? data : []))
.catch(() => setList([]))
.finally(() => setLoading(false));
}, []);
useDidShow(() => {
loadList();
});
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="地址管理" />
<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">
<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 ? <Text className="address-default-tag"></Text> : null}
</View>
<Text className="address-item-detail">
{[a.province, a.city, a.district, a.detail].filter(Boolean).join(' ')}
</Text>
<View className="address-item-actions">
<Text
className="address-action"
onClick={() =>
Taro.navigateTo({ url: `/pages/address-edit/index?id=${a.id}` })
}
>
</Text>
<Text
className="address-action"
onClick={() => toast('删除功能后续接入')}
>
</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) });
}}
>
</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: '/pages/address-edit/index' })}
onClick={() => Taro.navigateTo({ url: buildAddressEditUrl(undefined, checkoutCtx) })}
>
<Text></Text>
</View>
@@ -1,46 +1,30 @@
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 { toast } from '../../lib/api';
const QUICK = ['如何核销权益?', '订单多久发货?', '如何修改地址?', '联系人工客服'];
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
export default function CustomerServicePage() {
return (
<PageShell variant="sub" className="cs-page">
<SubPageHeader title="联系客服" />
<View className="sub-page-body inset-page">
<View className="cs-bubble cs-bubble--bot">
<Text></Text>
<View className="sub-page-body inset-page cs-body">
<Text className="cs-title">线</Text>
<Text className="cs-phone">{CUSTOMER_SERVICE_PHONE}</Text>
<Text className="cs-hint">9:00 - 21:00</Text>
<View
className="cs-call-btn"
onClick={() => {
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() =>
toast('无法拨打电话'),
);
}}
>
<Text></Text>
</View>
<View className="cs-bubble cs-bubble--user">
<Text></Text>
</View>
<View className="cs-bubble cs-bubble--bot">
<Text>
</Text>
</View>
</View>
<View className="cs-quick">
{QUICK.map((q) => (
<Text
key={q}
className="cs-quick-item"
onClick={() => {
if (q === '联系人工客服') {
Taro.makePhoneCall({ phoneNumber: '4008000000' }).catch(() =>
toast('客服热线后续配置'),
);
} else {
toast(q);
}
}}
>
{q}
</Text>
))}
</View>
</PageShell>
);
+22 -22
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
@@ -7,8 +7,8 @@ import CouponBadge from '../../components/CouponBadge';
import ProductCarousel from '../../components/ProductCarousel';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { request, toast } from '../../lib/api';
import { FALLBACK_CITY_CODE, getProductImages } from '../../lib/product-images';
import { getProductImages } from '../../lib/product-images';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
type Product = {
id: string;
name: string;
@@ -30,10 +30,15 @@ export default function HomePage() {
const [tab, setTab] = useState('QINGXIANG');
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const cityCode = FALLBACK_CITY_CODE;
const [displayCity, setDisplayCity] = useState('郑州市');
const [cityCode, setCityCode] = useState('410100');
useDidShow(() => {
syncTabBarSelected(0);
void resolveUserCity().then((resolved) => {
setDisplayCity(resolved.displayCity);
setCityCode(getCityCodeForCatalog(resolved));
});
});
useEffect(() => {
@@ -61,26 +66,21 @@ export default function HomePage() {
return (
<PageShell variant="tab" className="home-page">
<TabMainHeader
title="杜康好客"
extra={(
<View className="tab-main-header__extra-inner">
<View className="tab-main-city-pin" />
<Text className="tab-main-city-label"></Text>
</View>
)}
/>
<TabMainHeader title="杜康好客" />
<View className="home-aroma-nav">
{AROMA_TABS.map((t) => (
<Text
key={t.key}
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}${!t.open ? ' home-aroma-tab--muted' : ''}`}
onClick={() => onAromaTabClick(t.key, t.open)}
>
{t.label}
</Text>
))}
<View className="home-aroma-tabs">
{AROMA_TABS.map((t) => (
<Text
key={t.key}
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}${!t.open ? ' home-aroma-tab--muted' : ''}`}
onClick={() => onAromaTabClick(t.key, t.open)}
>
{t.label}
</Text>
))}
</View>
<Text className="home-aroma-city">{displayCity}</Text>
</View>
<View className="home-product-list">
+159 -68
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { View, Text, Input, Button } from '@tarojs/components';
import { View, Text, Input, Button, Image } from '@tarojs/components';
import { useRouter } from '@tarojs/taro';
import {
SmsScene,
@@ -9,8 +9,12 @@ import {
} from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import WechatLoginButton from '../../components/WechatLoginButton';
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
import { finishLoginNavigate } from '../../lib/auth-nav';
import { request, saveAuth, toast, type SessionPayload } from '../../lib/api';
import { bindWechatForUser } from '../../lib/wechat-auth';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { saveUserPhone } from '../../lib/user-phone';
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
import { loginWithWechat } from '../../lib/wechat-auth';
function normalizePhone(value: string) {
@@ -24,6 +28,10 @@ function isValidPhone(phone: string) {
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('');
@@ -34,9 +42,10 @@ export default function LoginPage() {
const [agreed, setAgreed] = useState(true);
const [msg, setMsg] = useState('');
const [sentHint, setSentHint] = useState('');
const [bindMode, setBindMode] = useState(false);
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
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);
useEffect(() => {
request<ClientRuntimeConfig>('/common/client-config')
@@ -44,6 +53,30 @@ export default function LoginPage() {
.catch(() => setWxAuthorize(true));
}, []);
useEffect(() => {
if (!isLoggedIn()) {
setCompleteMode(null);
return;
}
fetchUserProfile()
.then((me) => {
if (needPhone && !me.phoneVerified) {
setCompleteMode('phone');
return;
}
if (needWechat && !me.hasWechat) {
setCompleteMode('wechat');
return;
}
if (needPhone || needWechat) {
finishLoginNavigate(returnTo);
return;
}
setCompleteMode(null);
})
.catch(() => setCompleteMode(null));
}, [needPhone, needWechat, returnTo]);
useEffect(() => {
if (cooldown <= 0) return;
const timer = setTimeout(() => setCooldown((c) => Math.max(0, c - 1)), 1000);
@@ -58,8 +91,9 @@ export default function LoginPage() {
return true;
}
function applySessionAndLeave(data: SessionPayload | WechatLoginResult) {
function applySessionAndLeave(data: SessionPayload | WechatLoginResult, phone?: string) {
if (!data.accessToken) return;
if (phone) saveUserPhone(phone);
saveAuth({
accessToken: data.accessToken,
refreshToken: data.refreshToken,
@@ -95,12 +129,11 @@ export default function LoginPage() {
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: bindMode ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN,
},
data: { phone: normalized, scene },
});
setCooldown(60);
setSentHint('验证码已发送');
@@ -132,13 +165,24 @@ export default function LoginPage() {
data: { wxSessionKey, phone: normalized, code: code.trim() },
});
handleWechatLoginResult(data);
saveUserPhone(normalized);
return;
}
if (completeMode === 'phone' && isLoggedIn()) {
await request('/auth/phone/bind', {
method: 'POST',
data: { phone: normalized, code: code.trim() },
});
saveUserPhone(normalized);
toast('手机号验证成功', 'success');
finishLoginNavigate(returnTo);
return;
}
const data = await request<SessionPayload>('/auth/login/sms', {
method: 'POST',
data: { phone: normalized, code: code.trim() },
});
applySessionAndLeave(data);
applySessionAndLeave(data, normalized);
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
} finally {
@@ -152,6 +196,22 @@ export default function LoginPage() {
setSentHint('');
setWxLoading(true);
try {
if (completeMode === 'wechat' && isLoggedIn()) {
const result = await bindWechatForUser();
if (!result.ok && result.needBindPhone) {
setBindMode(true);
setWxSessionKey(result.wxSessionKey);
setCompleteMode('phone');
setMsg('请绑定手机号完成认证');
return;
}
if (result.ok) {
toast('微信授权成功', 'success');
finishLoginNavigate(returnTo);
return;
}
return;
}
const result = await loginWithWechat();
handleWechatLoginResult(result);
} catch (e) {
@@ -168,89 +228,120 @@ export default function LoginPage() {
const displayMsg = msg || sentHint;
const codeDisabled = cooldown > 0 || sending;
const showWechatLogin =
!bindMode && (process.env.TARO_ENV === 'weapp' || wxAuthorize);
(completeMode === 'wechat' || (!bindMode && !completeMode)) &&
(process.env.TARO_ENV === 'weapp' || wxAuthorize);
const showSmsForm = completeMode !== 'wechat';
const cardTitle =
completeMode === 'phone'
? '验证手机号'
: bindMode
? '绑定手机号'
: completeMode === 'wechat'
? '微信授权'
: '手机验证码登录';
return (
<PageShell variant="plain" className="login-page">
<View className="login-header">
<View className="login-logo-wrap">
<View className="login-logo">
<Text></Text>
<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"></Text>
<Text className="login-welcome-sub"></Text>
<Text className="login-welcome-title">
{completeMode === 'phone'
? '完成手机验证'
: completeMode === 'wechat'
? '完成微信授权'
: '欢迎来到杜康好客'}
</Text>
<Text className="login-welcome-sub">
{completeMode ? '完成后将返回继续支付' : '买美酒,享好礼'}
</Text>
</View>
</View>
<View className="login-main">
<View className="login-card">
<Text className="login-card-title">
{bindMode ? '绑定手机号' : '手机验证码登录'}
</Text>
<View className="login-field">
<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('');
}}
/>
{completeMode === 'wechat' ? (
<View className="login-card">
<Text className="login-card-title"></Text>
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
使
</Text>
{showWechatLogin ? (
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
) : null}
</View>
) : (
<View className="login-card">
<Text className="login-card-title">{cardTitle}</Text>
<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()}
<View className="login-field">
<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>
{displayMsg ? (
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
{displayMsg}
</Text>
) : null}
<Button
className="login-sms-btn"
loading={loading}
disabled={loading}
onClick={() => void login()}
>
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
</Text>
{loading
? '处理中...'
: completeMode === 'phone'
? '完成验证'
: bindMode
? '绑定并登录'
: '登录'}
</Button>
</View>
)}
{displayMsg ? (
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
{displayMsg}
</Text>
) : null}
<Button
className="login-sms-btn"
loading={loading}
disabled={loading}
onClick={() => void login()}
>
{loading ? '登录中...' : bindMode ? '绑定并登录' : '登录'}
</Button>
</View>
{showWechatLogin ? (
{showWechatLogin && 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}
onClick={() => void wechatLogin()}
/>
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
</>
) : null}
</View>
+67 -11
View File
@@ -1,10 +1,13 @@
import { useEffect, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { BRAND_LOGO_MARK_URL } from '@dukang/shared-types';
import { goLogin } from '../../lib/auth-nav';
import { bindWechatForUser } from '../../lib/wechat-auth';
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
const ORDER_SHORTCUTS = [
@@ -29,12 +32,17 @@ export default function MinePage() {
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);
useDidShow(() => {
syncTabBarSelected(3);
if (isLoggedIn()) {
loadProfile();
}
});
useEffect(() => {
function loadProfile() {
if (!loggedIn) return;
Promise.all([
request<UserProfile>('/auth/me'),
@@ -57,8 +65,42 @@ export default function MinePage() {
setOrderCounts(counts);
})
.catch(() => {});
}
useEffect(() => {
request<ClientRuntimeConfig>('/common/client-config')
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(true));
}, []);
useEffect(() => {
loadProfile();
}, [loggedIn]);
async function handleAvatarTap() {
if (!loggedIn) {
goLogin('/pages/mine/index');
return;
}
if (profile?.hasWechat || !wxAuthorize || process.env.TARO_ENV !== 'weapp') return;
setBindingWx(true);
try {
const result = await bindWechatForUser();
if (!result.ok && result.needBindPhone) {
goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey });
return;
}
if (result.ok) {
loadProfile();
toast('微信授权成功', 'success');
}
} catch (e) {
toast(e instanceof Error ? e.message : '微信授权失败');
} finally {
setBindingWx(false);
}
}
function handleService(item: (typeof SERVICES)[number]) {
if ('url' in item && item.url) {
Taro.navigateTo({ url: item.url });
@@ -73,6 +115,13 @@ export default function MinePage() {
}
}
function renderAvatarContent(profile: UserProfile | null) {
if (profile?.avatarUrl) {
return <Image className="mine-avatar-img" src={profile.avatarUrl} mode="aspectFill" />;
}
return <Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />;
}
if (!loggedIn) {
return (
<PageShell variant="tab" className="mine-page">
@@ -81,7 +130,7 @@ export default function MinePage() {
<View className="mine-header-texture" />
<View className="mine-profile">
<View className="mine-avatar">
<Text></Text>
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
</View>
<View>
<Text className="mine-profile-name"></Text>
@@ -104,7 +153,8 @@ export default function MinePage() {
}
const nickname = profile?.nickname || '用户';
const avatarUrl = profile?.avatarUrl;
const hasWechat = !!profile?.hasWechat;
const memberLabel = hasWechat ? '微信会员' : '未授权微信';
return (
<PageShell variant="tab" className="mine-page">
@@ -112,16 +162,22 @@ export default function MinePage() {
<View className="mine-header">
<View className="mine-header-texture" />
<View className="mine-profile">
<View className="mine-avatar">
{avatarUrl ? (
<Image className="mine-avatar-img" src={avatarUrl} mode="aspectFill" />
) : (
<Text>{nickname.slice(0, 1)}</Text>
)}
<View className="mine-avatar-wrap" onClick={() => void handleAvatarTap()}>
<View className="mine-avatar">
{renderAvatarContent(profile)}
</View>
{!hasWechat && wxAuthorize && process.env.TARO_ENV === 'weapp' ? (
<Text className="mine-avatar-badge">{bindingWx ? '授权中' : '授权'}</Text>
) : null}
</View>
<View>
<Text className="mine-profile-name">{nickname}</Text>
<Text className="mine-member-tag">{profile?.phone || '好客会员'}</Text>
<Text className={`mine-member-tag${hasWechat ? ' mine-member-tag--wechat' : ''}`}>
{memberLabel}
</Text>
{!hasWechat && wxAuthorize && process.env.TARO_ENV === 'weapp' ? (
<Text className="mine-wechat-hint"></Text>
) : null}
</View>
</View>
</View>
@@ -209,7 +265,7 @@ export default function MinePage() {
</View>
<View className="mine-footer">
<Text className="mine-version"> mini-user v0.1.0</Text>
<Text className="mine-version"></Text>
<Text className="mine-logout" onClick={() => logout()}>
退
</Text>
+222 -59
View File
@@ -3,126 +3,289 @@ import { View, Text, Image } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { buildAddressListUrl, buildPayUrl, readCheckoutContext } from '../../lib/checkout-nav';
import { maskPhone } from '../../lib/phone';
import { ensurePayReady } from '../../lib/pay-ready';
import { request, toast } from '../../lib/api';
import { getProductMainImage } from '../../lib/product-images';
type Product = {
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;
benefitDisplay?: number;
};
type OrderPreview = {
product: PreviewProduct;
quantity: number;
deliveryType: 'LOCAL' | 'CROSS_CITY';
productAmount: number;
freightPayType: 'COD' | null;
payAmount: number;
benefitAmount: number;
city?: { localMinQty: number; crossMinQty: number };
};
function formatAddress(a: Address) {
return `${a.province}${a.city}${a.district}${a.detail}`;
}
export default function OrderConfirmPage() {
const router = useRouter();
const productId = router.params.productId ?? '';
const initialQty = Math.max(2, Number(router.params.qty || 2));
const [product, setProduct] = useState<Product | null>(null);
const [qty, setQty] = useState(initialQty);
const checkoutCtx = readCheckoutContext(router.params);
const productId = checkoutCtx.productId ?? '';
const forceCross = checkoutCtx.cross === true;
const [quantity, setQuantity] = useState(Math.max(2, 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('');
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;
request<Product>(`/catalog/products/${productId}`)
.then(setProduct)
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [productId]);
let cancelled = false;
setPreviewLoading(true);
const body: { productId: string; quantity: number; addressId?: string } = {
productId,
quantity,
};
if (addressId) body.addressId = addressId;
const total = useMemo(() => {
if (!product) return 0;
return Number(product.price) * qty;
}, [product, qty]);
request<OrderPreview>('/trade/orders/preview', { method: 'POST', data: body })
.then((data) => {
if (!cancelled) {
setPreview(data);
setMsg('');
}
})
.catch((e) => {
if (!cancelled) {
setPreview(null);
setMsg(e instanceof Error ? e.message : '加载失败');
}
})
.finally(() => {
if (!cancelled) setPreviewLoading(false);
});
const benefit = useMemo(() => {
if (!product) return 0;
return Number(product.benefitDisplay ?? product.price) * qty;
}, [product, qty]);
return () => {
cancelled = true;
};
}, [productId, quantity, addressId]);
function changeQty(delta: number) {
setQty((q) => Math.max(2, q + delta));
const selectedAddress = useMemo(
() => addresses.find((a) => String(a.id) === addressId),
[addresses, addressId],
);
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
function updateQuantity(next: number) {
if (next < minQty) {
setMsg(
!isCross
? `同城配送至少购买 ${minQty}`
: `跨城配送至少购买 ${minQty} 瓶(1箱)`,
);
return;
}
setMsg('');
setQuantity(next);
}
function submit() {
if (!productId) return;
Taro.navigateTo({
url: `/pages/pay/index?productId=${productId}&qty=${qty}&amount=${total.toFixed(2)}`,
async function doSubmit() {
const order = await request<{ id: string }>('/trade/orders', {
method: 'POST',
data: {
productId,
quantity,
addressId,
},
});
Taro.redirectTo({
url: buildPayUrl({
orderId: order.id,
productId,
qty: String(quantity),
addressId,
cross: forceCross,
}),
});
}
async function submit() {
if (!addressId) {
setMsg('请选择收货地址');
return;
}
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=${quantity}&addressId=${addressId}${forceCross ? '&cross=1' : ''}`;
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) : '';
return (
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
<SubPageHeader title="确认订单" />
<View className="sub-page-body">
<View
className="order-card"
onClick={() => Taro.navigateTo({ url: '/pages/addresses/index' })}
onClick={() =>
Taro.navigateTo({
url: buildAddressListUrl({
productId,
qty: String(quantity),
addressId,
cross: forceCross,
}),
})
}
>
<Text className="order-card-title"></Text>
<Text className="u-muted"> 2 </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>
<View className="order-card">
<Text className="order-card-title"></Text>
{product ? (
<View>
{isCross ? (
<View className="order-card">
<Text className="u-muted">
</Text>
</View>
) : null}
{preview ? (
<>
<View className="order-card">
<Text className="order-card-title"></Text>
<View className="order-product-row">
<View className="order-product-thumb">
{getProductMainImage(product) ? (
{productImage ? (
<Image
className="order-product-thumb-img"
src={getProductMainImage(product)}
src={productImage}
mode="aspectFill"
/>
) : null}
</View>
<View style={{ flex: 1 }}>
<Text className="order-product-name">{product.name}</Text>
<Text className="order-product-price">¥{Number(product.price).toFixed(2)}</Text>
<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={() => changeQty(-1)}>
<View
className="order-qty-btn"
onClick={() => updateQuantity(quantity - 1)}
>
<Text></Text>
</View>
<Text className="order-qty-value">{qty}</Text>
<View className="order-qty-btn" onClick={() => changeQty(1)}>
<Text className="order-qty-value">{quantity}</Text>
<View
className="order-qty-btn"
onClick={() => updateQuantity(quantity + 1)}
>
<Text></Text>
</View>
</View>
</View>
</View>
) : (
<View className="u-empty"></View>
)}
</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">¥{total.toFixed(2)}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value--price">¥{benefit.toFixed(2)}</Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value"></Text>
</View>
</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}
{msg ? <Text className="u-muted" style={{ display: 'block', marginTop: 8 }}>{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">¥{total.toFixed(2)}</Text>
<Text className="order-confirm-total-value">
¥{preview ? preview.payAmount.toFixed(2) : '—'}
</Text>
</View>
<View className="order-confirm-submit" onClick={submit}>
<Text></Text>
<View
className="order-confirm-submit"
onClick={() => !loading && void submit()}
>
<Text>{loading ? '提交中…' : !addressId ? '请选择地址' : '提交订单'}</Text>
</View>
</View>
</PageShell>
+23 -4
View File
@@ -6,11 +6,30 @@ import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
const TABS = [
{ key: 'all', label: '全部订单' },
{ key: 'pending_pay', label: '待付款' },
{ key: 'paid', label: '已付款' },
{ key: 'completed', label: '已完成' },
] as const;
const STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '已付款',
OUT_WAREHOUSE: '已付款',
SHIPPED: '已付款',
DELIVERED: '已付款',
COMPLETED: '已完成',
CANCELLED: '已取消',
};
function orderStatusLabel(tab: string, status?: string): string {
if (tab !== 'all') {
return TABS.find((t) => t.key === tab)?.label || status || '';
}
if (!status) return '';
return STATUS_LABELS[status] || status;
}
type OrderRow = {
id: string;
orderNo?: string;
@@ -23,19 +42,19 @@ type OrderRow = {
export default function OrdersPage() {
const router = useRouter();
const initialTab = (router.params.tab as string) || 'pending_pay';
const initialTab = (router.params.tab as string) || 'all';
const [tab, setTab] = useState(initialTab);
const [orders, setOrders] = useState<OrderRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
request<{ items?: OrderRow[]; total?: number } | OrderRow[]>(
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?.items) ? data.items : []);
else setOrders(Array.isArray(data?.list) ? data.list : Array.isArray(data?.items) ? data.items : []);
})
.catch((e) => {
toast(e instanceof Error ? e.message : '加载失败');
@@ -71,7 +90,7 @@ export default function OrdersPage() {
<View className="order-list-head">
<Text className="order-list-no">{o.orderNo || o.id}</Text>
<Text className="order-list-status">
{TABS.find((t) => t.key === tab)?.label || o.status || ''}
{orderStatusLabel(tab, o.status)}
</Text>
</View>
<View className="order-list-body">
+116 -11
View File
@@ -1,18 +1,110 @@
import { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { toast } from '../../lib/api';
import { ensurePayReady } from '../../lib/pay-ready';
import {
fetchClientConfig,
fetchUserProfile,
isWechatAuthRequiredError,
needsWechatAuthForPay,
payOrder,
} from '../../lib/pay-wechat';
import { request, toast } from '../../lib/api';
export default function PayPage() {
const router = useRouter();
const amount = router.params.amount ?? '0.00';
const orderId = router.params.orderId ?? '';
const [loading, setLoading] = useState(false);
const [mockMode, setMockMode] = useState(true);
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
const [msg, setMsg] = useState('');
const [orderNo, setOrderNo] = useState('');
const [payAmount, setPayAmount] = useState('—');
function mockPay() {
toast('支付成功(Mock', 'success');
setTimeout(() => {
const returnPath = orderId
? `/pages/pay/index?orderId=${orderId}`
: '/pages/pay/index';
useEffect(() => {
if (!orderId) return;
void ensurePayReady(returnPath);
}, [orderId, returnPath]);
useEffect(() => {
async function load() {
try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
setMockMode(config.mockPay);
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
} catch {
/* ignore */
}
}
void load();
}, []);
useEffect(() => {
if (!orderId) {
setOrderNo('');
setPayAmount('—');
return;
}
request<{ orderNo?: string; payAmount?: number | string; totalAmount?: number | string }>(
`/trade/orders/${orderId}`,
)
.then((order) => {
setOrderNo(order.orderNo || '');
const amount = Number(order.payAmount ?? order.totalAmount ?? 0);
if (Number.isFinite(amount) && amount > 0) {
setPayAmount(amount.toFixed(2));
}
})
.catch((e) => {
setOrderNo('');
toast(e instanceof Error ? e.message : '加载订单失败');
});
}, [orderId]);
async function pay() {
if (!orderId) {
toast('订单不存在');
return;
}
if (needsWechatAuth) {
setMsg('请先完成微信授权后再支付');
const ready = await ensurePayReady(returnPath);
if (!ready) return;
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');
}
Taro.redirectTo({ url: '/pages/orders/index?tab=paid' });
}, 800);
} catch (e) {
if (isWechatAuthRequiredError(e)) {
setNeedsWechatAuth(true);
setMsg('微信支付需要先完成微信授权');
await ensurePayReady(returnPath);
return;
}
const message = e instanceof Error ? e.message : '支付失败';
setMsg(message);
toast(message);
} finally {
setLoading(false);
}
}
return (
@@ -23,23 +115,36 @@ export default function PayPage() {
<View className="pay-status-icon">
<Text>¥</Text>
</View>
<Text className="pay-status-title"></Text>
<Text className="pay-status-amount">¥{amount}</Text>
<Text className="pay-status-title">
{needsWechatAuth ? '需完成微信授权' : '待支付'}
</Text>
<Text className="pay-status-amount">¥{payAmount}</Text>
</View>
<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"></Text>
<Text className="order-row-value">
{mockMode ? 'Mock 模式由服务端直接标记已付款' : '将调起微信收银台'}
</Text>
</View>
</View>
{msg ? <Text className="u-muted" style={{ display: 'block', marginTop: 12 }}>{msg}</Text> : null}
</View>
<View className="pay-bar">
<View className="order-confirm-submit" style={{ flex: 1 }} onClick={mockPay}>
<Text></Text>
<View
className="order-confirm-submit"
style={{ flex: 1, opacity: loading ? 0.7 : 1 }}
onClick={() => !loading && void pay()}
>
<Text>{loading ? '支付中…' : needsWechatAuth ? '去授权' : '立即支付'}</Text>
</View>
</View>
</PageShell>
@@ -5,7 +5,9 @@ import type { ProductDetailContentDto } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import PageNavBar from '../../components/PageNavBar';
import ProductCarousel from '../../components/ProductCarousel';
import { request, toast } from '../../lib/api';
import { goLogin } from '../../lib/auth-nav';
import { ensurePayReady } from '../../lib/pay-ready';
import { isLoggedIn, request, toast } from '../../lib/api';
import {
getProductCarouselImages,
getProductDetailImages,
@@ -53,9 +55,16 @@ export default function ProductDetailPage() {
Taro.switchTab({ url: '/pages/home/index' });
}
function goBuy() {
async function goBuy() {
if (!productId) return;
Taro.navigateTo({ url: `/pages/order-confirm/index?productId=${productId}&qty=2` });
const returnPath = `/pages/order-confirm/index?productId=${productId}&qty=2`;
if (!isLoggedIn()) {
goLogin(returnPath);
return;
}
const ready = await ensurePayReady(returnPath);
if (!ready) return;
Taro.navigateTo({ url: returnPath });
}
if (!product) {
@@ -168,7 +177,7 @@ export default function ProductDetailPage() {
<Image className="product-detail-bar-home-icon" src={iconHome} mode="aspectFit" />
<Text className="product-detail-bar-home-label"></Text>
</View>
<View className="product-detail-buy-btn" onClick={goBuy}>
<View className="product-detail-buy-btn" onClick={() => void goBuy()}>
<Text className="product-detail-buy-btn-text"></Text>
</View>
</View>
@@ -105,19 +105,7 @@ export default function StoreDetailPage() {
<View className="store-detail-bar">
<View
className="store-detail-bar-btn store-detail-bar-btn--ghost"
onClick={() => {
if (store.phone) {
Taro.makePhoneCall({ phoneNumber: store.phone }).catch(() => toast('无法拨打'));
} else {
toast('暂无联系电话');
}
}}
>
<Text></Text>
</View>
<View
className="store-detail-bar-btn store-detail-bar-btn--primary"
className="store-detail-bar-btn store-detail-bar-btn--primary store-detail-bar-btn--full"
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
>
<Text></Text>
+27 -7
View File
@@ -4,13 +4,23 @@ import Taro, { useDidShow } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import RegionPicker from '../../components/RegionPicker';
import {
DEFAULT_REGION,
formatRegionLabel,
matchesRegionFilter,
type RegionSelection,
} from '../../lib/region-data';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
import { request, toast } from '../../lib/api';
type Store = {
id: string;
name: string;
address?: string;
province?: string;
cityName?: string;
district?: string;
coverUrl?: string | null;
openTime?: string | null;
@@ -26,21 +36,30 @@ export default function StoresPage() {
const [loading, setLoading] = useState(true);
const [categoryTab, setCategoryTab] = useState<string>('全部');
const [keyword, setKeyword] = useState('');
const [regionLabel, setRegionLabel] = useState('郑州市 · 全部区域');
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
const [regionOpen, setRegionOpen] = useState(false);
const [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE);
const regionLabel = formatRegionLabel(region);
useDidShow(() => {
syncTabBarSelected(1);
void resolveUserCity().then((resolved) => {
setRegion(resolved.region);
setCityCode(getCityCodeForCatalog(resolved));
});
});
useEffect(() => {
request<Store[]>('/stores')
setLoading(true);
const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores';
request<Store[]>(path)
.then((list) => setStores(Array.isArray(list) ? list : []))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, []);
}, [cityCode]);
const filtered = stores.filter((s) => {
if (!matchesRegionFilter(s, region)) return false;
if (!keyword.trim()) return true;
const q = keyword.trim();
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
@@ -59,11 +78,11 @@ export default function StoresPage() {
<View className="store-toolbar">
<View className="store-location" onClick={() => setRegionOpen(true)}>
<View className="store-location-pin" />
<Text>{regionLabel} </Text>
<Text className="store-location-text">{regionLabel} </Text>
</View>
<Input
className="store-search"
placeholder="搜索门店名称或地址"
placeholder="搜索门店"
value={keyword}
onInput={(e) => setKeyword(e.detail.value)}
/>
@@ -123,9 +142,10 @@ export default function StoresPage() {
{shouldRenderPageTabBar() ? <UserTabBar selected={1} /> : null}
<RegionPicker
open={regionOpen}
valueLabel="郑州市"
value={region}
levels={3}
onClose={() => setRegionOpen(false)}
onConfirm={(label) => setRegionLabel(`${label} · 全部区域`)}
onConfirm={(next) => setRegion(next)}
/>
</PageShell>
);
+118
View File
@@ -105,3 +105,121 @@
font-size: 15px;
box-sizing: border-box;
}
.address-form-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 var(--space-page) 12px;
padding: 12px 14px;
border-radius: var(--radius-md);
background: var(--color-card);
box-shadow: var(--shadow-card);
}
.address-form-error {
display: block;
margin: -6px var(--space-page) 12px;
font-size: 12px;
color: var(--color-heritage-red);
}
.region-picker-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(26, 26, 26, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
}
.region-picker-sheet {
width: 100%;
background: #fff;
border-radius: 16px 16px 0 0;
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 8px);
max-height: 56vh;
display: flex;
flex-direction: column;
}
.region-picker-toolbar {
display: flex;
align-items: stretch;
padding: 0 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
flex-shrink: 0;
}
.region-picker-tabs {
flex: 1;
display: flex;
align-items: stretch;
gap: 28px;
min-width: 0;
}
.region-picker-tab {
position: relative;
padding: 14px 0 12px;
font-size: 15px;
line-height: 22px;
color: var(--color-on-surface-variant);
white-space: nowrap;
}
.region-picker-tab.active {
color: var(--color-on-surface);
font-weight: 500;
}
.region-picker-tab.active::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: var(--color-heritage-red);
border-radius: 1px;
}
.region-picker-tab.disabled {
opacity: 0.45;
}
.region-picker-confirm {
flex-shrink: 0;
padding: 14px 0 12px 12px;
font-size: 15px;
line-height: 22px;
color: var(--color-on-surface-variant);
}
.region-picker-confirm.ready {
color: var(--color-heritage-red);
font-weight: 500;
}
.region-picker-list {
flex: 1;
min-height: 0;
max-height: 40vh;
padding: 4px 0 8px;
}
.region-picker-option {
padding: 14px 20px;
font-size: 16px;
line-height: 24px;
color: var(--color-on-surface);
}
.region-picker-option.selected {
color: var(--color-heritage-red);
}
.region-picker-option--all {
font-weight: 500;
}
+20 -1
View File
@@ -34,7 +34,7 @@
.home-aroma-nav {
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
gap: 8px;
padding: 8px var(--space-page);
background: rgba(250, 249, 247, 0.95);
@@ -44,6 +44,25 @@
z-index: 40;
}
.home-aroma-tabs {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.home-aroma-city {
flex-shrink: 0;
font-size: 11px;
color: var(--color-subtle-gray);
pointer-events: none;
max-width: 72px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-aroma-tab {
border: none;
background: transparent;
+7 -9
View File
@@ -18,24 +18,22 @@
.login-logo-wrap {
position: relative;
width: 120px;
height: 120px;
width: 200px;
height: 72px;
margin-bottom: 28px;
}
.login-logo {
width: 100%;
height: 100%;
border-radius: var(--radius-lg);
background: var(--color-card);
box-shadow: var(--shadow-card);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-headline);
font-size: 48px;
font-weight: 700;
color: var(--color-heritage-red);
}
.login-logo-img {
width: 100%;
height: 100%;
}
.login-logo-badge {
+35 -7
View File
@@ -5,7 +5,7 @@
.mine-header {
position: relative;
padding: 24px var(--space-page) 80px;
padding: 20px var(--space-page) 52px;
background: linear-gradient(135deg, #820012 0%, var(--color-heritage-red) 40%, #d4a373 100%);
overflow: hidden;
}
@@ -31,6 +31,34 @@
z-index: 1;
}
.mine-avatar-wrap {
position: relative;
flex-shrink: 0;
margin-right: 14px;
}
.mine-avatar-badge {
position: absolute;
right: -4px;
bottom: -2px;
background: var(--color-heritage-red);
color: #fff;
font-size: 9px;
padding: 2px 6px;
border-radius: 999px;
}
.mine-member-tag--wechat {
background: rgba(255, 255, 255, 0.25);
}
.mine-wechat-hint {
display: block;
margin-top: 6px;
font-size: 10px;
color: rgba(255, 255, 255, 0.75);
}
.mine-avatar {
width: 72px;
height: 72px;
@@ -72,18 +100,18 @@
}
.mine-main {
margin-top: -48px;
margin-top: -36px;
position: relative;
z-index: 2;
padding: 0 var(--space-page) 24px;
padding: 0 var(--space-page) 12px;
}
.mine-card {
background: var(--color-card);
border-radius: var(--radius-lg);
padding: 16px;
padding: 14px;
box-shadow: var(--shadow-card);
margin-bottom: 12px;
margin-bottom: 10px;
}
.mine-card-head {
@@ -230,14 +258,14 @@
.mine-footer {
text-align: center;
padding: 16px 0 8px;
padding: 8px 0 4px;
}
.mine-version {
display: block;
font-size: 12px;
color: var(--color-subtle-gray);
margin-bottom: 12px;
margin-bottom: 8px;
}
.mine-logout {
+14 -7
View File
@@ -25,7 +25,6 @@
position: sticky;
top: 0;
z-index: 50;
padding-left: var(--space-page);
background: var(--color-background);
box-shadow: var(--shadow-card);
box-sizing: border-box;
@@ -40,12 +39,15 @@
}
.tab-main-header__title {
position: absolute;
left: var(--nav-padding-left);
right: var(--nav-padding-right);
text-align: center;
font-family: var(--font-headline);
font-size: 18px;
font-weight: 700;
color: var(--color-heritage-red);
line-height: 1.2;
max-width: 52vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -78,7 +80,6 @@
left: 0;
right: 0;
z-index: 50;
padding-left: var(--space-page);
background: transparent;
box-sizing: border-box;
transition: background 0.3s, box-shadow 0.3s;
@@ -90,6 +91,7 @@
}
.page-nav-bar__content {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
@@ -129,9 +131,10 @@
}
.page-nav-bar__title {
flex: 1;
position: absolute;
left: var(--nav-padding-left);
right: var(--nav-padding-right);
opacity: 0;
padding: 0 8px;
font-family: var(--font-headline);
font-size: 16px;
font-weight: 600;
@@ -141,6 +144,7 @@
white-space: nowrap;
text-align: center;
transition: opacity 0.3s;
pointer-events: none;
}
.page-nav-bar__title--visible {
@@ -157,7 +161,6 @@
position: sticky;
top: 0;
z-index: 50;
padding-left: var(--space-page);
background: var(--color-background);
box-shadow: var(--shadow-card);
box-sizing: border-box;
@@ -192,14 +195,18 @@
}
.sub-page-header__title {
position: absolute;
left: var(--nav-padding-left);
right: var(--nav-padding-right);
text-align: center;
font-family: var(--font-headline);
font-size: 18px;
font-weight: 700;
color: var(--color-heritage-red);
max-width: 60vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
}
.sub-page-header__right {
+38
View File
@@ -250,3 +250,41 @@
font-size: 12px;
color: var(--color-on-surface);
}
.cs-body {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 48px;
text-align: center;
}
.cs-title {
font-size: 14px;
color: var(--color-subtle-gray);
margin-bottom: 12px;
}
.cs-phone {
font-family: var(--font-headline);
font-size: 32px;
font-weight: 700;
color: var(--color-heritage-red);
margin-bottom: 8px;
letter-spacing: 1px;
}
.cs-hint {
font-size: 12px;
color: var(--color-subtle-gray);
margin-bottom: 32px;
}
.cs-call-btn {
padding: 12px 32px;
border-radius: 999px;
background: var(--color-heritage-red);
color: #fff;
font-size: 15px;
font-weight: 600;
}
@@ -146,3 +146,8 @@
background: var(--color-heritage-red);
color: #fff;
}
.store-detail-bar-btn--full {
flex: 1;
margin-right: 0;
}
+17 -5
View File
@@ -4,13 +4,17 @@
}
.store-toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 var(--space-page) 12px;
}
.store-location {
display: flex;
align-items: center;
margin-bottom: 10px;
flex-shrink: 0;
max-width: 42%;
color: var(--color-on-surface-variant);
font-size: 12px;
font-weight: 500;
@@ -22,17 +26,25 @@
border-radius: 50%;
background: var(--color-heritage-red);
margin-right: 6px;
flex-shrink: 0;
}
.store-location-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-search {
height: 44px;
padding: 0 16px 0 40px;
flex: 1;
min-width: 0;
height: 40px;
padding: 0 12px;
border-radius: var(--radius-md);
background: var(--color-surface-container-low);
font-size: 14px;
font-size: 13px;
color: var(--color-on-surface);
box-sizing: border-box;
width: 100%;
}
.store-category-tabs {
+12
View File
@@ -33,6 +33,18 @@ export interface AppConfig {
/** 推广码 / C 端 H5 默认落地页(未配置 USER_H5_URL 时使用) */
export const DEFAULT_USER_H5_URL = 'https://user.runxian.top/user';
/** 品牌 Logo OSS 根路径(改环境时只改此处) */
export const BRAND_LOGO_OSS_BASE = 'https://dukang-dev.oss-cn-beijing.aliyuncs.com/logo/';
/** 方形 Logo(首页等品牌展示;商品列表顶栏仍用文字标题) */
export const BRAND_LOGO_URL = `${BRAND_LOGO_OSS_BASE}logo.png`;
/** 长方形 Logo(含文字,登录等场景) */
export const BRAND_LOGO_WIDE_URL = `${BRAND_LOGO_OSS_BASE}logo1.png`;
/** 仅图标 Logo(默认头像:未微信授权时) */
export const BRAND_LOGO_MARK_URL = `${BRAND_LOGO_OSS_BASE}logo2.png`;
/** 总部客服电话(C 端联系客服) */
export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
+3
View File
@@ -307,6 +307,9 @@ importers:
'@tarojs/taro':
specifier: 4.2.0
version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15))
element-china-area-data:
specifier: ^6.1.0
version: 6.1.0
react:
specifier: ^18.3.1
version: 18.3.1
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

@@ -5,6 +5,7 @@ import {
BindPhoneDto,
BindWechatDto,
BindWechatPhoneDto,
MiniWechatProfileDto,
BootstrapSessionDto,
CheckPartnerPhoneDto,
LoginSmsDto,
@@ -96,6 +97,23 @@ export class UserAuthController {
);
}
@Post('auth/wechat/mini-profile')
@UseGuards(JwtAuthGuard)
updateMiniWechatProfile(
@Req() req: Request,
@CurrentUser() user: AuthUser,
@Body() dto: MiniWechatProfileDto,
) {
if (user.actorType !== 'USER') {
throw new BadRequestException('仅用户可更新资料');
}
const clientApp = resolveUserClientApp(req);
if (clientApp !== ClientApp.USER_MINI) {
throw new BadRequestException('仅小程序端可调用');
}
return this.authService.updateMiniWechatProfile(user.actorId, dto);
}
@Get('auth/me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
@@ -1240,6 +1240,54 @@ export class AuthService {
return this.buildSessionResponse(user, clientApp, user.deviceKey);
}
async updateMiniWechatProfile(
userId: bigint,
input: { nickname?: string; avatarUrl?: string },
) {
const user = await this.assertActiveUser(userId);
if (!user.wxOpenId) {
throw new BadRequestException('请先完成微信授权');
}
const data: {
nickname?: string;
avatarResourceId?: bigint;
} = {};
const nickname = input.nickname?.trim();
if (nickname && this.isDefaultNickname(user.nickname)) {
data.nickname = nickname.slice(0, 64);
}
const avatarUrl = input.avatarUrl?.trim();
if (avatarUrl && !user.avatarResourceId) {
const avatar = await this.prisma.commonResource.create({
data: {
ownerType: 'USER',
ownerId: userId,
bizType: 'AVATAR',
mediaType: 'IMAGE',
ossBucket: 'wechat',
ossKey: `wx-avatar/${user.wxOpenId}`,
url: avatarUrl,
status: 'ACTIVE',
},
});
data.avatarResourceId = avatar.id;
}
if (!data.nickname && !data.avatarResourceId) {
return this.formatUserProfile(user);
}
const updated = await this.prisma.user.update({
where: { id: userId },
data,
include: { avatar: true },
});
return this.formatUserProfile(updated);
}
async bindUserWechat(
userId: bigint,
input: { code?: string; wxSessionKey?: string },
@@ -94,6 +94,16 @@ export class BindWechatDto {
platform?: 'h5' | 'mini';
}
export class MiniWechatProfileDto {
@IsString()
@IsOptional()
nickname?: string;
@IsString()
@IsOptional()
avatarUrl?: string;
}
export class CheckPartnerPhoneDto {
@IsString()
@IsNotEmpty()