02d89e6385
Add knowledge document GET/PUT and admin edit UI; auto-create support tickets on client 400 validation errors across user/shop/partner apps; batch create tasks and publish from support tickets; restore mini-user store env single-column layout. Co-authored-by: Cursor <cursoragent@cursor.com>
280 lines
8.1 KiB
TypeScript
280 lines
8.1 KiB
TypeScript
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
|
|
import { reportApiError } from '@dukang/client-logging';
|
|
import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
|
|
import { showPartnerToast } from './toast';
|
|
|
|
export const apiBase = '/api/v1';
|
|
const CLIENT_APP = 'PARTNER_H5';
|
|
|
|
const ACCESS_TOKEN = 'accessToken';
|
|
const REFRESH_TOKEN = 'refreshToken';
|
|
const LAST_PHONE = 'partnerLastPhone';
|
|
const PARTNER_PROFILE = 'partnerProfile';
|
|
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
|
|
export const PARTNER_WX_BOUND = 'partnerWxBound';
|
|
|
|
/** 手机号或微信验证通过后的免登录时长 */
|
|
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
|
'/partner/auth/token/refresh',
|
|
'/partner/auth/sms/send',
|
|
'/partner/auth/login/sms',
|
|
'/partner/auth/login/wechat',
|
|
];
|
|
|
|
export type PartnerSessionProfile = Pick<
|
|
PartnerMe,
|
|
| 'id'
|
|
| 'name'
|
|
| 'phone'
|
|
| 'companyName'
|
|
| 'isPrimary'
|
|
| 'permissions'
|
|
| 'primaryAccountId'
|
|
| 'primaryPhone'
|
|
| 'primaryName'
|
|
| 'hasWechat'
|
|
| 'wxNickname'
|
|
| 'wxAvatarUrl'
|
|
| 'managedWarehouseId'
|
|
| 'hasWarehouseAccess'
|
|
> & {
|
|
staffRole?: PartnerStaffRole;
|
|
};
|
|
|
|
export type PartnerSessionPayload = {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
partner?: PartnerSessionProfile;
|
|
};
|
|
|
|
export type ApiRequestOptions = RequestInit & {
|
|
/** 保留字段,兼容旧调用;错误提示由页面自行处理 */
|
|
silent?: boolean;
|
|
};
|
|
|
|
export function getLastPhone() {
|
|
return localStorage.getItem(LAST_PHONE) ?? '';
|
|
}
|
|
|
|
export function getPartnerProfile(): PartnerSessionProfile | null {
|
|
try {
|
|
const raw = localStorage.getItem(PARTNER_PROFILE);
|
|
return raw ? (JSON.parse(raw) as PartnerSessionProfile) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function hasPartnerWxSession() {
|
|
return localStorage.getItem(PARTNER_WX_BOUND) === '1';
|
|
}
|
|
|
|
export function isPartnerSessionExpired() {
|
|
const raw = localStorage.getItem(SESSION_EXPIRES_AT);
|
|
if (!raw) return false;
|
|
return Date.now() > Number(raw);
|
|
}
|
|
|
|
export function touchPartnerSession() {
|
|
if (!localStorage.getItem(REFRESH_TOKEN)) return;
|
|
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
|
}
|
|
|
|
export function saveAuth(data: PartnerSessionPayload) {
|
|
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
|
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
|
if (data.partner) {
|
|
localStorage.setItem(PARTNER_PROFILE, JSON.stringify(data.partner));
|
|
localStorage.setItem(LAST_PHONE, data.partner.phone);
|
|
}
|
|
}
|
|
|
|
/** 手机号验证成功后写入 7 天免验证码会话 */
|
|
export function saveRememberedSession(data: PartnerSessionPayload) {
|
|
saveAuth(data);
|
|
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
|
}
|
|
|
|
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
|
export function saveWechatSession(data: PartnerSessionPayload) {
|
|
saveRememberedSession(data);
|
|
localStorage.setItem(PARTNER_WX_BOUND, '1');
|
|
}
|
|
|
|
export function clearAuth(options?: { keepProfile?: boolean }) {
|
|
localStorage.removeItem(ACCESS_TOKEN);
|
|
localStorage.removeItem(REFRESH_TOKEN);
|
|
localStorage.removeItem(SESSION_EXPIRES_AT);
|
|
localStorage.removeItem(PARTNER_WX_BOUND);
|
|
if (!options?.keepProfile) {
|
|
localStorage.removeItem(PARTNER_PROFILE);
|
|
localStorage.removeItem(LAST_PHONE);
|
|
}
|
|
}
|
|
|
|
export function getToken() {
|
|
return localStorage.getItem(ACCESS_TOKEN);
|
|
}
|
|
|
|
export function isLoggedIn() {
|
|
return !!localStorage.getItem(ACCESS_TOKEN);
|
|
}
|
|
|
|
function profileFromMe(me: PartnerMe): PartnerSessionProfile {
|
|
return {
|
|
id: me.id,
|
|
name: me.name,
|
|
phone: me.phone,
|
|
companyName: me.companyName,
|
|
isPrimary: me.isPrimary,
|
|
staffRole: me.staffRole ?? undefined,
|
|
permissions: me.permissions,
|
|
primaryAccountId: me.primaryAccountId,
|
|
primaryPhone: me.primaryPhone,
|
|
primaryName: me.primaryName,
|
|
hasWechat: me.hasWechat,
|
|
wxNickname: me.wxNickname,
|
|
wxAvatarUrl: me.wxAvatarUrl,
|
|
managedWarehouseId: me.managedWarehouseId,
|
|
hasWarehouseAccess: me.hasWarehouseAccess,
|
|
};
|
|
}
|
|
|
|
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().catch(() => ({ code: res.status, message: '网络异常' }));
|
|
if (json.code !== 0) {
|
|
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
|
err.status = json.code === 401 ? 401 : json.code;
|
|
if (json.code === 400) {
|
|
reportApiError(
|
|
{ apiBase, clientApp: CLIENT_APP, getToken: () => localStorage.getItem(ACCESS_TOKEN) },
|
|
{ message: json.message || '请求失败', status: 400, url: path, category: 'validation_error' },
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
return json.data as T;
|
|
}
|
|
|
|
async function refreshSession(): Promise<PartnerSessionPayload | null> {
|
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
|
if (!refreshToken) return null;
|
|
try {
|
|
const data = await rawRequest<PartnerSessionPayload>(
|
|
'/partner/auth/token/refresh',
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({ refreshToken }),
|
|
},
|
|
null,
|
|
);
|
|
saveAuth(data);
|
|
touchPartnerSession();
|
|
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({ keepProfile: true });
|
|
throw e;
|
|
}
|
|
return requestWithAuthRetry<T>(path, options, true);
|
|
}
|
|
}
|
|
|
|
export async function request<T>(
|
|
clientApp: string,
|
|
path: string,
|
|
options: ApiRequestOptions = {},
|
|
): Promise<T> {
|
|
void clientApp;
|
|
const { silent, ...fetchOptions } = options;
|
|
try {
|
|
return await requestWithAuthRetry<T>(path, fetchOptions);
|
|
} catch (e) {
|
|
const err = e as Error & { status?: number };
|
|
const message = err.message || '请求失败';
|
|
if (err.status === 401) {
|
|
if (localStorage.getItem(ACCESS_TOKEN)) {
|
|
clearAuth({ keepProfile: true });
|
|
if (!silent) showPartnerToast(message, 'error');
|
|
if (typeof window !== 'undefined' && !isOnAppPath('/login')) {
|
|
window.location.href = toAppPath('/login');
|
|
}
|
|
}
|
|
} else if (!silent) {
|
|
showPartnerToast(message, 'error');
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export async function ensureSession(): Promise<{ authenticated: boolean; partner: PartnerSessionProfile | null }> {
|
|
if (!isLoggedIn()) {
|
|
return { authenticated: false, partner: null };
|
|
}
|
|
if (isPartnerSessionExpired()) {
|
|
clearAuth({ keepProfile: true });
|
|
return { authenticated: false, partner: getPartnerProfile() };
|
|
}
|
|
try {
|
|
const me = await rawRequest<PartnerMe>('/partner/me');
|
|
const partner = profileFromMe(me);
|
|
saveAuth({
|
|
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
|
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
|
partner,
|
|
});
|
|
touchPartnerSession();
|
|
return { authenticated: true, partner };
|
|
} catch (e) {
|
|
const err = e as Error & { status?: number };
|
|
if (err.status === 401) {
|
|
const refreshed = await refreshSession();
|
|
if (refreshed?.partner) {
|
|
return { authenticated: true, partner: refreshed.partner };
|
|
}
|
|
clearAuth({ keepProfile: true });
|
|
return { authenticated: false, partner: getPartnerProfile() };
|
|
}
|
|
const cached = getPartnerProfile();
|
|
if (cached) return { authenticated: true, partner: cached };
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/** @deprecated 使用 PartnerSessionPayload */
|
|
export type PartnerAuthPayload = PartnerSessionPayload;
|