diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index e7e92ba..65d9ecc 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -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}`;
}
diff --git a/apps/web/src/api/plugins.ts b/apps/web/src/api/plugins.ts
index 3217f2a..10925cc 100644
--- a/apps/web/src/api/plugins.ts
+++ b/apps/web/src/api/plugins.ts
@@ -115,6 +115,15 @@ export async function getPluginSchema(id: string): Promise
// ── 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 {
diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx
index 8240b41..274edc7 100644
--- a/apps/web/src/pages/Home.tsx
+++ b/apps/web/src/pages/Home.tsx
@@ -127,8 +127,15 @@ export default function Home() {
if (cancelled) return;
- const extractTotal = (res: PromiseSettledResult<{ data: { data?: { total?: number } } }>) =>
- res.status === 'fulfilled' ? (res.value.data?.data?.total ?? 0) : 0;
+ const extractTotal = (res: PromiseSettledResult<{ data: { data?: { total?: number } } }>) => {
+ if (res.status !== 'fulfilled') return 0;
+ const body = res.value.data;
+ if (body && typeof body === 'object' && 'data' in body) {
+ const inner = (body as { data?: { total?: number } }).data;
+ return inner?.total ?? 0;
+ }
+ return 0;
+ };
setStats({
userCount: extractTotal(usersRes),
@@ -147,7 +154,8 @@ export default function Home() {
loadStats();
return () => { cancelled = true; };
- }, [fetchUnreadCount]);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
const handleNavigate = useCallback((path: string) => {
navigate(path);
@@ -220,10 +228,10 @@ export default function Home() {
];
const recentActivities: ActivityItem[] = [
- { id: '1', text: '系统管理员 创建了 管理员角色', time: '刚刚', icon: },
- { id: '2', text: '系统管理员 配置了 工作流模板', time: '5 分钟前', icon: },
- { id: '3', text: '系统管理员 更新了 组织架构', time: '10 分钟前', icon: },
- { id: '4', text: '系统管理员 设置了 消息通知偏好', time: '30 分钟前', icon: },
+ { id: '1', text: '系统管理员 创建了 管理员角色', time: '刚刚', icon: },
+ { id: '2', text: '系统管理员 配置了 工作流模板', time: '5 分钟前', icon: },
+ { id: '3', text: '系统管理员 更新了 组织架构', time: '10 分钟前', icon: },
+ { id: '4', text: '系统管理员 设置了 消息通知偏好', time: '30 分钟前', icon: },
];
const priorityLabel: Record = { high: '紧急', medium: '一般', low: '低' };
@@ -351,7 +359,7 @@ export default function Home() {
{activity.icon}
-
+
{activity.text}
{activity.time}
diff --git a/apps/web/src/pages/PluginDashboardPage.tsx b/apps/web/src/pages/PluginDashboardPage.tsx
index 1cfddb0..58692e9 100644
--- a/apps/web/src/pages/PluginDashboardPage.tsx
+++ b/apps/web/src/pages/PluginDashboardPage.tsx
@@ -11,7 +11,7 @@ import {
type DashboardWidget,
} from '../api/plugins';
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboard/dashboardTypes';
-import { ENTITY_PALETTE, DEFAULT_PALETTE, ENTITY_ICONS, getDelayClass } from './dashboard/dashboardConstants';
+import { getEntityPalette, getEntityIcon, getDelayClass } from './dashboard/dashboardConstants';
import {
StatCard,
SkeletonStatCard,
@@ -89,27 +89,28 @@ export function PluginDashboardPage() {
const abortController = new AbortController();
async function loadAllCounts() {
const results: EntityStat[] = [];
- for (const entity of entities) {
+ for (let i = 0; i < entities.length; i++) {
+ const entity = entities[i];
if (abortController.signal.aborted) return;
+ const palette = getEntityPalette(entity.name, i);
+ const icon = getEntityIcon(entity.name);
try {
const count = await countPluginData(pluginId!, entity.name);
if (abortController.signal.aborted) return;
- const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
results.push({
name: entity.name,
displayName: entity.display_name || entity.name,
count,
- icon: ENTITY_ICONS[entity.name] || ,
+ icon,
gradient: palette.gradient,
iconBg: palette.iconBg,
});
} catch {
- const palette = ENTITY_PALETTE[entity.name] || DEFAULT_PALETTE;
results.push({
name: entity.name,
displayName: entity.display_name || entity.name,
count: 0,
- icon: ENTITY_ICONS[entity.name] || ,
+ icon,
gradient: palette.gradient,
iconBg: palette.iconBg,
});
@@ -206,8 +207,8 @@ export function PluginDashboardPage() {
);
// 当前实体的色板
const currentPalette = useMemo(
- () => ENTITY_PALETTE[selectedEntity] || DEFAULT_PALETTE,
- [selectedEntity],
+ () => getEntityPalette(selectedEntity, entities.findIndex((e) => e.name === selectedEntity)),
+ [selectedEntity, entities],
);
// ── 渲染 ──
if (schemaLoading) {
@@ -257,7 +258,7 @@ export function PluginDashboardPage() {
margin: 0,
}}
>
- CRM 数据全景视图,实时掌握业务动态
+ {pluginId ? `${pluginId.toUpperCase()} 数据统计` : '数据统计'}