diff --git a/apps/mini-user/package.json b/apps/mini-user/package.json index 0e6e508..f4c422d 100644 --- a/apps/mini-user/package.json +++ b/apps/mini-user/package.json @@ -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", diff --git a/apps/mini-user/src/app.config.ts b/apps/mini-user/src/app.config.ts index 57d7d6c..6c732f2 100644 --- a/apps/mini-user/src/app.config.ts +++ b/apps/mini-user/src/app.config.ts @@ -26,6 +26,12 @@ export default defineAppConfig({ navigationBarTextStyle: 'black', backgroundColor: '#FAF9F7', }, + permission: { + 'scope.userLocation': { + desc: '用于展示您所在城市的商品与门店', + }, + }, + requiredPrivateInfos: ['getLocation'], tabBar: { custom: false, color: '#999999', diff --git a/apps/mini-user/src/components/PageNavBar.tsx b/apps/mini-user/src/components/PageNavBar.tsx index 11e8992..02de225 100644 --- a/apps/mini-user/src/components/PageNavBar.tsx +++ b/apps/mini-user/src/components/PageNavBar.tsx @@ -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 ( 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(value); + const [activeTab, setActiveTab] = useState('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 ( - + e.stopPropagation()}> - - - 取消 - - 选择区域 + + + {tabs.map((tab) => { + const disabled = + (tab.key === 'city' && !draft.province) || + (tab.key === 'district' && (!draft.province || !draft.city)); + return ( + !disabled && onTabClick(tab.key)} + > + {tabLabel(tab.key, draft, tab.label)} + + ); + })} + { - onConfirm?.(valueLabel); - onClose(); - }} + className={`region-picker-confirm${canConfirm ? ' ready' : ''}`} + onClick={() => canConfirm && handleConfirm()} > 确定 - - {valueLabel} - - 完整省市区级联后续接入 - - + + + {listItems.map((item) => ( + onSelectItem(item)} + > + {item} + + ))} + ); diff --git a/apps/mini-user/src/components/SubPageHeader.tsx b/apps/mini-user/src/components/SubPageHeader.tsx index 9fe7762..0e274e6 100644 --- a/apps/mini-user/src/components/SubPageHeader.tsx +++ b/apps/mini-user/src/components/SubPageHeader.tsx @@ -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 ( - + ) { 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 }); }); diff --git a/apps/mini-user/src/lib/checkout-nav.ts b/apps/mini-user/src/lib/checkout-nav.ts new file mode 100644 index 0000000..64292ac --- /dev/null +++ b/apps/mini-user/src/lib/checkout-nav.ts @@ -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): CheckoutContext { + return { + productId: params.productId, + qty: params.qty, + addressId: params.addressId, + cross: params.cross === '1', + select: params.select === '1', + }; +} diff --git a/apps/mini-user/src/lib/mini-wechat-profile.ts b/apps/mini-user/src/lib/mini-wechat-profile.ts new file mode 100644 index 0000000..aea719b --- /dev/null +++ b/apps/mini-user/src/lib/mini-wechat-profile.ts @@ -0,0 +1,34 @@ +import Taro from '@tarojs/taro'; +import { request } from './api'; + +type MiniProfilePayload = { + nickname?: string; + avatarUrl?: string; +}; + +/** 小程序授权后拉取微信昵称/头像并上报服务端 */ +export async function syncMiniWechatProfile(): Promise { + 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 { + /* 用户拒绝或上报失败时不阻断主流程 */ + } +} diff --git a/apps/mini-user/src/lib/nav-bar.ts b/apps/mini-user/src/lib/nav-bar.ts index 9d79c8b..5af75bf 100644 --- a/apps/mini-user/src/lib/nav-bar.ts +++ b/apps/mini-user/src/lib/nav-bar.ts @@ -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 '--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 { 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 { + 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`, + }; +} diff --git a/apps/mini-user/src/lib/pay-ready.ts b/apps/mini-user/src/lib/pay-ready.ts new file mode 100644 index 0000000..e988a1d --- /dev/null +++ b/apps/mini-user/src/lib/pay-ready.ts @@ -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 { + 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; + } +} diff --git a/apps/mini-user/src/lib/pay-wechat.ts b/apps/mini-user/src/lib/pay-wechat.ts new file mode 100644 index 0000000..2815c8f --- /dev/null +++ b/apps/mini-user/src/lib/pay-wechat.ts @@ -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 { + return request('/common/client-config'); +} + +export async function fetchUserProfile(): Promise { + return request('/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 { + 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(`/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 { + const res = await Taro.login(); + if (!res.code) { + throw new Error(res.errMsg || '微信授权失败'); + } + const data = await request('/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 }; +} diff --git a/apps/mini-user/src/lib/phone.ts b/apps/mini-user/src/lib/phone.ts new file mode 100644 index 0000000..2f5bc30 --- /dev/null +++ b/apps/mini-user/src/lib/phone.ts @@ -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'); +} diff --git a/apps/mini-user/src/lib/region-data.ts b/apps/mini-user/src/lib/region-data.ts new file mode 100644 index 0000000..d12f6e9 --- /dev/null +++ b/apps/mini-user/src/lib/region-data.ts @@ -0,0 +1,166 @@ +import { regionData } from 'element-china-area-data'; + +export type RegionTree = Record>; + +function buildRegionTree(): RegionTree { + const tree: RegionTree = {}; + for (const province of regionData) { + const cities: Record = {}; + 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; +} diff --git a/apps/mini-user/src/lib/user-location.ts b/apps/mini-user/src/lib/user-location.ts new file mode 100644 index 0000000..d00bfc5 --- /dev/null +++ b/apps/mini-user/src/lib/user-location.ts @@ -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 { + return new Promise((resolve, reject) => { + Taro.getLocation({ + type: 'gcj02', + success: resolve, + fail: reject, + }); + }); +} + +/** 获取并解析用户当前城市;失败返回郑州市兜底 */ +export async function resolveUserCity(force = false): Promise { + 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; +} diff --git a/apps/mini-user/src/lib/user-phone.ts b/apps/mini-user/src/lib/user-phone.ts new file mode 100644 index 0000000..a873b4e --- /dev/null +++ b/apps/mini-user/src/lib/user-phone.ts @@ -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 ''; + } +} diff --git a/apps/mini-user/src/lib/wechat-auth.ts b/apps/mini-user/src/lib/wechat-auth.ts index d2ded0c..2810253 100644 --- a/apps/mini-user/src/lib/wechat-auth.ts +++ b/apps/mini-user/src/lib/wechat-auth.ts @@ -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 { @@ -8,8 +9,14 @@ export async function loginWithWechat(): Promise { if (!res.code) { throw new Error(res.errMsg || '微信登录失败,未获取到 code'); } - return request('/auth/login/wechat', { + const result = await request('/auth/login/wechat', { method: 'POST', data: { code: res.code, platform: 'mini' }, }); + if (result.accessToken) { + await syncMiniWechatProfile(); + } + return result; } + +export { bindWechatForUser } from './pay-wechat'; diff --git a/apps/mini-user/src/pages/address-edit/index.tsx b/apps/mini-user/src/pages/address-edit/index.tsx index 40eaf89..63e9c2d 100644 --- a/apps/mini-user/src/pages/address-edit/index.tsx +++ b/apps/mini-user/src/pages/address-edit/index.tsx @@ -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({ + 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('/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>>('/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() { setName(e.detail.value)} + value={form.receiverName} + onInput={(e) => setForm((prev) => ({ ...prev, receiverName: e.detail.value }))} /> @@ -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) })) + } /> @@ -51,9 +157,9 @@ export default function AddressEditPage() { toast('区域选择器后续接入')} + onClick={() => setPickerOpen(true)} > - {region || '请选择省市区'} + {regionText || '请选择省市区'} @@ -61,14 +167,31 @@ export default function AddressEditPage() {