feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
import { createUserTracker, getSessionId } from '@dukang/client-logging';
|
||||
import { apiBase } from './api';
|
||||
|
||||
const tracker = createUserTracker({
|
||||
apiBase,
|
||||
clientApp: 'USER_H5',
|
||||
});
|
||||
|
||||
export { getSessionId };
|
||||
|
||||
export function track(eventName: string, params?: Record<string, unknown>) {
|
||||
tracker.track(eventName, params);
|
||||
}
|
||||
|
||||
export function trackPageView(eventName: string, params?: Record<string, unknown>) {
|
||||
tracker.trackPageView(eventName, params);
|
||||
}
|
||||
|
||||
export function initUserAnalytics() {
|
||||
tracker.trackSessionStart();
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
export const BRAND = {
|
||||
red: '#A02D30',
|
||||
yellow: '#FFC107',
|
||||
bg: '#f5f5f5',
|
||||
text: '#333',
|
||||
muted: '#999',
|
||||
};
|
||||
|
||||
export const apiBase = '/api/v1';
|
||||
const CLIENT_APP = 'USER_H5';
|
||||
|
||||
export type UserProfile = {
|
||||
id: string;
|
||||
userNo: string;
|
||||
phone: string | null;
|
||||
phoneVerified: boolean;
|
||||
nickname: string | null;
|
||||
avatarUrl: string | null;
|
||||
hasWechat: boolean;
|
||||
};
|
||||
|
||||
export type SessionPayload = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
deviceKey?: string;
|
||||
phoneVerified: boolean;
|
||||
user?: UserProfile;
|
||||
};
|
||||
|
||||
const DEVICE_KEY = 'deviceKey';
|
||||
const ACCESS_TOKEN = 'accessToken';
|
||||
const REFRESH_TOKEN = 'refreshToken';
|
||||
|
||||
export function getDeviceKey() {
|
||||
return localStorage.getItem(DEVICE_KEY);
|
||||
}
|
||||
|
||||
export function saveSession(data: SessionPayload) {
|
||||
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||
if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey);
|
||||
}
|
||||
|
||||
export function saveAuth(data: { accessToken: string; refreshToken?: string; deviceKey?: string }) {
|
||||
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||
if (data.deviceKey) localStorage.setItem(DEVICE_KEY, data.deviceKey);
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem(ACCESS_TOKEN);
|
||||
localStorage.removeItem(REFRESH_TOKEN);
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
const AUTH_RECOVERY_EXEMPT_PATHS = ['/auth/session/bootstrap', '/auth/token/refresh'];
|
||||
|
||||
async function recoverSession(): Promise<SessionPayload> {
|
||||
const refreshed = await refreshSession();
|
||||
if (refreshed) return refreshed;
|
||||
clearAuth();
|
||||
return bootstrapSession();
|
||||
}
|
||||
|
||||
async function rawRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
token?: string | null,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': CLIENT_APP,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
const authToken = token ?? localStorage.getItem(ACCESS_TOKEN);
|
||||
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) {
|
||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||
err.status = json.code;
|
||||
throw err;
|
||||
}
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
async function requestWithAuthRetry<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
retried = false,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await rawRequest<T>(path, options);
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
const canRecover =
|
||||
err.status === 401 &&
|
||||
!retried &&
|
||||
!AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p));
|
||||
if (!canRecover) throw e;
|
||||
await recoverSession();
|
||||
return requestWithAuthRetry<T>(path, options, true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
_clientApp: string,
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
if (!isLoggedIn() && !AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p))) {
|
||||
await bootstrapSession();
|
||||
}
|
||||
return requestWithAuthRetry<T>(path, options);
|
||||
}
|
||||
|
||||
export async function bootstrapSession(): Promise<SessionPayload> {
|
||||
const deviceKey = getDeviceKey();
|
||||
const data = await rawRequest<SessionPayload>(
|
||||
'/auth/session/bootstrap',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(deviceKey ? { deviceKey } : {}),
|
||||
},
|
||||
null,
|
||||
);
|
||||
saveSession(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function refreshSession(): Promise<SessionPayload | null> {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const data = await rawRequest<SessionPayload>(
|
||||
'/auth/token/refresh',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
},
|
||||
null,
|
||||
);
|
||||
saveSession(data);
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSession(): Promise<SessionPayload> {
|
||||
if (isLoggedIn()) {
|
||||
try {
|
||||
const me = await rawRequest<UserProfile>('/auth/me');
|
||||
return {
|
||||
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||||
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||
deviceKey: getDeviceKey() ?? undefined,
|
||||
phoneVerified: !!me.phoneVerified,
|
||||
user: me,
|
||||
};
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
if (err.status === 401) {
|
||||
clearAuth();
|
||||
} else {
|
||||
const refreshed = await refreshSession();
|
||||
if (refreshed) return refreshed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bootstrapSession();
|
||||
}
|
||||
|
||||
export async function bindPhone(phone: string, code: string): Promise<SessionPayload> {
|
||||
if (!isLoggedIn()) {
|
||||
await bootstrapSession();
|
||||
}
|
||||
const data = await requestWithAuthRetry<SessionPayload>('/auth/phone/bind', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
saveSession(data);
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getWechatLocation } from '@dukang/weixin-sdk';
|
||||
import { weixinSdk } from './weixin';
|
||||
|
||||
export type ClientGpsLocation = {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
/** 尝试获取客户端 GPS(微信优先,其次 H5 Geolocation),失败返回 null 不阻塞下单 */
|
||||
export async function tryGetClientGpsLocation(): Promise<ClientGpsLocation | null> {
|
||||
const loc = await weixinSdk.getLocation();
|
||||
if (!loc) return null;
|
||||
return {
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated 使用 tryGetClientGpsLocation */
|
||||
export { getWechatLocation };
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types';
|
||||
import { isWechatEnv } from './weixin';
|
||||
|
||||
/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */
|
||||
export function getCustomerServiceWecomUrl(): string {
|
||||
const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim();
|
||||
return fromEnv || CUSTOMER_SERVICE_WECOM_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开企业微信客服会话(须在微信内;需用户点击手势)。
|
||||
* @returns true 已跳转;false 非微信环境已提示
|
||||
*/
|
||||
export function openWecomCustomerService(): boolean {
|
||||
if (!isWechatEnv()) {
|
||||
window.alert('请在微信中打开以联系在线客服');
|
||||
return false;
|
||||
}
|
||||
window.location.href = getCustomerServiceWecomUrl();
|
||||
return true;
|
||||
}
|
||||
|
||||
export { CUSTOMER_SERVICE_PHONE };
|
||||
@@ -0,0 +1,62 @@
|
||||
export type CheckoutContext = {
|
||||
productId?: string | null;
|
||||
qty?: string | number | null;
|
||||
addressId?: string | null;
|
||||
cross?: boolean | string | null;
|
||||
select?: boolean | string | null;
|
||||
};
|
||||
|
||||
export function readCheckoutContext(params: URLSearchParams): CheckoutContext {
|
||||
return {
|
||||
productId: params.get('productId'),
|
||||
qty: params.get('qty'),
|
||||
addressId: params.get('addressId'),
|
||||
cross: params.get('cross'),
|
||||
select: params.get('select'),
|
||||
};
|
||||
}
|
||||
|
||||
export function appendCheckoutContext(qs: URLSearchParams, ctx: CheckoutContext) {
|
||||
if (ctx.productId) qs.set('productId', ctx.productId);
|
||||
if (ctx.qty != null && ctx.qty !== '') qs.set('qty', String(ctx.qty));
|
||||
if (ctx.addressId) qs.set('addressId', ctx.addressId);
|
||||
if (ctx.cross === true || ctx.cross === '1') qs.set('cross', '1');
|
||||
if (ctx.select === true || ctx.select === '1') qs.set('select', '1');
|
||||
}
|
||||
|
||||
export function buildOrderConfirmUrl(search: CheckoutContext) {
|
||||
const qs = new URLSearchParams();
|
||||
if (search.productId) qs.set('productId', search.productId);
|
||||
if (search.qty != null && search.qty !== '') qs.set('qty', String(search.qty));
|
||||
if (search.addressId) qs.set('addressId', search.addressId);
|
||||
if (search.cross === true || search.cross === '1') qs.set('cross', '1');
|
||||
const query = qs.toString();
|
||||
return query ? `/order/confirm?${query}` : '/order/confirm';
|
||||
}
|
||||
|
||||
export function buildAddressListUrl(ctx: CheckoutContext = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
appendCheckoutContext(qs, ctx);
|
||||
const query = qs.toString();
|
||||
return query ? `/addresses?${query}` : '/addresses';
|
||||
}
|
||||
|
||||
export function buildAddressEditUrl(id: string | 'new', ctx: CheckoutContext = {}) {
|
||||
const path = id === 'new' ? '/addresses/new' : `/addresses/${id}/edit`;
|
||||
const qs = new URLSearchParams();
|
||||
appendCheckoutContext(qs, ctx);
|
||||
const query = qs.toString();
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
export function buildProductDetailUrl(productId?: string | null) {
|
||||
return productId ? `/product/${productId}` : '/';
|
||||
}
|
||||
|
||||
export function buildOrderAddressSelectUrl(orderId: string) {
|
||||
return `/addresses?orderId=${orderId}&select=1`;
|
||||
}
|
||||
|
||||
export function hasCheckoutContext(ctx: CheckoutContext) {
|
||||
return Boolean(ctx.productId || ctx.select === true || ctx.select === '1');
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Stitch 确认订单页商品缩略图 */
|
||||
export const STITCH_ORDER_PRODUCT_IMAGE =
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuAfqy5X1jKiMBB-L5amwR3xfLYbFBc_qsPbB9mdQZxWlV3rrOARPFVhLRDlW7r8Ig03O6c_ZJKLcVEsgYCblwKg8FZ4-EWwcc5bMNc3UsmBycu3bZ5E6S_aH9UBv0_nEP0sMD8rJsC_rMYBiGDMvRbd52taX-Ir_sfRiVvQu7ImFV-YvU54iXE2x51naVuR8qxwmK7YKitPClg0Pysga859a2-yiJ_ID0QR5xM2o84QbMwNyOEoDDTKSDNqG6J9jfeTsiIYb5vdVmc';
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { toAppPath } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import { request, saveSession, type UserProfile } from './api';
|
||||
|
||||
const WECHAT_AUTH_REQUIRED = 'WECHAT_AUTH_REQUIRED';
|
||||
|
||||
export function isWechatAuthRequiredError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message === WECHAT_AUTH_REQUIRED;
|
||||
}
|
||||
|
||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||
return request<ClientRuntimeConfig>('USER_H5', '/common/client-config');
|
||||
}
|
||||
|
||||
export async function fetchUserProfile(): Promise<UserProfile> {
|
||||
return request<UserProfile>('USER_H5', '/auth/me');
|
||||
}
|
||||
|
||||
/** 真实微信支付且未绑定微信时需要授权 */
|
||||
export function needsWechatAuthForPay(
|
||||
config: ClientRuntimeConfig,
|
||||
profile: UserProfile | null,
|
||||
): boolean {
|
||||
if (!isWxAuthorizeEnabled(config)) return false;
|
||||
return !config.mockPay && config.wechatPayEnabled && isWechatEnv() && !profile?.hasWechat;
|
||||
}
|
||||
|
||||
export function saveWechatLoginResult(result: WechatLoginResult): boolean {
|
||||
if (!result.accessToken) return false;
|
||||
saveSession({
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken ?? '',
|
||||
deviceKey: result.deviceKey,
|
||||
phoneVerified: !!result.phoneVerified,
|
||||
user: result.user as never,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function authorizeWechatForPay(): Promise<WechatLoginResult | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error('请在微信内打开以完成授权');
|
||||
}
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
export function buildLoginReturnUrl(pathname: string, search: string) {
|
||||
return `${toAppPath('/login')}?return=${encodeURIComponent(`${pathname}${search}`)}`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** 商品无图时的占位图 */
|
||||
export const PRODUCT_IMAGE_FALLBACK = '/images/1.png';
|
||||
|
||||
export type ProductImageSource = {
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
detailImageUrls?: string[] | null;
|
||||
};
|
||||
|
||||
function uniqueUrls(urls: Array<string | null | undefined>) {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const url of urls) {
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
result.push(url);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 首页/列表轮播图:优先 CAROUSEL,否则封面 */
|
||||
export function getProductImages(source?: ProductImageSource | null): string[] {
|
||||
const carousel = uniqueUrls(source?.carouselUrls ?? []);
|
||||
if (carousel.length > 0) return carousel;
|
||||
|
||||
const main = source?.mainImageUrl;
|
||||
if (main) return [main];
|
||||
|
||||
return [PRODUCT_IMAGE_FALLBACK];
|
||||
}
|
||||
|
||||
/** 单张主图:封面优先 */
|
||||
export function getProductMainImage(source?: ProductImageSource | null): string {
|
||||
return source?.mainImageUrl ?? source?.carouselUrls?.[0] ?? PRODUCT_IMAGE_FALLBACK;
|
||||
}
|
||||
|
||||
/** 详情页顶部轮播 */
|
||||
export function getProductCarouselImages(source?: ProductImageSource | null): string[] {
|
||||
const carousel = uniqueUrls(source?.carouselUrls ?? []);
|
||||
if (carousel.length > 0) return carousel;
|
||||
return getProductImages(source);
|
||||
}
|
||||
|
||||
/** 详情页图文长图 */
|
||||
export function getProductDetailImages(source?: ProductImageSource | null): string[] {
|
||||
return uniqueUrls(source?.detailImageUrls ?? []);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { apiBase } from './api';
|
||||
|
||||
export const PROMO_STORAGE_KEY = 'dukang_promo_code';
|
||||
export const PROMO_PID_STORAGE_KEY = 'dukang_promo_pid';
|
||||
|
||||
function readPromoFromSearch(search: string): { code: string | null; pid: string | null } {
|
||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
||||
const code = params.get('promo')?.trim();
|
||||
const pid = params.get('pid')?.trim();
|
||||
return {
|
||||
code: code ? code.toUpperCase() : null,
|
||||
pid: pid || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析 URL 中的 ?promo= / ?pid= 并写入 sessionStorage */
|
||||
export function capturePromoFromUrl(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
let parsed = readPromoFromSearch(window.location.search);
|
||||
if (!parsed.code && !parsed.pid && window.location.hash.includes('?')) {
|
||||
const hashQuery = window.location.hash.slice(window.location.hash.indexOf('?'));
|
||||
parsed = readPromoFromSearch(hashQuery);
|
||||
}
|
||||
if (parsed.code) {
|
||||
sessionStorage.setItem(PROMO_STORAGE_KEY, parsed.code);
|
||||
}
|
||||
if (parsed.pid) {
|
||||
sessionStorage.setItem(PROMO_PID_STORAGE_KEY, parsed.pid);
|
||||
}
|
||||
return parsed.code ?? sessionStorage.getItem(PROMO_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getStoredPromoCode(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return sessionStorage.getItem(PROMO_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getStoredPromoPid(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return sessionStorage.getItem(PROMO_PID_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/** 调用 /promo/touch 完成扫码归因(OptionalJwt:未登录也累加 scan_count) */
|
||||
export async function touchPromoIfNeeded(): Promise<void> {
|
||||
const promoCode = getStoredPromoCode();
|
||||
const qrcodeId = getStoredPromoPid();
|
||||
if (!promoCode && !qrcodeId) return;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': 'USER_H5',
|
||||
};
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/promo/touch`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
...(promoCode ? { promoCode } : {}),
|
||||
...(qrcodeId ? { qrcodeId } : {}),
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) return;
|
||||
} catch {
|
||||
/* 静默失败,不阻断用户流程 */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
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 const DEFAULT_REGION: RegionSelection = {
|
||||
province: '河南省',
|
||||
city: '郑州市',
|
||||
district: '金水区',
|
||||
};
|
||||
|
||||
export const FALLBACK_CITY_REGION: RegionSelection = {
|
||||
province: '河南省',
|
||||
city: '郑州市',
|
||||
district: REGION_ALL,
|
||||
};
|
||||
|
||||
export function regionFromGeo(province: string, city: string, district?: string): RegionSelection {
|
||||
const cityName = city.endsWith('市') ? city : `${city}市`;
|
||||
const provinceInTree = PROVINCES.includes(province) ? province : DEFAULT_REGION.province;
|
||||
const cities = getCities(provinceInTree);
|
||||
const matchedCity = cities.includes(cityName)
|
||||
? cityName
|
||||
: cities.find((c) => c.replace(/市$/, '') === city.replace(/市$/, '')) ?? cityName;
|
||||
const districts = getDistricts(provinceInTree, matchedCity);
|
||||
const districtName =
|
||||
district && districts.includes(district)
|
||||
? district
|
||||
: REGION_ALL;
|
||||
return normalizeRegionSelection({
|
||||
province: provinceInTree,
|
||||
city: cities.includes(matchedCity) ? matchedCity : matchedCity,
|
||||
district: districtName,
|
||||
});
|
||||
}
|
||||
|
||||
/** 校验已选地区是否仍存在于数据源中 */
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Stitch user/22 门店详情页 — 图集与地图占位 */
|
||||
export const STITCH_STORE_GALLERY = [
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDqN0DeYRsWNcXyfSRec8k2fhjJsqdji3-7zrtegkiEs5lwt3Sx4l79Uzmfys2pnl_gUY_m3Dpy5cAM8HW7JcR8qPtfO2G8YNcZ3x0DGSN1DUPJPq4emVhmIuwmaLEQ944UT9hjpNQsjdqieKV8R-X-2YvSOrsEa74kyfI5UNgRQaGdinhLw6co29ji3F9BRgfgWCQ1KqjotRBC4r9lzWBdeue-xryXvN8jEp_7hjwrBNOOZoIDPnKkpQQwLLpaa7Di6kfEfwzamCg',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuAW3oxOc6XpywVJmpwYxIPBlP32ftPIOUB8JbYcqOAcLg1gbzKIgbDgBaPVUyH0gjdoWa7Hi0u1-NBYBwc5Jd3YpqufVWIou_ySFB2oLXA6T0u7DgUWKhtxbMnqMue-oasf8GlEy_e7-Rh41ZxVFkc30tQVAYz-Psm3CNgfRFqXoHDXCZAZz5ggOFOB2dScURBVN9qp_Ribo4DuE4LARgf19R8eKZR8mbDQdHesLaVl0icifdcQEb75QM6-VqCR3ch9BNKzLJ5hKMM',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDuv4URDejJ5j26kuBPG2fqmOmI90qQomZki-aHr3MmdF47Pq5HM7tiH68E77rrF0XjeaZjkQ0e39j5gY1-N_981-eguGZn8VIRZI0n6t-f8QVIhAyjL8kg-5ZD2yRsfgw5mnOYYPMyNUI54efLiU4M6mni6nJvTAXMvX0oBMXtTItj5U66d9BIvie7VfHoVYMelEW9ppZsSRzA7ZoIu6aRp_72OwAIcTFuiI2zccaAmfTk7dChjjHIHZ85B8dDc2G8Tym34SuJAZc',
|
||||
] as const;
|
||||
|
||||
export const STITCH_STORE_MAP =
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuByauC4oncButUsGa_t2ntIVz-iPk9zVUnYA6_P_URyFYzrWALFa2TKfdpEyrGs61N_sEjRYksO_HeKCZGJQXfRhXqf1iXrk8JPIfzDwb33bDacTr2J0HM-cSnNjcM1c5l6r_yuzsE0zuLBZpuAWVPwkOJUkdfk6xxNpABh-OQ0B6736YmxFQM-WJ5h0eLHpRB7RuyTFr5c_TwTysKyY6QVDZ-oJrx9Vc3FQE7Pc64o3bzP6qW3GEqdqm4WVIAMKiNKqUcXvkN8E48';
|
||||
|
||||
export function getStoreGalleryImages(coverUrl?: string | null, media?: Array<{ url: string }>) {
|
||||
const fromMedia = (media || []).map((m) => m.url).filter(Boolean);
|
||||
if (fromMedia.length > 0) return fromMedia;
|
||||
if (coverUrl) return [coverUrl, ...STITCH_STORE_GALLERY.slice(1)];
|
||||
return [...STITCH_STORE_GALLERY];
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { apiBase } from './api';
|
||||
|
||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||
|
||||
export type UploadFileResult = {
|
||||
url: string;
|
||||
ossKey: string;
|
||||
bucket: string;
|
||||
mock: boolean;
|
||||
};
|
||||
|
||||
/** 经 API 服务端转存 OSS */
|
||||
export async function uploadFileToOss(
|
||||
file: File,
|
||||
options: { bizType: string; mediaType?: OssMediaType },
|
||||
): Promise<UploadFileResult> {
|
||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('bizType', options.bizType);
|
||||
formData.append('mediaType', mediaType);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'X-Client-App': 'USER_H5',
|
||||
};
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '上传失败');
|
||||
return json.data as UploadFileResult;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { SmsScene } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
import { validateMobilePhone } from './phone';
|
||||
|
||||
export function useSmsCode() {
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sentHint, setSentHint] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [mockSms, setMockSms] = useState(true);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClientConfig()
|
||||
.then((cfg) => setMockSms(cfg.mockSms))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startCooldown = useCallback(() => {
|
||||
setCodeCooldown(60);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = setInterval(() => {
|
||||
setCodeCooldown((c) => {
|
||||
if (c <= 1) {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
return 0;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const sendCode = useCallback(
|
||||
async (phone: string, scene: SmsScene) => {
|
||||
const phoneCheck = validateMobilePhone(phone);
|
||||
if (!phoneCheck.ok) {
|
||||
setError(phoneCheck.message ?? '请输入正确的手机号码');
|
||||
return false;
|
||||
}
|
||||
setSending(true);
|
||||
setError('');
|
||||
setSentHint('');
|
||||
try {
|
||||
await request('USER_H5', '/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene }),
|
||||
});
|
||||
setSentHint(mockSms ? '验证码已发送(开发模式)' : '验证码已发送,请注意查收');
|
||||
startCooldown();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '发送失败');
|
||||
return false;
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[mockSms, startCooldown],
|
||||
);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setError('');
|
||||
setSentHint('');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sendCode,
|
||||
sending,
|
||||
codeCooldown,
|
||||
sentHint,
|
||||
error,
|
||||
setError,
|
||||
clearMessages,
|
||||
mockSms,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { trackPageView } from './analytics';
|
||||
|
||||
export function usePageView(eventName: string, params?: Record<string, unknown>) {
|
||||
const fired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (fired.current) return;
|
||||
fired.current = true;
|
||||
trackPageView(eventName, params);
|
||||
}, [eventName, params]);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import {
|
||||
authorizeWechatForPay,
|
||||
fetchClientConfig,
|
||||
fetchUserProfile,
|
||||
needsWechatAuthForPay,
|
||||
saveWechatLoginResult,
|
||||
} from './pay-wechat';
|
||||
|
||||
export type WechatAuthEnsureResult =
|
||||
| { ok: true }
|
||||
| { ok: false; redirecting: true }
|
||||
| { ok: false; needBindPhone: true; wxSessionKey: string };
|
||||
|
||||
export async function checkNeedsWechatAuth(): Promise<boolean> {
|
||||
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
|
||||
return needsWechatAuthForPay(config, profile);
|
||||
}
|
||||
|
||||
/** 真实微信支付前确保已绑定微信;OAuth 跳转时返回 redirecting */
|
||||
export async function ensureWechatAuthForPay(): Promise<WechatAuthEnsureResult> {
|
||||
if (!isWechatEnv()) return { ok: true };
|
||||
if (!(await checkNeedsWechatAuth())) return { ok: true };
|
||||
|
||||
const result = await authorizeWechatForPay();
|
||||
if (!result) return { ok: false, redirecting: true };
|
||||
|
||||
if (result.needBindPhone && result.wxSessionKey) {
|
||||
return { ok: false, needBindPhone: true, wxSessionKey: result.wxSessionKey };
|
||||
}
|
||||
|
||||
if (saveWechatLoginResult(result)) {
|
||||
return { ok: true };
|
||||
}
|
||||
return { ok: false, redirecting: true };
|
||||
}
|
||||
|
||||
export async function handleWechatAuthCallback(): Promise<WechatLoginResult | null> {
|
||||
if (!isWechatEnv()) return null;
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return null;
|
||||
return weixinSdk.handleOAuthCallback();
|
||||
}
|
||||
|
||||
export async function loginWithWechatSdk(): Promise<WechatLoginResult | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error('请在微信内打开以使用微信一键授权');
|
||||
}
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
export function applyWechatLoginResult(result: WechatLoginResult): boolean {
|
||||
return saveWechatLoginResult(result);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { getWechatLocationDetailed } from '@dukang/weixin-sdk';
|
||||
import { apiBase } from './api';
|
||||
import { weixinSdk } from './weixin';
|
||||
import { regionFromGeo, type RegionSelection } from './region-data';
|
||||
|
||||
export const GPS_CITY_STORAGE_KEY = 'dukang_gps_city';
|
||||
export const CITY_STORAGE_KEY = 'dukang_selected_city';
|
||||
export const FALLBACK_CITY_CODE = '410100';
|
||||
|
||||
export type ResolvedUserCity = {
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity: boolean;
|
||||
region: RegionSelection;
|
||||
displayCity: string;
|
||||
};
|
||||
|
||||
type GpsCityCache = ResolvedUserCity & { timestamp: number };
|
||||
|
||||
function readCache(): GpsCityCache | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(GPS_CITY_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as GpsCityCache;
|
||||
if (Date.now() - parsed.timestamp > 30 * 60 * 1000) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(data: ResolvedUserCity) {
|
||||
sessionStorage.setItem(
|
||||
GPS_CITY_STORAGE_KEY,
|
||||
JSON.stringify({ ...data, timestamp: Date.now() } satisfies GpsCityCache),
|
||||
);
|
||||
}
|
||||
|
||||
async function reportLocationToServer(payload: {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
sdk: 'jssdk' | 'geolocation';
|
||||
status: 'success' | 'fail';
|
||||
errMsg?: string;
|
||||
}) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': 'USER_H5',
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/common/wechat/location`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) {
|
||||
throw new Error(json.message || '定位上报失败');
|
||||
}
|
||||
return json.data as {
|
||||
province?: string;
|
||||
city?: string;
|
||||
district?: string;
|
||||
cityCode?: string;
|
||||
cityName?: string;
|
||||
openCity?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取并解析用户当前城市(微信 JSSDK 优先),失败返回 null */
|
||||
export async function resolveUserCity(force = false): Promise<ResolvedUserCity | null> {
|
||||
if (!force) {
|
||||
const cached = readCache();
|
||||
if (cached) return cached;
|
||||
}
|
||||
|
||||
const outcome = await getWechatLocationDetailed({
|
||||
apiBase,
|
||||
clientApp: 'USER_H5',
|
||||
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||
});
|
||||
|
||||
if (!outcome.location) {
|
||||
await reportLocationToServer({
|
||||
sdk: outcome.sdk,
|
||||
status: 'fail',
|
||||
errMsg: outcome.errMsg,
|
||||
}).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await reportLocationToServer({
|
||||
latitude: outcome.location.latitude,
|
||||
longitude: outcome.location.longitude,
|
||||
sdk: outcome.sdk,
|
||||
status: 'success',
|
||||
});
|
||||
const resolved = toResolved(data);
|
||||
if (resolved) {
|
||||
writeCache(resolved);
|
||||
if (resolved.openCity && resolved.cityCode) {
|
||||
localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode);
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function syncCityCodeFromGps(resolved: ResolvedUserCity) {
|
||||
if (resolved.openCity && resolved.cityCode) {
|
||||
localStorage.setItem(CITY_STORAGE_KEY, resolved.cityCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { WechatShareData } from '@dukang/weixin-sdk';
|
||||
import { getWechatShareLink, toAppPath } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
|
||||
export const DEFAULT_SHARE_TITLE = '你吃饭,杜康买单';
|
||||
export const DEFAULT_SHARE_DESC = '杜康好客 · 买酒享权益,全城门店可用';
|
||||
export const WECHAT_SHARE_HINT = '请点击右上角 ··· 分享给好友';
|
||||
|
||||
export function getDefaultShareImageUrl(): string {
|
||||
if (typeof window === 'undefined') return toAppPath('/logo.png');
|
||||
return new URL(toAppPath('/logo.png'), window.location.origin).href;
|
||||
}
|
||||
|
||||
export function buildDefaultShareData(
|
||||
overrides?: Partial<WechatShareData>,
|
||||
): WechatShareData {
|
||||
return {
|
||||
title: overrides?.title ?? DEFAULT_SHARE_TITLE,
|
||||
desc: overrides?.desc ?? DEFAULT_SHARE_DESC,
|
||||
link: overrides?.link ?? getWechatShareLink(),
|
||||
imgUrl: overrides?.imgUrl ?? getDefaultShareImageUrl(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyDefaultWechatShare(
|
||||
overrides?: Partial<WechatShareData>,
|
||||
): Promise<void> {
|
||||
if (!isWechatEnv()) return;
|
||||
await weixinSdk.setShare(buildDefaultShareData(overrides));
|
||||
}
|
||||
|
||||
export function handleShareButtonClick(onHint: (message: string) => void): void {
|
||||
const showHint = (message: string) => {
|
||||
onHint(message);
|
||||
if (message) {
|
||||
window.setTimeout(() => onHint(''), 2500);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isWechatEnv()) {
|
||||
showHint('请在微信内打开后分享');
|
||||
return;
|
||||
}
|
||||
void applyDefaultWechatShare()
|
||||
.then(() => showHint(WECHAT_SHARE_HINT))
|
||||
.catch(() => showHint('分享配置失败,请刷新页面后重试'));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
|
||||
|
||||
const CLIENT_APP = 'USER_H5';
|
||||
|
||||
export const weixinSdk = createWeixinSdk({
|
||||
apiBase: '/api/v1',
|
||||
clientApp: CLIENT_APP,
|
||||
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||
});
|
||||
|
||||
export { isWechatEnv };
|
||||
Reference in New Issue
Block a user