feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 deletions
+21
View File
@@ -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();
}
+188
View File
@@ -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;
}
+24
View File
@@ -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 };
+23
View File
@@ -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 };
+62
View File
@@ -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');
}
+3
View File
@@ -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';
+53
View File
@@ -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}`)}`;
}
+19
View File
@@ -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 };
}
+47
View File
@@ -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 ?? []);
}
+70
View File
@@ -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 {
/* 静默失败,不阻断用户流程 */
}
}
+144
View File
@@ -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 };
}
+16
View File
@@ -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];
}
+37
View File
@@ -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;
}
+81
View File
@@ -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,
};
}
+11
View File
@@ -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]);
}
+58
View File
@@ -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);
}
+144
View File
@@ -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);
}
}
+47
View File
@@ -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('分享配置失败,请刷新页面后重试'));
}
+11
View File
@@ -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 };