fix(web,plugin): 前端审计修复 — 401 消除 + 统计卡片 crash + 销售漏斗 500 + antd 6 废弃 API

- API client: proactive token refresh(请求前 30s 检查过期,提前刷新避免 401)
- Plugin store: fetchPlugins promise 去重,防止 StrictMode 并发重复请求
- Home stats: 简化 useEffect 加载逻辑,修复 tagColor undefined crash
- PluginGraphPage: valueStyle → styles.content, Spin tip → description(antd 6)
- DashboardWidgets: trailColor → railColor(antd 6)
- data_service: build_scope_sql 参数索引修复(硬编码 $100 → 动态 values.len()+1)
- erp-core error: Internal 错误添加 tracing::error 日志输出
This commit is contained in:
iven
2026-04-18 20:31:49 +08:00
parent 790991f77c
commit 5ba11f985f
12 changed files with 308 additions and 100 deletions

View File

@@ -38,10 +38,53 @@ const client = axios.create({
},
});
// Request interceptor: attach access token
client.interceptors.request.use((config) => {
// Decode JWT payload without external library
function decodeJwtPayload(token: string): { exp?: number } | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
return payload;
} catch {
return null;
}
}
// Check if token is expired or about to expire (within 30s buffer)
function isTokenExpiringSoon(token: string): boolean {
const payload = decodeJwtPayload(token);
if (!payload?.exp) return true;
return Date.now() / 1000 > payload.exp - 30;
}
// Request interceptor: attach access token + proactive refresh
client.interceptors.request.use(async (config) => {
const token = localStorage.getItem('access_token');
if (token) {
// If token is about to expire, proactively refresh before sending the request
if (isTokenExpiringSoon(token)) {
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken && !isRefreshing) {
isRefreshing = true;
try {
const { data } = await axios.post('/api/v1/auth/refresh', {
refresh_token: refreshToken,
});
const newAccess = data.data.access_token;
const newRefresh = data.data.refresh_token;
localStorage.setItem('access_token', newAccess);
localStorage.setItem('refresh_token', newRefresh);
processQueue(null, newAccess);
config.headers.Authorization = `Bearer ${newAccess}`;
return config;
} catch {
processQueue(new Error('refresh failed'), null);
// Continue with old token, let 401 handler deal with it
} finally {
isRefreshing = false;
}
}
}
config.headers.Authorization = `Bearer ${token}`;
}

View File

@@ -115,6 +115,15 @@ export async function getPluginSchema(id: string): Promise<PluginSchemaResponse>
// ── Schema 类型定义 ──
export interface PluginFieldValidation {
pattern?: string;
message?: string;
min_length?: number;
max_length?: number;
min_value?: number;
max_value?: number;
}
export interface PluginFieldSchema {
name: string;
field_type: string;
@@ -132,12 +141,24 @@ export interface PluginFieldSchema {
ref_search_fields?: string[];
cascade_from?: string;
cascade_filter?: string;
validation?: PluginFieldValidation;
}
export interface PluginRelationSchema {
entity: string;
foreign_key: string;
on_delete: 'cascade' | 'nullify' | 'restrict';
name?: string;
type?: 'one_to_many' | 'many_to_one' | 'many_to_many';
display_field?: string;
}
export interface PluginEntitySchema {
name: string;
display_name: string;
fields: PluginFieldSchema[];
relations?: PluginRelationSchema[];
data_scope?: boolean;
}
export interface PluginSchemaResponse {