小程序修改
This commit is contained in:
@@ -121,4 +121,6 @@ export type UserProfile = {
|
||||
phone?: string | null;
|
||||
nickname?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
phoneVerified?: boolean;
|
||||
hasWechat?: boolean;
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
/* 用户拒绝或上报失败时不阻断主流程 */
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user