小程序修改

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
+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';