功能: - 退货库存预警:横林二仓/苏北/睢宁/山东青岗集/河南洛宁 五张看板, 按各仓客退上架仓对应的自定义库存位取数(各仓位不同:横林/苏北/睢宁 c1,山东 c2) - 支持导入聚水潭「箱及仓位库存」导出文件覆盖创建时间、件数、供应商 (副本只有仓库级数量,没有箱级维度,真实上架时间只能靠导入) - 退货入库核销与每日库存监控:状态落本地系统库,含定时任务与差异清单 - 服务端导出(xlsx/csv),不受列表分页与展示上限约束 稳定性: - 前端幂等请求在后端重启窗口内自动退避重连,不再直接抛 500 - 后端补齐统一 JSON 错误兜底与 /api JSON 404,异常不再返回 HTML - 隧道 ssh 改为绝对路径解析并隐藏窗口,避免非交互式启动 ENOENT 与黑窗弹窗 - 业务库 DATETIME 按北京时间解析(useUTC: false),修复时间显示整体 +8 小时 合并:与上游「隧道断线自愈 + 配置负缓存」为同一问题的两份实现, 本分支采用上游方案(带 TTL 的负缓存 + 统一失效函数 + 单测)。
191 lines
6.0 KiB
JavaScript
191 lines
6.0 KiB
JavaScript
import axios from 'axios';
|
||
import { message } from 'antd';
|
||
import { getRequestErrorMessage, isAuthSessionFailure } from './authSessionPolicy';
|
||
import { requestDesktopSessionRenewal } from '../utils/desktopEmbedSession';
|
||
|
||
const DEFAULT_REQUEST_TIMEOUT_MS = Math.max(
|
||
Number(import.meta.env.VITE_API_REQUEST_TIMEOUT_MS) || 120000,
|
||
15000
|
||
);
|
||
|
||
const request = axios.create({
|
||
baseURL: '', // Proxy handles the base URL
|
||
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||
});
|
||
const AUTH_USER_STORAGE_KEY = 'auth_user';
|
||
|
||
// 后端重启窗口内的自动重连
|
||
// ------------------------------------------------------------------
|
||
// 本地/线上后端在「隧道掉线 -> 监督器重启后端」期间会有 20~90 秒不可用,
|
||
// 此时 Vite 代理拿不到后端,会直接回 500(响应体是 HTML,没有 error 字段),
|
||
// 用户看到的就是一句没头没尾的 "Request failed with status code 500"。
|
||
// 这里对**幂等请求**(GET/HEAD/OPTIONS)在网络错误或 5xx 时做退避重试,
|
||
// 重启窗口过去后请求会自己成功,页面不再报错。
|
||
// 累计等待约 80 秒,覆盖最坏情况下的后端启动耗时。
|
||
const RETRY_BACKOFF_MS = [1500, 3000, 5000, 8000, 13000, 21000, 30000];
|
||
const RETRYABLE_METHODS = ['get', 'head', 'options'];
|
||
const RETRY_NOTICE_KEY = 'api-reconnect-notice';
|
||
|
||
function isRetryableRequest(config) {
|
||
if (!config || config.retry === false) {
|
||
return false;
|
||
}
|
||
const method = String(config.method || 'get').toLowerCase();
|
||
return RETRYABLE_METHODS.includes(method);
|
||
}
|
||
|
||
function isRetryableError(error) {
|
||
if (axios.isCancel?.(error) || error?.code === 'ERR_CANCELED') {
|
||
return false;
|
||
}
|
||
const status = error?.response?.status;
|
||
// 无响应:网络中断、连接被拒(后端还没起来)、代理转发失败
|
||
if (!status) {
|
||
return true;
|
||
}
|
||
// 5xx:后端内部错误 / 网关与代理错误
|
||
return status >= 500 && status <= 599;
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
||
}
|
||
|
||
function notifyReconnecting() {
|
||
message.open({
|
||
key: RETRY_NOTICE_KEY,
|
||
type: 'loading',
|
||
content: '服务正在重启,正在自动重连…',
|
||
duration: 0,
|
||
});
|
||
}
|
||
|
||
function clearReconnectNotice() {
|
||
message.destroy(RETRY_NOTICE_KEY);
|
||
}
|
||
|
||
function clearAuthSession() {
|
||
localStorage.removeItem('token');
|
||
localStorage.removeItem(AUTH_USER_STORAGE_KEY);
|
||
}
|
||
|
||
function parseJwtPayload(token) {
|
||
if (!token) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const [, payload] = token.split('.');
|
||
if (!payload) {
|
||
return null;
|
||
}
|
||
|
||
const normalized = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||
const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '=');
|
||
return JSON.parse(window.atob(padded));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function isLocalPreviewSession() {
|
||
const isLocalHost = ['localhost', '127.0.0.1'].includes(window.location.hostname);
|
||
if (!isLocalHost) {
|
||
return false;
|
||
}
|
||
|
||
const payload = parseJwtPayload(localStorage.getItem('token'));
|
||
return payload?.authSource === 'local-preview';
|
||
}
|
||
|
||
function isPublicAuthPage() {
|
||
return ['/login', '/callback', '/install'].includes(window.location.pathname);
|
||
}
|
||
|
||
function shouldBypass401Redirect(error) {
|
||
const url = error?.config?.url || '';
|
||
return url.includes('/api/auth/verify') || isLocalPreviewSession();
|
||
}
|
||
|
||
// Request interceptor
|
||
request.interceptors.request.use(
|
||
(config) => {
|
||
const token = localStorage.getItem('token');
|
||
if (token) {
|
||
config.headers['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
return config;
|
||
},
|
||
(error) => {
|
||
return Promise.reject(error);
|
||
}
|
||
);
|
||
|
||
// Response interceptor
|
||
request.interceptors.response.use(
|
||
(response) => {
|
||
const res = response.data;
|
||
// Compatible with backend interfaces that don't return code
|
||
if (res && res.code && res.code !== 200) {
|
||
const shouldBypassRedirect = res.code === 401 && shouldBypass401Redirect(response);
|
||
|
||
if (!shouldBypassRedirect && !response.config?.silentError) {
|
||
message.error(res.message || 'Error');
|
||
}
|
||
|
||
if (res.code === 401 && !shouldBypassRedirect) {
|
||
// Handle unauthorized - 但如果已在登录页则不重定向
|
||
clearAuthSession();
|
||
if (!isPublicAuthPage()) {
|
||
if (!requestDesktopSessionRenewal('api-unauthorized')) {
|
||
window.location.href = '/login';
|
||
}
|
||
}
|
||
}
|
||
return Promise.reject(new Error(res.message || 'Error'));
|
||
}
|
||
clearReconnectNotice();
|
||
return res;
|
||
},
|
||
async (error) => {
|
||
const config = error?.config;
|
||
|
||
// ---- 后端重启窗口内自动重试(只对幂等请求、只对网络错误/5xx)----
|
||
// 说明:这里用 `return request(config)`(不带 await)而不是 await,
|
||
// 这样重试失败时由「内层」这一分支继续退避;外层调用栈不会重新进入本函数,
|
||
// 因此最终失败提示天然只会出现一次,也不会把重试次数叠加错。
|
||
if (isRetryableRequest(config) && !config.__retryExhausted && isRetryableError(error)) {
|
||
const attempt = Number(config.__retryAttempt || 0);
|
||
if (attempt < RETRY_BACKOFF_MS.length) {
|
||
config.__retryAttempt = attempt + 1;
|
||
if (attempt === 0 && !config.silentError) {
|
||
notifyReconnecting();
|
||
}
|
||
await sleep(RETRY_BACKOFF_MS[attempt]);
|
||
return request(config);
|
||
}
|
||
// 次数用尽:标记后不再重试,并把连接提示收掉
|
||
config.__retryExhausted = true;
|
||
config.__retryAttempt = 0;
|
||
clearReconnectNotice();
|
||
}
|
||
|
||
const authSessionFailure = isAuthSessionFailure(error.response);
|
||
if (!authSessionFailure && !error?.config?.silentError) {
|
||
message.error(getRequestErrorMessage(error));
|
||
}
|
||
if (authSessionFailure && !shouldBypass401Redirect(error)) {
|
||
clearAuthSession();
|
||
// 如果已在登录页则不重定向,避免无限循环
|
||
if (!isPublicAuthPage()) {
|
||
if (!requestDesktopSessionRenewal('api-unauthorized')) {
|
||
window.location.href = '/login';
|
||
}
|
||
}
|
||
}
|
||
return Promise.reject(error);
|
||
}
|
||
);
|
||
|
||
export default request;
|