213 lines
6.9 KiB
TypeScript
213 lines
6.9 KiB
TypeScript
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
|
|
|
|
const TOKEN_KEY = 'olons_web_session';
|
|
const DEVICE_KEY = 'olons_web_device';
|
|
const LOGIN_NOTICE_PENDING_KEY = 'olons_login_notice_pending';
|
|
|
|
const reasonMessages: Record<string, string> = {
|
|
AUTH_REQUIRED: '登录已失效,请重新登录',
|
|
FORBIDDEN: '当前账户无权执行此操作',
|
|
INVALID_CREDENTIALS: '手机号或密码错误',
|
|
ACCOUNT_ALREADY_REGISTERED: '该账户或手机号已经注册',
|
|
REFERRAL_CODE_INVALID: '邀请码无效,请检查后重新输入',
|
|
RATE_LIMITED: '操作过于频繁,请稍后再试',
|
|
TASK_DISABLED: '领取功能暂未开放',
|
|
OUTSIDE_BUSINESS_HOURS: '当前不在营业时间内',
|
|
INSUFFICIENT_BALANCE: '可用余额不足299元,暂时无法领取',
|
|
DAILY_LIMIT_REACHED: '今日领取次数已用完',
|
|
PENDING_ORDER_EXISTS: '存在未完成的任务,请先处理',
|
|
NO_TASK_AVAILABLE: '当前暂无可领取任务',
|
|
ORDER_EXPIRED: '该任务已超时',
|
|
ORDER_STATE_INVALID: '当前任务状态不允许此操作',
|
|
TRADE_PASSWORD_NOT_SET: '请先设置支付密码',
|
|
TRADE_PASSWORD_INVALID: '支付密码不正确',
|
|
PAYMENT_ACCOUNT_FIELDS_INVALID: '绑卡信息填写不完整或格式不正确',
|
|
QR_ATTACHMENT_INVALID: '二维码图片无效,请重新上传',
|
|
IMAGE_INVALID: '图片格式或大小不符合要求',
|
|
PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用',
|
|
WITHDRAWAL_DISABLED: '取出功能暂未开放',
|
|
WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则',
|
|
WITHDRAWAL_DAILY_LIMIT_REACHED: '今日取出次数已用完',
|
|
WITHDRAWAL_CHANNEL_UNAVAILABLE: '当前没有可用的取出通道',
|
|
RECHARGE_AMOUNT_INVALID: '储入金额不符合当前通道规则',
|
|
RECHARGE_CHANNEL_UNAVAILABLE: '当前没有可用的储入通道',
|
|
RECHARGE_VOUCHER_INVALID: '储入凭证无效',
|
|
SERVICE_UNAVAILABLE: '服务暂不可用,请稍后重试',
|
|
CUSTOMER_SERVICE_NOT_FOUND: '客服已下线或不存在',
|
|
CUSTOMER_SERVICE_NOT_BOUND: '客服账号尚未配置',
|
|
IM_RELATION_PENDING: '好友申请已发送,请等待客服确认',
|
|
IM_RELATION_NOT_ALLOWED: '当前无法与该客服建立会话',
|
|
IM_SERVICE_UNAVAILABLE: '客服系统暂不可用,请稍后重试',
|
|
INTERNAL: '服务异常,请稍后重试',
|
|
};
|
|
|
|
export interface ApiEnvelope<T> {
|
|
code: number;
|
|
data: T;
|
|
message?: string;
|
|
reason?: string;
|
|
request_id?: string;
|
|
server_time?: string;
|
|
}
|
|
|
|
export class MobileApiError extends Error {
|
|
reason: string;
|
|
status: number;
|
|
|
|
constructor(message: string, reason = '', status = 0) {
|
|
super(message);
|
|
this.reason = reason;
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
const client = axios.create({
|
|
baseURL: '',
|
|
timeout: 15000,
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
|
|
client.interceptors.request.use((config) => {
|
|
const token = sessionStorage.getItem(TOKEN_KEY);
|
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
|
config.headers['X-Request-ID'] = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`;
|
|
config.headers['X-Device-ID'] = getDeviceID();
|
|
return config;
|
|
});
|
|
|
|
client.interceptors.response.use(
|
|
(response) => response,
|
|
(error: AxiosError<ApiEnvelope<unknown>>) => {
|
|
const status = error.response?.status || 0;
|
|
const payload = error.response?.data;
|
|
if (status === 401) {
|
|
clearSession();
|
|
const currentPath = window.location.hash.replace(/^#/, '') || '/app/home';
|
|
if (!currentPath.startsWith('/app/auth')) {
|
|
window.location.hash = `/app/auth?redirect=${encodeURIComponent(currentPath)}`;
|
|
}
|
|
}
|
|
const reason = payload?.reason || '';
|
|
return Promise.reject(
|
|
new MobileApiError(
|
|
reasonMessages[reason] ||
|
|
payload?.message ||
|
|
(status ? '请求失败,请稍后重试' : '网络连接失败'),
|
|
reason,
|
|
status
|
|
)
|
|
);
|
|
}
|
|
);
|
|
|
|
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
|
const method = String(config.method || 'GET').toUpperCase();
|
|
const attempts = method === 'GET' ? 3 : 1;
|
|
let lastError: unknown;
|
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
try {
|
|
const response = await client.request<ApiEnvelope<T>>(config);
|
|
if (response.data.code !== 0) {
|
|
const reason = response.data.reason || '';
|
|
throw new MobileApiError(
|
|
reasonMessages[reason] || response.data.message || '请求失败',
|
|
reason,
|
|
response.status
|
|
);
|
|
}
|
|
return response.data.data;
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (!shouldRetry(error, attempt, attempts)) throw error;
|
|
await new Promise((resolve) => window.setTimeout(resolve, attempt === 0 ? 250 : 600));
|
|
}
|
|
}
|
|
throw lastError;
|
|
}
|
|
|
|
function shouldRetry(error: unknown, attempt: number, attempts: number) {
|
|
if (attempt >= attempts - 1) return false;
|
|
if (axios.isCancel(error)) return false;
|
|
return error instanceof MobileApiError && (error.status === 0 || error.status === 503);
|
|
}
|
|
|
|
function getDeviceID() {
|
|
let value = localStorage.getItem(DEVICE_KEY);
|
|
if (!value) {
|
|
value = crypto.randomUUID?.() || `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
localStorage.setItem(DEVICE_KEY, value);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function deviceID() {
|
|
return getDeviceID();
|
|
}
|
|
|
|
export function saveSession(token: string) {
|
|
sessionStorage.setItem(TOKEN_KEY, token);
|
|
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '1');
|
|
}
|
|
|
|
export function clearSession() {
|
|
sessionStorage.removeItem(TOKEN_KEY);
|
|
sessionStorage.removeItem(LOGIN_NOTICE_PENDING_KEY);
|
|
}
|
|
|
|
export function hasSession() {
|
|
return Boolean(sessionStorage.getItem(TOKEN_KEY));
|
|
}
|
|
|
|
export function shouldShowLoginNotice() {
|
|
return sessionStorage.getItem(LOGIN_NOTICE_PENDING_KEY) !== '0';
|
|
}
|
|
|
|
export function completeLoginNotice() {
|
|
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '0');
|
|
}
|
|
|
|
export function idempotencyKey() {
|
|
return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
|
|
export function money(value?: number, currency = 'CNY') {
|
|
const amount = Number.isFinite(value) ? Number(value) / 100 : 0;
|
|
return new Intl.NumberFormat('zh-CN', {
|
|
style: 'currency',
|
|
currency,
|
|
minimumFractionDigits: 2,
|
|
}).format(amount);
|
|
}
|
|
|
|
export function dateTime(value?: string) {
|
|
if (!value) return '--';
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return '--';
|
|
return new Intl.DateTimeFormat('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
})
|
|
.format(date)
|
|
.replaceAll('/', '-');
|
|
}
|
|
|
|
export function safeImage(url?: string) {
|
|
if (!url) return '';
|
|
try {
|
|
const parsed = new URL(url, window.location.origin);
|
|
return parsed.protocol === 'https:' || parsed.origin === window.location.origin
|
|
? parsed.href
|
|
: '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
export function errorText(error: unknown) {
|
|
return error instanceof Error ? error.message : '操作失败,请稍后重试';
|
|
}
|