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()} 数据统计` : '数据统计'}