feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { createStoreTracker } from '@dukang/client-logging';
|
||||
import { apiBase } from './api';
|
||||
|
||||
const tracker = createStoreTracker({
|
||||
apiBase,
|
||||
clientApp: 'SHOP_H5',
|
||||
getToken: () => localStorage.getItem('shopAccessToken'),
|
||||
getStoreId: () => {
|
||||
try {
|
||||
const raw = localStorage.getItem('shopSession');
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as { storeId?: string };
|
||||
return parsed.storeId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export function trackStore(eventName: string, params?: Record<string, unknown>) {
|
||||
tracker.track(eventName, params);
|
||||
}
|
||||
|
||||
export function trackStorePageView(eventName: string, params?: Record<string, unknown>) {
|
||||
tracker.trackPageView(eventName, params);
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
export const apiBase = '/api/v1';
|
||||
const CLIENT_APP = 'SHOP_H5';
|
||||
|
||||
export type ShopStoreOption = {
|
||||
storeId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
district?: string;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
export type StoreSessionStore = {
|
||||
id: string;
|
||||
storeId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
storeName: string;
|
||||
isPrimary?: boolean;
|
||||
stores?: ShopStoreOption[];
|
||||
};
|
||||
|
||||
export type StoreProfile = {
|
||||
id: string;
|
||||
storeId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
status?: string;
|
||||
isPrimary?: boolean;
|
||||
account?: {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
isPrimary: boolean;
|
||||
hasWechat?: boolean;
|
||||
};
|
||||
store?: ShopStoreOption | { id: string; name: string } | null;
|
||||
stores?: ShopStoreOption[];
|
||||
};
|
||||
|
||||
export type ShopSessionPayload = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
store?: StoreSessionStore;
|
||||
stores?: ShopStoreOption[];
|
||||
account?: StoreProfile['account'];
|
||||
selectedStoreId?: string;
|
||||
};
|
||||
|
||||
const ACCESS_TOKEN = 'accessToken';
|
||||
const REFRESH_TOKEN = 'refreshToken';
|
||||
const LAST_PHONE = 'shopLastPhone';
|
||||
const STORE_PROFILE = 'shopStoreProfile';
|
||||
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
|
||||
export const SHOP_WX_BOUND = 'shopWxBound';
|
||||
|
||||
/** 手机号或微信验证通过后的免登录时长 */
|
||||
export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||||
'/shop/auth/token/refresh',
|
||||
'/shop/auth/sms/send',
|
||||
'/shop/auth/login/sms',
|
||||
'/shop/auth/login/wechat',
|
||||
];
|
||||
|
||||
export function getLastPhone() {
|
||||
return localStorage.getItem(LAST_PHONE) ?? '';
|
||||
}
|
||||
|
||||
export function getStoreProfile(): StoreSessionStore | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORE_PROFILE);
|
||||
return raw ? (JSON.parse(raw) as StoreSessionStore) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasShopWxSession() {
|
||||
return localStorage.getItem(SHOP_WX_BOUND) === '1';
|
||||
}
|
||||
|
||||
export function isShopSessionExpired() {
|
||||
const raw = localStorage.getItem(SESSION_EXPIRES_AT);
|
||||
if (!raw) return false;
|
||||
return Date.now() > Number(raw);
|
||||
}
|
||||
|
||||
export function touchShopSession() {
|
||||
if (!localStorage.getItem(REFRESH_TOKEN)) return;
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
export function saveAuth(data: ShopSessionPayload) {
|
||||
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||
if (data.store) {
|
||||
const profile: StoreSessionStore = {
|
||||
...data.store,
|
||||
stores: data.stores ?? data.store.stores,
|
||||
isPrimary: data.account?.isPrimary ?? data.store.isPrimary,
|
||||
};
|
||||
localStorage.setItem(STORE_PROFILE, JSON.stringify(profile));
|
||||
if (data.store.phone) localStorage.setItem(LAST_PHONE, data.store.phone);
|
||||
} else if (data.account) {
|
||||
localStorage.setItem(
|
||||
STORE_PROFILE,
|
||||
JSON.stringify({
|
||||
id: data.account.id,
|
||||
storeId: '',
|
||||
name: data.account.name,
|
||||
phone: data.account.phone,
|
||||
storeName: '',
|
||||
isPrimary: data.account.isPrimary,
|
||||
stores: data.stores ?? [],
|
||||
} satisfies StoreSessionStore),
|
||||
);
|
||||
localStorage.setItem(LAST_PHONE, data.account.phone);
|
||||
}
|
||||
}
|
||||
|
||||
/** 手机号验证成功后写入 7 天免验证码会话 */
|
||||
export function saveRememberedSession(data: ShopSessionPayload) {
|
||||
saveAuth(data);
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
||||
export function saveWechatSession(data: ShopSessionPayload) {
|
||||
saveRememberedSession(data);
|
||||
localStorage.setItem(SHOP_WX_BOUND, '1');
|
||||
}
|
||||
|
||||
export function clearAuth(options?: { keepProfile?: boolean }) {
|
||||
localStorage.removeItem(ACCESS_TOKEN);
|
||||
localStorage.removeItem(REFRESH_TOKEN);
|
||||
localStorage.removeItem(SESSION_EXPIRES_AT);
|
||||
localStorage.removeItem(SHOP_WX_BOUND);
|
||||
if (!options?.keepProfile) {
|
||||
localStorage.removeItem(STORE_PROFILE);
|
||||
localStorage.removeItem(LAST_PHONE);
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
function profileFromMe(me: StoreProfile): StoreSessionStore {
|
||||
const selected =
|
||||
me.store && 'storeId' in me.store
|
||||
? me.store
|
||||
: me.store && 'id' in me.store
|
||||
? { storeId: String((me.store as { id: string }).id), name: me.store.name }
|
||||
: null;
|
||||
return {
|
||||
id: me.account?.id ?? me.id,
|
||||
storeId: selected?.storeId ?? me.storeId ?? '',
|
||||
name: me.account?.name ?? me.name,
|
||||
phone: me.account?.phone ?? me.phone,
|
||||
storeName: selected?.name ?? '',
|
||||
isPrimary: me.account?.isPrimary ?? me.isPrimary,
|
||||
stores: me.stores ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function needsStoreSelection(session: {
|
||||
store?: StoreSessionStore | null;
|
||||
stores?: ShopStoreOption[];
|
||||
selectedStoreId?: string;
|
||||
}): boolean {
|
||||
const storeId = session.store?.storeId || session.selectedStoreId || '';
|
||||
const stores = session.stores ?? session.store?.stores ?? [];
|
||||
if (stores.length > 1 && !storeId) return true;
|
||||
if (!storeId && stores.length !== 1) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function selectStore(storeId: string): Promise<ShopSessionPayload> {
|
||||
const data = await requestWithAuthRetry<ShopSessionPayload>('/shop/auth/select-store', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ storeId }),
|
||||
});
|
||||
saveAuth(data);
|
||||
touchShopSession();
|
||||
return data;
|
||||
}
|
||||
|
||||
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 });
|
||||
let json: { code: number; message?: string; data?: T };
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
const err = new Error(res.ok ? '接口返回异常' : `网络异常(HTTP ${res.status})`) as Error & {
|
||||
status?: number;
|
||||
};
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||
err.status = res.status >= 500 ? res.status : json.code;
|
||||
throw err;
|
||||
}
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
async function refreshSession(): Promise<ShopSessionPayload | null> {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const data = await rawRequest<ShopSessionPayload>(
|
||||
'/shop/auth/token/refresh',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
},
|
||||
null,
|
||||
);
|
||||
saveAuth(data);
|
||||
touchShopSession();
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
const refreshed = await refreshSession();
|
||||
if (!refreshed) {
|
||||
clearAuth();
|
||||
throw e;
|
||||
}
|
||||
return requestWithAuthRetry<T>(path, options, true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(clientApp: string, path: string, options: RequestInit = {}): Promise<T> {
|
||||
void clientApp;
|
||||
return requestWithAuthRetry<T>(path, options);
|
||||
}
|
||||
|
||||
export async function ensureSession(): Promise<{
|
||||
authenticated: boolean;
|
||||
store: StoreSessionStore | null;
|
||||
needsSelectStore: boolean;
|
||||
}> {
|
||||
if (!isLoggedIn()) {
|
||||
return { authenticated: false, store: null, needsSelectStore: false };
|
||||
}
|
||||
if (isShopSessionExpired()) {
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, store: getStoreProfile(), needsSelectStore: false };
|
||||
}
|
||||
try {
|
||||
const me = await rawRequest<StoreProfile>('/shop/auth/me');
|
||||
const store = profileFromMe(me);
|
||||
saveAuth({
|
||||
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||||
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||
store,
|
||||
stores: me.stores,
|
||||
account: me.account,
|
||||
});
|
||||
touchShopSession();
|
||||
return {
|
||||
authenticated: true,
|
||||
store,
|
||||
needsSelectStore: needsStoreSelection({ store, stores: me.stores }),
|
||||
};
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
if (err.status === 401) {
|
||||
const refreshed = await refreshSession();
|
||||
if (refreshed) {
|
||||
return {
|
||||
authenticated: true,
|
||||
store: refreshed.store ?? getStoreProfile(),
|
||||
needsSelectStore: needsStoreSelection(refreshed),
|
||||
};
|
||||
}
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, store: getStoreProfile(), needsSelectStore: false };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { apiBase } from './api';
|
||||
|
||||
const CLIENT_APP = 'SHOP_H5';
|
||||
const ACCESS_TOKEN = 'accessToken';
|
||||
|
||||
export type ClientErrorLevel = 'fatal' | 'error' | 'warn';
|
||||
export type ClientErrorCategory =
|
||||
| 'js_error'
|
||||
| 'unhandled_rejection'
|
||||
| 'api_error'
|
||||
| 'network'
|
||||
| 'render'
|
||||
| 'bridge'
|
||||
| 'other';
|
||||
|
||||
export type ClientErrorPayload = {
|
||||
level: ClientErrorLevel;
|
||||
category: ClientErrorCategory;
|
||||
message: string;
|
||||
stack?: string;
|
||||
pagePath?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function currentPagePath(): string | undefined {
|
||||
try {
|
||||
return typeof window !== 'undefined' ? window.location.pathname : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
try {
|
||||
return localStorage.getItem(ACCESS_TOKEN) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 上报客户端错误(失败静默,避免递归) */
|
||||
export function reportClientError(payload: ClientErrorPayload): void {
|
||||
const body = {
|
||||
level: payload.level,
|
||||
category: payload.category,
|
||||
message: String(payload.message || 'unknown').slice(0, 1000),
|
||||
stack: payload.stack ? String(payload.stack).slice(0, 4000) : undefined,
|
||||
pagePath: (payload.pagePath || currentPagePath() || '').slice(0, 128) || undefined,
|
||||
clientApp: CLIENT_APP,
|
||||
extra: payload.extra,
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': CLIENT_APP,
|
||||
};
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
void fetch(`${apiBase}/common/client-errors`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
/** 安装 H5 全局未捕获错误钩子(幂等) */
|
||||
export function installClientErrorReporting(): void {
|
||||
if (installed || typeof window === 'undefined') return;
|
||||
installed = true;
|
||||
|
||||
window.addEventListener('error', (ev) => {
|
||||
reportClientError({
|
||||
level: 'fatal',
|
||||
category: 'js_error',
|
||||
message: ev.message || 'window.error',
|
||||
stack: ev.error instanceof Error ? ev.error.stack : undefined,
|
||||
extra: { filename: ev.filename, lineno: ev.lineno, colno: ev.colno },
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (ev) => {
|
||||
const reason = ev.reason;
|
||||
reportClientError({
|
||||
level: 'error',
|
||||
category: 'unhandled_rejection',
|
||||
message:
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: typeof reason === 'string'
|
||||
? reason
|
||||
: 'unhandledrejection',
|
||||
stack: reason instanceof Error ? reason.stack : undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { REDEEM_WEAKNET_FAIL_THRESHOLD } from '@dukang/shared-types';
|
||||
import type { RedeemErrorClass, RedeemFailureReportResult } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
|
||||
export function isNetworkError(e: unknown): boolean {
|
||||
const err = e as Error & { status?: number };
|
||||
const status = err.status;
|
||||
const message = (err.message ?? String(e)).toLowerCase();
|
||||
if (status != null && status >= 500) return true;
|
||||
if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
|
||||
return true;
|
||||
}
|
||||
if (/failed to fetch|network|timeout|timed out|abort|offline|连接|网络|超时/.test(message)) {
|
||||
return true;
|
||||
}
|
||||
// 无 status 的 fetch 失败通常是网络类
|
||||
if (status == null && e instanceof TypeError) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function reportRedeemFailure(
|
||||
token: string,
|
||||
step: 'preview' | 'confirm',
|
||||
error: unknown,
|
||||
): Promise<RedeemFailureReportResult | null> {
|
||||
const err = error as Error & { status?: number };
|
||||
const errorClass: RedeemErrorClass = isNetworkError(error) ? 'NETWORK' : 'BUSINESS';
|
||||
try {
|
||||
return await request<RedeemFailureReportResult>('SHOP_H5', '/shop/redeem/failures', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
errorClass,
|
||||
message: err.message?.slice(0, 200),
|
||||
step,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
if (errorClass === 'NETWORK') {
|
||||
return {
|
||||
failCount: REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||
thresholdReached: true,
|
||||
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { REDEEM_WEAKNET_FAIL_THRESHOLD };
|
||||
@@ -0,0 +1,22 @@
|
||||
/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */
|
||||
export function parseRedeemTokenFromScan(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^[a-f0-9]{32}$/i.test(trimmed)) {
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
try {
|
||||
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
|
||||
const fromQuery = url.searchParams.get('token');
|
||||
if (fromQuery && /^[a-f0-9]{32}$/i.test(fromQuery)) {
|
||||
return fromQuery.toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
/* not a URL */
|
||||
}
|
||||
|
||||
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
|
||||
return hexMatch ? hexMatch[0].toLowerCase() : null;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||
|
||||
export type PackageFormItem = StorePackageItemDto;
|
||||
|
||||
export function emptyPackage(index = 0): PackageFormItem {
|
||||
return {
|
||||
name: '',
|
||||
price: '',
|
||||
dishes: '',
|
||||
usableTime: '',
|
||||
otherNotes: '',
|
||||
sortOrder: index,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePackageFormItems(raw: PackageFormItem[]): PackageFormItem[] {
|
||||
return raw
|
||||
.map((item, index) => ({
|
||||
name: item.name.trim(),
|
||||
price: item.price.trim(),
|
||||
dishes: item.dishes.trim(),
|
||||
usableTime: item.usableTime?.trim() || '',
|
||||
otherNotes: item.otherNotes?.trim() || '',
|
||||
sortOrder: index,
|
||||
}))
|
||||
.filter((item) => item.name || item.price || item.dishes || item.usableTime || item.otherNotes);
|
||||
}
|
||||
|
||||
export function validatePackageFormItems(items: PackageFormItem[]): string | null {
|
||||
const filled = normalizePackageFormItems(items);
|
||||
if (filled.length > STORE_PACKAGE_MAX_COUNT) {
|
||||
return `套餐最多 ${STORE_PACKAGE_MAX_COUNT} 条`;
|
||||
}
|
||||
for (let i = 0; i < filled.length; i++) {
|
||||
const item = filled[i];
|
||||
if (!item.name) return `第 ${i + 1} 条套餐名称不能为空`;
|
||||
const price = Number(item.price);
|
||||
if (!Number.isFinite(price) || price < 0) return `第 ${i + 1} 条套餐价格须为非负数字`;
|
||||
if (!item.dishes) return `第 ${i + 1} 条套餐菜品不能为空`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { apiBase } from './api';
|
||||
import { getStoreProfile } from './api';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
export type UploadFileResult = {
|
||||
url: string;
|
||||
ossKey: string;
|
||||
bucket: string;
|
||||
mock: boolean;
|
||||
};
|
||||
|
||||
export type RegisteredResource = {
|
||||
id: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('bizType', bizType);
|
||||
formData.append('mediaType', 'IMAGE');
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = { 'X-Client-App': 'SHOP_H5' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code === 401) {
|
||||
localStorage.removeItem('accessToken');
|
||||
throw new Error('未登录');
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
throw new Error(json.message || '上传失败');
|
||||
}
|
||||
return json.data as UploadFileResult;
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
throw new Error('上传超时,请检查网络后重试');
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function registerResource(upload: UploadFileResult, fileName: string): Promise<RegisteredResource> {
|
||||
const profile = getStoreProfile();
|
||||
if (!profile?.storeId) throw new Error('门店信息缺失,请重新登录');
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': 'SHOP_H5',
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/common/resources`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
ownerType: 'STORE',
|
||||
ownerId: profile.storeId,
|
||||
bizType: 'REDEEM_PENDING_PHOTO',
|
||||
mediaType: 'IMAGE',
|
||||
ossKey: upload.ossKey,
|
||||
url: upload.url,
|
||||
ossBucket: upload.bucket,
|
||||
fileName,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '登记资源失败');
|
||||
return { id: String(json.data.id), url: String(json.data.url) };
|
||||
}
|
||||
|
||||
/** 上传核销码照片并登记为 CommonResource,返回 resourceId */
|
||||
export async function uploadRedeemPendingPhoto(file: File): Promise<RegisteredResource> {
|
||||
const uploaded = await uploadFileToOss(file, 'REDEEM_PENDING_PHOTO');
|
||||
return registerResource(uploaded, file.name || 'redeem-pending.jpg');
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { trackStorePageView } from './analytics';
|
||||
|
||||
export function useStorePageView(eventName: string, params?: Record<string, unknown>) {
|
||||
const fired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (fired.current) return;
|
||||
fired.current = true;
|
||||
trackStorePageView(eventName, params);
|
||||
}, [eventName, params]);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-types';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import { request, saveWechatSession, type ShopSessionPayload } from './api';
|
||||
|
||||
export type ShopAccountProfile = {
|
||||
id: string;
|
||||
storeId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
/** 是否已绑定微信(来自 /shop/auth/me account.hasWechat) */
|
||||
hasWechat?: boolean;
|
||||
wxOpenId?: string | null;
|
||||
store?: { name: string };
|
||||
};
|
||||
|
||||
/** 微信未绑定门店账号时的登录提示 */
|
||||
export const SHOP_WX_NEED_PHONE_LOGIN_MSG = '请先用手机号登录';
|
||||
|
||||
const SHOP_WX_LOGIN_HINT_KEY = 'shop_wx_login_hint';
|
||||
|
||||
export function isShopWechatUnboundError(message: string): boolean {
|
||||
return message.includes('首次登录') || message.includes('手机验证码') || message.includes('请先用手机号');
|
||||
}
|
||||
|
||||
export function formatShopWechatError(e: unknown): string {
|
||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
||||
if (isShopWechatUnboundError(text)) return SHOP_WX_NEED_PHONE_LOGIN_MSG;
|
||||
return text;
|
||||
}
|
||||
|
||||
/** OAuth 在启动层失败时暂存提示,供登录页展示 */
|
||||
export function stashShopWechatLoginHint(message: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(SHOP_WX_LOGIN_HINT_KEY, message);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeShopWechatLoginHint(): string | null {
|
||||
try {
|
||||
const hint = sessionStorage.getItem(SHOP_WX_LOGIN_HINT_KEY);
|
||||
if (hint) sessionStorage.removeItem(SHOP_WX_LOGIN_HINT_KEY);
|
||||
return hint;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||
return request<ClientRuntimeConfig>('SHOP_H5', '/common/client-config');
|
||||
}
|
||||
|
||||
export async function fetchShopAccount(): Promise<ShopAccountProfile> {
|
||||
const me = await request<{
|
||||
id?: string;
|
||||
storeId?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
account?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
phone?: string;
|
||||
hasWechat?: boolean;
|
||||
};
|
||||
store?: { name?: string } | null;
|
||||
}>('SHOP_H5', '/shop/auth/me');
|
||||
return {
|
||||
id: String(me.account?.id ?? me.id ?? ''),
|
||||
storeId: String(me.storeId ?? ''),
|
||||
name: String(me.account?.name ?? me.name ?? ''),
|
||||
phone: String(me.account?.phone ?? me.phone ?? ''),
|
||||
hasWechat: !!me.account?.hasWechat,
|
||||
store: me.store?.name ? { name: String(me.store.name) } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function needsWechatAuth(
|
||||
profile: ShopAccountProfile | null,
|
||||
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
|
||||
): boolean {
|
||||
if (config && !isWxAuthorizeEnabled(config)) return false;
|
||||
const unbound = !!profile && !(profile.hasWechat || profile.wxOpenId);
|
||||
return isWechatEnv() && unbound;
|
||||
}
|
||||
|
||||
export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null): Promise<boolean> {
|
||||
const config = await fetchClientConfig();
|
||||
return needsWechatAuth(profile, config);
|
||||
}
|
||||
|
||||
export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPayload | null {
|
||||
if (!result.accessToken || !result.refreshToken) return null;
|
||||
const store = result.store as Record<string, unknown> | undefined;
|
||||
const stores = Array.isArray((result as { stores?: unknown }).stores)
|
||||
? ((result as { stores: ShopSessionPayload['stores'] }).stores)
|
||||
: undefined;
|
||||
const account = (result as { account?: ShopSessionPayload['account'] }).account;
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
stores,
|
||||
account,
|
||||
selectedStoreId: (result as { selectedStoreId?: string }).selectedStoreId,
|
||||
store: store
|
||||
? {
|
||||
id: String(store.id ?? account?.id ?? ''),
|
||||
storeId: String(store.storeId ?? ''),
|
||||
name: String(store.name ?? account?.name ?? ''),
|
||||
phone: String(store.phone ?? account?.phone ?? ''),
|
||||
storeName: String(store.storeName ?? store.name ?? ''),
|
||||
isPrimary: account?.isPrimary,
|
||||
stores,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 处理微信登录/绑定结果,写入 7 天免登录 session */
|
||||
export function handleShopWechatLoginResult(result: WechatLoginResult): ShopSessionPayload | null {
|
||||
const session = sessionFromWechatLogin(result);
|
||||
if (!session) return null;
|
||||
saveWechatSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** @deprecated 使用 handleShopWechatLoginResult */
|
||||
export function saveShopWechatAuth(result: WechatLoginResult): boolean {
|
||||
return !!handleShopWechatLoginResult(result);
|
||||
}
|
||||
|
||||
export async function handleShopWechatCallback(): Promise<WechatLoginResult | null> {
|
||||
if (!isWechatEnv()) return null;
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return null;
|
||||
return weixinSdk.handleOAuthCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信一键登录(已绑定微信的门店账号免验证码)。
|
||||
* 返回 session = 已登录;void = 已跳转授权页等待回调。
|
||||
*/
|
||||
export async function loginShopWithWechat(): Promise<ShopSessionPayload | null | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return null;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
const result = await weixinSdk.login();
|
||||
if (result) return handleShopWechatLoginResult(result);
|
||||
}
|
||||
|
||||
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
|
||||
export async function bindShopWechatAfterSmsLogin(): Promise<void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) return;
|
||||
await weixinSdk.login();
|
||||
}
|
||||
|
||||
export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
return weixinSdk.login();
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
|
||||
|
||||
export const weixinSdk = createWeixinSdk({
|
||||
apiBase: '/api/v1',
|
||||
clientApp: 'SHOP_H5',
|
||||
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||
wechatLoginPath: '/shop/auth/login/wechat',
|
||||
});
|
||||
|
||||
export { isWechatEnv };
|
||||
Reference in New Issue
Block a user